commit fb47c53d5282f2d640af5eed27ee45dd4a8f6f47 Author: Felix Baumann Date: Sat Jun 27 17:07:34 2026 +0000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..faf74fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.env +db.sqlite3 +media/ +staticfiles/ +venv/ +.venv/ diff --git a/Procfile.tailwind b/Procfile.tailwind new file mode 100644 index 0000000..bd98629 --- /dev/null +++ b/Procfile.tailwind @@ -0,0 +1,2 @@ +django: python manage.py runserver +tailwind: python manage.py tailwind start \ No newline at end of file diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..f5f838a --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,34 @@ +from django.contrib import admin +from .models import Role, UserProfile + + +@admin.register(Role) +class RoleAdmin(admin.ModelAdmin): + + list_display = [ + "name", + ] + + search_fields = [ + "name", + ] + + +@admin.register(UserProfile) +class UserProfileAdmin(admin.ModelAdmin): + + list_display = [ + "user", + "role", + ] + + list_filter = [ + "role", + ] + + search_fields = [ + "user__username", + "user__first_name", + "user__last_name", + "user__email", + ] \ No newline at end of file diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..9b3fc5a --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' diff --git a/accounts/decorators.py b/accounts/decorators.py new file mode 100644 index 0000000..55faa29 --- /dev/null +++ b/accounts/decorators.py @@ -0,0 +1,50 @@ +from functools import wraps + +from django.shortcuts import redirect +from django.contrib import messages + + +def permission_required(permission): + + def decorator(view_func): + + @wraps(view_func) + def wrapper(request, *args, **kwargs): + + # Superuser haben alles + if request.user.is_superuser: + return view_func( + request, + *args, + **kwargs + ) + + + profile = getattr( + request.user, + "profile", + None + ) + + + if profile and profile.has_permission(permission): + + return view_func( + request, + *args, + **kwargs + ) + + + messages.error( + request, + "Keine Berechtigung für diese Aktion!" + ) + + + return redirect("core:dashboard") + + + return wrapper + + return decorator \ No newline at end of file diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..22e0ef3 --- /dev/null +++ b/accounts/forms.py @@ -0,0 +1,61 @@ +from django import forms +from django.contrib.auth.models import User +from .models import Role +from core.forms.base import BaseForm + +class UserAddForm(BaseForm): + + password = forms.CharField( + label="Passwort", + widget=forms.PasswordInput + ) + + role = forms.ModelChoiceField( + queryset=Role.objects.order_by("name"), + required=False, + label="Rolle" + ) + + class Meta: + model = User + + fields = [ + "username", + "first_name", + "last_name", + "email", + ] + + labels = { + "username": "Benutzername", + "first_name": "Vorname", + "last_name": "Nachname", + "email": "E-Mail", + } + + help_texts = { + "username": "", + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.apply_style() + + +class RoleForm(BaseForm): + + class Meta: + model = Role + + fields = [ + "name", + ] + + labels = { + "name": "Rollenname", + } + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.apply_style() \ No newline at end of file diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..2c3ef45 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,97 @@ +# Generated by Django 6.0.6 on 2026-06-23 22:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='Rolle')), + ('projects_view', models.BooleanField(default=False, help_text='Benutzer darf Projekte einsehen.', verbose_name='Projekte anzeigen')), + ('projects_archived_view', models.BooleanField(default=False, help_text='Benutzer darf archivierte Projekte einsehen.', verbose_name='Archivierte Projekte anzeigen')), + ('projects_detail_view', models.BooleanField(default=False, help_text='Benutzer darf Details des Projekts einsehen.', verbose_name='Projektdetails anzeigen')), + ('projects_add', models.BooleanField(default=False, help_text='Benutzer darf neue Projekte anlegen.', verbose_name='Projekt erstellen')), + ('projects_contact_add', models.BooleanField(default=False, help_text='Benutzer darf Kontakte mit dem Projekt verknüpfen.', verbose_name='Projektkontakt verknüpfen')), + ('projects_contact_delete', models.BooleanField(default=False, help_text='Benutzer darf Kontakte aus dem Projekt entfernen.', verbose_name='Projektkontakt entfernen')), + ('projects_location_add', models.BooleanField(default=False, help_text='Benutzer darf Standorte mit dem Projekt verknüpfen.', verbose_name='Projektstandort verknüpfen')), + ('projects_location_delete', models.BooleanField(default=False, help_text='Benutzer darf Standorte aus dem Projekt entfernen.', verbose_name='Projektstandort entfernen')), + ('projects_edit', models.BooleanField(default=False, help_text='Benutzer darf Projekte ändern.', verbose_name='Projekte bearbeiten')), + ('projects_user_add', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter im Projekt hinzufügen.', verbose_name='Projektmitarbeiter erstellen')), + ('projects_user_delete', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter aus dem Projekt entfernen.', verbose_name='Projektmitarbeiter entfernen')), + ('projects_finance_view', models.BooleanField(default=False, help_text='Benutzer darf Projektfinanzen einsehen.', verbose_name='Projektfinanzen anzeigen')), + ('projects_finance_income_add', models.BooleanField(default=False, help_text='Benutzer darf Einnahmen hinzufügen.', verbose_name='Projekteinnahmen erfassen')), + ('projects_finance_expense_add', models.BooleanField(default=False, help_text='Benutzer darf Ausgaben hinzufügen.', verbose_name='Projektausgaben erfassen')), + ('projects_time_tracking_view', models.BooleanField(default=False, help_text='Benutzer darf Projektzeiten sehen.', verbose_name='Projektzeiten anzeigen')), + ('projects_time_tracking_edit', models.BooleanField(default=False, help_text='Benutzer darf Projektzeiten ändern.', verbose_name='Projektzeiten bearbeiten')), + ('projects_time_tracking_delete', models.BooleanField(default=False, help_text='Benutzer darf Projektzeiten löschen.', verbose_name='Projektzeiten löschen')), + ('projects_documents_view', models.BooleanField(default=False, help_text='Benutzer darf Dokumente sehen.', verbose_name='Projektdokumente anzeigen')), + ('projects_documents_add', models.BooleanField(default=False, help_text='Benutzer darf Dokumente hochladen.', verbose_name='Projektdokumente hinzufügen')), + ('projects_documents_delete', models.BooleanField(default=False, help_text='Benutzer darf Dokumente löschen.', verbose_name='Projektdokumente löschen')), + ('projects_inventory_inventory_view', models.BooleanField(default=False, help_text='Benutzer darf Projektinventar sehen.', verbose_name='Projektinventar anzeigen')), + ('projects_inventory_inventory_add', models.BooleanField(default=False, help_text='Benutzer darf Inventar hinzufügen.', verbose_name='Projektinventar hinzufügen')), + ('projects_inventory_inventory_edit', models.BooleanField(default=False, help_text='Benutzer darf Inventar ändern.', verbose_name='Projektinventar bearbeiten')), + ('projects_inventory_inventory_delete', models.BooleanField(default=False, help_text='Benutzer darf Inventar löschen.', verbose_name='Projektinventar löschen')), + ('projects_inventory_shopping_add', models.BooleanField(default=False, help_text='Benutzer darf Einkaufspositionen erstellen.', verbose_name='Einkauf hinzufügen')), + ('projects_inventory_shopping_edit', models.BooleanField(default=False, help_text='Benutzer darf Einkaufspositionen ändern.', verbose_name='Einkauf bearbeiten')), + ('projects_inventory_shopping_delete', models.BooleanField(default=False, help_text='Benutzer darf Einkaufspositionen löschen.', verbose_name='Einkauf löschen')), + ('inventory_view', models.BooleanField(default=False, help_text='Benutzer darf Inventar einsehen.', verbose_name='Inventar anzeigen')), + ('inventory_add', models.BooleanField(default=False, help_text='Benutzer darf Inventareinträge erstellen.', verbose_name='Inventar hinzufügen')), + ('inventory_edit', models.BooleanField(default=False, help_text='Benutzer darf Inventareinträge ändern.', verbose_name='Inventar bearbeiten')), + ('inventory_delete', models.BooleanField(default=False, help_text='Benutzer darf Inventareinträge löschen.', verbose_name='Inventar löschen')), + ('invoices_view', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen verwalten.', verbose_name='Rechnungsbereich anzeigen')), + ('invoices_customers_view', models.BooleanField(default=False, help_text='Benutzer darf Kunden sehen.', verbose_name='Kunden anzeigen')), + ('invoices_customers_detail_view', models.BooleanField(default=False, help_text='Benutzer darf Details des Kunden sehen.', verbose_name='Kundendetails anzeigen')), + ('invoices_customers_add', models.BooleanField(default=False, help_text='Benutzer darf Kunden anlegen.', verbose_name='Kunden erstellen')), + ('invoices_customers_edit', models.BooleanField(default=False, help_text='Benutzer darf Kunden ändern.', verbose_name='Kunden bearbeiten')), + ('invoices_customers_archive', models.BooleanField(default=False, help_text='Benutzer darf Kunden archivieren.', verbose_name='Kunden archivieren')), + ('invoices_customers_activate', models.BooleanField(default=False, help_text='Benutzer darf Kunden wieder aktivieren.', verbose_name='Kunden reaktivieren')), + ('invoices_items_view', models.BooleanField(default=False, help_text='Benutzer darf Artikel sehen.', verbose_name='Artikel anzeigen')), + ('invoices_items_add', models.BooleanField(default=False, help_text='Benutzer darf Artikel erstellen.', verbose_name='Artikel erstellen')), + ('invoices_items_edit', models.BooleanField(default=False, help_text='Benutzer darf Artikel ändern.', verbose_name='Artikel bearbeiten')), + ('invoices_items_delete', models.BooleanField(default=False, help_text='Benutzer darf Artikel löschen.', verbose_name='Artikel löschen')), + ('invoices_invoices_view', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen sehen.', verbose_name='Rechnungen anzeigen')), + ('invoices_invoices_project_add', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen aus Projekten erstellen.', verbose_name='Projekt-Rechnung erstellen')), + ('invoices_invoices_customer_add', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen für Kunden erstellen.', verbose_name='Kunden-Rechnung erstellen')), + ('invoices_invoices_invoices_view', models.BooleanField(default=False, help_text='Benutzer darf Rechnungsdetails sehen.', verbose_name='Rechnungsdetails anzeigen')), + ('invoices_invoices_invoices_item_add', models.BooleanField(default=False, help_text='Benutzer darf Positionen hinzufügen.', verbose_name='Rechnungsposition hinzufügen')), + ('invoices_invoices_invoices_item_delete', models.BooleanField(default=False, help_text='Benutzer darf Positionen löschen.', verbose_name='Rechnungsposition löschen')), + ('invoices_invoices_invoices_preview', models.BooleanField(default=False, help_text='Benutzer darf Vorschauen öffnen.', verbose_name='Rechnungsvorschau anzeigen')), + ('users_view', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter sehen.', verbose_name='Benutzer anzeigen')), + ('users_add', models.BooleanField(default=False, help_text='Benutzer darf neue Mitarbeiter anlegen.', verbose_name='Benutzer erstellen')), + ('users_edit', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter bearbeiten.', verbose_name='Benutzer bearbeiten')), + ('users_archive', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter deaktivieren.', verbose_name='Benutzer archivieren')), + ('users_activate', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter aktivieren.', verbose_name='Benutzer aktivieren')), + ('users_groups_add', models.BooleanField(default=False, help_text='Benutzer darf neue Rollen erstellen.', verbose_name='Rollen erstellen')), + ('users_groups_edit', models.BooleanField(default=False, help_text='Benutzer darf Rollen und Berechtigungen ändern.', verbose_name='Rollen bearbeiten')), + ('users_groups_delete', models.BooleanField(default=False, help_text='Benutzer darf Rollen entfernen.', verbose_name='Rollen löschen')), + ('settings_view', models.BooleanField(default=False, help_text='Benutzer darf Einstellungskategorien sehen.', verbose_name='Einstellungsübersicht anzeigen')), + ('settings_detail_view', models.BooleanField(default=False, help_text='Benutzer darf Systemeinstellungen sehen.', verbose_name='Einstellungen anzeigen')), + ('settings_add', models.BooleanField(default=False, help_text='Benutzer darf neue Einstellungen hinzufügen.', verbose_name='Einstellungen erstellen')), + ('settings_delete', models.BooleanField(default=False, help_text='Benutzer darf Einstellungen entfernen.', verbose_name='Einstellungen löschen')), + ], + options={ + 'verbose_name': 'Rolle', + 'verbose_name_plural': 'Rollen', + }, + ), + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.role')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/accounts/migrations/0002_role_invoices_invoices_invoices_download.py b/accounts/migrations/0002_role_invoices_invoices_invoices_download.py new file mode 100644 index 0000000..d7edc16 --- /dev/null +++ b/accounts/migrations/0002_role_invoices_invoices_invoices_download.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-23 23:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='role', + name='invoices_invoices_invoices_download', + field=models.BooleanField(default=False, help_text='Benutzer darf Rechnungen herunterladen.', verbose_name='Rechnung herunterladen'), + ), + ] diff --git a/accounts/migrations/0003_role_invoices_invoices_invoices_cancel.py b/accounts/migrations/0003_role_invoices_invoices_invoices_cancel.py new file mode 100644 index 0000000..c90dee8 --- /dev/null +++ b/accounts/migrations/0003_role_invoices_invoices_invoices_cancel.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-24 08:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_role_invoices_invoices_invoices_download'), + ] + + operations = [ + migrations.AddField( + model_name='role', + name='invoices_invoices_invoices_cancel', + field=models.BooleanField(default=False, help_text='Benutzer darf Rechnungen stornieren.', verbose_name='Rechnung stornieren'), + ), + ] diff --git a/accounts/migrations/0004_role_invoices_invoices_invoices_cancel_download.py b/accounts/migrations/0004_role_invoices_invoices_invoices_cancel_download.py new file mode 100644 index 0000000..aadb661 --- /dev/null +++ b/accounts/migrations/0004_role_invoices_invoices_invoices_cancel_download.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-24 09:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_role_invoices_invoices_invoices_cancel'), + ] + + operations = [ + migrations.AddField( + model_name='role', + name='invoices_invoices_invoices_cancel_download', + field=models.BooleanField(default=False, help_text='Benutzer darf Stornorechnungen herunterladen.', verbose_name='Stornorechnung Download'), + ), + ] diff --git a/accounts/migrations/0005_remove_role_invoices_invoices_invoices_cancel_download_and_more.py b/accounts/migrations/0005_remove_role_invoices_invoices_invoices_cancel_download_and_more.py new file mode 100644 index 0000000..af5552b --- /dev/null +++ b/accounts/migrations/0005_remove_role_invoices_invoices_invoices_cancel_download_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.6 on 2026-06-24 13:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_role_invoices_invoices_invoices_cancel_download'), + ] + + operations = [ + migrations.RemoveField( + model_name='role', + name='invoices_invoices_invoices_cancel_download', + ), + migrations.AddField( + model_name='role', + name='invoices_invoices_invoices_paid', + field=models.BooleanField(default=False, help_text='Benutzer darf Rechnungsstatus ändern.', verbose_name='Rechnung auf bezahlt setzen'), + ), + ] diff --git a/accounts/migrations/0006_role_invoices_offers_add_and_more.py b/accounts/migrations/0006_role_invoices_offers_add_and_more.py new file mode 100644 index 0000000..27e67d6 --- /dev/null +++ b/accounts/migrations/0006_role_invoices_offers_add_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 6.0.6 on 2026-06-25 16:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0005_remove_role_invoices_invoices_invoices_cancel_download_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='role', + name='invoices_offers_add', + field=models.BooleanField(default=False, help_text='Benutzer darf Angebote für Kunden erstellen.', verbose_name='Angebot erstellen'), + ), + migrations.AddField( + model_name='role', + name='invoices_offers_offers_download', + field=models.BooleanField(default=False, help_text='Benutzer darf Angebote herunterladen.', verbose_name='Angebot herunterladen'), + ), + migrations.AddField( + model_name='role', + name='invoices_offers_offers_item_add', + field=models.BooleanField(default=False, help_text='Benutzer darf Positionen hinzufügen.', verbose_name='Angebotsposition hinzufügen'), + ), + migrations.AddField( + model_name='role', + name='invoices_offers_offers_item_delete', + field=models.BooleanField(default=False, help_text='Benutzer darf Positionen löschen.', verbose_name='Angebotsposition löschen'), + ), + migrations.AddField( + model_name='role', + name='invoices_offers_offers_preview', + field=models.BooleanField(default=False, help_text='Benutzer darf Vorschauen öffnen.', verbose_name='Angebotsvorschau anzeigen'), + ), + migrations.AddField( + model_name='role', + name='invoices_offers_offers_status', + field=models.BooleanField(default=False, help_text='Benutzer darf Angebotsstatus ändern.', verbose_name='Angebotsstatus bearbeiten'), + ), + migrations.AddField( + model_name='role', + name='invoices_offers_offers_view', + field=models.BooleanField(default=False, help_text='Benutzer darf Angebotsdetails sehen.', verbose_name='Angebotsdetails anzeigen'), + ), + migrations.AddField( + model_name='role', + name='invoices_offers_view', + field=models.BooleanField(default=False, help_text='Benutzer darf Angebote sehen.', verbose_name='Angebote anzeigen'), + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..095f31c --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,193 @@ +from django.db import models +from django.contrib.auth.models import User + +class Role(models.Model): + + name = models.CharField( + max_length=100, + unique=True, + verbose_name="Rolle" + ) + + projects_view = models.BooleanField(default=False, verbose_name="Projekte anzeigen", help_text="Benutzer darf Projekte einsehen.") + projects_archived_view = models.BooleanField(default=False, verbose_name="Archivierte Projekte anzeigen", help_text="Benutzer darf archivierte Projekte einsehen.") + projects_detail_view = models.BooleanField(default=False, verbose_name="Projektdetails anzeigen", help_text="Benutzer darf Details des Projekts einsehen.") + projects_add = models.BooleanField(default=False, verbose_name="Projekt erstellen", help_text="Benutzer darf neue Projekte anlegen.") + projects_contact_add = models.BooleanField(default=False, verbose_name="Projektkontakt verknüpfen", help_text="Benutzer darf Kontakte mit dem Projekt verknüpfen.") + projects_contact_delete = models.BooleanField(default=False, verbose_name="Projektkontakt entfernen", help_text="Benutzer darf Kontakte aus dem Projekt entfernen.") + projects_location_add = models.BooleanField(default=False, verbose_name="Projektstandort verknüpfen", help_text="Benutzer darf Standorte mit dem Projekt verknüpfen.") + projects_location_delete = models.BooleanField(default=False, verbose_name="Projektstandort entfernen", help_text="Benutzer darf Standorte aus dem Projekt entfernen.") + projects_edit = models.BooleanField(default=False, verbose_name="Projekte bearbeiten", help_text="Benutzer darf Projekte ändern.") + + projects_user_add = models.BooleanField(default=False, verbose_name="Projektmitarbeiter erstellen", help_text="Benutzer darf Mitarbeiter im Projekt hinzufügen.") + projects_user_delete = models.BooleanField(default=False, verbose_name="Projektmitarbeiter entfernen", help_text="Benutzer darf Mitarbeiter aus dem Projekt entfernen.") + + projects_finance_view = models.BooleanField(default=False, verbose_name="Projektfinanzen anzeigen", help_text="Benutzer darf Projektfinanzen einsehen.") + projects_finance_income_add = models.BooleanField(default=False, verbose_name="Projekteinnahmen erfassen", help_text="Benutzer darf Einnahmen hinzufügen.") + projects_finance_expense_add = models.BooleanField(default=False, verbose_name="Projektausgaben erfassen", help_text="Benutzer darf Ausgaben hinzufügen.") + + projects_time_tracking_view = models.BooleanField(default=False, verbose_name="Projektzeiten anzeigen", help_text="Benutzer darf Projektzeiten sehen.") + projects_time_tracking_edit = models.BooleanField(default=False, verbose_name="Projektzeiten bearbeiten", help_text="Benutzer darf Projektzeiten ändern.") + projects_time_tracking_delete = models.BooleanField(default=False, verbose_name="Projektzeiten löschen", help_text="Benutzer darf Projektzeiten löschen.") + + projects_documents_view = models.BooleanField(default=False, verbose_name="Projektdokumente anzeigen", help_text="Benutzer darf Dokumente sehen.") + projects_documents_add = models.BooleanField(default=False, verbose_name="Projektdokumente hinzufügen", help_text="Benutzer darf Dokumente hochladen.") + projects_documents_delete = models.BooleanField(default=False, verbose_name="Projektdokumente löschen", help_text="Benutzer darf Dokumente löschen.") + + projects_inventory_inventory_view = models.BooleanField(default=False, verbose_name="Projektinventar anzeigen", help_text="Benutzer darf Projektinventar sehen.") + projects_inventory_inventory_add = models.BooleanField(default=False, verbose_name="Projektinventar hinzufügen", help_text="Benutzer darf Inventar hinzufügen.") + projects_inventory_inventory_edit = models.BooleanField(default=False, verbose_name="Projektinventar bearbeiten", help_text="Benutzer darf Inventar ändern.") + projects_inventory_inventory_delete = models.BooleanField(default=False, verbose_name="Projektinventar löschen", help_text="Benutzer darf Inventar löschen.") + + projects_inventory_shopping_add = models.BooleanField(default=False, verbose_name="Einkauf hinzufügen", help_text="Benutzer darf Einkaufspositionen erstellen.") + projects_inventory_shopping_edit = models.BooleanField(default=False, verbose_name="Einkauf bearbeiten", help_text="Benutzer darf Einkaufspositionen ändern.") + projects_inventory_shopping_delete = models.BooleanField(default=False, verbose_name="Einkauf löschen", help_text="Benutzer darf Einkaufspositionen löschen.") + #projects_inventory_shopping_buy = models.BooleanField(default=False, verbose_name="Einkäufe durchführen", help_text="Benutzer darf Einkäufe als erledigt markieren.") + + + # ===================== + # Inventar + # ===================== + inventory_view = models.BooleanField(default=False, verbose_name="Inventar anzeigen", help_text="Benutzer darf Inventar einsehen.") + inventory_add = models.BooleanField(default=False, verbose_name="Inventar hinzufügen", help_text="Benutzer darf Inventareinträge erstellen.") + inventory_edit = models.BooleanField(default=False, verbose_name="Inventar bearbeiten", help_text="Benutzer darf Inventareinträge ändern.") + inventory_delete = models.BooleanField(default=False, verbose_name="Inventar löschen", help_text="Benutzer darf Inventareinträge löschen.") + + + # ===================== + # Rechnungen + # ===================== + invoices_view = models.BooleanField(default=False, verbose_name="Rechnungsbereich anzeigen", help_text="Benutzer darf Rechnungen verwalten.") + + invoices_customers_view = models.BooleanField(default=False, verbose_name="Kunden anzeigen", help_text="Benutzer darf Kunden sehen.") + invoices_customers_detail_view = models.BooleanField(default=False, verbose_name="Kundendetails anzeigen", help_text="Benutzer darf Details des Kunden sehen.") + invoices_customers_add = models.BooleanField(default=False, verbose_name="Kunden erstellen", help_text="Benutzer darf Kunden anlegen.") + invoices_customers_edit = models.BooleanField(default=False, verbose_name="Kunden bearbeiten", help_text="Benutzer darf Kunden ändern.") + invoices_customers_archive = models.BooleanField(default=False, verbose_name="Kunden archivieren", help_text="Benutzer darf Kunden archivieren.") + invoices_customers_activate = models.BooleanField(default=False, verbose_name="Kunden reaktivieren", help_text="Benutzer darf Kunden wieder aktivieren.") + + invoices_items_view = models.BooleanField(default=False, verbose_name="Artikel anzeigen", help_text="Benutzer darf Artikel sehen.") + invoices_items_add = models.BooleanField(default=False, verbose_name="Artikel erstellen", help_text="Benutzer darf Artikel erstellen.") + invoices_items_edit = models.BooleanField(default=False, verbose_name="Artikel bearbeiten", help_text="Benutzer darf Artikel ändern.") + invoices_items_delete = models.BooleanField(default=False, verbose_name="Artikel löschen", help_text="Benutzer darf Artikel löschen.") + + invoices_invoices_view = models.BooleanField(default=False, verbose_name="Rechnungen anzeigen", help_text="Benutzer darf Rechnungen sehen.") + invoices_invoices_project_add = models.BooleanField(default=False, verbose_name="Projekt-Rechnung erstellen", help_text="Benutzer darf Rechnungen aus Projekten erstellen.") + invoices_invoices_customer_add = models.BooleanField(default=False, verbose_name="Kunden-Rechnung erstellen", help_text="Benutzer darf Rechnungen für Kunden erstellen.") + invoices_invoices_invoices_view = models.BooleanField(default=False, verbose_name="Rechnungsdetails anzeigen", help_text="Benutzer darf Rechnungsdetails sehen.") + invoices_invoices_invoices_item_add = models.BooleanField(default=False, verbose_name="Rechnungsposition hinzufügen", help_text="Benutzer darf Positionen hinzufügen.") + invoices_invoices_invoices_item_delete = models.BooleanField(default=False, verbose_name="Rechnungsposition löschen", help_text="Benutzer darf Positionen löschen.") + invoices_invoices_invoices_preview = models.BooleanField(default=False, verbose_name="Rechnungsvorschau anzeigen", help_text="Benutzer darf Vorschauen öffnen.") + invoices_invoices_invoices_download = models.BooleanField(default=False, verbose_name="Rechnung herunterladen", help_text="Benutzer darf Rechnungen herunterladen.") + invoices_invoices_invoices_cancel = models.BooleanField(default=False, verbose_name="Rechnung stornieren", help_text="Benutzer darf Rechnungen stornieren.") + invoices_invoices_invoices_paid = models.BooleanField(default=False, verbose_name="Rechnung auf bezahlt setzen", help_text="Benutzer darf Rechnungsstatus ändern.") + + + invoices_offers_view = models.BooleanField(default=False, verbose_name="Angebote anzeigen", help_text="Benutzer darf Angebote sehen.") + invoices_offers_add = models.BooleanField(default=False, verbose_name="Angebot erstellen", help_text="Benutzer darf Angebote für Kunden erstellen.") + invoices_offers_offers_view = models.BooleanField(default=False, verbose_name="Angebotsdetails anzeigen", help_text="Benutzer darf Angebotsdetails sehen.") + invoices_offers_offers_item_add = models.BooleanField(default=False, verbose_name="Angebotsposition hinzufügen", help_text="Benutzer darf Positionen hinzufügen.") + invoices_offers_offers_item_delete = models.BooleanField(default=False, verbose_name="Angebotsposition löschen", help_text="Benutzer darf Positionen löschen.") + invoices_offers_offers_preview = models.BooleanField(default=False, verbose_name="Angebotsvorschau anzeigen", help_text="Benutzer darf Vorschauen öffnen.") + invoices_offers_offers_download = models.BooleanField(default=False, verbose_name="Angebot herunterladen", help_text="Benutzer darf Angebote herunterladen.") + invoices_offers_offers_status = models.BooleanField(default=False, verbose_name="Angebotsstatus bearbeiten", help_text="Benutzer darf Angebotsstatus ändern.") + + + + + # ===================== + # Benutzerverwaltung + # ===================== + users_view = models.BooleanField(default=False, verbose_name="Benutzer anzeigen", help_text="Benutzer darf Mitarbeiter sehen.") + users_add = models.BooleanField(default=False, verbose_name="Benutzer erstellen", help_text="Benutzer darf neue Mitarbeiter anlegen.") + users_edit = models.BooleanField(default=False, verbose_name="Benutzer bearbeiten", help_text="Benutzer darf Mitarbeiter bearbeiten.") + users_archive = models.BooleanField(default=False, verbose_name="Benutzer archivieren", help_text="Benutzer darf Mitarbeiter deaktivieren.") + users_activate = models.BooleanField(default=False, verbose_name="Benutzer aktivieren", help_text="Benutzer darf Mitarbeiter aktivieren.") + + users_groups_add = models.BooleanField(default=False, verbose_name="Rollen erstellen", help_text="Benutzer darf neue Rollen erstellen.") + users_groups_edit = models.BooleanField(default=False, verbose_name="Rollen bearbeiten", help_text="Benutzer darf Rollen und Berechtigungen ändern.") + users_groups_delete = models.BooleanField(default=False, verbose_name="Rollen löschen", help_text="Benutzer darf Rollen entfernen.") + + + # ===================== + # Einstellungen + # ===================== + settings_view = models.BooleanField(default=False, verbose_name="Einstellungsübersicht anzeigen", help_text="Benutzer darf Einstellungskategorien sehen.") + settings_detail_view = models.BooleanField(default=False, verbose_name="Einstellungen anzeigen", help_text="Benutzer darf Systemeinstellungen sehen.") + settings_add = models.BooleanField(default=False, verbose_name="Einstellungen erstellen", help_text="Benutzer darf neue Einstellungen hinzufügen.") + #settings_edit = models.BooleanField(default=False, verbose_name="Einstellungen bearbeiten", help_text="Benutzer darf Einstellungen ändern.") + settings_delete = models.BooleanField(default=False, verbose_name="Einstellungen löschen", help_text="Benutzer darf Einstellungen entfernen.") + + + # ===================== + # Zeiterfassung + # ===================== + #time_tracking_own_view = models.BooleanField(default=False, verbose_name="Eigene Zeiten anzeigen", help_text="Benutzer darf eigene Arbeitszeiten sehen.") + #time_tracking_own_add = models.BooleanField(default=False, verbose_name="Eigene Zeiten erfassen", help_text="Benutzer darf eigene Arbeitszeiten hinzufügen.") + #time_tracking_own_edit = models.BooleanField(default=False, verbose_name="Eigene Zeiten bearbeiten", help_text="Benutzer darf eigene Arbeitszeiten ändern.") + #time_tracking_own_delete = models.BooleanField(default=False, verbose_name="Eigene Zeiten löschen", help_text="Benutzer darf eigene Arbeitszeiten löschen.") + + #time_tracking_all_view = models.BooleanField(default=False, verbose_name="Alle Zeiten anzeigen", help_text="Benutzer darf Arbeitszeiten aller Benutzer sehen.") + #time_tracking_all_add = models.BooleanField(default=False, verbose_name="Alle Zeiten erfassen", help_text="Benutzer darf Arbeitszeiten für andere Benutzer hinzufügen.") + #time_tracking_all_edit = models.BooleanField(default=False, verbose_name="Alle Zeiten bearbeiten", help_text="Benutzer darf Arbeitszeiten anderer Benutzer ändern.") + #time_tracking_all_delete = models.BooleanField(default=False, verbose_name="Alle Zeiten löschen", help_text="Benutzer darf Arbeitszeiten anderer Benutzer löschen.") + + + # ===================== + # Finanzen + # ===================== + #finance_view = models.BooleanField(default=False, verbose_name="Finanzen anzeigen", help_text="Benutzer darf Finanzdaten sehen.") + #finance_export = models.BooleanField(default=False, verbose_name="Finanzen exportieren", help_text="Benutzer darf Finanzdaten exportieren.") + + + # ===================== + # Investments + # ===================== + #invests_view = models.BooleanField(default=False, verbose_name="Investitionen anzeigen", help_text="Benutzer darf Investitionen sehen.") + #invests_add = models.BooleanField(default=False, verbose_name="Investitionen erstellen", help_text="Benutzer darf neue Investitionen anlegen.") + #invests_edit = models.BooleanField(default=False, verbose_name="Investitionen bearbeiten", help_text="Benutzer darf Investitionen ändern.") + #invests_delete = models.BooleanField(default=False, verbose_name="Investitionen löschen", help_text="Benutzer darf Investitionen entfernen.") + + + class Meta: + verbose_name = "Rolle" + verbose_name_plural = "Rollen" + + def __str__(self): + return self.name + + + + +class UserProfile(models.Model): + + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name="profile" + ) + + role = models.ForeignKey( + Role, + on_delete=models.SET_NULL, + null=True, + blank=True + ) + + + def has_permission(self, permission): + + if self.user.is_superuser: + return True + + if not self.role: + return False + + return getattr( + self.role, + permission.replace(".", "_"), + False + ) + + def __str__(self): + return self.user.username \ No newline at end of file diff --git a/accounts/tables.py b/accounts/tables.py new file mode 100644 index 0000000..281520b --- /dev/null +++ b/accounts/tables.py @@ -0,0 +1,102 @@ +import django_tables2 as tables +from django.contrib.auth.models import User +from .models import Role + +class RoleTable(tables.Table): + toggle_prefix = "role" + detail_template = "accounts/tables/user/role_edit_form.html" + name = tables.Column( + verbose_name="Rolle" + ) + + class Meta: + model = Role + template_name = "django_tables2/table_toggle.html" + fields = ( + "name", + ) + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + +class ArchivedUserTable(tables.Table): + toggle_prefix = "user" + detail_template = "accounts/tables/user/archive_edit_form.html" + username = tables.Column( + verbose_name="Benutzername" + ) + first_name = tables.Column( + verbose_name="Vorname" + ) + last_name = tables.Column( + verbose_name="Nachname" + ) + email = tables.Column( + verbose_name="E-Mail" + ) + role = tables.TemplateColumn( + template_name="accounts/tables/user/role.html", + verbose_name="Rolle", + orderable=False + ) + superuser = tables.TemplateColumn( + template_name="accounts/tables/user/superuser.html", + verbose_name="Superuser", + orderable=False + ) + + class Meta: + model = User + template_name = "django_tables2/table_toggle.html" + fields = ( + "username", + "first_name", + "last_name", + "email", + "role", + "superuser", + ) + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + +class UserTable(tables.Table): + toggle_prefix = "user" + detail_template = "accounts/tables/user/edit_form.html" + username = tables.Column( + verbose_name="Benutzername" + ) + first_name = tables.Column( + verbose_name="Vorname" + ) + last_name = tables.Column( + verbose_name="Nachname" + ) + email = tables.Column( + verbose_name="E-Mail" + ) + role = tables.TemplateColumn( + template_name="accounts/tables/user/role.html", + verbose_name="Rolle", + orderable=False + ) + superuser = tables.TemplateColumn( + template_name="accounts/tables/user/superuser.html", + verbose_name="Superuser", + orderable=False + ) + + class Meta: + model = User + template_name = "django_tables2/table_toggle.html" + fields = ( + "username", + "first_name", + "last_name", + "email", + "role", + "superuser", + ) + attrs = { + "class": "w-full text-sm min-w-[700px]" + } \ No newline at end of file diff --git a/accounts/templates/accounts/add.html b/accounts/templates/accounts/add.html new file mode 100644 index 0000000..9c42d16 --- /dev/null +++ b/accounts/templates/accounts/add.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %} +Mitarbeiter +{% endblock %} + +{% block header_title %} +Mitarbeiter hinzufügen +{% endblock %} + +{% block header_description %} +Neuen Mitarbeiter anlegen +{% endblock %} + +{% block content %} + +
+ +
+ {% csrf_token %} + +
+ {{ form.as_p }} +
+ + + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/login.html b/accounts/templates/accounts/login.html new file mode 100644 index 0000000..d8b6619 --- /dev/null +++ b/accounts/templates/accounts/login.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} + +{% block title %} +Login +{% endblock %} + +{% block header_title %} +
+ {% if company_settings.logo %} + + + {% elif company_settings.companyname %} + {{ company_settings.companyname }} + {% else %} + Projektverwaltung + {% endif %} +
+{% endblock %} + +{% block header_description %} +{% endblock %} + +{% block content %} + +
+ +
+ +

+ Anmelden +

+ +
+ {% csrf_token %} + + {% for field in form %} +
+ + + {% if field.errors %} +

{{ field.errors|join:", " }}

+ {% endif %} +
+ {% endfor %} + + + +
+ +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/overview.html b/accounts/templates/accounts/overview.html new file mode 100644 index 0000000..f9b58fc --- /dev/null +++ b/accounts/templates/accounts/overview.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} + +{% block title %} +Mitarbeiter +{% endblock %} + +{% block header_title %} +Mitarbeiter +{% endblock %} + +{% block header_description %} +Mitarbeiter, Rollen und Berechtigungen verwalten +{% endblock %} + +{% block content %} +{% load django_tables2 %} +
+
+
+
+

Aktionen

+

Mitarbeiter oder Rollen hinzufügen

+
+ + + +
+
+
+ + +
+ +
+

+ Benutzer +

+ +

+ Aktive Benutzer mit Rollen und Berechtigungen +

+
+ +
+ + {% render_table table %} + +
+ +
+ + + + + +
+ +
+

+ Archivierte Benutzer +

+ +

+ Deaktivierte Benutzer +

+
+ +
+ + {% render_table archived_table %} +
+
+ + + +
+ +
+

+ Rollen +

+ +

+ Rollen und Berechtigungen verwalten +

+
+ + +
+ + {% render_table role_table %} + +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/role_add.html b/accounts/templates/accounts/role_add.html new file mode 100644 index 0000000..ad0e84e --- /dev/null +++ b/accounts/templates/accounts/role_add.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %} +Mitarbeiter +{% endblock %} + +{% block header_title %} +Rolle hinzufügen +{% endblock %} + +{% block header_description %} +Neue Rolle anlegen +{% endblock %} + +{% block content %} + +
+ +
+ {% csrf_token %} + +
+ {{ form.as_p }} +
+ + + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/accounts/templates/accounts/tables/user/archive_edit_form.html b/accounts/templates/accounts/tables/user/archive_edit_form.html new file mode 100644 index 0000000..a780962 --- /dev/null +++ b/accounts/templates/accounts/tables/user/archive_edit_form.html @@ -0,0 +1,121 @@ +
+ {% csrf_token %} + +
+ +
+ +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + +
+ + + + + + +
+ +
+ +
\ No newline at end of file diff --git a/accounts/templates/accounts/tables/user/edit_form.html b/accounts/templates/accounts/tables/user/edit_form.html new file mode 100644 index 0000000..1352f10 --- /dev/null +++ b/accounts/templates/accounts/tables/user/edit_form.html @@ -0,0 +1,121 @@ +
+ {% csrf_token %} + +
+ +
+ +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + +
+ + + + + + +
+ +
+ +
\ No newline at end of file diff --git a/accounts/templates/accounts/tables/user/role.html b/accounts/templates/accounts/tables/user/role.html new file mode 100644 index 0000000..ca806b5 --- /dev/null +++ b/accounts/templates/accounts/tables/user/role.html @@ -0,0 +1 @@ +{{ record.profile.role.name|default:"-" }} \ No newline at end of file diff --git a/accounts/templates/accounts/tables/user/role_edit_form.html b/accounts/templates/accounts/tables/user/role_edit_form.html new file mode 100644 index 0000000..5d925ba --- /dev/null +++ b/accounts/templates/accounts/tables/user/role_edit_form.html @@ -0,0 +1,63 @@ +
+ {% csrf_token %} + +
+ +

+ Berechtigungen +

+ + +
+ + {% for permission in item.permissions %} + + + + {% endfor %} + +
+ + +
+ + + + + + +
+ +
+ +
\ No newline at end of file diff --git a/accounts/templates/accounts/tables/user/superuser.html b/accounts/templates/accounts/tables/user/superuser.html new file mode 100644 index 0000000..aea62a0 --- /dev/null +++ b/accounts/templates/accounts/tables/user/superuser.html @@ -0,0 +1,5 @@ +{% if record.is_superuser %} + Ja +{% else %} + Nein +{% endif %} \ No newline at end of file diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..352b6a8 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,19 @@ +from django.urls import path +from . import views + +app_name = "accounts" + +urlpatterns = [ + path("login/", views.CustomLoginView.as_view(), name="login"), + path("logout/", views.CustomLogoutView.as_view(), name="logout"), + path("", views.overview, name="overview"), + + path("add/",views.user_add,name="user_add"), + path("/edit/",views.user_edit,name="user_edit"), + path("/archive/",views.user_archive,name="user_archive"), + path("/reactivate/",views.user_reactivate,name="user_reactivate"), + + path("role/add/",views.role_add,name="role_add"), + path("role//edit/",views.role_edit,name="role_edit"), + path("role//delete/",views.role_delete,name="role_delete"), +] \ No newline at end of file diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..5ae5a56 --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,398 @@ +from django.contrib.auth.views import LoginView, LogoutView +from django.contrib.auth.decorators import login_required +from accounts.decorators import permission_required +from django.contrib.auth.models import User +from django.shortcuts import render, redirect, get_object_or_404 +from .forms import UserAddForm, RoleForm +from .models import Role, UserProfile +from .tables import UserTable, ArchivedUserTable, RoleTable +from django_tables2 import RequestConfig + + +class CustomLoginView(LoginView): + template_name = "accounts/login.html" + redirect_authenticated_user = True + +class CustomLogoutView(LogoutView): + pass + + +@login_required +@permission_required("users_view") +def overview(request): + + users = ( + User.objects + .filter(is_active=True) + .select_related( + "profile", + "profile__role" + ) + .order_by("username") + ) + + archived_users = ( + User.objects + .filter(is_active=False) + .select_related( + "profile", + "profile__role" + ) + .order_by("username") + ) + + roles = list( + Role.objects.order_by("name") + ) + + + # ================================================== + # ROLE PERMISSIONS FÜR CHECKBOXEN + # ================================================== + + permission_fields = [] + + for field in Role._meta.fields: + + if field.get_internal_type() == "BooleanField": + + permission_fields.append({ + "name": field.name, + "verbose_name": field.verbose_name or field.name + }) + + + for role in roles: + + role.permissions = [] + + for permission in permission_fields: + + role.permissions.append({ + + "name": permission["name"], + + "verbose_name": permission["verbose_name"], + + "value": getattr( + role, + permission["name"] + ) + + }) + + + # ================================================== + # USER ROLLEN-PERMISSIONS + # ================================================== + + for user in list(users) + list(archived_users): + + role = getattr( + getattr(user, "profile", None), + "role", + None + ) + + user.role_permissions = [] + + if role: + + for permission in permission_fields: + + if getattr( + role, + permission["name"] + ): + user.role_permissions.append( + permission["verbose_name"] + ) + + + # ================================================== + # TABLES + # ================================================== + table = UserTable(users) + table.roles = roles + RequestConfig( + request, + paginate=False + ).configure(table) + + archived_table = ArchivedUserTable(archived_users) + archived_table.roles = roles + RequestConfig( + request, + paginate=False + ).configure(archived_table) + + + role_table = RoleTable(roles) + role_table.roles = roles + RequestConfig( + request, + paginate=False + ).configure(role_table) + + + return render( + request, + "accounts/overview.html", + { + "table": table, + "archived_table": archived_table, + "role_table": role_table, + } + ) + +@login_required +@permission_required("users_edit") +def user_edit(request, user_id): + + user = get_object_or_404( + User.objects.select_related( + "profile" + ), + id=user_id + ) + + if user.is_superuser: + return redirect("accounts:overview") + + if request.method != "POST": + return redirect("accounts:overview") + + # ------------------------- + # USER + # ------------------------- + user.username = request.POST.get( + "username", + user.username + ) + + user.first_name = request.POST.get( + "first_name", + user.first_name + ) + + user.last_name = request.POST.get( + "last_name", + user.last_name + ) + + user.email = request.POST.get( + "email", + user.email + ) + + user.save() + + # ------------------------- + # PROFILE / ROLE + # ------------------------- + profile, _ = UserProfile.objects.get_or_create( + user=user + ) + + role_id = request.POST.get("role") + + if role_id: + profile.role_id = role_id + else: + profile.role = None + + profile.save() + + return redirect( + "accounts:overview" + ) + + + +@login_required +@permission_required("users_archive") +def user_archive(request, user_id): + + user = get_object_or_404( + User, + id=user_id + ) + + if user.is_superuser: + return redirect("accounts:overview") + + if request.method == "POST": + user.is_active = False + user.save() + + return redirect("accounts:overview") + + +@login_required +@permission_required("users_activate") +def user_reactivate(request, user_id): + + user = get_object_or_404( + User, + id=user_id + ) + + if user.is_superuser: + return redirect("accounts:overview") + + if request.method == "POST": + user.is_active = True + user.save() + + return redirect("accounts:overview") + + + + + +@login_required +@permission_required("users_add") +def user_add(request): + + form = UserAddForm( + request.POST or None + ) + + if form.is_valid(): + + user = form.save(commit=False) + + password = form.cleaned_data["password"] + + user.set_password(password) + user.save() + + UserProfile.objects.create( + user=user, + role=form.cleaned_data["role"] + ) + + return redirect( + "accounts:overview" + ) + + return render( + request, + "accounts/add.html", + { + "form": form + } + ) + + + + + + +def get_role_permissions(): + + fields = [] + + for field in Role._meta.fields: + + if field.get_internal_type() == "BooleanField": + + fields.append({ + "name": field.name, + "label": field.verbose_name or field.name + }) + + return fields + + + +@login_required +@permission_required("users_groups_edit") +def role_edit(request, role_id): + + role = get_object_or_404( + Role, + id=role_id + ) + + + if request.method == "POST": + + for field in Role._meta.fields: + + if ( + field.get_internal_type() + == "BooleanField" + ): + + setattr( + role, + field.name, + field.name in request.POST + ) + + + role.name = request.POST.get( + "name", + role.name + ) + + + role.save() + + + return redirect( + "accounts:overview" + ) + + +@login_required +@permission_required("users_groups_delete") +def role_delete(request, role_id): + + role = get_object_or_404( + Role, + id=role_id + ) + + + if request.method == "POST": + + role.delete() + + + return redirect( + "accounts:overview" + ) + + +@login_required +@permission_required("users_groups_add") +def role_add(request): + + if request.method == "POST": + + form = RoleForm( + request.POST + ) + + if form.is_valid(): + + form.save() + + return redirect( + "accounts:overview" + ) + + else: + + form = RoleForm() + + + return render( + request, + "accounts/role_add.html", + { + "form": form + } + ) \ No newline at end of file diff --git a/change.py b/change.py new file mode 100644 index 0000000..c1b89bc --- /dev/null +++ b/change.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 + +import re +from pathlib import Path + +ROOT = Path("/opt/projects") + +EXCLUDE_DIRS = { + "venv", + "__pycache__", + ".git", + "node_modules", + "staticfiles", + "media", +} + +CLASS_RE = re.compile(r'class="([^"]*)"') + + +for path in ROOT.rglob("*"): + + if not path.is_file(): + continue + + if any(part in EXCLUDE_DIRS for part in path.parts): + continue + + if path.suffix not in {".html", ".htm"}: + continue + + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + + + changed = False + + + def replace(match): + classes = match.group(1).split() + + has_btn = "btn" in classes + has_btn_variant = any( + c.startswith("btn-") + for c in classes + ) + + if has_btn_variant and not has_btn: + classes.insert(0, "btn") + return f'class="{" ".join(classes)}"' + + return match.group(0) + + + new_text = CLASS_RE.sub(replace, text) + + + if new_text != text: + path.write_text( + new_text, + encoding="utf-8" + ) + + print(f"✔ geändert: {path}") \ No newline at end of file diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..ffbb5f5 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..1bd0ec6 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,132 @@ +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-a*^y8tl19j6+i9029ye&n1nhyaze(sd&!drt1++kfq_lok)5o@' +DEBUG = True +ALLOWED_HOSTS = ["91.99.235.6", "localhost"] + + +# Application definition +INSTALLED_APPS = [ + 'accounts', + 'core', + 'projects', + 'finance', + 'inventory', + 'documents', + 'invoices', + 'masterdata.apps.MasterdataConfig', + 'time_tracking', + 'tailwind', + 'theme', + "django_tables2", + "django_filters", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + "core.context_processors.company_settings", + "core.context_processors.project_switcher", + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/6.0/topics/i18n/ +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.0/howto/static-files/ +STATIC_URL = 'static/' + + + +# TAILWIND +TAILWIND_APP_NAME = 'theme' + + +# ACCOUNTS +LOGIN_URL = "accounts:login" +LOGIN_REDIRECT_URL = "core:dashboard" +LOGOUT_REDIRECT_URL = "accounts:login" +#AUTH_USER_MODEL = "accounts.User" + +# MEDIEN +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + + + +if DEBUG: + INSTALLED_APPS += ["django_browser_reload"] + MIDDLEWARE += [ + "django_browser_reload.middleware.BrowserReloadMiddleware", + ] \ No newline at end of file diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..45e62dc --- /dev/null +++ b/config/urls.py @@ -0,0 +1,22 @@ +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static + +urlpatterns = [ + path("", include("core.urls")), + path("admin/", admin.site.urls), + path("accounts/", include("accounts.urls")), + path("projects/", include("projects.urls")), + path("projects/", include("finance.urls")), + path("projects/", include("documents.urls")), + path("projects/", include("time_tracking.urls")), + path("projects/", include("inventory.urls")), + path("inventory/", include("inventory.urls_global")), + path("invoices/", include("invoices.urls")), + path("settings/", include("masterdata.urls")), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL,document_root=settings.MEDIA_ROOT) + urlpatterns += [path("__reload__/", include("django_browser_reload.urls")),] \ No newline at end of file diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..4ced574 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/admin.py b/core/admin.py new file mode 100644 index 0000000..0fcb367 --- /dev/null +++ b/core/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from .models import CompanySetting + + +@admin.register(CompanySetting) +class CompanySettingAdmin(admin.ModelAdmin): + + list_display = ( + "name", + "city", + "updated_at", + ) \ No newline at end of file diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 0000000..26f78a8 --- /dev/null +++ b/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = 'core' diff --git a/core/context_processors.py b/core/context_processors.py new file mode 100644 index 0000000..bf9f1bd --- /dev/null +++ b/core/context_processors.py @@ -0,0 +1,21 @@ +from projects.models import ProjectMembership +from .models import CompanySetting + +def project_switcher(request): + if not request.user.is_authenticated: + return {"user_projects": []} + + memberships = ProjectMembership.objects.select_related("project").filter( + user=request.user, + project__active=True + ) + + return { + "user_projects": [m.project for m in memberships] + } + + +def company_settings(request): + return { + "company_settings": CompanySetting.objects.first() + } \ No newline at end of file diff --git a/core/forms/base.py b/core/forms/base.py new file mode 100644 index 0000000..193a9e7 --- /dev/null +++ b/core/forms/base.py @@ -0,0 +1,16 @@ +from django import forms + + +class BaseForm(forms.ModelForm): + + default_class = ( + "w-full px-4 py-3 rounded-lg border border-slate-300 dark:border-slate-600 " + "bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white " + "placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 " + "focus:border-transparent transition" + ) + + def apply_style(self): + for field_name, field in self.fields.items(): + existing = field.widget.attrs.get("class", "") + field.widget.attrs["class"] = f"{self.default_class} {existing}".strip() \ No newline at end of file diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..3a0fd31 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 6.0.6 on 2026-06-23 22:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CompanySetting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('companyname', models.CharField(max_length=255, verbose_name='Firmenname')), + ('name', models.CharField(max_length=255, verbose_name='Geschäftsführer')), + ('logo', models.ImageField(blank=True, null=True, upload_to='company/', verbose_name='Logo')), + ('slogan', models.CharField(blank=True, max_length=255, verbose_name='Slogan')), + ('color', models.CharField(default='#0f172a', max_length=20, verbose_name='Farbe')), + ('street', models.CharField(max_length=255, verbose_name='Straße')), + ('zip', models.CharField(max_length=20, verbose_name='PLZ')), + ('city', models.CharField(max_length=255, verbose_name='Ort')), + ('country', models.CharField(default='Deutschland', max_length=100, verbose_name='Land')), + ('phone', models.CharField(blank=True, max_length=100, verbose_name='Telefon')), + ('email', models.EmailField(blank=True, max_length=100, verbose_name='E-Mail')), + ('website', models.URLField(blank=True, max_length=100, verbose_name='Webseite')), + ('tax_number', models.CharField(blank=True, max_length=100, verbose_name='Steuernummer')), + ('vat_number', models.CharField(blank=True, max_length=100, verbose_name='USt-IdNr.')), + ('is_small_business', models.BooleanField(default=False, verbose_name='Kleinunternehmer (§19 UStG)')), + ('small_business_text', models.TextField(blank=True, verbose_name='Kleinunternehmer Hinweis')), + ('invoice_header_text', models.TextField(blank=True, verbose_name='Rechnungszusatztext')), + ('bank_name', models.CharField(blank=True, max_length=255, verbose_name='Bank')), + ('iban', models.CharField(blank=True, max_length=50, verbose_name='IBAN')), + ('bic', models.CharField(blank=True, max_length=50, verbose_name='BIC')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Firmeneinstellung', + 'verbose_name_plural': 'Firmeneinstellungen', + }, + ), + ] diff --git a/core/migrations/0002_companysetting_invoice_number_documentnumbersettings.py b/core/migrations/0002_companysetting_invoice_number_documentnumbersettings.py new file mode 100644 index 0000000..a0a8ff8 --- /dev/null +++ b/core/migrations/0002_companysetting_invoice_number_documentnumbersettings.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0.6 on 2026-06-24 09:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='companysetting', + name='invoice_number', + field=models.CharField(blank=True, max_length=50, verbose_name='BIC'), + ), + migrations.CreateModel( + name='DocumentNumberSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('invoice_prefix', models.CharField(default='RE', max_length=20)), + ('invoice_next_number', models.PositiveIntegerField(default=1)), + ('cancel_prefix', models.CharField(default='ST', max_length=20)), + ('cancel_next_number', models.PositiveIntegerField(default=1)), + ('offer_prefix', models.CharField(default='ANG', max_length=20)), + ('offer_next_number', models.PositiveIntegerField(default=1)), + ('company', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='number_settings', to='core.companysetting')), + ], + ), + ] diff --git a/core/migrations/0003_delete_documentnumbersettings.py b/core/migrations/0003_delete_documentnumbersettings.py new file mode 100644 index 0000000..e1c0e10 --- /dev/null +++ b/core/migrations/0003_delete_documentnumbersettings.py @@ -0,0 +1,16 @@ +# Generated by Django 6.0.6 on 2026-06-24 09:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_companysetting_invoice_number_documentnumbersettings'), + ] + + operations = [ + migrations.DeleteModel( + name='DocumentNumberSettings', + ), + ] diff --git a/core/migrations/0004_companysetting_offer_header_text.py b/core/migrations/0004_companysetting_offer_header_text.py new file mode 100644 index 0000000..af87337 --- /dev/null +++ b/core/migrations/0004_companysetting_offer_header_text.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-25 14:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_delete_documentnumbersettings'), + ] + + operations = [ + migrations.AddField( + model_name='companysetting', + name='offer_header_text', + field=models.TextField(blank=True, verbose_name='Angebotszusatztext'), + ), + ] diff --git a/core/migrations/0005_remove_companysetting_invoice_number.py b/core/migrations/0005_remove_companysetting_invoice_number.py new file mode 100644 index 0000000..f12d3f7 --- /dev/null +++ b/core/migrations/0005_remove_companysetting_invoice_number.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.6 on 2026-06-26 06:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_companysetting_offer_header_text'), + ] + + operations = [ + migrations.RemoveField( + model_name='companysetting', + name='invoice_number', + ), + ] diff --git a/core/migrations/0006_companysetting_reminder_header_text.py b/core/migrations/0006_companysetting_reminder_header_text.py new file mode 100644 index 0000000..f30fb5e --- /dev/null +++ b/core/migrations/0006_companysetting_reminder_header_text.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-27 08:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0005_remove_companysetting_invoice_number'), + ] + + operations = [ + migrations.AddField( + model_name='companysetting', + name='reminder_header_text', + field=models.TextField(blank=True, verbose_name='Mahnungszusatztext'), + ), + ] diff --git a/core/migrations/__init__.py b/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..3b1f118 --- /dev/null +++ b/core/models.py @@ -0,0 +1,41 @@ +from django.db import models + + +class CompanySetting(models.Model): + companyname = models.CharField(max_length=255,verbose_name="Firmenname") + name = models.CharField(max_length=255,verbose_name="Geschäftsführer") + logo = models.ImageField(upload_to="company/",blank=True,null=True,verbose_name="Logo") + slogan = models.CharField(max_length=255,blank=True,verbose_name="Slogan") + color = models.CharField(max_length=20,default="#0f172a",verbose_name="Farbe") + street = models.CharField(max_length=255,verbose_name="Straße") + zip = models.CharField(max_length=20,verbose_name="PLZ") + city = models.CharField(max_length=255,verbose_name="Ort") + country = models.CharField(max_length=100,default="Deutschland",verbose_name="Land") + phone = models.CharField(max_length=100,blank=True,verbose_name="Telefon" ) + email = models.EmailField(max_length=100,blank=True,verbose_name="E-Mail") + website = models.URLField(max_length=100,blank=True,verbose_name="Webseite") + + tax_number = models.CharField(max_length=100,blank=True,verbose_name="Steuernummer") + vat_number = models.CharField(max_length=100,blank=True,verbose_name="USt-IdNr.") + + is_small_business = models.BooleanField(default=False,verbose_name="Kleinunternehmer (§19 UStG)") + small_business_text = models.TextField(blank=True,verbose_name="Kleinunternehmer Hinweis") + invoice_header_text = models.TextField(blank=True,verbose_name="Rechnungszusatztext") + offer_header_text = models.TextField(blank=True,verbose_name="Angebotszusatztext") + reminder_header_text = models.TextField(blank=True,verbose_name="Mahnungszusatztext") + + bank_name = models.CharField(max_length=255,blank=True,verbose_name="Bank") + iban = models.CharField(max_length=50,blank=True,verbose_name="IBAN") + bic = models.CharField(max_length=50,blank=True,verbose_name="BIC") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Firmeneinstellung" + verbose_name_plural = "Firmeneinstellungen" + + + def __str__(self): + return self.name + diff --git a/core/settings_registry.py b/core/settings_registry.py new file mode 100644 index 0000000..c9b3ec8 --- /dev/null +++ b/core/settings_registry.py @@ -0,0 +1,10 @@ +from masterdata.registry import register_setting +from .models import CompanySetting + + +register_setting( + group="System", + name="Firmeneinstellungen", + model=CompanySetting, + setting_type="single", +) \ No newline at end of file diff --git a/core/templates/base.html b/core/templates/base.html new file mode 100644 index 0000000..97cfb1a --- /dev/null +++ b/core/templates/base.html @@ -0,0 +1,342 @@ +{% load static tailwind_tags %} + + + + + + + + + {% block title %} + Projektverwaltung + {% endblock %} + + + {% tailwind_css %} + + + + + +
+ + {% if user.is_authenticated %} + + + + + {% endif %} + + + {% if user.is_authenticated %} +
+ {% else %} +
+ {% endif %} +
+ + {{ company_settings.companyname|default:"Projektverwaltung" }} +
+
+
+

+ {% block header_title %} + {% endblock %} +

+

+ {% block header_description %} + {% endblock %} +

+
+ {% block content %} + {% endblock %} +
+
+
+ + + +{% if messages %} +
+ {% for message in messages %} +
+
+ {{ message }} +
+
+ {% endfor %} +
+ +{% endif %} + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/templates/core/dashboard.html b/core/templates/core/dashboard.html new file mode 100644 index 0000000..e9af484 --- /dev/null +++ b/core/templates/core/dashboard.html @@ -0,0 +1,114 @@ +{% extends "base.html" %} + +{% block title %}Dashboard{% endblock %} + +{% block header_title %} +Dashboard +{% endblock %} + +{% block header_description %} +Willkommen {{ user.username }} +{% endblock %} + +{% block content %} + + + + + + + + + + + + +
+
+
+
+

Aktionen

+
+ +
+ + Projekt +
+ +
+
+
+ +{% if memberships %} +
+ {% for m in memberships %} + +

+ {{ m.project.name }} +

+

+ Rolle: {{ m.get_role_display }} +

+
+ {% endfor %} +
+{% else %} +
+ Du bist noch keinem aktiven Projekt zugewiesen. +
+{% endif %} + +{% if archived_count %} + + {{ archived_count }} archivierte + {% if archived_count == 1 %} + Projekt anzeigen + {% else %} + Projekte anzeigen + {% endif %} + +{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/core/templates/django_tables2/table.html b/core/templates/django_tables2/table.html new file mode 100644 index 0000000..18242ef --- /dev/null +++ b/core/templates/django_tables2/table.html @@ -0,0 +1,47 @@ +{% load django_tables2 %} + + + + + + {% for column in table.columns %} + + {% endfor %} + + + + + + + {% for row in table.paginated_rows %} + + + + {% for column, cell in row.items %} + + + + {% endfor %} + + + + {% empty %} + + + + + + {% endfor %} + + + +
+ {{ column.header }} +
+ {{ cell }} +
+ + Keine Daten vorhanden + +
\ No newline at end of file diff --git a/core/templates/django_tables2/table_toggle.html b/core/templates/django_tables2/table_toggle.html new file mode 100644 index 0000000..915d10a --- /dev/null +++ b/core/templates/django_tables2/table_toggle.html @@ -0,0 +1,95 @@ +{% load django_tables2 %} + +
+ + + + + + + + {% for column in table.columns %} + + {% endfor %} + + + + + + + + {% for row in table.paginated_rows %} + + + + + + + {% for column, cell in row.items %} + + + + {% endfor %} + + + + + {% if table.detail_template %} + + + + + + + + {% endif %} + + + {% empty %} + + + + + + {% endfor %} + + + +
+ {{ column.header }} +
+ + + {{ cell }} +
+ Keine Daten vorhanden +
+ +
+ + + \ No newline at end of file diff --git a/core/tests.py b/core/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..b9184f2 --- /dev/null +++ b/core/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import dashboard + +app_name = "core" + +urlpatterns = [ + path("", dashboard, name="dashboard"), +] \ No newline at end of file diff --git a/core/views.py b/core/views.py new file mode 100644 index 0000000..b8d0d72 --- /dev/null +++ b/core/views.py @@ -0,0 +1,9 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import render +from projects.models import ProjectMembership + +@login_required +def dashboard(request): + memberships = ProjectMembership.objects.select_related("project").filter(user=request.user,project__active=True) + archived_count = ProjectMembership.objects.filter(user=request.user,project__active=False).count() + return render(request,"core/dashboard.html",{"memberships": memberships,"archived_count": archived_count,}) \ No newline at end of file diff --git a/documents/__init__.py b/documents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/documents/admin.py b/documents/admin.py new file mode 100644 index 0000000..b58eb6d --- /dev/null +++ b/documents/admin.py @@ -0,0 +1,46 @@ +from django.contrib import admin + +# Register your models here. +from django.contrib import admin + +from .models import DocumentCategory, Document + + +@admin.register(DocumentCategory) +class DocumentCategoryAdmin(admin.ModelAdmin): + list_display = ("name",) + search_fields = ("name",) + +@admin.register(Document) +class DocumentAdmin(admin.ModelAdmin): + list_display = ( + "title", + "project", + "category", + "uploaded_by", + "created_at", + ) + + list_filter = ( + "project", + "category", + "created_at", + ) + + search_fields = ( + "title", + ) + + readonly_fields = ( + "created_at", + ) + + autocomplete_fields = ( + "project", + "category", + "uploaded_by", + ) + + ordering = ( + "-created_at", + ) \ No newline at end of file diff --git a/documents/apps.py b/documents/apps.py new file mode 100644 index 0000000..93ca755 --- /dev/null +++ b/documents/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DocumentsConfig(AppConfig): + name = 'documents' diff --git a/documents/forms.py b/documents/forms.py new file mode 100644 index 0000000..749fb16 --- /dev/null +++ b/documents/forms.py @@ -0,0 +1,11 @@ +from .models import Document +from core.forms.base import BaseForm + +class DocumentForm(BaseForm): + class Meta: + model = Document + fields = ["title", "category", "file"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.apply_style() \ No newline at end of file diff --git a/documents/migrations/0001_initial.py b/documents/migrations/0001_initial.py new file mode 100644 index 0000000..0a6bc1e --- /dev/null +++ b/documents/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.6 on 2026-06-23 22:12 + +import documents.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Document', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Name')), + ('file', models.FileField(upload_to=documents.models.document_upload_path, verbose_name='Datei')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='DocumentCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120, unique=True)), + ], + options={ + 'verbose_name': 'Dokumentenkategorie', + 'verbose_name_plural': 'Dokumentenkategorien', + 'ordering': ['name'], + }, + ), + ] diff --git a/documents/migrations/0002_initial.py b/documents/migrations/0002_initial.py new file mode 100644 index 0000000..da3d76e --- /dev/null +++ b/documents/migrations/0002_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.6 on 2026-06-23 22:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('documents', '0001_initial'), + ('projects', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='document', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='projects.project'), + ), + migrations.AddField( + model_name='document', + name='uploaded_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='document', + name='category', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='documents.documentcategory', verbose_name='Kategorie'), + ), + ] diff --git a/documents/migrations/__init__.py b/documents/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/documents/models.py b/documents/models.py new file mode 100644 index 0000000..fbd221c --- /dev/null +++ b/documents/models.py @@ -0,0 +1,32 @@ +from django.db import models +from projects.models import Project +import os + +def document_upload_path(instance, filename): + return f"projects/{instance.project.id}/documents/{filename}" + +class DocumentCategory(models.Model): + name = models.CharField(max_length=120, unique=True) + + class Meta: + verbose_name = "Dokumentenkategorie" + verbose_name_plural = "Dokumentenkategorien" + ordering = ["name"] + + def __str__(self): + return self.name + +class Document(models.Model): + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="documents") + category = models.ForeignKey(DocumentCategory, on_delete=models.CASCADE, verbose_name="Kategorie") + title = models.CharField(max_length=255, verbose_name="Name") + file = models.FileField(upload_to=document_upload_path, verbose_name="Datei") + uploaded_by = models.ForeignKey("auth.User", on_delete=models.SET_NULL, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + @property + def extension(self): + return os.path.splitext(self.file.name)[1].lower() + + def __str__(self): + return self.title \ No newline at end of file diff --git a/documents/settings_registry.py b/documents/settings_registry.py new file mode 100644 index 0000000..f6e3b01 --- /dev/null +++ b/documents/settings_registry.py @@ -0,0 +1,12 @@ +from masterdata.registry import register_setting + +from .models import ( + DocumentCategory, +) + + +register_setting( + group="Dokumente", + name="Kategorien", + model=DocumentCategory, +) \ No newline at end of file diff --git a/documents/templates/documents/add.html b/documents/templates/documents/add.html new file mode 100644 index 0000000..ca6b807 --- /dev/null +++ b/documents/templates/documents/add.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %} +{{ project.name }} - Dokumente +{% endblock %} + +{% block header_title %} +Dokumente hochladen +{% endblock %} + +{% block header_description %} +Dokumente und Belege hochladen +{% endblock %} + +{% block content %} + +
+ +
+ {% csrf_token %} + +
+ {{ form.as_p }} +
+ + + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/documents/templates/documents/overview.html b/documents/templates/documents/overview.html new file mode 100644 index 0000000..98863f4 --- /dev/null +++ b/documents/templates/documents/overview.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} + +{% block title %} +{{ project.name }} - Dokumente +{% endblock %} + +{% block header_title %} +Dokumente +{% endblock %} + +{% block header_description %} +Dateien & Bilder +{% endblock %} + +{% block content %} +
+
+
+
+

Aktionen

+

Dokumente hochladen

+
+ + + +
+
+
+ + + + + + +{% for cat, docs in grouped.items %} + +
+ +
+

+ {{ cat.name }} +

+ + + {{ docs|length }} Dokumente + +
+ + {% if docs %} + +
+ + {% for d in docs %} + +
+ + + + + {% if d.extension == ".jpg" or d.extension == ".jpeg" or d.extension == ".png" or d.extension == ".webp" %} + +
+ +
+ + {% else %} + +
+
📄
+
Keine Vorschau verfügbar
+ +
+ + {% endif %} + +
+ + +
+

{{ d.title }}

+
Hochgeladen von {{ d.uploaded_by }}
+
{{ d.created_at|date:"d.m.Y H:i" }}
+
+
+ {% csrf_token %} + +
+
+
+
+ {% endfor %} + +
+ + {% else %} +
+ Keine Dokumente vorhanden. +
+ + {% endif %} + +
+ +{% empty %} +
+ Noch keine Kategorien vorhanden. +
+{% endfor %} +{% endblock %} \ No newline at end of file diff --git a/documents/tests.py b/documents/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/documents/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/documents/urls.py b/documents/urls.py new file mode 100644 index 0000000..6b96283 --- /dev/null +++ b/documents/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import views + +app_name = "documents" + +urlpatterns = [ + path("/documents/", views.document_overview, name="overview"), + path("/documents/delete//", views.document_delete, name="delete"), + path("/documents/add/", views.document_add, name="add"), +] \ No newline at end of file diff --git a/documents/views.py b/documents/views.py new file mode 100644 index 0000000..6158ba5 --- /dev/null +++ b/documents/views.py @@ -0,0 +1,88 @@ +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib.auth.decorators import login_required +from accounts.decorators import permission_required +from projects.models import ProjectMembership +from .models import Document, DocumentCategory +from .forms import DocumentForm + + +@login_required +@permission_required("projects_documents_view") +def document_overview(request, project_id): + + membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id) + project = membership.project + + documents = Document.objects.filter(project=project).order_by("-created_at") + categories = DocumentCategory.objects.all() + + form = DocumentForm() + + if request.method == "POST": + form = DocumentForm(request.POST, request.FILES) + if form.is_valid(): + doc = form.save(commit=False) + doc.project = project + doc.uploaded_by = request.user + doc.save() + return redirect("documents:overview", project.id) + + # gruppiert nach Kategorie + grouped = {} + + for cat in categories: + docs = list(documents.filter(category=cat)) + + if docs: + grouped[cat] = docs + + uncategorized = documents.filter(category__isnull=True) + + return render(request, "documents/overview.html", { + "project": project, + "form": form, + "grouped": grouped, + "uncategorized": uncategorized, + }) + + +@login_required +@permission_required("projects_documents_delete") +def document_delete(request, project_id, doc_id): + membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id) + doc = get_object_or_404(Document, id=doc_id, project=membership.project) + doc.file.delete() + doc.delete() + return redirect("documents:overview", project_id) + + +@login_required +@permission_required("projects_documents_add") +def document_add(request, project_id): + membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id) + project = membership.project + + documents = Document.objects.filter(project=project).order_by("-created_at") + categories = DocumentCategory.objects.all() + + form = DocumentForm() + + if request.method == "POST": + form = DocumentForm(request.POST, request.FILES) + if form.is_valid(): + doc = form.save(commit=False) + doc.project = project + doc.uploaded_by = request.user + doc.save() + return redirect("documents:overview", project.id) + else: + form = DocumentForm() + + return render( + request, + "documents/add.html", + { + "project": project, + "form": form, + } + ) \ No newline at end of file diff --git a/finance/__init__.py b/finance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/finance/admin.py b/finance/admin.py new file mode 100644 index 0000000..e8e3319 --- /dev/null +++ b/finance/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin +from .models import Category, Supplier, PaymentMethod, Expense, Income + + +admin.site.register(Category) +admin.site.register(Supplier) +admin.site.register(PaymentMethod) + + +@admin.register(Expense) +class ExpenseAdmin(admin.ModelAdmin): + list_display = ("amount", "project", "category", "date", "created_by") + list_filter = ("project", "category", "date") + search_fields = ( + "name", + "project__name", + ) + + +@admin.register(Income) +class IncomeAdmin(admin.ModelAdmin): + list_display = ("amount", "project", "category", "date", "created_by") + list_filter = ("project", "category", "date") \ No newline at end of file diff --git a/finance/apps.py b/finance/apps.py new file mode 100644 index 0000000..06859cb --- /dev/null +++ b/finance/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FinanceConfig(AppConfig): + name = 'finance' diff --git a/finance/forms.py b/finance/forms.py new file mode 100644 index 0000000..3636120 --- /dev/null +++ b/finance/forms.py @@ -0,0 +1,38 @@ +from django import forms +from .models import Income, Expense +from core.forms.base import BaseForm +from django.utils import timezone + +class IncomeForm(BaseForm): + class Meta: + model = Income + fields = ["name", "amount", "date", "category", "payment_method", "note"] + widgets = { + "date": forms.DateTimeInput(attrs={"type": "datetime-local"}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if not self.instance.pk: + self.fields["date"].initial = timezone.now() + + self.apply_style() + + +class ExpenseForm(BaseForm): + class Meta: + model = Expense + fields = ["name", "amount", "date", "category", "supplier", "payment_method", "receipt", "note"] + + widgets = { + "date": forms.DateTimeInput(attrs={"type": "datetime-local"}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if not self.instance.pk: + self.fields["date"].initial = timezone.now() + + self.apply_style() diff --git a/finance/migrations/0001_initial.py b/finance/migrations/0001_initial.py new file mode 100644 index 0000000..1a55e95 --- /dev/null +++ b/finance/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# Generated by Django 6.0.6 on 2026-06-23 22:12 + +import django.db.models.deletion +import finance.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ], + options={ + 'verbose_name': 'Finanzkategorie', + 'verbose_name_plural': 'Finanzkategorien', + }, + ), + migrations.CreateModel( + name='Income', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, null=True, verbose_name='Name')), + ('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Betrag (€)')), + ('date', models.DateTimeField(verbose_name='Datum')), + ('note', models.TextField(blank=True, verbose_name='Notiz')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')), + ], + ), + migrations.CreateModel( + name='PaymentMethod', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ], + options={ + 'verbose_name': 'Zahlungsmethode', + 'verbose_name_plural': 'Zahlungsmethoden', + }, + ), + migrations.CreateModel( + name='Supplier', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=150)), + ], + options={ + 'verbose_name': 'Lieferant', + 'verbose_name_plural': 'Lieferanten', + }, + ), + migrations.CreateModel( + name='Expense', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Betrag (€)')), + ('date', models.DateTimeField(verbose_name='Datum')), + ('receipt', models.FileField(blank=True, null=True, upload_to=finance.models.receipt_upload_path, verbose_name='Beleg')), + ('note', models.TextField(blank=True, verbose_name='Notiz')), + ('invoiced', models.BooleanField(default=False, verbose_name='Abgerechnet')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')), + ('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.category', verbose_name='Kategorie')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')), + ], + ), + ] diff --git a/finance/migrations/0002_initial.py b/finance/migrations/0002_initial.py new file mode 100644 index 0000000..f5a284d --- /dev/null +++ b/finance/migrations/0002_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 6.0.6 on 2026-06-23 22:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('finance', '0001_initial'), + ('projects', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='expense', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expenses', to='projects.project', verbose_name='Projekt'), + ), + migrations.AddField( + model_name='income', + name='category', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.category', verbose_name='Kategorie'), + ), + migrations.AddField( + model_name='income', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von'), + ), + migrations.AddField( + model_name='income', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incomes', to='projects.project', verbose_name='Projekt'), + ), + migrations.AddField( + model_name='income', + name='payment_method', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.paymentmethod', verbose_name='Zahlungsmethode'), + ), + migrations.AddField( + model_name='expense', + name='payment_method', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.paymentmethod', verbose_name='Zahlungsmethode'), + ), + migrations.AddField( + model_name='expense', + name='supplier', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.supplier', verbose_name='Lieferant'), + ), + ] diff --git a/finance/migrations/__init__.py b/finance/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/finance/models.py b/finance/models.py new file mode 100644 index 0000000..5a5e41f --- /dev/null +++ b/finance/models.py @@ -0,0 +1,65 @@ +from django.db import models +from django.contrib.auth import get_user_model +from projects.models import Project +import os + +User = get_user_model() + +def receipt_upload_path(instance, filename): + return f"projects/{instance.project.id}/receipts/{filename}" + +class Category(models.Model): + name = models.CharField(max_length=100) + class Meta: + verbose_name = "Finanzkategorie" + verbose_name_plural = "Finanzkategorien" + + def __str__(self): + return self.name + +class Supplier(models.Model): + name = models.CharField(max_length=150) + class Meta: + verbose_name = "Lieferant" + verbose_name_plural = "Lieferanten" + + def __str__(self): + return self.name + +class PaymentMethod(models.Model): + name = models.CharField(max_length=50) + class Meta: + verbose_name = "Zahlungsmethode" + verbose_name_plural = "Zahlungsmethoden" + + def __str__(self): + return self.name + +class Expense(models.Model): + name = models.CharField(max_length=255, verbose_name="Name") + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="expenses", verbose_name="Projekt") + category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, verbose_name="Kategorie") + supplier = models.ForeignKey(Supplier, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Lieferant") + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="Erstellt von") + payment_method = models.ForeignKey(PaymentMethod, on_delete=models.SET_NULL, null=True, verbose_name="Zahlungsmethode") + amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Betrag (€)") + date = models.DateTimeField(verbose_name="Datum") + receipt = models.FileField(upload_to=receipt_upload_path, null=True, blank=True, verbose_name="Beleg") + note = models.TextField(blank=True, verbose_name="Notiz") + invoiced = models.BooleanField(default=False,verbose_name="Abgerechnet") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") + def __str__(self): + return f"{self.name} - {self.amount} €" + +class Income(models.Model): + name = models.CharField(max_length=255, verbose_name="Name", null=True) + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="incomes", verbose_name="Projekt") + category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, verbose_name="Kategorie") + amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Betrag (€)") + date = models.DateTimeField(verbose_name="Datum") + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="Erstellt von") + payment_method = models.ForeignKey(PaymentMethod, on_delete=models.SET_NULL, null=True, verbose_name="Zahlungsmethode") + note = models.TextField(blank=True, verbose_name="Notiz") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") + def __str__(self): + return f"+{self.amount} € - {self.project.name}" \ No newline at end of file diff --git a/finance/settings_registry.py b/finance/settings_registry.py new file mode 100644 index 0000000..fa2b523 --- /dev/null +++ b/finance/settings_registry.py @@ -0,0 +1,26 @@ +from masterdata.registry import register_setting + +from .models import ( + Category, + Supplier, + PaymentMethod, +) + + +register_setting( + group="Finanzen", + name="Kategorien", + model=Category, +) + +register_setting( + group="Finanzen", + name="Lieferanten", + model=Supplier, +) + +register_setting( + group="Finanzen", + name="Zahlungsarten", + model=PaymentMethod, +) \ No newline at end of file diff --git a/finance/tables.py b/finance/tables.py new file mode 100644 index 0000000..16191d4 --- /dev/null +++ b/finance/tables.py @@ -0,0 +1,163 @@ +import django_tables2 as tables +from django.utils.html import format_html + +class PeriodAnalyticsTable(tables.Table): + period = tables.Column(verbose_name="Zeitraum") + income = tables.Column(verbose_name="Einnahmen") + expense = tables.Column(verbose_name="Ausgaben") + profit = tables.Column(verbose_name="Gewinn") + + actions = tables.TemplateColumn( + template_name="finance/tables/period_actions.html", + verbose_name="Aktionen", + orderable=False, + ) + + class Meta: + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + template_name = "django_tables2/table.html" + fields = ( + "period", + "income", + "expense", + "profit", + "actions", + ) + + def __init__(self, *args, period_type="day", **kwargs): + self.period_type = period_type + super().__init__(*args, **kwargs) + + def render_period(self, value): + if self.period_type == "year": + return value.strftime("%Y") + + if self.period_type == "month": + return value.strftime("%m / %Y") # z.B. 06.2026 + + if self.period_type == "week": + week = value.isocalendar().week + return f"KW {week:02d} / {value.year}" + + return value.strftime("%d.%m.%Y") + + def render_income(self, value): + return f"{value:.2f} €" + + def render_expense(self, value): + return f"{value:.2f} €" + + def render_profit(self, value): + return f"{value:.2f} €" + + + + + + + + + + + + + + + +class FinanceTransactionTable(tables.Table): + + type = tables.Column( + verbose_name="Typ" + ) + + name = tables.Column( + verbose_name="Name" + ) + + amount = tables.Column( + verbose_name="Betrag" + ) + + date = tables.DateColumn( + verbose_name="Datum", + format="d.m.Y" + ) + + category = tables.Column( + verbose_name="Kategorie" + ) + + created_by = tables.Column( + verbose_name="Erstellt von" + ) + + payment_method = tables.Column( + verbose_name="Zahlungsmethode" + ) + + supplier = tables.Column( + verbose_name="Lieferant" + ) + + receipt = tables.TemplateColumn( + template_name="finance/tables/receipt.html", + verbose_name="Beleg", + orderable=False, + ) + + note = tables.Column( + verbose_name="Notiz" + ) + + class Meta: + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + template_name = "django_tables2/table.html" + fields = ( + "type", + "name", + "amount", + "date", + "category", + "created_by", + "payment_method", + "supplier", + "receipt", + "note", + ) + + + + def render_type(self, value): + color = "text-green-600" if value == "Einnahme" else "text-red-600" + return format_html( + '{}', + color, + value, + ) + + def render_amount(self, value): + if value is None: + return "/" + return f"{value:.2f} €" + + def render_name(self, value): + return value or "/" + + def render_category(self, value): + return value or "/" + + def render_created_by(self, value): + return value or "/" + + def render_payment_method(self, value): + return value or "/" + + def render_supplier(self, value): + return value or "/" + + def render_note(self, value): + return value or "/" \ No newline at end of file diff --git a/finance/templates/finance/form.html b/finance/templates/finance/form.html new file mode 100644 index 0000000..1b927f0 --- /dev/null +++ b/finance/templates/finance/form.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block title %} +{{ project.name }} - Finanzen +{% endblock %} + +{% block header_title %} +Finanzen tracken +{% endblock %} + +{% block header_description %} +Einnahme oder Ausgabe hinzufügen +{% endblock %} + +{% block content %} + +
+ +
+ {% csrf_token %} + + {{ form.as_p }} + + + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/finance/templates/finance/overview.html b/finance/templates/finance/overview.html new file mode 100644 index 0000000..08dd5e0 --- /dev/null +++ b/finance/templates/finance/overview.html @@ -0,0 +1,206 @@ +{% extends "base.html" %} + +{% block title %} +{{ project.name }} - Finanzen +{% endblock %} + +{% block header_title %} +Finanzen +{% endblock %} + +{% block header_description %} +Einnahmen & Ausgaben +{% endblock %} + + +{% block content %} +{% load django_tables2 %} + +
+
+
+
+

Aktionen

+

Einnahmen und Ausgaben tracken

+
+ + + +
+
+
+ +
+ + + +
+ +
+

Filter

+

Zeitraum für aktuelle Auswertung

+
+ +
+ +
+ + +
+ +
+ + +
+ + + +
+ +
+ + + + + +
+ +
+

Einnahmen

+

+ {{ total_income }} € +

+
+ +
+

Ausgaben

+

+ {{ total_expense }} € +

+
+ +
+

Gewinn

+

+ {{ total_profit }} € +

+
+ +
+ + + + + +
+ + +
+ +
+

Einnahmen

+
+ +
+ + {% for i in incomes %} +
+
+

+{{ i.amount }} € {{ i.name }}

+

{{ i.date|date:"d.m.Y" }} · {{ i.category }}

+
+
+ {% empty %} +

Keine Einnahmen

+ {% endfor %} + +
+ +
+ + + +
+ +
+

Ausgaben

+
+ +
+ + {% for e in expenses %} +
+
+

-{{ e.amount }} € {{ e.name }}

+

{{ e.date|date:"d.m.Y" }} · {{ e.category }}

+
+
+ {% empty %} +

Keine Ausgaben

+ {% endfor %} + +
+ +
+ +
+ +
+ + +
+ +
+

+ Zeitraum-Analyse +

+ +

+ Aggregierte Auswertung nach Zeitraum +

+
+ + + + + +
+ + {% render_table table %} + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/finance/templates/finance/tables/period_actions.html b/finance/templates/finance/tables/period_actions.html new file mode 100644 index 0000000..572d477 --- /dev/null +++ b/finance/templates/finance/tables/period_actions.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/finance/templates/finance/tables/receipt.html b/finance/templates/finance/tables/receipt.html new file mode 100644 index 0000000..14d2e62 --- /dev/null +++ b/finance/templates/finance/tables/receipt.html @@ -0,0 +1,9 @@ +{% if record.receipt %} + + Download + +{% else %} +— +{% endif %} \ No newline at end of file diff --git a/finance/templates/finance/view.html b/finance/templates/finance/view.html new file mode 100644 index 0000000..7ea96bf --- /dev/null +++ b/finance/templates/finance/view.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block title %}{{ project.name }} - Finanzen{% endblock %} + +{% block header_title %} +Finanzen Details +{% endblock %} + +{% block header_description %} +Zeitraum: {{ start_date|date:"d.m.Y" }} → {{ end_date|date:"d.m.Y" }} +{% endblock %} + +{% block content %} +{% load django_tables2 %} + +
+
{% render_table table %}
+
+ +{% endblock %} \ No newline at end of file diff --git a/finance/tests.py b/finance/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/finance/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/finance/urls.py b/finance/urls.py new file mode 100644 index 0000000..1298f4d --- /dev/null +++ b/finance/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from .views import finance_overview, finance_view, add_income, add_expense + +app_name = "finance" + +urlpatterns = [ + path("/finance/", finance_overview, name="overview"), + path("/finance/view/", finance_view, name="view"), + path("/finance/add-income/", add_income, name="add_income"), + path("/finance/add-expense/", add_expense, name="add_expense"), +] \ No newline at end of file diff --git a/finance/views.py b/finance/views.py new file mode 100644 index 0000000..375acc8 --- /dev/null +++ b/finance/views.py @@ -0,0 +1,331 @@ +from django.contrib.auth.decorators import login_required +from accounts.decorators import permission_required +from django.shortcuts import render, get_object_or_404, redirect +from django.db.models.functions import TruncMonth, TruncYear, TruncWeek, TruncDay +from django.db.models import Sum +from datetime import date, datetime, timedelta +from projects.models import ProjectMembership +from .models import Income, Expense +import csv +from django.http import HttpResponse +from calendar import monthrange +from .forms import IncomeForm, ExpenseForm +from .tables import PeriodAnalyticsTable, FinanceTransactionTable + +# ========================================================= +# OVERVIEW +# ========================================================= +@login_required +@permission_required("projects_finance_view") +def finance_overview(request, project_id): + + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + # ------------------------- + # DATE FILTER (for KPI ONLY) + # ------------------------- + start = request.GET.get("start") + end = request.GET.get("end") + + if not start or not end: + start_date = end_date = date.today() + else: + start_date = datetime.strptime(start, "%Y-%m-%d").date() + end_date = datetime.strptime(end, "%Y-%m-%d").date() + + # ------------------------- + # KPI DATA (FILTERED) + # ------------------------- + filtered_incomes = Income.objects.filter( + project=project, + date__date__range=[start_date, end_date] + ) + + filtered_expenses = Expense.objects.filter( + project=project, + date__date__range=[start_date, end_date] + ) + + total_income = sum(i.amount for i in filtered_incomes) + total_expense = sum(e.amount for e in filtered_expenses) + total_profit = total_income - total_expense + + incomes = Income.objects.filter( + project=project, + date__date__range=[start_date, end_date] + ).order_by("-date") + + expenses = Expense.objects.filter( + project=project, + date__date__range=[start_date, end_date] + ).order_by("-date") + + # ------------------------- + # PERIOD VIEW + # ------------------------- + period = request.GET.get("period", "year") + + if period == "year": + trunc = TruncYear("date") + elif period == "week": + trunc = TruncWeek("date") + elif period == "day": + trunc = TruncDay("date") + else: + trunc = TruncMonth("date") + + income_stats = ( + Income.objects.filter(project=project) + .annotate(period_key=trunc) + .values("period_key") + .annotate(total=Sum("amount")) + .order_by("-period_key") + ) + + expense_stats = ( + Expense.objects.filter(project=project) + .annotate(period_key=trunc) + .values("period_key") + .annotate(total=Sum("amount")) + .order_by("-period_key") + ) + + expense_map = { + str(e["period_key"]): e["total"] + for e in expense_stats + } + + period_data = [] + + for i in income_stats: + period_start = i["period_key"] + income_total = i["total"] or 0 + expense_total = expense_map.get(str(period_start), 0) + + if period == "year": + period_end = period_start.replace(month=12, day=31) + + elif period == "week": + period_end = period_start + timedelta(days=6) + + elif period == "day": + period_end = period_start + + else: + last_day = monthrange(period_start.year, period_start.month)[1] + period_end = period_start.replace(day=last_day) + + period_data.append({ + "period": period_start, + "period_start": period_start, + "period_end": period_end, + "income": income_total, + "expense": expense_total, + "profit": income_total - expense_total, + }) + + # ------------------------- + # RETURN + # ------------------------- + + table = PeriodAnalyticsTable( + period_data, + period_type=period, + request=request, + ) + + return render(request, "finance/overview.html", { + "project": project, + + # KPI (filtered) + "total_income": total_income, + "total_expense": total_expense, + "total_profit": total_profit, + + # LISTS (all data) + "incomes": incomes, + "expenses": expenses, + + # PERIOD + "period_data": period_data, + "period": period, + + # UI + "start_date": start_date, + "end_date": end_date, + + "table": table, + }) + + +@login_required +@permission_required("projects_finance_view") +def finance_view(request, project_id): + + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + start = request.GET.get("start") + end = request.GET.get("end") + + incomes = Income.objects.filter(project=project) + expenses = Expense.objects.filter(project=project) + + if start: + try: + start_date = datetime.strptime(start, "%Y-%m-%d").date() + incomes = incomes.filter(date__date__gte=start_date) + expenses = expenses.filter(date__date__gte=start_date) + except ValueError: + pass + + if end: + try: + end_date = datetime.strptime(end, "%Y-%m-%d").date() + incomes = incomes.filter(date__date__lte=end_date) + expenses = expenses.filter(date__date__lte=end_date) + except ValueError: + pass + + # ------------------------- + # MERGED LIST (LIKE EXPORT ORDER) + # ------------------------- + transactions = [] + + for i in incomes: + transactions.append({ + "type": "Einnahme", + "date": i.date, + "amount": i.amount, + "category": i.category, + "name": i.name, + "created_by": i.created_by, + "note": i.note, + }) + + for e in expenses: + transactions.append({ + "type": "Ausgabe", + "date": e.date, + "amount": e.amount, + "category": e.category, + "name": e.name, + "created_by": e.created_by, + "note": e.note, + "supplier": e.supplier, + "payment_method": e.payment_method, + "receipt": e.receipt, + }) + + transactions.sort( + key=lambda x: x["date"], + reverse=True, + ) + + table = FinanceTransactionTable( + transactions, + request=request, + ) + + return render( + request, + "finance/view.html", + { + "project": project, + "table": table, + "start_date": start_date, + "end_date": end_date, + }, + ) + + + + + + + + + + + + + +# ========================= +# INCOME CREATE +# ========================= +@login_required +@permission_required("projects_finance_income_add") +def add_income(request, project_id): + + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + if request.method == "POST": + form = IncomeForm(request.POST) + + if form.is_valid(): + income = form.save(commit=False) + income.project = project + income.created_by = request.user + income.save() + return redirect("finance:overview", project_id=project.id) + + else: + form = IncomeForm() + + return render(request, "finance/form.html", { + "form": form, + "project": project, + "type": "income", + }) + + +# ========================= +# EXPENSE CREATE +# ========================= +@login_required +@permission_required("projects_finance_expense_add") +def add_expense(request, project_id): + + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + if request.method == "POST": + form = ExpenseForm(request.POST, request.FILES) + + if form.is_valid(): + expense = form.save(commit=False) + expense.project = project + expense.created_by = request.user + expense.save() + return redirect("finance:overview", project_id=project.id) + + else: + form = ExpenseForm() + + return render(request, "finance/form.html", { + "form": form, + "project": project, + "type": "expense", + }) \ No newline at end of file diff --git a/inventory/__init__.py b/inventory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inventory/admin.py b/inventory/admin.py new file mode 100644 index 0000000..034a2c6 --- /dev/null +++ b/inventory/admin.py @@ -0,0 +1,83 @@ +from django.contrib import admin + +from .models import ( + InventoryCategory, + InventoryLocation, + InventoryItem, + ProjectInventoryItem, + ShoppingItem, +) + +@admin.register(InventoryCategory) +class InventoryCategoryAdmin(admin.ModelAdmin): + list_display = ("name",) + search_fields = ("name",) + + +@admin.register(InventoryLocation) +class InventoryLocationAdmin(admin.ModelAdmin): + list_display = ("name",) + search_fields = ("name",) + + +@admin.register(InventoryItem) +class InventoryItemAdmin(admin.ModelAdmin): + list_display = ( + "name", + "quantity", + "category", + "location", + "responsible", + "article_number", + ) + list_filter = ( + "category", + "location", + ) + search_fields = ( + "name", + "article_number", + "description", + ) + + +@admin.register(ProjectInventoryItem) +class ProjectInventoryItemAdmin(admin.ModelAdmin): + list_display = ( + "project", + "item", + "quantity", + "status", + "created_at", + ) + list_filter = ( + "status", + "project", + ) + search_fields = ( + "project__name", + "item__name", + ) + + +@admin.register(ShoppingItem) +class ShoppingItemAdmin(admin.ModelAdmin): + list_display = ( + "name", + "project", + "quantity", + "price", + "category", + "supplier", + "created_at", + ) + list_filter = ( + "project", + "category", + "supplier", + ) + search_fields = ( + "name", + "description", + "project__name", + ) \ No newline at end of file diff --git a/inventory/apps.py b/inventory/apps.py new file mode 100644 index 0000000..532ae97 --- /dev/null +++ b/inventory/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class InventoryConfig(AppConfig): + name = 'inventory' diff --git a/inventory/forms.py b/inventory/forms.py new file mode 100644 index 0000000..fdbda6d --- /dev/null +++ b/inventory/forms.py @@ -0,0 +1,43 @@ +from django import forms + +from .models import InventoryItem, ShoppingItem +from core.forms.base import BaseForm + + +class InventoryItemForm(BaseForm): + + class Meta: + model = InventoryItem + fields = [ + "name", + "description", + "quantity", + "location", + "article_number", + "responsible", + "category", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.apply_style() + + +class ShoppingItemForm(BaseForm): + + class Meta: + model = ShoppingItem + + fields = [ + "name", + "description", + "quantity", + "supplier", + "price", + "category", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.apply_style() \ No newline at end of file diff --git a/inventory/migrations/0001_initial.py b/inventory/migrations/0001_initial.py new file mode 100644 index 0000000..a425efc --- /dev/null +++ b/inventory/migrations/0001_initial.py @@ -0,0 +1,90 @@ +# Generated by Django 6.0.6 on 2026-06-23 22:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='InventoryCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True, verbose_name='Name')), + ], + options={ + 'verbose_name': 'Inventarkategorie', + 'verbose_name_plural': 'Inventarkategorien', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='InventoryLocation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True, verbose_name='Name')), + ], + options={ + 'verbose_name': 'Inventarstandort', + 'verbose_name_plural': 'Inventarstandorte', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ShoppingItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('description', models.TextField(blank=True, verbose_name='Beschreibung')), + ('quantity', models.PositiveIntegerField(default=1, verbose_name='Anzahl')), + ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Preis')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Einkaufslistenartikel', + 'verbose_name_plural': 'Einkaufslistenartikel', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='InventoryItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('description', models.TextField(blank=True, verbose_name='Beschreibung')), + ('quantity', models.PositiveIntegerField(default=0, verbose_name='Anzahl')), + ('article_number', models.CharField(blank=True, max_length=255, verbose_name='Artikelnummer')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.inventorycategory', verbose_name='Kategorie')), + ('responsible', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Verantwortlicher')), + ('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.inventorylocation', verbose_name='Standort')), + ], + options={ + 'verbose_name': 'Inventarartikel', + 'verbose_name_plural': 'Inventarartikel', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='ProjectInventoryItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=1, verbose_name='Anzahl')), + ('status', models.CharField(choices=[('open', 'Offen'), ('packed', 'Eingepackt'), ('on_site', 'Vor Ort')], default='open', max_length=20, verbose_name='Status')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.inventoryitem', verbose_name='Artikel')), + ], + options={ + 'verbose_name': 'Projektartikel', + 'verbose_name_plural': 'Projektartikel', + }, + ), + ] diff --git a/inventory/migrations/0002_initial.py b/inventory/migrations/0002_initial.py new file mode 100644 index 0000000..bb89df3 --- /dev/null +++ b/inventory/migrations/0002_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 6.0.6 on 2026-06-23 22:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('finance', '0002_initial'), + ('inventory', '0001_initial'), + ('projects', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='projectinventoryitem', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='projects.project', verbose_name='Projekt'), + ), + migrations.AddField( + model_name='shoppingitem', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.category', verbose_name='Kategorie'), + ), + migrations.AddField( + model_name='shoppingitem', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shopping_items', to='projects.project', verbose_name='Projekt'), + ), + migrations.AddField( + model_name='shoppingitem', + name='supplier', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.supplier', verbose_name='Lieferant'), + ), + migrations.AlterUniqueTogether( + name='projectinventoryitem', + unique_together={('project', 'item')}, + ), + ] diff --git a/inventory/migrations/__init__.py b/inventory/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inventory/models.py b/inventory/models.py new file mode 100644 index 0000000..b55301e --- /dev/null +++ b/inventory/models.py @@ -0,0 +1,87 @@ +from django.db import models +from django.contrib.auth.models import User +from projects.models import Project +from finance.models import Category, Supplier +from django.db.models import Sum + +class InventoryCategory(models.Model): + name = models.CharField(max_length=255, unique=True, verbose_name="Name") + + class Meta: + verbose_name = "Inventarkategorie" + verbose_name_plural = "Inventarkategorien" + ordering = ["name"] + + def __str__(self): + return self.name + + +class InventoryLocation(models.Model): + name = models.CharField(max_length=255, unique=True, verbose_name="Name") + + class Meta: + verbose_name = "Inventarstandort" + verbose_name_plural = "Inventarstandorte" + ordering = ["name"] + + def __str__(self): + return self.name + + +class InventoryItem(models.Model): + name = models.CharField(max_length=255, verbose_name="Name") + description = models.TextField(blank=True, verbose_name="Beschreibung") + quantity = models.PositiveIntegerField(default=0, verbose_name="Anzahl") + location = models.ForeignKey(InventoryLocation, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Standort") + article_number = models.CharField(max_length=255, blank=True, verbose_name="Artikelnummer") + responsible = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Verantwortlicher") + category = models.ForeignKey(InventoryCategory, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Kategorie") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Inventarartikel" + verbose_name_plural = "Inventarartikel" + ordering = ["name"] + + def __str__(self): + return self.name + + +class ProjectInventoryItem(models.Model): + class Status(models.TextChoices): + OPEN = "open", "Offen" + PACKED = "packed", "Eingepackt" + ON_SITE = "on_site", "Vor Ort" + + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="inventory_items", verbose_name="Projekt") + item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE, verbose_name="Artikel") + quantity = models.PositiveIntegerField(default=1, verbose_name="Anzahl") + status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN, verbose_name="Status") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Projektartikel" + verbose_name_plural = "Projektartikel" + unique_together = ("project", "item") + + def __str__(self): + return f"{self.project} - {self.item}" + + +class ShoppingItem(models.Model): + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="shopping_items", verbose_name="Projekt") + name = models.CharField(max_length=255, verbose_name="Name") + description = models.TextField(blank=True, verbose_name="Beschreibung") + quantity = models.PositiveIntegerField(default=1, verbose_name="Anzahl") + supplier = models.ForeignKey(Supplier, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Lieferant") + price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Preis") + category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Kategorie") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Einkaufslistenartikel" + verbose_name_plural = "Einkaufslistenartikel" + ordering = ["name"] + + def __str__(self): + return self.name \ No newline at end of file diff --git a/inventory/settings_registry.py b/inventory/settings_registry.py new file mode 100644 index 0000000..eb0d598 --- /dev/null +++ b/inventory/settings_registry.py @@ -0,0 +1,19 @@ +from masterdata.registry import register_setting + +from .models import ( + InventoryCategory, + InventoryLocation, +) + + +register_setting( + group="Inventar", + name="Kategorien", + model=InventoryCategory, +) + +register_setting( + group="Inventar", + name="Standorte", + model=InventoryLocation, +) \ No newline at end of file diff --git a/inventory/tables.py b/inventory/tables.py new file mode 100644 index 0000000..ccc167a --- /dev/null +++ b/inventory/tables.py @@ -0,0 +1,197 @@ +import django_tables2 as tables +from .models import ProjectInventoryItem, ShoppingItem, InventoryItem +import django_tables2 as tables + +class InventoryGlobalTable(tables.Table): + toggle_prefix = "inv" + detail_template = "inventory/tables/inventory_global/edit_form.html" + + name = tables.Column( + verbose_name="Name" + ) + + category = tables.Column( + verbose_name="Kategorie" + ) + + available = tables.TemplateColumn( + template_name="inventory/tables/inventory_global/available.html", + verbose_name="Anzahl", + orderable=False + ) + + location = tables.Column( + verbose_name="Standort" + ) + + article_number = tables.Column( + verbose_name="Artikelnummer" + ) + + class Meta: + model = InventoryItem + template_name = "django_tables2/table_toggle.html" + fields = ( + "expand", + "name", + "category", + "available", + "location", + "article_number", + ) + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + + +class InventoryAddTable(tables.Table): + name = tables.Column( + accessor="name", + verbose_name="Name" + ) + + category = tables.Column( + accessor="category", + verbose_name="Kategorie" + ) + + available = tables.Column( + accessor="available_quantity", + verbose_name="Verfügbar" + ) + + article_number = tables.Column( + accessor="article_number", + verbose_name="Artikelnummer" + ) + + quantity = tables.TemplateColumn( + template_name="inventory/tables/inventory_add/quantity.html", + verbose_name="Anzahl hinzufügen", + orderable=False + ) + + class Meta: + model = InventoryItem + fields = ( + "name", + "category", + "available", + "article_number", + "quantity", + ) + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + +class ShoppingItemTable(tables.Table): + + name = tables.TemplateColumn( + template_name="inventory/tables/shopping/name.html", + verbose_name="Name" + ) + + category = tables.TemplateColumn( + template_name="inventory/tables/shopping/category.html", + verbose_name="Kategorie" + ) + + quantity = tables.TemplateColumn( + template_name="inventory/tables/shopping/quantity.html", + verbose_name="Anzahl" + ) + + supplier = tables.TemplateColumn( + template_name="inventory/tables/shopping/supplier.html", + verbose_name="Lieferant" + ) + + price = tables.TemplateColumn( + template_name="inventory/tables/shopping/price.html", + verbose_name="Preis" + ) + + description = tables.TemplateColumn( + template_name="inventory/tables/shopping/description.html", + verbose_name="Beschreibung" + ) + + actions = tables.TemplateColumn( + template_name="inventory/tables/shopping/actions.html", + verbose_name="Aktionen", + orderable=False + ) + + class Meta: + model = ShoppingItem + fields = ( + "name", + "category", + "quantity", + "supplier", + "price", + "description", + "actions", + ) + attrs = { + "class": "w-full text-sm" + } + +class ProjectInventoryItemTable(tables.Table): + + name = tables.Column( + accessor="item.name", + verbose_name="Name" + ) + + category = tables.Column( + accessor="item.category", + verbose_name="Kategorie" + ) + + quantity = tables.TemplateColumn( + template_name="inventory/tables/quantity.html", + verbose_name="Anzahl" + ) + + location = tables.Column( + accessor="item.location", + verbose_name="Standort" + ) + + article_number = tables.Column( + accessor="item.article_number", + verbose_name="Artikelnummer" + ) + + responsible = tables.Column( + accessor="item.responsible", + verbose_name="Verantwortlicher" + ) + + status = tables.TemplateColumn( + template_name="inventory/tables/status.html", + verbose_name="Status" + ) + + actions = tables.TemplateColumn( + template_name="inventory/tables/actions.html", + verbose_name="Aktionen", + orderable=False + ) + + class Meta: + model = ProjectInventoryItem + fields = ( + "name", + "category", + "quantity", + "location", + "article_number", + "responsible", + "status", + "actions", + ) + attrs = { + "class": "w-full text-sm" + } \ No newline at end of file diff --git a/inventory/templates/inventory/add.html b/inventory/templates/inventory/add.html new file mode 100644 index 0000000..5c8226d --- /dev/null +++ b/inventory/templates/inventory/add.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %} +{{ project.name }} - Inventar +{% endblock %} + +{% block header_title %} +Packartikel hinzufügen +{% endblock %} + +{% block header_description %} +Artikel zur Packliste hinzufügen +{% endblock %} + +{% block content %} +{% load django_tables2 %} +
+
+ {% csrf_token %} + +
+ {% render_table table %} +
+ + +
+
+ +{% endblock %} \ No newline at end of file diff --git a/inventory/templates/inventory/global/add.html b/inventory/templates/inventory/global/add.html new file mode 100644 index 0000000..35730c5 --- /dev/null +++ b/inventory/templates/inventory/global/add.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %} +Inventar +{% endblock %} + +{% block header_title %} +Inventarartikel hinzufügen +{% endblock %} + +{% block header_description %} +Neuen Artikel zum globalen Inventar hinzufügen +{% endblock %} + +{% block content %} + +
+ + +
+ {% csrf_token %} + +
+ {{ form.as_p }} +
+ + + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/inventory/templates/inventory/global/overview.html b/inventory/templates/inventory/global/overview.html new file mode 100644 index 0000000..aabeca2 --- /dev/null +++ b/inventory/templates/inventory/global/overview.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %} +Inventar +{% endblock %} + +{% block header_title %} +Inventar +{% endblock %} + +{% block header_description %} +Globale Inventarverwaltung +{% endblock %} + +{% block content %} +{% load django_tables2 %} +
+
+
+
+

Aktionen

+

Inventarartikel hinzufügen

+
+ +
+ + Artikel +
+ +
+
+
+ + +
+ +
+

Inventarliste

+

Gelagerte und verfügbare Artikel

+
+ +
+ {% render_table table %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/inventory/templates/inventory/overview.html b/inventory/templates/inventory/overview.html new file mode 100644 index 0000000..74d4192 --- /dev/null +++ b/inventory/templates/inventory/overview.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block title %} +{{ project.name }} - Inventar +{% endblock %} + +{% block header_title %} +Pack-/Einkaufsliste +{% endblock %} + +{% block header_description %} +Vorhandenen & benötigtes Material +{% endblock %} + +{% block content %} +{% load django_tables2 %} +
+
+
+
+

Aktionen

+

Artikel zur Packliste oder Einkaufsliste hinzufügen

+
+ + + +
+
+
+ +
+ +
+

Packliste

+

Vorhandene und benötigte Artikel

+
+ +
+ {% render_table table %} +
+ +
+ + + + + +
+ +
+ +
+

+ Einkaufsliste +

+ +

+ Nicht-Vorhandene und benötigte Artikel +

+
+
+ +
+ + {% render_table shopping_table %} + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/inventory/templates/inventory/shopping_add.html b/inventory/templates/inventory/shopping_add.html new file mode 100644 index 0000000..de286f6 --- /dev/null +++ b/inventory/templates/inventory/shopping_add.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %} +{{ project.name }} - Inventar +{% endblock %} + +{% block header_title %} +Einkaufsartikel hinzufügen +{% endblock %} + +{% block header_description %} +Artikel zur Einkaufsliste hinzufügen +{% endblock %} + +{% block content %} + +
+
+ + {% csrf_token %} + +
+ + {{ form.as_p }} + +
+ + + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/inventory/templates/inventory/tables/actions.html b/inventory/templates/inventory/tables/actions.html new file mode 100644 index 0000000..59c1b14 --- /dev/null +++ b/inventory/templates/inventory/tables/actions.html @@ -0,0 +1,29 @@ +
+ + {% csrf_token %} + + + + + + + +
\ No newline at end of file diff --git a/inventory/templates/inventory/tables/inventory_add/quantity.html b/inventory/templates/inventory/tables/inventory_add/quantity.html new file mode 100644 index 0000000..d6a76c0 --- /dev/null +++ b/inventory/templates/inventory/tables/inventory_add/quantity.html @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/inventory/templates/inventory/tables/inventory_global/available.html b/inventory/templates/inventory/tables/inventory_global/available.html new file mode 100644 index 0000000..9495293 --- /dev/null +++ b/inventory/templates/inventory/tables/inventory_global/available.html @@ -0,0 +1,10 @@ +{% if record.available_quantity > 0 %} + + {{ record.available_quantity }} + +{% elif record.available_quantity == 0 %} + + 0 + +{% endif %} +/ {{ record.quantity }} \ No newline at end of file diff --git a/inventory/templates/inventory/tables/inventory_global/edit_form.html b/inventory/templates/inventory/tables/inventory_global/edit_form.html new file mode 100644 index 0000000..9957806 --- /dev/null +++ b/inventory/templates/inventory/tables/inventory_global/edit_form.html @@ -0,0 +1,155 @@ +
+ {% csrf_token %} + + + +
+ +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ +
+ + +
+ + + + + + +
+ +
\ No newline at end of file diff --git a/inventory/templates/inventory/tables/inventory_global/expand.html b/inventory/templates/inventory/tables/inventory_global/expand.html new file mode 100644 index 0000000..3a30642 --- /dev/null +++ b/inventory/templates/inventory/tables/inventory_global/expand.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/inventory/templates/inventory/tables/quantity.html b/inventory/templates/inventory/tables/quantity.html new file mode 100644 index 0000000..f7bbfe5 --- /dev/null +++ b/inventory/templates/inventory/tables/quantity.html @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/inventory/templates/inventory/tables/shopping/actions.html b/inventory/templates/inventory/tables/shopping/actions.html new file mode 100644 index 0000000..04d8ddb --- /dev/null +++ b/inventory/templates/inventory/tables/shopping/actions.html @@ -0,0 +1,32 @@ +
+ + {% csrf_token %} + + + +
+ + + + + +
+ +
\ No newline at end of file diff --git a/inventory/templates/inventory/tables/shopping/category.html b/inventory/templates/inventory/tables/shopping/category.html new file mode 100644 index 0000000..e32c156 --- /dev/null +++ b/inventory/templates/inventory/tables/shopping/category.html @@ -0,0 +1,15 @@ + \ No newline at end of file diff --git a/inventory/templates/inventory/tables/shopping/description.html b/inventory/templates/inventory/tables/shopping/description.html new file mode 100644 index 0000000..480d7d6 --- /dev/null +++ b/inventory/templates/inventory/tables/shopping/description.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/inventory/templates/inventory/tables/shopping/name.html b/inventory/templates/inventory/tables/shopping/name.html new file mode 100644 index 0000000..98ad1e7 --- /dev/null +++ b/inventory/templates/inventory/tables/shopping/name.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/inventory/templates/inventory/tables/shopping/price.html b/inventory/templates/inventory/tables/shopping/price.html new file mode 100644 index 0000000..5125ed6 --- /dev/null +++ b/inventory/templates/inventory/tables/shopping/price.html @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/inventory/templates/inventory/tables/shopping/quantity.html b/inventory/templates/inventory/tables/shopping/quantity.html new file mode 100644 index 0000000..790ba65 --- /dev/null +++ b/inventory/templates/inventory/tables/shopping/quantity.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/inventory/templates/inventory/tables/shopping/supplier.html b/inventory/templates/inventory/tables/shopping/supplier.html new file mode 100644 index 0000000..db7e62f --- /dev/null +++ b/inventory/templates/inventory/tables/shopping/supplier.html @@ -0,0 +1,15 @@ + \ No newline at end of file diff --git a/inventory/templates/inventory/tables/status.html b/inventory/templates/inventory/tables/status.html new file mode 100644 index 0000000..372d28a --- /dev/null +++ b/inventory/templates/inventory/tables/status.html @@ -0,0 +1,21 @@ + \ No newline at end of file diff --git a/inventory/tests.py b/inventory/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/inventory/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/inventory/urls.py b/inventory/urls.py new file mode 100644 index 0000000..c6e94f4 --- /dev/null +++ b/inventory/urls.py @@ -0,0 +1,16 @@ +from django.urls import path +from . import views + +app_name = "inventory" + +urlpatterns = [ + path("/inventory/", views.inventory_overview, name="overview"), + path("/inventory/add/", views.inventory_add, name="add"), + path("/inventory/shopping-add/", views.shopping_add, name="shopping_add"), + + path("/inventory/edit//", views.inventory_edit, name="edit"), + path("/inventory/shopping-edit//", views.shopping_edit, name="shopping_edit"), + + path("/inventory/delete//", views.inventory_delete, name="delete"), + path("/inventory/shopping-delete//", views.shopping_delete, name="shopping_delete"), +] \ No newline at end of file diff --git a/inventory/urls_global.py b/inventory/urls_global.py new file mode 100644 index 0000000..7e5aa8d --- /dev/null +++ b/inventory/urls_global.py @@ -0,0 +1,11 @@ +from django.urls import path +from . import views + +app_name = "inventory_global" + +urlpatterns = [ + path("", views.inventory_global_overview, name="overview"), + path("add/", views.inventory_global_add, name="add"), + path("edit//", views.inventory_global_edit, name="edit"), + path("delete//", views.inventory_global_delete, name="delete"), +] \ No newline at end of file diff --git a/inventory/views.py b/inventory/views.py new file mode 100644 index 0000000..3eed875 --- /dev/null +++ b/inventory/views.py @@ -0,0 +1,431 @@ +from django.contrib.auth.decorators import login_required +from accounts.decorators import permission_required +from django.shortcuts import render, get_object_or_404, redirect +from projects.models import ProjectMembership +from .models import InventoryItem, InventoryCategory, InventoryLocation, ProjectInventoryItem, ShoppingItem, Category, Supplier +from django.contrib.auth.models import User +from .forms import InventoryItemForm, ShoppingItemForm +from django.db.models import Sum, Value, F +from django_tables2 import RequestConfig +from .tables import ProjectInventoryItemTable, ShoppingItemTable, InventoryAddTable, InventoryGlobalTable +from django.db.models.functions import Coalesce + +@login_required +@permission_required("projects_inventory_inventory_view") +def inventory_overview(request, project_id): + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + items = ( + ProjectInventoryItem.objects + .select_related( + "item", + "item__category", + "item__location", + "item__responsible", + ) + .filter(project=project) + .order_by("item__name") + ) + + table = ProjectInventoryItemTable(items) + + # Zusatzdaten für TemplateColumns + table.project = project + + RequestConfig( + request, + paginate=False + ).configure(table) + + shopping_items = ( + ShoppingItem.objects + .select_related( + "category", + "supplier" + ) + .filter(project=project) + .order_by("name") + ) + + + shopping_table = ShoppingItemTable(shopping_items) + + shopping_table.project = project + shopping_table.categories = Category.objects.order_by("name") + shopping_table.suppliers = Supplier.objects.order_by("name") + + return render(request, "inventory/overview.html", { + "project": project, + "table": table, + "shopping_table": shopping_table, + }) + + +@login_required +@permission_required("projects_inventory_shopping_add") +def shopping_add(request, project_id): + + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + if request.method == "POST": + + form = ShoppingItemForm(request.POST) + + if form.is_valid(): + item = form.save(commit=False) + item.project = project + item.save() + return redirect( + "inventory:overview", + project_id=project.id + ) + + else: + + form = ShoppingItemForm() + + return render( + request, + "inventory/shopping_add.html", + { + "project": project, + "form": form, + } + ) + + +@login_required +@permission_required("projects_inventory_inventory_add") +def inventory_add(request, project_id): + + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + items = ( + InventoryItem.objects + .select_related( + "category", + "location" + ) + .annotate( + used_quantity=Coalesce( + Sum("projectinventoryitem__quantity"), + Value(0) + ) + ) + .annotate( + available_quantity=F("quantity") - F("used_quantity") + ) + .filter( + available_quantity__gt=0 + ) + .order_by("name") + ) + + if request.method == "POST": + + for item in items: + + quantity = request.POST.get( + f"item_{item.id}" + ) + + if not quantity: + continue + + quantity = int(quantity) + + if quantity <= 0: + continue + + if quantity > item.available_quantity: + continue + + project_item, created = ProjectInventoryItem.objects.get_or_create( + project=project, + item=item, + defaults={ + "quantity": quantity, + "status": ProjectInventoryItem.Status.OPEN, + } + ) + + if not created: + project_item.quantity += quantity + project_item.save() + + return redirect( + "inventory:overview", + project_id=project.id + ) + + table = InventoryAddTable(items) + + RequestConfig( + request, + paginate=False + ).configure(table) + + return render( + request, + "inventory/add.html", + { + "project": project, + "table": table, + } + ) + + + + + + +@login_required +@permission_required("inventory_view") +def inventory_global_overview(request): + + items = ( + InventoryItem.objects + .select_related( + "category", + "location", + "responsible" + ) + .annotate( + used_quantity=Coalesce( + Sum("projectinventoryitem__quantity"), + Value(0) + ) + ) + .annotate( + available_quantity=F("quantity") - F("used_quantity") + ) + .order_by("name") + ) + + for item in items: + + used_quantity = ( + ProjectInventoryItem.objects + .filter(item=item) + .aggregate(total=Sum("quantity")) + .get("total") or 0 + ) + + item.available_quantity = max( + 0, + item.quantity - used_quantity + ) + + table = InventoryGlobalTable(items) + + table.categories = InventoryCategory.objects.all() + table.locations = InventoryLocation.objects.all() + table.users = User.objects.order_by("username") + + RequestConfig( + request, + paginate=False + ).configure(table) + + return render( + request, + "inventory/global/overview.html", + { + "table": table, + } + ) + + +@login_required +@permission_required("inventory_add") +def inventory_global_add(request): + + if request.method == "POST": + form = InventoryItemForm(request.POST) + + if form.is_valid(): + form.save() + return redirect("inventory_global:overview") + + else: + form = InventoryItemForm() + + return render(request, "inventory/global/add.html", {"form": form,}) + + + + + + + + + + + + + + + + + +@login_required +@permission_required("projects_inventory_inventory_delete") +def inventory_delete(request, project_id, item_id): + membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id) + + item = get_object_or_404(ProjectInventoryItem, id=item_id, project=membership.project) + + if request.method == "POST": + item.delete() + + return redirect("inventory:overview", project_id=project_id) + + + +@login_required +@permission_required("projects_inventory_shopping_delete") +def shopping_delete(request, project_id, item_id): + membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id) + + item = get_object_or_404(ShoppingItem, id=item_id, project=membership.project) + + if request.method == "POST": + item.delete() + + return redirect("inventory:overview", project_id=project_id) + + + +@login_required +@permission_required("inventory_delete") +def inventory_global_delete(request, item_id): + + item = get_object_or_404( + InventoryItem, + id=item_id + ) + + if request.method == "POST": + item.delete() + + return redirect("inventory_global:overview") + + + + + + + + + + +@login_required +@permission_required("projects_inventory_inventory_edit") +def inventory_edit(request, project_id, item_id): + membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id) + item = get_object_or_404(ProjectInventoryItem, id=item_id, project=membership.project) + if request.method == "POST": + item.quantity = request.POST.get("quantity") or 0 + item.status = request.POST.get("status") + item.save() + return redirect("inventory:overview", project_id=project_id) + + return render( + request, + "inventory/edit.html", + { + "project": membership.project, + "item": item, + } + ) + + + + + + + + + + + + +@login_required +@permission_required("projects_inventory_shopping_edit") +def shopping_edit(request, project_id, item_id): + membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id) + item = get_object_or_404(ShoppingItem, id=item_id, project=membership.project) + if request.method == "POST": + item.name = request.POST.get("name") + item.description = request.POST.get("description", "") + item.quantity = request.POST.get("quantity") or 1 + item.price = request.POST.get("price") or None + item.category_id = request.POST.get("category") or None + item.supplier_id = request.POST.get("supplier") or None + item.save() + return redirect("inventory:overview", project_id=project_id) + + return render( + request, + "inventory/shopping_edit.html", + { + "project": membership.project, + "item": item, + "categories": Category.objects.all(), + "suppliers": Supplier.objects.all(), + } + ) + + + + + + + + +@login_required +@permission_required("inventory_edit") +def inventory_global_edit(request, item_id): + item = get_object_or_404( + InventoryItem, + id=item_id + ) + if request.method == "POST": + item.name = request.POST.get("name") + item.quantity = request.POST.get("quantity") + item.article_number = request.POST.get("article_number") + item.description = request.POST.get("description", "") + item.category_id = request.POST.get("category") or None + item.location_id = request.POST.get("location") or None + item.responsible_id = request.POST.get("responsible") or None + item.save() + return redirect("inventory_global:overview") + + return render( + request, + "inventory/global/edit.html", + { + "item": item, + "categories": InventoryCategory.objects.all(), + "locations": InventoryLocation.objects.all(), + "users": User.objects.order_by("username"), + } + ) \ No newline at end of file diff --git a/invoices/__init__.py b/invoices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invoices/admin.py b/invoices/admin.py new file mode 100644 index 0000000..27115e0 --- /dev/null +++ b/invoices/admin.py @@ -0,0 +1,275 @@ +from django.contrib import admin +from .models import ( + Customer, + CustomerContact, + CustomerLocation, + Item, + Invoice, + InvoiceItem, + Unit, + Reminder +) + +@admin.register(Unit) +class UnitAdmin(admin.ModelAdmin): + list_display = ( + "name", + "short_name", + ) + + search_fields = ( + "name", + "short_name", + ) + +class CustomerContactInline(admin.TabularInline): + model = CustomerContact + extra = 0 + + +class CustomerLocationInline(admin.TabularInline): + model = CustomerLocation + extra = 0 + + +@admin.register(Customer) +class CustomerAdmin(admin.ModelAdmin): + list_display = ( + "customer_number", + "company_name", + "customer_type", + "phone", + "email", + ) + + search_fields = ( + "customer_number", + "company_name", + "email", + "phone", + "tax_number", + "vat_number", + ) + + list_filter = ( + "customer_type", + ) + + inlines = ( + CustomerContactInline, + CustomerLocationInline, + ) + + +@admin.register(CustomerContact) +class CustomerContactAdmin(admin.ModelAdmin): + list_display = ( + "first_name", + "last_name", + "customer", + "phone", + "email", + ) + + search_fields = ( + "first_name", + "last_name", + "email", + "phone", + "customer__company_name", + ) + + +@admin.register(CustomerLocation) +class CustomerLocationAdmin(admin.ModelAdmin): + list_display = ( + "name", + "customer", + "city", + "country", + ) + + search_fields = ( + "name", + "city", + "street", + "customer__company_name", + ) + + +@admin.register(Item) +class ItemAdmin(admin.ModelAdmin): + list_display = ( + "description", + "item_type", + "unit", + "price_net", + ) + + search_fields = ( + "description", + ) + + list_filter = ( + "item_type", + "unit", + ) + + + + + + +class InvoiceItemInline(admin.TabularInline): + model = InvoiceItem + extra = 0 + + +@admin.register(Invoice) +class InvoiceAdmin(admin.ModelAdmin): + + list_display = ( + "invoice_number", + "customer", + "project", + "status", + "issue_date", + "due_date", + ) + + list_filter = ( + "status", + "issue_date", + "customer", + ) + + search_fields = ( + "invoice_number", + "customer__company_name", + ) + + autocomplete_fields = ( + "customer", + "project", + ) + + +@admin.register(InvoiceItem) +class InvoiceItemAdmin(admin.ModelAdmin): + + list_display = ( + "description", + "invoice", + "quantity", + "unit_price_net", + ) + + search_fields = ( + "description", + "invoice__invoice_number", + ) + + autocomplete_fields = ( + "invoice", + "item", + "time_entry", + "expense", + ) + + + + + + + + + + + +from .models import ( + Offer, + OfferItem, +) + + +class OfferItemInline(admin.TabularInline): + model = OfferItem + extra = 0 + + +@admin.register(Offer) +class OfferAdmin(admin.ModelAdmin): + + list_display = ( + "offer_number", + "customer", + "status", + "issue_date", + "due_date", + "created_at", + ) + + list_filter = ( + "status", + "issue_date", + ) + + search_fields = ( + "offer_number", + "contact_name", + "customer__company_name", + ) + + readonly_fields = ( + "created_at", + "pdf_generated_at", + ) + + inlines = [ + OfferItemInline, + ] + + +@admin.register(OfferItem) +class OfferItemAdmin(admin.ModelAdmin): + + list_display = ( + "offer", + "description", + "quantity", + "unit", + "unit_price_net", + "unit_tax_rate", + "created_at", + ) + + search_fields = ( + "description", + "offer__offer_number", + ) + + autocomplete_fields = ( + "offer", + "item", + ) + + +@admin.register(Reminder) +class ReminderAdmin(admin.ModelAdmin): + list_filter = ( + "level", + "issue_date", + "due_date", + "pdf_generated_at", + ) + + search_fields = ( + "reminder_number", + ) + + readonly_fields = ( + "created_at", + "pdf_generated_at", + ) + + ordering = ("-created_at",) diff --git a/invoices/apps.py b/invoices/apps.py new file mode 100644 index 0000000..c929ba1 --- /dev/null +++ b/invoices/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class InvoicesConfig(AppConfig): + name = 'invoices' diff --git a/invoices/forms.py b/invoices/forms.py new file mode 100644 index 0000000..f3722ee --- /dev/null +++ b/invoices/forms.py @@ -0,0 +1,641 @@ +from django import forms +from core.forms.base import BaseForm +from .models import Customer, Item, Invoice, InvoiceItem, CustomerContact, CustomerLocation, Offer, OfferItem, Reminder +from projects.models import Project +from time_tracking.models import TimeEntry +from finance.models import Expense +from django.utils import timezone +from django.db.models import Q, Max + +class CustomerForm(BaseForm): + class Meta: + model = Customer + fields = [ + "customer_number", + "customer_type", + "company_name", + "tax_number", + "vat_number", + "phone", + "email", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.apply_style() + + + +class ItemForm(BaseForm): + class Meta: + model = Item + fields = [ + "item_type", + "description", + "unit", + "price_net", + "tax_rate", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.apply_style() + + + + + +class CustomerInvoiceForm(BaseForm): + + contact = forms.ModelChoiceField( + queryset=CustomerContact.objects.none(), + label="Ansprechpartner", + required=True + ) + + location = forms.ModelChoiceField( + queryset=CustomerLocation.objects.none(), + label="Standort", + required=True + ) + + class Meta: + model = Invoice + fields = [ + "customer", + "contact", + "location", + "issue_date", + "due_date", + "note", + ] + + widgets = { + "issue_date": forms.DateInput(attrs={"type": "date"}), + "due_date": forms.DateInput(attrs={"type": "date"}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.apply_style() + + customer_id = ( + self.data.get("customer") + or getattr(self.instance, "customer_id", None) + ) + + if customer_id: + self.fields["contact"].queryset = CustomerContact.objects.filter(customer_id=customer_id) + self.fields["location"].queryset = CustomerLocation.objects.filter(customer_id=customer_id) + else: + self.fields["contact"].queryset = CustomerContact.objects.none() + self.fields["location"].queryset = CustomerLocation.objects.none() + + + + + +class ProjectInvoiceForm(BaseForm): + + contact = forms.ModelChoiceField( + queryset=CustomerContact.objects.none(), + required=False, + label="Ansprechpartner", + widget=forms.Select(attrs={ + "class": "customer-field" + }) + ) + + location = forms.ModelChoiceField( + queryset=CustomerLocation.objects.none(), + required=False, + label="Standort", + widget=forms.Select(attrs={ + "class": "customer-field" + }) + ) + + + contact_name_manual = forms.CharField( + required=False, + label="Ansprechpartner", + widget=forms.TextInput(attrs={ + "class": "manual-field" + }) + ) + + contact_email_manual = forms.EmailField( + required=False, + label="E-Mail", + widget=forms.EmailInput(attrs={ + "class": "manual-field" + }) + ) + + contact_phone_manual = forms.CharField( + required=False, + label="Telefon", + widget=forms.TextInput(attrs={ + "class": "manual-field" + }) + ) + + + location_name_manual = forms.CharField( + required=False, + label="Standort", + widget=forms.TextInput(attrs={ + "class": "manual-field" + }) + ) + + location_street_manual = forms.CharField( + required=False, + label="Straße", + widget=forms.TextInput(attrs={ + "class": "manual-field" + }) + ) + + location_zip_manual = forms.CharField( + required=False, + label="PLZ", + widget=forms.TextInput(attrs={ + "class": "manual-field" + }) + ) + + location_city_manual = forms.CharField( + required=False, + label="Ort", + widget=forms.TextInput(attrs={ + "class": "manual-field" + }) + ) + + location_country_manual = forms.CharField( + required=False, + label="Land", + widget=forms.TextInput(attrs={ + "class": "manual-field" + }) + ) + + + class Meta: + + model = Invoice + + fields = [ + "project", + + "contact", + "location", + + "contact_name_manual", + "contact_email_manual", + "contact_phone_manual", + + "location_name_manual", + "location_street_manual", + "location_zip_manual", + "location_city_manual", + "location_country_manual", + + "issue_date", + "due_date", + "note", + ] + + + widgets = { + + "issue_date": forms.DateInput( + attrs={ + "type": "date" + } + ), + + "due_date": forms.DateInput( + attrs={ + "type": "date" + } + ), + } + + + + + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + self.apply_style() + + + project_id = self.data.get("project") or getattr( + self.instance, + "project_id", + None + ) + + + if project_id: + + project = Project.objects.filter( + id=project_id + ).first() + + + if project and project.customer: + + self.fields["contact"].queryset = CustomerContact.objects.filter( + customer=project.customer + ) + + self.fields["location"].queryset = CustomerLocation.objects.filter( + customer=project.customer + ) + + + + + +class InvoiceItemForm(BaseForm): + + class Meta: + model = InvoiceItem + + fields = [ + "source_type", + "item", + "time_entry", + "expense", + "description", + "quantity", + "unit", + "unit_price_net", + "unit_tax_rate", + 'service_period_from', + 'service_period_to', + ] + widgets = { + + "quantity": forms.NumberInput(attrs={ + "step": "1.00" + }), + + "unit_price_net": forms.NumberInput(attrs={ + "step": "1.00" + }), + + "unit_tax_rate": forms.NumberInput(attrs={ + "step": "1.00" + }), + "issue_date": forms.DateInput( + attrs={ + "type": "date" + } + ), + + "service_period_from": forms.DateInput( + attrs={ + "type": "date" + } + ), + + "service_period_to": forms.DateInput( + attrs={ + "type": "date" + } + ), + + } + + + def __init__( + self, + *args, + invoice=None, + **kwargs + ): + + super().__init__(*args, **kwargs) + self.apply_style() + self.invoice = invoice + self.fields["item"].queryset = Item.objects.all() + self.fields["time_entry"].queryset = TimeEntry.objects.none() + self.fields["expense"].queryset = Expense.objects.none() + + + if invoice and invoice.project: + + + self.fields["source_type"].choices = [ + ("item", "Artikel"), + ("time", "Zeiterfassung"), + ("expense", "Ausgabe"), + ] + + + self.fields["time_entry"].queryset = TimeEntry.objects.filter( + project=invoice.project,end__isnull=False + ) + + + self.fields["expense"].queryset = Expense.objects.filter( + project=invoice.project + ) + + else: + self.fields["source_type"].choices = [ + ("item", "Artikel"), + ] + + self.fields["item"].widget.attrs["class"] += " source-item" + self.fields["time_entry"].widget.attrs["class"] += " source-time" + self.fields["expense"].widget.attrs["class"] += " source-expense" + + def save(self, commit=True): + + obj = super().save(commit=False) + + + source = self.cleaned_data.get( + "source_type" + ) + + + # ------------------------- + # ARTIKEL + # ------------------------- + + if source == "item": + + item = self.cleaned_data["item"] + + obj.item = item + obj.time_entry = None + obj.expense = None + + obj.description = self.cleaned_data["description"] + obj.unit = self.cleaned_data["unit"] + obj.unit_price_net = self.cleaned_data["unit_price_net"] + obj.unit_tax_rate = self.cleaned_data["unit_tax_rate"] + + + + # ------------------------- + # ZEITERFASSUNG + # ------------------------- + + elif source == "time": + + entry = self.cleaned_data["time_entry"] + + obj.item = None + obj.time_entry = entry + obj.expense = None + + + obj.description = self.cleaned_data["description"] + obj.unit = self.cleaned_data["unit"] + obj.unit_price_net = self.cleaned_data["unit_price_net"] + obj.unit_tax_rate = self.cleaned_data["unit_tax_rate"] + + + + # ------------------------- + # AUSGABE + # ------------------------- + + elif source == "expense": + + expense = self.cleaned_data["expense"] + + obj.item = None + obj.time_entry = None + obj.expense = expense + + + obj.description = self.cleaned_data["description"] + obj.unit = self.cleaned_data["unit"] + obj.unit_price_net = self.cleaned_data["unit_price_net"] + obj.unit_tax_rate = self.cleaned_data["unit_tax_rate"] + + + + if commit: + obj.save() + + + return obj + + + + + + + + + + + + + + + + + + + + + + + + + +class OfferForm(BaseForm): + + contact = forms.ModelChoiceField( + queryset=CustomerContact.objects.none(), + label="Ansprechpartner", + required=True + ) + + location = forms.ModelChoiceField( + queryset=CustomerLocation.objects.none(), + label="Standort", + required=True + ) + + class Meta: + model = Offer + fields = [ + "customer", + "contact", + "location", + "issue_date", + "due_date", + "note", + ] + + widgets = { + "issue_date": forms.DateInput(attrs={"type": "date"}), + "due_date": forms.DateInput(attrs={"type": "date"}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.apply_style() + + customer_id = ( + self.data.get("customer") + or getattr(self.instance, "customer_id", None) + ) + + if customer_id: + self.fields["contact"].queryset = CustomerContact.objects.filter(customer_id=customer_id) + self.fields["location"].queryset = CustomerLocation.objects.filter(customer_id=customer_id) + else: + self.fields["contact"].queryset = CustomerContact.objects.none() + self.fields["location"].queryset = CustomerLocation.objects.none() + + + + + +class OfferItemForm(BaseForm): + + class Meta: + model = OfferItem + + fields = [ + "item", + "description", + "quantity", + "unit", + "unit_price_net", + "unit_tax_rate", + ] + + widgets = { + "quantity": forms.NumberInput( + attrs={"step": "1.00"} + ), + "unit_price_net": forms.NumberInput( + attrs={"step": "1.00"} + ), + "unit_tax_rate": forms.NumberInput( + attrs={"step": "1.00"} + ), + } + + def __init__( + self, + *args, + offer=None, + **kwargs + ): + + super().__init__(*args, **kwargs) + + self.apply_style() + + self.offer = offer + + self.fields["item"].queryset = ( + Item.objects.all() + ) + + def save(self, commit=True): + + obj = super().save(commit=False) + + item = self.cleaned_data.get("item") + + if item: + obj.item = item + + if commit: + obj.save() + + return obj + + + + + + + + +class ReminderForm(BaseForm): + + class Meta: + model = Reminder + fields = [ + "invoice", + "issue_date", + "due_date", + "fee", + "note", + ] + + widgets = { + "issue_date": forms.DateInput(attrs={"type": "date"}), + "due_date": forms.DateInput(attrs={"type": "date"}), + } + + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + + possible_invoices = [] + + + invoices = Invoice.objects.filter( + status=Invoice.Status.OVERDUE, + due_date__lt=timezone.now().date(), + ).prefetch_related( + "reminders" + ) + + + for invoice in invoices: + + reminders = invoice.reminders.exclude( + status=Reminder.Status.CANCELED + ).order_by("-level") + + + # Keine Mahnung vorhanden + if not reminders.exists(): + possible_invoices.append(invoice) + continue + + + last_reminder = reminders.first() + + + # Nur nach expired möglich + if last_reminder.status != Reminder.Status.EXPIRED: + continue + + + # Maximal 3 Mahnungen + if last_reminder.level >= 3: + continue + + + possible_invoices.append(invoice) + + + self.fields["invoice"].queryset = Invoice.objects.filter( + id__in=[i.id for i in possible_invoices] + ).order_by("due_date") + + + self.apply_style() \ No newline at end of file diff --git a/invoices/migrations/0001_initial.py b/invoices/migrations/0001_initial.py new file mode 100644 index 0000000..cccb19e --- /dev/null +++ b/invoices/migrations/0001_initial.py @@ -0,0 +1,145 @@ +# Generated by Django 6.0.6 on 2026-06-23 22:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Customer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('customer_number', models.CharField(max_length=50, unique=True, verbose_name='Kunden-ID')), + ('customer_type', models.CharField(choices=[('private', 'Privat'), ('company', 'Unternehmen')], default='company', max_length=20, verbose_name='Kundentyp')), + ('company_name', models.CharField(max_length=255, verbose_name='Firmenname / Kundenname')), + ('tax_number', models.CharField(blank=True, max_length=100, verbose_name='Steuernummer')), + ('vat_number', models.CharField(blank=True, max_length=100, verbose_name='UST-IdNr')), + ('phone', models.CharField(blank=True, max_length=100, verbose_name='Telefon')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='E-Mail')), + ('active', models.BooleanField(default=True, verbose_name='Status')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Kunde', + 'verbose_name_plural': 'Kunden', + 'ordering': ['company_name'], + }, + ), + migrations.CreateModel( + name='InvoiceItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('source_type', models.CharField(choices=[('item', 'Artikel'), ('time', 'Zeiterfassung'), ('expense', 'Ausgabe')], default='item', max_length=20, verbose_name='Quelle')), + ('description', models.CharField(max_length=255, verbose_name='Beschreibung')), + ('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Menge')), + ('unit', models.CharField(max_length=50, verbose_name='Einheit')), + ('unit_price_net', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Einzelpreis Netto')), + ('unit_tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='MwSt. Satz (%)')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Rechnungsposition', + 'verbose_name_plural': 'Rechnungspositionen', + }, + ), + migrations.CreateModel( + name='Item', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('item_type', models.CharField(choices=[('service', 'Dienstleistung'), ('product', 'Ware')], default='product', max_length=20, verbose_name='Art')), + ('description', models.CharField(max_length=255, verbose_name='Beschreibung')), + ('price_net', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Preis Netto')), + ('tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='MwSt. Satz (%)')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Posten', + 'verbose_name_plural': 'Posten', + 'ordering': ['description'], + }, + ), + migrations.CreateModel( + name='Unit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='Bezeichnung')), + ('short_name', models.CharField(max_length=20, verbose_name='Kürzel')), + ], + options={ + 'verbose_name': 'Einheit', + 'verbose_name_plural': 'Einheiten', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='CustomerContact', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=150, verbose_name='Vorname')), + ('last_name', models.CharField(max_length=150, verbose_name='Nachname')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='E-Mail')), + ('phone', models.CharField(blank=True, max_length=100, verbose_name='Telefon')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contacts', to='invoices.customer', verbose_name='Kunde')), + ], + options={ + 'verbose_name': 'Ansprechpartner', + 'verbose_name_plural': 'Ansprechpartner', + 'ordering': ['last_name', 'first_name'], + }, + ), + migrations.CreateModel( + name='CustomerLocation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Bezeichnung')), + ('zip_code', models.CharField(blank=True, max_length=20, verbose_name='PLZ')), + ('city', models.CharField(blank=True, max_length=255, verbose_name='Ort')), + ('street', models.CharField(blank=True, max_length=255, verbose_name='Adresse')), + ('country', models.CharField(blank=True, max_length=100, verbose_name='Land')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='invoices.customer', verbose_name='Kunde')), + ], + options={ + 'verbose_name': 'Standort', + 'verbose_name_plural': 'Standorte', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Invoice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('invoice_number', models.CharField(max_length=50, unique=True, verbose_name='Rechnungsnummer')), + ('status', models.CharField(choices=[('draft', 'Entwurf'), ('sent', 'Versendet'), ('paid', 'Bezahlt'), ('canceled', 'Storniert')], default='draft', max_length=20, verbose_name='Status')), + ('issue_date', models.DateField(verbose_name='Rechnungsdatum')), + ('due_date', models.DateField(blank=True, null=True, verbose_name='Fälligkeitsdatum')), + ('note', models.TextField(blank=True, verbose_name='Notiz')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('pdf_file', models.FileField(blank=True, null=True, upload_to='invoice_pdfs/', verbose_name='PDF')), + ('pdf_generated_at', models.DateTimeField(blank=True, null=True, verbose_name='PDF erstellt am')), + ('tax_number', models.CharField(blank=True, max_length=100, verbose_name='Steuernummer Kunde')), + ('vat_number', models.CharField(blank=True, max_length=100, verbose_name='USt-IdNr Kunde')), + ('contact_name', models.CharField(max_length=255, verbose_name='Ansprechpartner')), + ('contact_email', models.EmailField(blank=True, max_length=254, verbose_name='E-Mail Ansprechpartner')), + ('contact_phone', models.CharField(blank=True, max_length=100, verbose_name='Telefon Ansprechpartner')), + ('location_name', models.CharField(max_length=255, verbose_name='Standort')), + ('location_street', models.CharField(blank=True, max_length=255, verbose_name='Straße')), + ('location_city', models.CharField(blank=True, max_length=255, verbose_name='Ort')), + ('location_zip', models.CharField(blank=True, max_length=20, verbose_name='PLZ')), + ('location_country', models.CharField(blank=True, max_length=100, verbose_name='Land')), + ('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to='invoices.customer', verbose_name='Kunde')), + ], + options={ + 'verbose_name': 'Rechnung', + 'verbose_name_plural': 'Rechnungen', + 'ordering': ['-issue_date'], + }, + ), + ] diff --git a/invoices/migrations/0002_initial.py b/invoices/migrations/0002_initial.py new file mode 100644 index 0000000..2db02a0 --- /dev/null +++ b/invoices/migrations/0002_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 6.0.6 on 2026-06-23 22:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('finance', '0002_initial'), + ('invoices', '0001_initial'), + ('projects', '0001_initial'), + ('time_tracking', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='invoice', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoices', to='projects.project', verbose_name='Projekt'), + ), + migrations.AddField( + model_name='invoiceitem', + name='expense', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.expense', verbose_name='Ausgabe'), + ), + migrations.AddField( + model_name='invoiceitem', + name='invoice', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='invoices.invoice', verbose_name='Rechnung'), + ), + migrations.AddField( + model_name='invoiceitem', + name='time_entry', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='time_tracking.timeentry', verbose_name='Zeiterfassung'), + ), + migrations.AddField( + model_name='invoiceitem', + name='item', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='invoices.item', verbose_name='Artikel'), + ), + migrations.AddField( + model_name='item', + name='unit', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='item_items', to='invoices.unit', verbose_name='Einheit'), + ), + ] diff --git a/invoices/migrations/0003_invoice_pdf_canceled_file_and_more.py b/invoices/migrations/0003_invoice_pdf_canceled_file_and_more.py new file mode 100644 index 0000000..68a331b --- /dev/null +++ b/invoices/migrations/0003_invoice_pdf_canceled_file_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-06-24 08:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='invoice', + name='pdf_canceled_file', + field=models.FileField(blank=True, null=True, upload_to='invoice_pdfs/', verbose_name='Stornorechnung'), + ), + migrations.AddField( + model_name='invoice', + name='pdf_canceled_generated_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Stornorechnung erstellt am'), + ), + ] diff --git a/invoices/migrations/0004_documentnumbersettings.py b/invoices/migrations/0004_documentnumbersettings.py new file mode 100644 index 0000000..467e37b --- /dev/null +++ b/invoices/migrations/0004_documentnumbersettings.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.6 on 2026-06-24 09:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_delete_documentnumbersettings'), + ('invoices', '0003_invoice_pdf_canceled_file_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='DocumentNumberSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('invoice_prefix', models.CharField(default='RE', max_length=20)), + ('invoice_next_number', models.PositiveIntegerField(default=1)), + ('cancel_prefix', models.CharField(default='ST', max_length=20)), + ('cancel_next_number', models.PositiveIntegerField(default=1)), + ('offer_prefix', models.CharField(default='ANG', max_length=20)), + ('offer_next_number', models.PositiveIntegerField(default=1)), + ('company', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='number_settings', to='core.companysetting')), + ], + ), + ] diff --git a/invoices/migrations/0005_remove_documentnumbersettings_cancel_prefix_and_more.py b/invoices/migrations/0005_remove_documentnumbersettings_cancel_prefix_and_more.py new file mode 100644 index 0000000..a78f87e --- /dev/null +++ b/invoices/migrations/0005_remove_documentnumbersettings_cancel_prefix_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 6.0.6 on 2026-06-24 09:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0004_documentnumbersettings'), + ] + + operations = [ + migrations.RemoveField( + model_name='documentnumbersettings', + name='cancel_prefix', + ), + migrations.RemoveField( + model_name='documentnumbersettings', + name='invoice_prefix', + ), + migrations.RemoveField( + model_name='documentnumbersettings', + name='offer_prefix', + ), + migrations.AddField( + model_name='documentnumbersettings', + name='cancel_format', + field=models.CharField(default='ST-{year}-{number}', max_length=100), + ), + migrations.AddField( + model_name='documentnumbersettings', + name='invoice_format', + field=models.CharField(default='RE-{year}-{number}', max_length=100), + ), + migrations.AddField( + model_name='documentnumbersettings', + name='offer_format', + field=models.CharField(default='ANG-{year}-{number}', max_length=100), + ), + ] diff --git a/invoices/migrations/0006_alter_documentnumbersettings_options_and_more.py b/invoices/migrations/0006_alter_documentnumbersettings_options_and_more.py new file mode 100644 index 0000000..6d5f508 --- /dev/null +++ b/invoices/migrations/0006_alter_documentnumbersettings_options_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 6.0.6 on 2026-06-24 09:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_delete_documentnumbersettings'), + ('invoices', '0005_remove_documentnumbersettings_cancel_prefix_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='documentnumbersettings', + options={'verbose_name': 'Dokumentnummer', 'verbose_name_plural': 'Dokumentnummern'}, + ), + migrations.AlterField( + model_name='documentnumbersettings', + name='cancel_format', + field=models.CharField(default='ST-{year}-{number}', max_length=100, verbose_name='Stornoformat'), + ), + migrations.AlterField( + model_name='documentnumbersettings', + name='cancel_next_number', + field=models.PositiveIntegerField(default=1, verbose_name='Storno ID (nächste)'), + ), + migrations.AlterField( + model_name='documentnumbersettings', + name='company', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='number_settings', to='core.companysetting', verbose_name='Unternehmen'), + ), + migrations.AlterField( + model_name='documentnumbersettings', + name='invoice_format', + field=models.CharField(default='RE-{year}-{number}', max_length=100, verbose_name='Rechnungsformat'), + ), + migrations.AlterField( + model_name='documentnumbersettings', + name='invoice_next_number', + field=models.PositiveIntegerField(default=1, verbose_name='Rechnungs ID (nächste)'), + ), + migrations.AlterField( + model_name='documentnumbersettings', + name='offer_format', + field=models.CharField(default='ANG-{year}-{number}', max_length=100, verbose_name='Angebotsformat'), + ), + migrations.AlterField( + model_name='documentnumbersettings', + name='offer_next_number', + field=models.PositiveIntegerField(default=1, verbose_name='Angebots ID (nächste)'), + ), + ] diff --git a/invoices/migrations/0007_invoice_cancel_number.py b/invoices/migrations/0007_invoice_cancel_number.py new file mode 100644 index 0000000..4543292 --- /dev/null +++ b/invoices/migrations/0007_invoice_cancel_number.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-24 11:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0006_alter_documentnumbersettings_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='invoice', + name='cancel_number', + field=models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='Stornorechnungsnummer'), + ), + ] diff --git a/invoices/migrations/0008_reminder.py b/invoices/migrations/0008_reminder.py new file mode 100644 index 0000000..6744a9b --- /dev/null +++ b/invoices/migrations/0008_reminder.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.6 on 2026-06-24 18:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0007_invoice_cancel_number'), + ] + + operations = [ + migrations.CreateModel( + name='Reminder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reminder_number', models.CharField(max_length=50, unique=True, verbose_name='Mahnungsnummer')), + ('level', models.PositiveSmallIntegerField(default=1, help_text='1 = Erste Mahnung, 2 = Zweite Mahnung, 3 = Letzte Mahnung', verbose_name='Mahnstufe')), + ('issue_date', models.DateField(verbose_name='Mahndatum')), + ('due_date', models.DateField(blank=True, null=True, verbose_name='Neue Zahlungsfrist')), + ('fee', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Mahngebühr')), + ('interest', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Verzugszinsen')), + ('note', models.TextField(blank=True, verbose_name='Hinweis')), + ('pdf_file', models.FileField(blank=True, null=True, upload_to='reminder_pdfs/', verbose_name='PDF')), + ('pdf_generated_at', models.DateTimeField(blank=True, null=True, verbose_name='PDF erstellt am')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reminders', to='invoices.invoice', verbose_name='Rechnung')), + ], + options={ + 'verbose_name': 'Mahnung', + 'verbose_name_plural': 'Mahnungen', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/invoices/migrations/0009_offer_offeritem.py b/invoices/migrations/0009_offer_offeritem.py new file mode 100644 index 0000000..9d6d493 --- /dev/null +++ b/invoices/migrations/0009_offer_offeritem.py @@ -0,0 +1,64 @@ +# Generated by Django 6.0.6 on 2026-06-24 19:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0008_reminder'), + ('projects', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Offer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('offer_number', models.CharField(max_length=50, unique=True, verbose_name='Angebotsnummer')), + ('status', models.CharField(choices=[('draft', 'Entwurf'), ('sent', 'Versendet'), ('accepted', 'Akzeptiert'), ('rejected', 'Abgelehnt'), ('expired', 'Ausgelaufen')], default='draft', max_length=20, verbose_name='Status')), + ('issue_date', models.DateField(verbose_name='Angebotsdatum')), + ('due_date', models.DateField(blank=True, null=True, verbose_name='Gültig bis')), + ('note', models.TextField(blank=True, verbose_name='Notiz')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('pdf_file', models.FileField(blank=True, null=True, upload_to='offer_pdfs/', verbose_name='PDF')), + ('pdf_generated_at', models.DateTimeField(blank=True, null=True, verbose_name='PDF erstellt am')), + ('tax_number', models.CharField(blank=True, max_length=100, verbose_name='Steuernummer Kunde')), + ('vat_number', models.CharField(blank=True, max_length=100, verbose_name='USt-IdNr Kunde')), + ('contact_name', models.CharField(max_length=255, verbose_name='Ansprechpartner')), + ('contact_email', models.EmailField(blank=True, max_length=254, verbose_name='E-Mail Ansprechpartner')), + ('contact_phone', models.CharField(blank=True, max_length=100, verbose_name='Telefon Ansprechpartner')), + ('location_name', models.CharField(max_length=255, verbose_name='Standort')), + ('location_street', models.CharField(blank=True, max_length=255, verbose_name='Straße')), + ('location_city', models.CharField(blank=True, max_length=255, verbose_name='Ort')), + ('location_zip', models.CharField(blank=True, max_length=20, verbose_name='PLZ')), + ('location_country', models.CharField(blank=True, max_length=100, verbose_name='Land')), + ('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='offers', to='invoices.customer', verbose_name='Kunde')), + ('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='offers', to='projects.project', verbose_name='Projekt')), + ], + options={ + 'verbose_name': 'Angebot', + 'verbose_name_plural': 'Angebote', + 'ordering': ['-issue_date'], + }, + ), + migrations.CreateModel( + name='OfferItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(max_length=255, verbose_name='Beschreibung')), + ('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Menge')), + ('unit', models.CharField(max_length=50, verbose_name='Einheit')), + ('unit_price_net', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Einzelpreis Netto')), + ('unit_tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='MwSt. Satz (%)')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='invoices.item', verbose_name='Artikel')), + ('offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='invoices.offer', verbose_name='Angebot')), + ], + options={ + 'verbose_name': 'Angebotsposition', + 'verbose_name_plural': 'Angebotspositionen', + }, + ), + ] diff --git a/invoices/migrations/0010_item_service_period_from_item_service_period_to.py b/invoices/migrations/0010_item_service_period_from_item_service_period_to.py new file mode 100644 index 0000000..146a4b8 --- /dev/null +++ b/invoices/migrations/0010_item_service_period_from_item_service_period_to.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-06-25 15:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0009_offer_offeritem'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='service_period_from', + field=models.DateField(blank=True, null=True, verbose_name='Leistungszeitraum von'), + ), + migrations.AddField( + model_name='item', + name='service_period_to', + field=models.DateField(blank=True, null=True, verbose_name='Leistungszeitraum bis'), + ), + ] diff --git a/invoices/migrations/0011_remove_item_service_period_from_and_more.py b/invoices/migrations/0011_remove_item_service_period_from_and_more.py new file mode 100644 index 0000000..b8523f3 --- /dev/null +++ b/invoices/migrations/0011_remove_item_service_period_from_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.6 on 2026-06-25 15:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0010_item_service_period_from_item_service_period_to'), + ] + + operations = [ + migrations.RemoveField( + model_name='item', + name='service_period_from', + ), + migrations.RemoveField( + model_name='item', + name='service_period_to', + ), + migrations.AddField( + model_name='invoiceitem', + name='service_period_from', + field=models.DateField(blank=True, null=True, verbose_name='Leistungszeitraum von'), + ), + migrations.AddField( + model_name='invoiceitem', + name='service_period_to', + field=models.DateField(blank=True, null=True, verbose_name='Leistungszeitraum bis'), + ), + ] diff --git a/invoices/migrations/0012_remove_offer_project.py b/invoices/migrations/0012_remove_offer_project.py new file mode 100644 index 0000000..f49d9a1 --- /dev/null +++ b/invoices/migrations/0012_remove_offer_project.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.6 on 2026-06-25 16:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0011_remove_item_service_period_from_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='offer', + name='project', + ), + ] diff --git a/invoices/migrations/0013_reminder_status.py b/invoices/migrations/0013_reminder_status.py new file mode 100644 index 0000000..8137a9c --- /dev/null +++ b/invoices/migrations/0013_reminder_status.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-27 05:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0012_remove_offer_project'), + ] + + operations = [ + migrations.AddField( + model_name='reminder', + name='status', + field=models.CharField(choices=[('draft', 'Entwurf'), ('sent', 'Versendet'), ('paid', 'Bezahlt'), ('partial', 'Teilweise bezahlt'), ('cancelled', 'Storniert'), ('expired', 'Frist abgelaufen')], default='draft', max_length=20, verbose_name='Status'), + ), + ] diff --git a/invoices/migrations/0014_alter_invoice_status.py b/invoices/migrations/0014_alter_invoice_status.py new file mode 100644 index 0000000..2dce8a8 --- /dev/null +++ b/invoices/migrations/0014_alter_invoice_status.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-27 06:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0013_reminder_status'), + ] + + operations = [ + migrations.AlterField( + model_name='invoice', + name='status', + field=models.CharField(choices=[('draft', 'Entwurf'), ('sent', 'Versendet'), ('paid', 'Bezahlt'), ('overdue', 'Überfällig'), ('canceled', 'Storniert')], default='draft', max_length=20, verbose_name='Status'), + ), + ] diff --git a/invoices/migrations/0015_alter_documentnumbersettings_offer_format_and_more.py b/invoices/migrations/0015_alter_documentnumbersettings_offer_format_and_more.py new file mode 100644 index 0000000..9e61b50 --- /dev/null +++ b/invoices/migrations/0015_alter_documentnumbersettings_offer_format_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-06-27 06:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0014_alter_invoice_status'), + ] + + operations = [ + migrations.AlterField( + model_name='documentnumbersettings', + name='offer_format', + field=models.CharField(default='M-{year}-{number}', max_length=100, verbose_name='Mahnungsformat'), + ), + migrations.AlterField( + model_name='documentnumbersettings', + name='offer_next_number', + field=models.PositiveIntegerField(default=1, verbose_name='Mahnungs ID (nächste)'), + ), + ] diff --git a/invoices/migrations/0016_documentnumbersettings_reminder_format_and_more.py b/invoices/migrations/0016_documentnumbersettings_reminder_format_and_more.py new file mode 100644 index 0000000..8e0ee54 --- /dev/null +++ b/invoices/migrations/0016_documentnumbersettings_reminder_format_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.6 on 2026-06-27 06:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0015_alter_documentnumbersettings_offer_format_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='documentnumbersettings', + name='reminder_format', + field=models.CharField(default='M-{year}-{number}', max_length=100, verbose_name='Mahnungsformat'), + ), + migrations.AddField( + model_name='documentnumbersettings', + name='reminder_next_number', + field=models.PositiveIntegerField(default=1, verbose_name='Mahnungs ID (nächste)'), + ), + migrations.AlterField( + model_name='documentnumbersettings', + name='offer_format', + field=models.CharField(default='ANG-{year}-{number}', max_length=100, verbose_name='Angebotsformat'), + ), + migrations.AlterField( + model_name='documentnumbersettings', + name='offer_next_number', + field=models.PositiveIntegerField(default=1, verbose_name='Angebots ID (nächste)'), + ), + ] diff --git a/invoices/migrations/0017_alter_reminder_status.py b/invoices/migrations/0017_alter_reminder_status.py new file mode 100644 index 0000000..91e4267 --- /dev/null +++ b/invoices/migrations/0017_alter_reminder_status.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-27 06:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0016_documentnumbersettings_reminder_format_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='reminder', + name='status', + field=models.CharField(choices=[('draft', 'Entwurf'), ('sent', 'Versendet'), ('paid', 'Bezahlt'), ('partial', 'Teilweise bezahlt'), ('canceled', 'Storniert'), ('expired', 'Frist abgelaufen')], default='draft', max_length=20, verbose_name='Status'), + ), + ] diff --git a/invoices/migrations/0018_alter_reminder_status.py b/invoices/migrations/0018_alter_reminder_status.py new file mode 100644 index 0000000..5cf8b12 --- /dev/null +++ b/invoices/migrations/0018_alter_reminder_status.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-27 07:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0017_alter_reminder_status'), + ] + + operations = [ + migrations.AlterField( + model_name='reminder', + name='status', + field=models.CharField(choices=[('draft', 'Entwurf'), ('sent', 'Versendet'), ('paid', 'Bezahlt'), ('canceled', 'Storniert'), ('expired', 'Frist abgelaufen')], default='draft', max_length=20, verbose_name='Status'), + ), + ] diff --git a/invoices/migrations/0019_alter_invoice_invoice_number.py b/invoices/migrations/0019_alter_invoice_invoice_number.py new file mode 100644 index 0000000..67acdde --- /dev/null +++ b/invoices/migrations/0019_alter_invoice_invoice_number.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-27 08:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0018_alter_reminder_status'), + ] + + operations = [ + migrations.AlterField( + model_name='invoice', + name='invoice_number', + field=models.CharField(blank=True, max_length=50, verbose_name='Rechnungsnummer'), + ), + ] diff --git a/invoices/migrations/0020_alter_offer_offer_number.py b/invoices/migrations/0020_alter_offer_offer_number.py new file mode 100644 index 0000000..dedbe6f --- /dev/null +++ b/invoices/migrations/0020_alter_offer_offer_number.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-27 08:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0019_alter_invoice_invoice_number'), + ] + + operations = [ + migrations.AlterField( + model_name='offer', + name='offer_number', + field=models.CharField(blank=True, max_length=50, verbose_name='Angebotsnummer'), + ), + ] diff --git a/invoices/migrations/0021_alter_reminder_reminder_number.py b/invoices/migrations/0021_alter_reminder_reminder_number.py new file mode 100644 index 0000000..38e62f2 --- /dev/null +++ b/invoices/migrations/0021_alter_reminder_reminder_number.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-27 08:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0020_alter_offer_offer_number'), + ] + + operations = [ + migrations.AlterField( + model_name='reminder', + name='reminder_number', + field=models.CharField(blank=True, max_length=50, verbose_name='Mahnungsnummer'), + ), + ] diff --git a/invoices/migrations/0022_reminder_interest_rate_alter_reminder_interest.py b/invoices/migrations/0022_reminder_interest_rate_alter_reminder_interest.py new file mode 100644 index 0000000..ce551e7 --- /dev/null +++ b/invoices/migrations/0022_reminder_interest_rate_alter_reminder_interest.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-06-27 08:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0021_alter_reminder_reminder_number'), + ] + + operations = [ + migrations.AddField( + model_name='reminder', + name='interest_rate', + field=models.DecimalField(decimal_places=2, default=0, max_digits=5, verbose_name='Verzugszinssatz (%)'), + ), + migrations.AlterField( + model_name='reminder', + name='interest', + field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Verzugszinsen Betrag'), + ), + ] diff --git a/invoices/migrations/0023_reminder_interest_days.py b/invoices/migrations/0023_reminder_interest_days.py new file mode 100644 index 0000000..41cc617 --- /dev/null +++ b/invoices/migrations/0023_reminder_interest_days.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-27 09:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0022_reminder_interest_rate_alter_reminder_interest'), + ] + + operations = [ + migrations.AddField( + model_name='reminder', + name='interest_days', + field=models.PositiveIntegerField(default=0, verbose_name='Verzugstage'), + ), + ] diff --git a/invoices/migrations/0024_alter_reminder_interest_alter_reminder_interest_days_and_more.py b/invoices/migrations/0024_alter_reminder_interest_alter_reminder_interest_days_and_more.py new file mode 100644 index 0000000..189c205 --- /dev/null +++ b/invoices/migrations/0024_alter_reminder_interest_alter_reminder_interest_days_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.6 on 2026-06-27 09:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0023_reminder_interest_days'), + ] + + operations = [ + migrations.AlterField( + model_name='reminder', + name='interest', + field=models.DecimalField(blank=True, decimal_places=2, default=0, max_digits=10, verbose_name='Verzugszinsen Betrag'), + ), + migrations.AlterField( + model_name='reminder', + name='interest_days', + field=models.PositiveIntegerField(blank=True, default=0, verbose_name='Verzugstage'), + ), + migrations.AlterField( + model_name='reminder', + name='interest_rate', + field=models.DecimalField(blank=True, decimal_places=2, default=0, max_digits=5, verbose_name='Verzugszinssatz (%)'), + ), + ] diff --git a/invoices/migrations/0025_remove_reminder_interest_and_more.py b/invoices/migrations/0025_remove_reminder_interest_and_more.py new file mode 100644 index 0000000..2412b6f --- /dev/null +++ b/invoices/migrations/0025_remove_reminder_interest_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.6 on 2026-06-27 09:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0024_alter_reminder_interest_alter_reminder_interest_days_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='reminder', + name='interest', + ), + migrations.RemoveField( + model_name='reminder', + name='interest_days', + ), + migrations.RemoveField( + model_name='reminder', + name='interest_rate', + ), + ] diff --git a/invoices/migrations/__init__.py b/invoices/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invoices/models.py b/invoices/models.py new file mode 100644 index 0000000..46cf7f0 --- /dev/null +++ b/invoices/models.py @@ -0,0 +1,595 @@ +from django.db import models +from django.core.exceptions import ValidationError +from core.models import CompanySetting + +class Unit(models.Model): + name = models.CharField( + max_length=50, + verbose_name="Bezeichnung" + ) + short_name = models.CharField( + max_length=20, + verbose_name="Kürzel" + ) + class Meta: + verbose_name = "Einheit" + verbose_name_plural = "Einheiten" + ordering = ["name"] + def __str__(self): + return self.name + +class Customer(models.Model): + class CustomerType(models.TextChoices): + PRIVATE = "private", "Privat" + COMPANY = "company", "Unternehmen" + + customer_number = models.CharField(max_length=50, unique=True, verbose_name="Kunden-ID") + customer_type = models.CharField(max_length=20, choices=CustomerType.choices, default=CustomerType.COMPANY, verbose_name="Kundentyp") + + company_name = models.CharField(max_length=255, verbose_name="Firmenname / Kundenname") + + tax_number = models.CharField(max_length=100, blank=True, verbose_name="Steuernummer") + vat_number = models.CharField(max_length=100, blank=True, verbose_name="UST-IdNr") + + phone = models.CharField(max_length=100, blank=True, verbose_name="Telefon") + email = models.EmailField(blank=True, verbose_name="E-Mail") + + active = models.BooleanField(default=True, verbose_name="Status") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Kunde" + verbose_name_plural = "Kunden" + ordering = ["company_name"] + + def __str__(self): + return f"{self.customer_number} - {self.company_name}" + + +class CustomerContact(models.Model): + customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="contacts", verbose_name="Kunde") + + first_name = models.CharField(max_length=150, verbose_name="Vorname") + last_name = models.CharField(max_length=150, verbose_name="Nachname") + + email = models.EmailField(blank=True, verbose_name="E-Mail") + phone = models.CharField(max_length=100, blank=True, verbose_name="Telefon") + + class Meta: + verbose_name = "Ansprechpartner" + verbose_name_plural = "Ansprechpartner" + ordering = ["last_name", "first_name"] + + def __str__(self): + return f"{self.first_name} {self.last_name}" + + +class CustomerLocation(models.Model): + customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="locations", verbose_name="Kunde") + + name = models.CharField(max_length=255, verbose_name="Bezeichnung") + + zip_code = models.CharField(max_length=20, blank=True, verbose_name="PLZ") + city = models.CharField(max_length=255, blank=True, verbose_name="Ort") + street = models.CharField(max_length=255, blank=True, verbose_name="Adresse") + country = models.CharField(max_length=100, blank=True, verbose_name="Land") + + class Meta: + verbose_name = "Standort" + verbose_name_plural = "Standorte" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Item(models.Model): + class ItemType(models.TextChoices): + SERVICE = "service", "Dienstleistung" + PRODUCT = "product", "Ware" + + item_type = models.CharField(max_length=20, choices=ItemType.choices, default=ItemType.PRODUCT, verbose_name="Art") + description = models.CharField(max_length=255, verbose_name="Beschreibung") + unit = models.ForeignKey(Unit,on_delete=models.PROTECT,related_name="item_items",verbose_name="Einheit") + + price_net = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Preis Netto") + tax_rate = models.DecimalField( + max_digits=5, + decimal_places=2, + default=19.00, + verbose_name="MwSt. Satz (%)" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Posten" + verbose_name_plural = "Posten" + ordering = ["description"] + + def __str__(self): + return self.description + + + + + + + + + + + + + + + +class Invoice(models.Model): + class Status(models.TextChoices): + DRAFT = "draft", "Entwurf" + SENT = "sent", "Versendet" + PAID = "paid", "Bezahlt" + OVERDUE = "overdue", "Überfällig" + CANCELED = "canceled", "Storniert" + + invoice_number = models.CharField(max_length=50,verbose_name="Rechnungsnummer", blank=True) + customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="invoices", null=True, blank=True, verbose_name="Kunde") + project = models.ForeignKey("projects.Project", on_delete=models.SET_NULL, null=True, blank=True, related_name="invoices", verbose_name="Projekt") + status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT, verbose_name="Status") + issue_date = models.DateField(verbose_name="Rechnungsdatum") + due_date = models.DateField(null=True, blank=True, verbose_name="Fälligkeitsdatum") + note = models.TextField(blank=True, verbose_name="Notiz") + created_at = models.DateTimeField(auto_now_add=True) + + pdf_file = models.FileField(upload_to="invoice_pdfs/",null=True,blank=True,verbose_name="PDF") + pdf_generated_at = models.DateTimeField(null=True,blank=True,verbose_name="PDF erstellt am") + + cancel_number = models.CharField(max_length=50,unique=True,null=True,blank=True,verbose_name="Stornorechnungsnummer") + pdf_canceled_file = models.FileField(upload_to="invoice_pdfs/",null=True,blank=True,verbose_name="Stornorechnung") + pdf_canceled_generated_at = models.DateTimeField(null=True,blank=True,verbose_name="Stornorechnung erstellt am") + + tax_number = models.CharField(max_length=100, blank=True, verbose_name="Steuernummer Kunde") + vat_number = models.CharField(max_length=100, blank=True, verbose_name="USt-IdNr Kunde") + contact_name = models.CharField(max_length=255, verbose_name="Ansprechpartner") + contact_email = models.EmailField(blank=True, verbose_name="E-Mail Ansprechpartner") + contact_phone = models.CharField(max_length=100, blank=True, verbose_name="Telefon Ansprechpartner") + location_name = models.CharField(max_length=255, verbose_name="Standort") + location_street = models.CharField(max_length=255, blank=True, verbose_name="Straße") + location_city = models.CharField(max_length=255, blank=True, verbose_name="Ort") + location_zip = models.CharField(max_length=20, blank=True, verbose_name="PLZ") + location_country = models.CharField(max_length=100, blank=True, verbose_name="Land") + + class Meta: + verbose_name = "Rechnung" + verbose_name_plural = "Rechnungen" + ordering = ["-issue_date"] + + def save(self, *args, **kwargs): + + if self.pk: + + old = Invoice.objects.get(pk=self.pk) + + if old.status in [ + self.Status.SENT, + self.Status.PAID, + self.Status.CANCELED, + ]: + + protected_fields = [ + field.name + for field in self._meta.fields + if field.name not in [ + "status", + "pdf_file", + "pdf_generated_at", + "created_at", + "pdf_canceled_file", + "pdf_canceled_generated_at", + "cancel_number", + ] + ] + + for field in protected_fields: + + if getattr(old, field) != getattr(self, field): + + raise ValidationError( + "Eine finale Rechnung kann nicht mehr bearbeitet werden." + ) + + super().save(*args, **kwargs) + + def __str__(self): + return self.invoice_number + + +class InvoiceItem(models.Model): + invoice = models.ForeignKey(Invoice,on_delete=models.CASCADE,related_name="items",verbose_name="Rechnung") + source_type = models.CharField(max_length=20,choices=[("item","Artikel"),("time","Zeiterfassung"),("expense","Ausgabe"),],default="item",verbose_name="Quelle",) + item = models.ForeignKey(Item,on_delete=models.SET_NULL,null=True,blank=True,verbose_name="Artikel") # Quelle 1 + time_entry = models.ForeignKey("time_tracking.TimeEntry",on_delete=models.SET_NULL,null=True,blank=True,verbose_name="Zeiterfassung") # Quelle 2 + expense = models.ForeignKey("finance.Expense",on_delete=models.SET_NULL,null=True,blank=True,verbose_name="Ausgabe") # Quelle 3 + description = models.CharField(max_length=255,verbose_name="Beschreibung") + quantity = models.DecimalField(max_digits=10,decimal_places=2,default=1,verbose_name="Menge") + unit = models.CharField(max_length=50,verbose_name="Einheit") + unit_price_net = models.DecimalField(max_digits=10,decimal_places=2,verbose_name="Einzelpreis Netto") + unit_tax_rate = models.DecimalField(max_digits=5,decimal_places=2,default=19.00,verbose_name="MwSt. Satz (%)") + service_period_from = models.DateField(null=True,blank=True,verbose_name="Leistungszeitraum von",) + service_period_to = models.DateField(null=True,blank=True,verbose_name="Leistungszeitraum bis",) + created_at = models.DateTimeField(auto_now_add=True) + + @property + def calculated_total_net(self): + return self.quantity * self.unit_price_net + + class Meta: + verbose_name = "Rechnungsposition" + verbose_name_plural = "Rechnungspositionen" + + def clean(self): + + sources = [ + self.item, + self.time_entry, + self.expense, + ] + + selected = sum(1 for s in sources if s) + + if selected != 1: + raise ValidationError( + "Genau eine Quelle muss ausgewählt werden." + ) + + def __str__(self): + return self.description + + + + + + + + + + + + + + + + + + + + + + +class Reminder(models.Model): + class Status(models.TextChoices): + DRAFT = "draft", "Entwurf" + SENT = "sent", "Versendet" + PAID = "paid", "Bezahlt" + #PARTIALLY_PAID = "partial", "Teilweise bezahlt" + CANCELED = "canceled", "Storniert" + EXPIRED = "expired", "Frist abgelaufen" + + invoice = models.ForeignKey("Invoice",on_delete=models.PROTECT,related_name="reminders",verbose_name="Rechnung") + reminder_number = models.CharField(max_length=50,verbose_name="Mahnungsnummer", blank=True) + + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.DRAFT, + verbose_name="Status", + ) + level = models.PositiveSmallIntegerField(default=1,verbose_name="Mahnstufe",help_text="1 = Erste Mahnung, 2 = Zweite Mahnung, 3 = Letzte Mahnung",) + issue_date = models.DateField(verbose_name="Mahndatum",) + due_date = models.DateField(null=True,blank=True,verbose_name="Neue Zahlungsfrist",) + fee = models.DecimalField(max_digits=10,decimal_places=2,default=0,verbose_name="Mahngebühr",) + note = models.TextField(blank=True,verbose_name="Hinweis",) + pdf_file = models.FileField(upload_to="reminder_pdfs/",null=True,blank=True,verbose_name="PDF",) + pdf_generated_at = models.DateTimeField(null=True,blank=True,verbose_name="PDF erstellt am",) + created_at = models.DateTimeField(auto_now_add=True,) + + class Meta: + ordering = ["-created_at"] + verbose_name = "Mahnung" + verbose_name_plural = "Mahnungen" + + def __str__(self): + return self.reminder_number + + + + + + + + + + + + + + + + + + + + + + + + + + + + +class Offer(models.Model): + + class Status(models.TextChoices): + DRAFT = "draft", "Entwurf" + SENT = "sent", "Versendet" + ACCEPTED = "accepted", "Akzeptiert" + REJECTED = "rejected", "Abgelehnt" + EXPIRED = "expired", "Ausgelaufen" + + offer_number = models.CharField(max_length=50,verbose_name="Angebotsnummer", blank=True) + + + + + customer = models.ForeignKey( + Customer, + on_delete=models.PROTECT, + related_name="offers", + null=True, + blank=True, + verbose_name="Kunde", + ) + + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.DRAFT, + verbose_name="Status", + ) + + issue_date = models.DateField( + verbose_name="Angebotsdatum", + ) + + due_date = models.DateField( + null=True, + blank=True, + verbose_name="Gültig bis", + ) + + note = models.TextField( + blank=True, + verbose_name="Notiz", + ) + + created_at = models.DateTimeField( + auto_now_add=True, + ) + + pdf_file = models.FileField( + upload_to="offer_pdfs/", + null=True, + blank=True, + verbose_name="PDF", + ) + + pdf_generated_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="PDF erstellt am", + ) + + tax_number = models.CharField( + max_length=100, + blank=True, + verbose_name="Steuernummer Kunde", + ) + + vat_number = models.CharField( + max_length=100, + blank=True, + verbose_name="USt-IdNr Kunde", + ) + + contact_name = models.CharField( + max_length=255, + verbose_name="Ansprechpartner", + ) + + contact_email = models.EmailField( + blank=True, + verbose_name="E-Mail Ansprechpartner", + ) + + contact_phone = models.CharField( + max_length=100, + blank=True, + verbose_name="Telefon Ansprechpartner", + ) + + location_name = models.CharField( + max_length=255, + verbose_name="Standort", + ) + + location_street = models.CharField( + max_length=255, + blank=True, + verbose_name="Straße", + ) + + location_city = models.CharField( + max_length=255, + blank=True, + verbose_name="Ort", + ) + + location_zip = models.CharField( + max_length=20, + blank=True, + verbose_name="PLZ", + ) + + location_country = models.CharField( + max_length=100, + blank=True, + verbose_name="Land", + ) + + class Meta: + verbose_name = "Angebot" + verbose_name_plural = "Angebote" + ordering = ["-issue_date"] + + def save(self, *args, **kwargs): + + if self.pk: + + old = Offer.objects.get(pk=self.pk) + + if old.status in [ + self.Status.ACCEPTED, + self.Status.REJECTED, + self.Status.EXPIRED, + ]: + + protected_fields = [ + field.name + for field in self._meta.fields + if field.name not in [ + "status", + "pdf_file", + "pdf_generated_at", + ] + ] + + for field in protected_fields: + + if getattr(old, field) != getattr(self, field): + + raise ValidationError( + "Ein finales Angebot kann nicht mehr bearbeitet werden." + ) + + super().save(*args, **kwargs) + + def __str__(self): + return self.offer_number + + +class OfferItem(models.Model): + offer = models.ForeignKey( + Offer, + on_delete=models.CASCADE, + related_name="items", + verbose_name="Angebot", + ) + + item = models.ForeignKey( + Item, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="Artikel", + ) + + description = models.CharField( + max_length=255, + verbose_name="Beschreibung", + ) + + quantity = models.DecimalField( + max_digits=10, + decimal_places=2, + default=1, + verbose_name="Menge", + ) + + unit = models.CharField( + max_length=50, + verbose_name="Einheit", + ) + + unit_price_net = models.DecimalField( + max_digits=10, + decimal_places=2, + verbose_name="Einzelpreis Netto", + ) + + unit_tax_rate = models.DecimalField( + max_digits=5, + decimal_places=2, + default=19.00, + verbose_name="MwSt. Satz (%)", + ) + + created_at = models.DateTimeField( + auto_now_add=True, + ) + + @property + def calculated_total_net(self): + return self.quantity * self.unit_price_net + + class Meta: + verbose_name = "Angebotsposition" + verbose_name_plural = "Angebotspositionen" + + def __str__(self): + return self.description + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +class DocumentNumberSettings(models.Model): + company = models.OneToOneField(CompanySetting,on_delete=models.CASCADE,related_name="number_settings",verbose_name="Unternehmen") + invoice_format = models.CharField(max_length=100,default="RE-{year}-{number}",verbose_name="Rechnungsformat") + invoice_next_number = models.PositiveIntegerField(default=1,verbose_name="Rechnungs ID (nächste)") + cancel_format = models.CharField(max_length=100,default="ST-{year}-{number}",verbose_name="Stornoformat") + cancel_next_number = models.PositiveIntegerField(default=1,verbose_name="Storno ID (nächste)") + offer_format = models.CharField(max_length=100,default="ANG-{year}-{number}",verbose_name="Angebotsformat") + offer_next_number = models.PositiveIntegerField(default=1,verbose_name="Angebots ID (nächste)") + reminder_format = models.CharField(max_length=100,default="M-{year}-{number}",verbose_name="Mahnungsformat") + reminder_next_number = models.PositiveIntegerField(default=1,verbose_name="Mahnungs ID (nächste)") + + class Meta: + verbose_name = "Dokumentnummer" + verbose_name_plural = "Dokumentnummern" diff --git a/invoices/settings_registry.py b/invoices/settings_registry.py new file mode 100644 index 0000000..91b981c --- /dev/null +++ b/invoices/settings_registry.py @@ -0,0 +1,17 @@ +from masterdata.registry import register_setting + +from .models import ( + Unit, DocumentNumberSettings +) +register_setting( + group="Rechnungswesen", + name="Einheiten", + model=Unit, +) + +register_setting( + group="Rechnungswesen", + name="Dokumentnummern", + model=DocumentNumberSettings, + setting_type="single", +) \ No newline at end of file diff --git a/invoices/tables.py b/invoices/tables.py new file mode 100644 index 0000000..b1365c0 --- /dev/null +++ b/invoices/tables.py @@ -0,0 +1,426 @@ +import django_tables2 as tables +from .models import Item, Customer, Reminder, Invoice, InvoiceItem, Offer, OfferItem + +class OfferItemTable(tables.Table): + + unit_price_net = tables.TemplateColumn( + template_name="invoices/tables/offer_items/unit_price.html", + verbose_name="Einzelpreis", + orderable=False, + ) + + unit_tax_rate = tables.TemplateColumn( + template_name="invoices/tables/offer_items/tax_rate.html", + verbose_name="Steuerart", + orderable=False, + ) + + total = tables.TemplateColumn( + template_name="invoices/tables/offer_items/total.html", + verbose_name="Gesamt", + orderable=False, + ) + + actions = tables.TemplateColumn( + template_name="invoices/tables/offer_items/actions.html", + verbose_name="Aktionen", + orderable=False, + ) + + class Meta: + model = OfferItem + + fields = ( + "description", + "quantity", + "unit", + "unit_price_net", + "unit_tax_rate", + "total", + "actions", + ) + + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + + +class OfferTable(tables.Table): + + customer = tables.Column( + accessor="customer.company_name", + verbose_name="Kunde" + ) + + status = tables.Column( + accessor="get_status_display", + verbose_name="Status" + ) + + issue_date = tables.DateColumn( + format="d.m.Y", + verbose_name="Datum" + ) + + due_date = tables.DateColumn( + format="d.m.Y", + verbose_name="Gültig bis" + ) + + actions = tables.TemplateColumn( + template_name="invoices/tables/offers/actions.html", + verbose_name="Aktion", + orderable=False + ) + + class Meta: + model = Offer + + fields = ( + "offer_number", + "customer", + "status", + "issue_date", + "due_date", + "actions", + ) + + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + + +class InvoiceItemTable(tables.Table): + + service_period = tables.TemplateColumn( + template_name="invoices/tables/invoice_items/service_period.html", + verbose_name="Leistungsdatum", + orderable=False, + ) + + unit_price_net = tables.TemplateColumn( + template_name="invoices/tables/invoice_items/unit_price.html", + verbose_name="Einzelpreis", + orderable=False, + ) + + unit_tax_rate = tables.TemplateColumn( + template_name="invoices/tables/invoice_items/tax_rate.html", + verbose_name="Steuerart", + orderable=False, + ) + + total = tables.TemplateColumn( + template_name="invoices/tables/invoice_items/total.html", + verbose_name="Gesamt", + orderable=False, + ) + + actions = tables.TemplateColumn( + template_name="invoices/tables/invoice_items/actions.html", + verbose_name="Aktionen", + orderable=False, + ) + + class Meta: + model = InvoiceItem + + fields = ( + "description", + "service_period", + "quantity", + "unit", + "unit_price_net", + "unit_tax_rate", + "total", + "actions", + ) + + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + + +class InvoiceTable(tables.Table): + + customer = tables.Column( + accessor="customer.company_name", + verbose_name="Kunde" + ) + + project = tables.Column( + verbose_name="Projekt", + ) + + status = tables.Column( + accessor="get_status_display", + verbose_name="Status" + ) + + issue_date = tables.DateColumn( + format="d.m.Y", + verbose_name="Datum" + ) + + due_date = tables.DateColumn( + format="d.m.Y", + verbose_name="Fälligkeitsdatum" + ) + + + actions = tables.TemplateColumn( + template_name="invoices/tables/invoices/actions.html", + verbose_name="Aktion", + orderable=False + ) + + + class Meta: + + model = Invoice + + fields = ( + "invoice_number", + "customer", + "project", + "status", + "issue_date", + "due_date", + "actions", + ) + + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + +class ReminderTable(tables.Table): + + invoice = tables.TemplateColumn( + template_name="invoices/tables/reminders/invoice.html", + verbose_name="Rechnung", + orderable=False + ) + + level = tables.Column( + verbose_name="Mahnstufe" + ) + + status = tables.Column( + accessor="get_status_display", + verbose_name="Status" + ) + + issue_date = tables.DateColumn( + format="d.m.Y", + verbose_name="Datum" + ) + + due_date = tables.DateColumn( + format="d.m.Y", + verbose_name="Neue Zahlungsfrist" + ) + + actions = tables.TemplateColumn( + template_name="invoices/tables/reminders/actions.html", + verbose_name="Aktion", + orderable=False + ) + + + class Meta: + + model = Reminder + + fields = ( + "reminder_number", + "invoice", + "level", + "status", + "issue_date", + "due_date", + "actions", + ) + + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + +class ItemTable(tables.Table): + item_type = tables.TemplateColumn( + template_name="invoices/tables/items/item_type.html", + verbose_name="Typ", + orderable=False + ) + + description = tables.TemplateColumn( + template_name="invoices/tables/items/description.html", + verbose_name="Beschreibung", + orderable=False + ) + + unit = tables.TemplateColumn( + template_name="invoices/tables/items/unit.html", + verbose_name="Einheit", + orderable=False + ) + + price_net = tables.TemplateColumn( + template_name="invoices/tables/items/price_net.html", + verbose_name="Preis Netto", + orderable=False + ) + + tax_rate = tables.TemplateColumn( + template_name="invoices/tables/items/tax_rate.html", + verbose_name="MwSt. Satz (%)", + orderable=False + ) + + actions = tables.TemplateColumn( + template_name="invoices/tables/items/actions.html", + verbose_name="Aktion", + orderable=False + ) + + + class Meta: + model = Item + fields = ( + "item_type", + "description", + "unit", + "price_net", + "tax_rate", + "actions", + ) + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + + + + + +class CustomerTable(tables.Table): + toggle_prefix = "customer" + + detail_template = ( + "invoices/tables/customer/detail.html" + ) + + customer_number = tables.Column( + verbose_name="Kunden-ID" + ) + + company_name = tables.Column( + verbose_name="Name" + ) + + customer_type = tables.Column( + accessor="get_customer_type_display", + verbose_name="Typ" + ) + + phone = tables.Column( + verbose_name="Telefon" + ) + + email = tables.Column( + verbose_name="E-Mail" + ) + + tax_number = tables.Column( + verbose_name="Steuernummer" + ) + + vat_number = tables.Column( + verbose_name="UST-IdNr" + ) + + + class Meta: + model = Customer + + template_name = ( + "django_tables2/table_toggle.html" + ) + + fields = ( + "customer_number", + "company_name", + "customer_type", + "phone", + "email", + "tax_number", + "vat_number", + ) + + attrs = { + "class": "w-full text-sm min-w-[700px]" + } + + + + + + + + + +class CustomerArchivedTable(tables.Table): + toggle_prefix = "customer" + + detail_template = ( + "invoices/tables/customer/detail_archived.html" + ) + + customer_number = tables.Column( + verbose_name="Kunden-ID" + ) + + company_name = tables.Column( + verbose_name="Name" + ) + + customer_type = tables.Column( + accessor="get_customer_type_display", + verbose_name="Typ" + ) + + phone = tables.Column( + verbose_name="Telefon" + ) + + email = tables.Column( + verbose_name="E-Mail" + ) + + tax_number = tables.Column( + verbose_name="Steuernummer" + ) + + vat_number = tables.Column( + verbose_name="UST-IdNr" + ) + + + class Meta: + model = Customer + + template_name = ( + "django_tables2/table_toggle.html" + ) + + fields = ( + "customer_number", + "company_name", + "customer_type", + "phone", + "email", + "tax_number", + "vat_number", + ) + + attrs = { + "class": "w-full text-sm min-w-[700px]" + } \ No newline at end of file diff --git a/invoices/templates/invoices/customers/add.html b/invoices/templates/invoices/customers/add.html new file mode 100644 index 0000000..dc6cbcd --- /dev/null +++ b/invoices/templates/invoices/customers/add.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %} +Rechnungswesen - Kunden +{% endblock %} + +{% block header_title %} +Kunde hinzufügen +{% endblock %} + +{% block header_description %} +Neuen Kunden anlegen +{% endblock %} + +{% block content %} + +
+ +
+ {% csrf_token %} + +
+ {{ form.as_p }} +
+ + + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/customers/detail.html b/invoices/templates/invoices/customers/detail.html new file mode 100644 index 0000000..d40d07c --- /dev/null +++ b/invoices/templates/invoices/customers/detail.html @@ -0,0 +1,230 @@ +{% extends "base.html" %} + +{% block title %} +Rechnungswesen - Kunden +{% endblock %} + +{% block header_title %} +{{ customer.customer_number }} - {{ customer.company_name }} +{% endblock %} + +{% block header_description %} +Kunde bearbeiten +{% endblock %} + +{% block content %} +
+ {% csrf_token %} + +
+ +

Kundendaten

+ +
+ + {% for field in form %} +
+ + + {{ field }} + + {% if field.errors %} +
+ {{ field.errors }} +
+ {% endif %} +
+ {% endfor %} + +
+ + +
+ + +
+ +

+ Ansprechpartner +

+ + {% for contact in customer.contacts.all %} +
+ +
+ + + + + + + + + +
+ + + +
+ {% endfor %} + +
+ + + +
+ + + +
+ +

+ Standorte +

+ + {% for location in customer.locations.all %} +
+ +
+ + + + + + + + + + + +
+ + + +
+ {% endfor %} + +
+ + + +
+ +
+ + + + +
+ +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/customers/overview.html b/invoices/templates/invoices/customers/overview.html new file mode 100644 index 0000000..e7605aa --- /dev/null +++ b/invoices/templates/invoices/customers/overview.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} + +{% block title %} +Rechnungswesen - Kunden +{% endblock %} + +{% block header_title %} +Kunden +{% endblock %} + +{% block header_description %} +Kundenstammdaten verwalten +{% endblock %} + +{% block content %} +{% load django_tables2 %} +
+
+
+
+

Aktionen

+

+ Kunden anlegen +

+
+ +
+
+
+ +
+ +
+

Kundenliste

+

Alle Kunden, inkl. Standorte und Ansprechpartner

+
+ +
+ {% render_table table %} +
+
+ + +
+ +
+

Archivierte Kunden

+

Alle inaktiven Kunden, inkl. Standorte und Ansprechpartner

+
+ +
+ + {% render_table table_archived %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/invoices/add_customer.html b/invoices/templates/invoices/invoices/add_customer.html new file mode 100644 index 0000000..9a73be2 --- /dev/null +++ b/invoices/templates/invoices/invoices/add_customer.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} + +{% block title %}Rechnungswesen - Rechnungen{% endblock %} + +{% block header_title %} +Kundenrechnung +{% endblock %} + +{% block header_description %} +Rechnung auf Basis eines Kunden erstellen +{% endblock %} + +{% block content %} + +
+ +
+ {% csrf_token %} + + {{ form.as_p }} + + + +
+ +
+ + + +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/invoices/add_project.html b/invoices/templates/invoices/invoices/add_project.html new file mode 100644 index 0000000..6dc2270 --- /dev/null +++ b/invoices/templates/invoices/invoices/add_project.html @@ -0,0 +1,221 @@ +{% extends "base.html" %} + +{% block title %}Rechnungswesen - Rechnungen{% endblock %} + +{% block header_title %} +Projektrechnung +{% endblock %} + +{% block header_description %} +Rechnung auf Basis eines Projekts erstellen +{% endblock %} + +{% block content %} + +
+ +
+ {% csrf_token %} + + {{ form.as_p }} + + + +
+ +
+ + + +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/invoices/detail.html b/invoices/templates/invoices/invoices/detail.html new file mode 100644 index 0000000..fdcdff5 --- /dev/null +++ b/invoices/templates/invoices/invoices/detail.html @@ -0,0 +1,239 @@ +{% extends "base.html" %} + +{% block title %}Rechnungswesen - Rechnungen{% endblock %} + +{% block header_title %} +Rechnung {{ invoice.invoice_number }} +{% endblock %} + +{% block header_description %} +Details & Positionen +{% endblock %} + +{% block content %} +{% load django_tables2 %} +
+
+
+
+

Aktionen

+

+ Positionen zur Rechnung hinzufügen +

+
+
+ {% if invoice.status == "draft" %} + + Position + Vorschau + Download + Löschen + {% endif %} + {% if invoice.status == "sent" %} + Download + Stornieren + Zahlung verbuchen + Überfällig + {% endif %} + {% if invoice.status == "paid" %} + Download + Stornieren + {% endif %} + {% if invoice.status == "canceled" %} + Download + Stornorechnung + {% endif %} + {% if invoice.status == "overdue" %} + Download + {% endif %} +
+ +
+
+
+ +{% if reminders %} +
+ +

Mahnungen

+ +
+ {% for reminder in reminders %} + {% if reminder.status != "canceled" %} +
+ +
+ +
+ + {{ reminder.reminder_number }} + + +

+ Mahnstufe {{ reminder.level }} + · {{ reminder.issue_date|date:"d.m.Y" }} +

+
+ + + + {{ reminder.get_status_display }} + + +
+ + +
+ + {% if reminder.due_date %} +

+ Neue Zahlungsfrist: + + {{ reminder.due_date|date:"d.m.Y" }} + +

+ {% endif %} + + + {% if reminder.fee %} +

+ Mahngebühr: + + {{ reminder.fee|floatformat:2 }} € + +

+ {% endif %} + +
+ +
+ {% endif %} + {% endfor %} +
+
+{% endif %} + + +
+ +

Allgemein

+ +
+ + +
+
Kunde
+
{{ invoice.customer.company_name }}
+
+ + +
+
Projekt
+
+ {% if invoice.project %} + {{ invoice.project.name }} + {% else %} + - + {% endif %} +
+
+ + +
+
Status
+
{{ invoice.get_status_display }}
+
+ + +
+
Rechnungsdatum
+
{{ invoice.issue_date|date:"d.m.Y" }}
+
+ +
+
Fälligkeitsdatum
+
{{ invoice.due_date|date:"d.m.Y" }}
+
+
+
+ + + +
+ +

Rechnungsadresse

+ +
+ + +
+
Ansprechpartner
+
{{ invoice.contact_name|default:"-" }}
+ + {% if invoice.contact_email %} +
{{ invoice.contact_email }}
+ {% endif %} + + {% if invoice.contact_phone %} +
{{ invoice.contact_phone }}
+ {% endif %} +
+ + +
+
Standort
+ +
+ {{ invoice.location_name|default:"-" }} +
+ +
+ {{ invoice.location_street }} +
+ +
+ {{ invoice.location_zip }} {{ invoice.location_city }} +
+ +
+ {{ invoice.location_country }} +
+
+ +
+
+ + +
+

Rechnungshinweis

+
+
+
{{ invoice.note|default:"-" }}
+
+
+
+ + + +
+ +

Positionen

+ +
+ {% render_table table %} + +
+ + +
+ Gesamt: {{ total_net|floatformat:2 }} € +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/invoices/item_add.html b/invoices/templates/invoices/invoices/item_add.html new file mode 100644 index 0000000..85c9845 --- /dev/null +++ b/invoices/templates/invoices/invoices/item_add.html @@ -0,0 +1,197 @@ +{% extends "base.html" %} + +{% block title %} +Rechnungswesen - Rechnungen +{% endblock %} + +{% block header_title %} +Rechnungsposition hinzufügen +{% endblock %} + +{% block header_description %} +Neue Rechnungsposition anlegen +{% endblock %} + +{% block content %} + +
+ +
+ {% csrf_token %} + + {{ form.as_p }} + + + +
+ +
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/invoices/overview.html b/invoices/templates/invoices/invoices/overview.html new file mode 100644 index 0000000..ce6e21d --- /dev/null +++ b/invoices/templates/invoices/invoices/overview.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block title %}Rechnungswesen - Rechnungen{% endblock %} + +{% block header_title %}Rechnungen{% endblock %} +{% block header_description %}Rechnungen erstellen und verwalten{% endblock %} + +{% block content %} +{% load django_tables2 %} +
+
+
+
+

Aktionen

+

Neue Rechnung erstellen

+
+ + +
+
+
+
+
+

Rechnungen

+

Alle Rechnungen mit aktuellem Status

+
+
+ {% render_table table %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/invoices/pdf.html b/invoices/templates/invoices/invoices/pdf.html new file mode 100644 index 0000000..f06d9ca --- /dev/null +++ b/invoices/templates/invoices/invoices/pdf.html @@ -0,0 +1,747 @@ + + + + + + + + + + + + + + + + + + +
+ + +
+ + {% if company.logo %} + + {% else %} +

+ {{ company.companyname }} +

+ + {% if company.slogan %} +

+ {{ company.slogan }} +

+ {% endif %} + {% endif %} + + + +

+{{ company.name }}
+{{ company.street }} · {{ company.zip }} {{ company.city }} +

+ + +
+ + + +
+ + +
+RECHNUNG +
+ + + + + + + + + + + + + + + + + + + + +{% if invoice.due_date %} + + + + + + + + + +{% endif %} + + +
+Rechnungsnummer + +{{ invoice.invoice_number }} +
+Rechnungsdatum + +{{ invoice.issue_date|date:"d.m.Y" }} +
+Fälligkeitsdatum + +{{ invoice.due_date|date:"d.m.Y" }} +
+ + +
+ + +
+ + + +
+ + + + + + + +
+ + +
+Rechnungsempfänger +
+ + + +
+ +{% if invoice.customer.type == "company" %} +{{ invoice.customer.company_name }} +{% else %} +{{ invoice.contact_name }} +{% endif %} + +
+ + +{% if invoice.customer.type == "company" %} + +

+z.Hd. {{ invoice.contact_name }} +

+ +{% endif %} + + +

+ +{{ invoice.location_street }}
+ +{{ invoice.location_zip }} +{{ invoice.location_city }} + +

+ + +
+ + + + + + + + +
+ + +
+ +Rechnung + +
+ + +{% if company.invoice_header_text %} + +

+{{ company.invoice_header_text|linebreaksbr }} +

+ +{% endif %} + + +
+ + + + + + + +
+ + + + + + + + + + + + {% if show_service_date %} + + {% endif %} + + + + + + + + +{% if not company.is_small_business %} + + + +{% endif %} + + + + + + + + + + + + + + +{% for item in items %} + + + + + + + + + + + {% if show_service_date %} + + {% endif %} + + + + + + + + + + +{% if not company.is_small_business %} + + + +{% endif %} + + + + + + + + + + +{% endfor %} + + + + + +
+Nr. + +Beschreibung +Leistungsdatum +Menge + +Einheit + +Preis + +Steuer + +Gesamt +
+{{ forloop.counter }} + +{{ item.description }} + + {% if item.service_period_from %} + {{ item.service_period_from|date:"d.m.Y" }} + {% endif %} + {% if item.service_period_to %} + - {{ item.service_period_to|date:"d.m.Y" }} + {% endif %} + +{{ item.quantity }} + +{{ item.unit }} + +{{ item.unit_price_net|floatformat:2 }} € + +{{ item.unit_tax_rate|floatformat:0 }} % + +{{ item.calculated_total_net|floatformat:2 }} € +
+
+ + + + + + + +
+ + +
+ + +Netto + + + +{{ total_net|floatformat:2 }} € + + +
+ + + +{% if not company.is_small_business %} +{% for tax_rate,tax_amount in tax_summary.items %} + +
+ + +Umsatzsteuer {{ tax_rate|floatformat:0 }} % + + + + +{{ tax_amount|floatformat:2 }} € + + + +
+ + +{% endfor %} + +{% endif %} + + + + +
+ + +Gesamt + + + + + +{% if company.is_small_business %} +{{ total_net|floatformat:2 }} +{% else %} +{{ total_gross|floatformat:2 }} +{% endif %} + +€ + + + + +
+ + +
+ + + + + +{% if invoice.note %} + +
+ +
+Rechnungshinweis +
+ +
+ +{{ invoice.note|linebreaksbr }} + +
+ +{% endif %} + + + + + + +{% if company.is_small_business %} + +
+ +
+ Kleinunternehmerhinweis +
+
+ {{ company.small_business_text }} +
+ +{% endif %} + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/invoices/templates/invoices/invoices/pdf_cancel.html b/invoices/templates/invoices/invoices/pdf_cancel.html new file mode 100644 index 0000000..23b95ee --- /dev/null +++ b/invoices/templates/invoices/invoices/pdf_cancel.html @@ -0,0 +1,756 @@ + + + + + + + + + + + + + + + + + + +
+ + +
+ + {% if company.logo %} + + {% else %} +

+ {{ company.companyname }} +

+ + {% if company.slogan %} +

+ {{ company.slogan }} +

+ {% endif %} + {% endif %} + + + +

+{{ company.name }}
+{{ company.street }} · {{ company.zip }} {{ company.city }} +

+ + +
+ + + +
+ + +
+ STORNO +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Stornonummer + + {{ cancel_number }} +
+ Stornodatum + + {{ cancel_date|date:"d.m.Y" }} +
+ Ursprüngliche Rechnung + + {{ invoice.invoice_number }} +
+ Rechnungsdatum + + {{ invoice.issue_date|date:"d.m.Y" }} +
+ +
+ + +
+ + + +
+ + + + + + + +
+ + +
+Rechnungsempfänger +
+ + + +
+ +{% if invoice.customer.type == "company" %} +{{ invoice.customer.company_name }} +{% else %} +{{ invoice.contact_name }} +{% endif %} + +
+ + +{% if invoice.customer.type == "company" %} + +

+z.Hd. {{ invoice.contact_name }} +

+ +{% endif %} + + +

+ +{{ invoice.location_street }}
+ +{{ invoice.location_zip }} +{{ invoice.location_city }} + +

+ + +
+ + + + + + + + +
+ + +
+Stornorechnung +
+ +

+Diese Stornorechnung storniert die Rechnung +{{ invoice.invoice_number }} +vom {{ invoice.issue_date|date:"d.m.Y" }} vollständig. +

+ +

+Stornodatum: {{ cancel_date|date:"d.m.Y" }} +

+ + +
+ + + + + + + +
+ + + + + + + + + + + + {% if show_service_date %} + + {% endif %} + + + + + + + + +{% if not company.is_small_business %} + + + +{% endif %} + + + + + + + + + + + + + + +{% for item in items %} + + + + + + + + + + + {% if show_service_date %} + + {% endif %} + + + + + + + + + + +{% if not company.is_small_business %} + + + +{% endif %} + + + + + + + + + + +{% endfor %} + + + + + +
+Nr. + +Beschreibung +Leistungsdatum +Menge + +Einheit + +Preis + +Steuer + +Gesamt +
+{{ forloop.counter }} + +{{ item.description }} + + {% if item.service_period_from %} + {{ item.service_period_from|date:"d.m.Y" }} + {% endif %} + {% if item.service_period_to %} + - {{ item.service_period_to|date:"d.m.Y" }} + {% endif %} + +{{ item.quantity }} + +{{ item.unit }} + +-{{ item.unit_price_net|floatformat:2 }} € + +{{ item.unit_tax_rate|floatformat:0 }} % + +-{{ item.calculated_total_net|floatformat:2 }} € +
+
+ + + + + + + +
+ + +
+ + +Netto + + + +-{{ total_net|floatformat:2 }} € + + +
+ + + +{% if not company.is_small_business %} +{% for tax_rate,tax_amount in tax_summary.items %} + +
+ + +Umsatzsteuer {{ tax_rate|floatformat:0 }} % + + + + +-{{ tax_amount|floatformat:2 }} € + + + +
+ + +{% endfor %} + +{% endif %} + + + + +
+ + +Gesamt + + + + + +{% if company.is_small_business %} +-{{ total_net|floatformat:2 }} +{% else %} +-{{ total_gross|floatformat:2 }} +{% endif %} + +€ + + + + +
+ + +
+ +
+ +
+ Stornohinweis +
+ +
+ + Die Rechnung {{ invoice.invoice_number }} + wird durch diese Stornorechnung vollständig aufgehoben. + + {% if original_status == "paid" %} + +

+ + Die ursprüngliche Rechnung wurde bereits bezahlt. + + Es entsteht ein Guthaben in Höhe von + + {% if company.is_small_business %} + {{ total_net|floatformat:2 }} + {% else %} + {{ total_gross|floatformat:2 }} + {% endif %} + + €. + + Die Rückerstattung erfolgt separat. + + {% endif %} + +
+ + + + + + +{% if company.is_small_business %} + +
+ +
+ Kleinunternehmerhinweis +
+
+ {{ company.small_business_text }} +
+ +{% endif %} + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/invoices/templates/invoices/invoices/preview.html b/invoices/templates/invoices/invoices/preview.html new file mode 100644 index 0000000..cbdabc9 --- /dev/null +++ b/invoices/templates/invoices/invoices/preview.html @@ -0,0 +1,465 @@ +{% extends "base.html" %} + +{% block title %}Rechnungswesen - Rechnungen{% endblock %} + +{% block header_title %}Rechnung {{ invoice.invoice_number }}{% endblock %} + +{% block header_description %}Vorschau{% endblock %} + +{% block content %} + +
+ + +
+ +
+ {% if company.logo %} + + {% else %} +

+ {{ company.companyname }} +

+ + {% if company.slogan %} +

+ {{ company.slogan }} +

+ {% endif %} + {% endif %} + + + +
+

{{ company.name }}

+

+ {{ company.street }} + · + {{ company.zip }} + {{ company.city }} +

+
+
+ + +
+ +

+ RECHNUNG +

+ + + + + + + + + + + + + + + + {% if invoice.due_date %} + + + + + {% endif %} + +
+ Rechnungsnummer + + {{ invoice.invoice_number }} +
+ Rechnungsdatum + + {{ invoice.issue_date|date:"d.m.Y" }} +
+ Fälligkeitsdatum + + {{ invoice.due_date|date:"d.m.Y" }} +
+ +
+ +
+ + +
+ + + + +
+ +

+ Rechnungsempfänger +

+ +

+ + {% if invoice.customer.type == "company" %} + {{ invoice.customer.company_name }} + {% else %} + {{ invoice.contact_name }} + {% endif %} + +

+ + + {% if invoice.customer.type == "company" %} +

+ z.Hd. {{ invoice.contact_name }} +

+ {% endif %} + + +

+ {{ invoice.location_street }}
+ {{ invoice.location_zip }} + {{ invoice.location_city }} +

+ +
+ + + + + +
+ +

+ Rechnung +

+ + + {% if company.invoice_header_text %} +
+ {{ company.invoice_header_text|linebreaks }} +
+ {% endif %} + +
+ + + + + +
+ + + + + + + + + + + + {% if show_service_date %} + + {% endif %} + + + + + + + + + {% if not company.is_small_business %} + + {% endif %} + + + + + + + + + + + + {% for item in items %} + + + + + + + + {% if show_service_date %} + + {% endif %} + + + + + + + + + {% if not company.is_small_business %} + + {% endif %} + + + + + + + {% endfor %} + + + +
+ Nr. + + Beschreibung + Leistungsdatum + Menge + + Einheit + + Preis + + Steuer + + Gesamt +
+ {{ forloop.counter }} + + {{ item.description }} + + {% if item.service_period_from %} + {{ item.service_period_from|date:"d.m.Y" }} + {% endif %} + {% if item.service_period_to %} + - {{ item.service_period_to|date:"d.m.Y" }} + {% endif %} + + {{ item.quantity }} + + {{ item.unit }} + + {{ item.unit_price_net|floatformat:2 }} € + + {{ item.unit_tax_rate|floatformat:0 }} % + + {{ item.calculated_total_net|floatformat:2 }} € +
+ +
+ + + +
+ +
+ +
+ + Netto + + + + {{ total_net|floatformat:2 }} € + +
+ + + {% if not company.is_small_business %} + + {% for tax_rate, tax_amount in tax_summary.items %} + +
+ + + Umsatzsteuer {{ tax_rate|floatformat:0 }} % + + + + {{ tax_amount|floatformat:2 }} € + + +
+ + {% endfor %} + + {% endif %} + + + +
+ + + Gesamt + + + + {% if company.is_small_business %} + {{ total_net|floatformat:2 }} € + {% else %} + {{ total_gross|floatformat:2 }} € + {% endif %} + + + +
+ +
+ +
+ + + + + + + + {% if invoice.note %} + +
+ +

+ Rechnungshinweis +

+ + +
+ {{ invoice.note|linebreaks }} +
+ +
+ + {% endif %} + + + + + +{% if company.is_small_business %} + +
+ +

+ Kleinunternehmerhinweis +

+
+ {{ company.small_business_text }} +
+
+ +{% endif %} + + + + + + +
+ + + + +
+ +

+ Kontakt +

+ + + {% if company.phone %} +

{{ company.phone }}

+ {% endif %} + + + {% if company.email %} +

{{ company.email }}

+ {% endif %} + + + {% if company.website %} +

{{ company.website }}

+ {% endif %} + +
+ + + + + + +
+ +

+ Steuern +

+ + + {% if company.tax_number %} +

+ Steuernummer: + {{ company.tax_number }} +

+ {% endif %} + + + {% if company.vat_number %} +

+ USt-IdNr.: + {{ company.vat_number }} +

+ {% endif %} + +
+ + + + + + + +
+ +

+ Bankverbindung +

+ + + {% if company.bank_name %} +

+ {{ company.bank_name }} +

+ {% endif %} + + + {% if company.iban %} +

+ IBAN: + {{ company.iban }} +

+ {% endif %} + + + {% if company.bic %} +

+ BIC: + {{ company.bic }} +

+ {% endif %} + +
+ + +
+ + +
+ +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/items/add.html b/invoices/templates/invoices/items/add.html new file mode 100644 index 0000000..c17d90c --- /dev/null +++ b/invoices/templates/invoices/items/add.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %} +Rechnungswesen - Posten +{% endblock %} + +{% block header_title %} +Posten hinzufügen +{% endblock %} + +{% block header_description %} +Neue Ware oder Leistung hinzufügen +{% endblock %} + +{% block content %} + +
+ +
+ {% csrf_token %} + +
+ {{ form.as_p }} +
+ + + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/items/overview.html b/invoices/templates/invoices/items/overview.html new file mode 100644 index 0000000..66ad0d9 --- /dev/null +++ b/invoices/templates/invoices/items/overview.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} + +{% block title %} +Rechnungswesen - Posten +{% endblock %} + +{% block header_title %} +Posten +{% endblock %} + +{% block header_description %} +Leistungen und Artikel verwalten +{% endblock %} + +{% block content %} +{% load django_tables2 %} +
+
+
+
+

Aktionen

+

+ Leistungen und Artikel anlegen +

+
+ +
+
+
+ +
+ +
+ +
+

+ Artikel und Leistungen +

+ +

+ Übersicht über alle Artikel und Leistungen +

+
+
+ +
+ {% render_table table %} + +
+
+{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/offers/add.html b/invoices/templates/invoices/offers/add.html new file mode 100644 index 0000000..a389666 --- /dev/null +++ b/invoices/templates/invoices/offers/add.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} + +{% block title %}Rechnungswesen - Angebote{% endblock %} + +{% block header_title %} +Angebot erstellen +{% endblock %} + +{% block header_description %} +Angebot für einen Kunden erstellen +{% endblock %} + +{% block content %} + +
+ +
+ {% csrf_token %} + + {{ form.as_p }} + + + +
+ +
+ + + +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/offers/detail.html b/invoices/templates/invoices/offers/detail.html new file mode 100644 index 0000000..d14f4a8 --- /dev/null +++ b/invoices/templates/invoices/offers/detail.html @@ -0,0 +1,156 @@ +{% extends "base.html" %} + +{% block title %}Rechnungswesen - Angebote{% endblock %} + +{% block header_title %} +Angebot {{ offer.offer_number }} +{% endblock %} + +{% block header_description %} +Details & Positionen +{% endblock %} + +{% block content %} +{% load django_tables2 %} +
+
+
+
+

Aktionen

+

+ Positionen zum Angebot hinzufügen +

+
+
+ {% if offer.status == "draft" %} + + Position + Vorschau + Download + Löschen + {% endif %} + {% if offer.status == "sent" %} + Download + Akzeptiert + Abgelehnt + Ausgelaufen + {% endif %} + {% if offer.status == "accepted" or offer.status == "rejected" or offer.status == "expired" %} + Download + {% endif %} +
+ +
+
+
+ + +
+ +

Allgemein

+ +
+ + +
+
Kunde
+
{{ offer.customer.company_name }}
+
+ + +
+
Status
+
{{ offer.get_status_display }}
+
+ + +
+
Angebotsdatum
+
{{ offer.issue_date|date:"d.m.Y" }}
+
+ +
+
Gültig bis
+
{{ offer.due_date|date:"d.m.Y" }}
+
+
+
+ + + +
+ +

Angebotsadresse

+ +
+ + +
+
Ansprechpartner
+
{{ offer.contact_name|default:"-" }}
+ + {% if offer.contact_email %} +
{{ offer.contact_email }}
+ {% endif %} + + {% if offer.contact_phone %} +
{{ offer.contact_phone }}
+ {% endif %} +
+ + +
+
Standort
+ +
+ {{ offer.location_name|default:"-" }} +
+ +
+ {{ offer.location_street }} +
+ +
+ {{ offer.location_zip }} {{ offer.location_city }} +
+ +
+ {{ offer.location_country }} +
+
+ +
+
+ + +
+

Angebotshinweis

+
+
+
{{ offer.note|default:"-" }}
+
+
+
+ + + +
+ +

Positionen

+
+ {% render_table table %} +
+ + +
+ Gesamt: {{ total_net|floatformat:2 }} € +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/offers/item_add.html b/invoices/templates/invoices/offers/item_add.html new file mode 100644 index 0000000..85c9845 --- /dev/null +++ b/invoices/templates/invoices/offers/item_add.html @@ -0,0 +1,197 @@ +{% extends "base.html" %} + +{% block title %} +Rechnungswesen - Rechnungen +{% endblock %} + +{% block header_title %} +Rechnungsposition hinzufügen +{% endblock %} + +{% block header_description %} +Neue Rechnungsposition anlegen +{% endblock %} + +{% block content %} + +
+ +
+ {% csrf_token %} + + {{ form.as_p }} + + + +
+ +
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/offers/overview.html b/invoices/templates/invoices/offers/overview.html new file mode 100644 index 0000000..acb193f --- /dev/null +++ b/invoices/templates/invoices/offers/overview.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block title %}Rechnungswesen - Angebote{% endblock %} + +{% block header_title %}Angebote{% endblock %} +{% block header_description %}Angebote erstellen und verwalten{% endblock %} + +{% block content %} +{% load django_tables2 %} +
+
+
+
+

Aktionen

+

Neues Angebot erstellen

+
+ +
+ + Angebot +
+ +
+
+
+
+
+

Angebote

+

Alle Angebote mit aktuellem Status

+
+
+ {% render_table table %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/offers/pdf.html b/invoices/templates/invoices/offers/pdf.html new file mode 100644 index 0000000..fe9a905 --- /dev/null +++ b/invoices/templates/invoices/offers/pdf.html @@ -0,0 +1,732 @@ + + + + + + + + + + + + + + + + + + +
+ + +
+ + {% if company.logo %} + + {% else %} +

+ {{ company.companyname }} +

+ + {% if company.slogan %} +

+ {{ company.slogan }} +

+ {% endif %} + {% endif %} + + + +

+{{ company.name }}
+{{ company.street }} · {{ company.zip }} {{ company.city }} +

+ + +
+ + + +
+ + +
+ANGEBOT +
+ + + + + + + + + + + + + + + + + + + + +{% if offer.due_date %} + + + + + + + + + +{% endif %} + + +
+Angebotsnummer + +{{ offer.offer_number }} +
+Angebotsdatum + +{{ offer.issue_date|date:"d.m.Y" }} +
+Gültig bis + +{{ offer.due_date|date:"d.m.Y" }} +
+ + +
+ + +
+ + + +
+ + + + + + + +
+ + +
+Angebotsempfänger +
+ + + +
+ +{% if offer.customer.type == "company" %} +{{ offer.customer.company_name }} +{% else %} +{{ offer.contact_name }} +{% endif %} + +
+ + +{% if offer.customer.type == "company" %} + +

+z.Hd. {{ offer.contact_name }} +

+ +{% endif %} + + +

+ +{{ offer.location_street }}
+ +{{ offer.location_zip }} +{{ offer.location_city }} + +

+ + +
+ + + + + + + + +
+ + +
+ +Angebot + +
+ + +{% if company.offer_header_text %} + +

+{{ company.offer_header_text|linebreaksbr }} +

+ +{% endif %} + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + +{% if not company.is_small_business %} + + + +{% endif %} + + + + + + + + + + + + + + +{% for item in items %} + + + + + + + + + + + + + + + + + + + +{% if not company.is_small_business %} + + + +{% endif %} + + + + + + + + + + +{% endfor %} + + + + + +
+Nr. + +Beschreibung + +Menge + +Einheit + +Preis + +Steuer + +Gesamt +
+{{ forloop.counter }} + +{{ item.description }} + +{{ item.quantity }} + +{{ item.unit }} + +{{ item.unit_price_net|floatformat:2 }} € + +{{ item.unit_tax_rate|floatformat:0 }} % + +{{ item.calculated_total_net|floatformat:2 }} € +
+
+ + + + + + + +
+ + +
+ + +Netto + + + +{{ total_net|floatformat:2 }} € + + +
+ + + +{% if not company.is_small_business %} +{% for tax_rate,tax_amount in tax_summary.items %} + +
+ + +Umsatzsteuer {{ tax_rate|floatformat:0 }} % + + + + +{{ tax_amount|floatformat:2 }} € + + + +
+ + +{% endfor %} + +{% endif %} + + + + +
+ + +Gesamt + + + + + +{% if company.is_small_business %} +{{ total_net|floatformat:2 }} +{% else %} +{{ total_gross|floatformat:2 }} +{% endif %} + +€ + + + + +
+ + +
+ + + + + +{% if offer.note %} + +
+ +
+Angebotshinweis +
+ +
+ +{{ offer.note|linebreaksbr }} + +
+ +{% endif %} + + + + + + +{% if company.is_small_business %} + +
+ +
+ Kleinunternehmerhinweis +
+
+ {{ company.small_business_text }} +
+ +{% endif %} + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/invoices/templates/invoices/offers/preview.html b/invoices/templates/invoices/offers/preview.html new file mode 100644 index 0000000..02d1660 --- /dev/null +++ b/invoices/templates/invoices/offers/preview.html @@ -0,0 +1,450 @@ +{% extends "base.html" %} + +{% block title %}Rechnungswesen - Angebote{% endblock %} + +{% block header_title %}Angebot {{ offer.offer_number }}{% endblock %} + +{% block header_description %}Vorschau{% endblock %} + +{% block content %} + +
+ + +
+ +
+ {% if company.logo %} + + {% else %} +

+ {{ company.companyname }} +

+ + {% if company.slogan %} +

+ {{ company.slogan }} +

+ {% endif %} + {% endif %} + + + +
+

{{ company.name }}

+

+ {{ company.street }} + · + {{ company.zip }} + {{ company.city }} +

+
+
+ + +
+ +

+ ANGEBOT +

+ + + + + + + + + + + + + + + + {% if offer.due_date %} + + + + + {% endif %} + +
+ Angebotsnummer + + {{ offer.offer_number }} +
+ Angebotsdatum + + {{ offer.issue_date|date:"d.m.Y" }} +
+ Gültig bis + + {{ offer.due_date|date:"d.m.Y" }} +
+ +
+ +
+ + +
+ + + + +
+ +

+ Angebotsempfänger +

+ +

+ + {% if offer.customer.type == "company" %} + {{ offer.customer.company_name }} + {% else %} + {{ offer.contact_name }} + {% endif %} + +

+ + + {% if offer.customer.type == "company" %} +

+ z.Hd. {{ offer.contact_name }} +

+ {% endif %} + + +

+ {{ offer.location_street }}
+ {{ offer.location_zip }} + {{ offer.location_city }} +

+ +
+ + + + + +
+ +

+ Angebot +

+ + + {% if company.offer_header_text %} +
+ {{ company.offer_header_text|linebreaks }} +
+ {% endif %} + +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + {% if not company.is_small_business %} + + {% endif %} + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + + + + + + {% if not company.is_small_business %} + + {% endif %} + + + + + + + {% endfor %} + + + +
+ Nr. + + Beschreibung + + Menge + + Einheit + + Preis + + Steuer + + Gesamt +
+ {{ forloop.counter }} + + {{ item.description }} + + {{ item.quantity }} + + {{ item.unit }} + + {{ item.unit_price_net|floatformat:2 }} € + + {{ item.unit_tax_rate|floatformat:0 }} % + + {{ item.calculated_total_net|floatformat:2 }} € +
+ +
+ + + +
+ +
+ +
+ + Netto + + + + {{ total_net|floatformat:2 }} € + +
+ + + {% if not company.is_small_business %} + + {% for tax_rate, tax_amount in tax_summary.items %} + +
+ + + Umsatzsteuer {{ tax_rate|floatformat:0 }} % + + + + {{ tax_amount|floatformat:2 }} € + + +
+ + {% endfor %} + + {% endif %} + + + +
+ + + Gesamt + + + + {% if company.is_small_business %} + {{ total_net|floatformat:2 }} € + {% else %} + {{ total_gross|floatformat:2 }} € + {% endif %} + + + +
+ +
+ +
+ + + + + + + + {% if offer.note %} + +
+ +

+ Angebotshinweis +

+ + +
+ {{ offer.note|linebreaks }} +
+ +
+ + {% endif %} + + + + + +{% if company.is_small_business %} + +
+ +

+ Kleinunternehmerhinweis +

+
+ {{ company.small_business_text }} +
+
+ +{% endif %} + + + + + + +
+ + + + +
+ +

+ Kontakt +

+ + + {% if company.phone %} +

{{ company.phone }}

+ {% endif %} + + + {% if company.email %} +

{{ company.email }}

+ {% endif %} + + + {% if company.website %} +

{{ company.website }}

+ {% endif %} + +
+ + + + + + +
+ +

+ Steuern +

+ + + {% if company.tax_number %} +

+ Steuernummer: + {{ company.tax_number }} +

+ {% endif %} + + + {% if company.vat_number %} +

+ USt-IdNr.: + {{ company.vat_number }} +

+ {% endif %} + +
+ + + + + + + +
+ +

+ Bankverbindung +

+ + + {% if company.bank_name %} +

+ {{ company.bank_name }} +

+ {% endif %} + + + {% if company.iban %} +

+ IBAN: + {{ company.iban }} +

+ {% endif %} + + + {% if company.bic %} +

+ BIC: + {{ company.bic }} +

+ {% endif %} + +
+ + +
+ + +
+ +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/overview.html b/invoices/templates/invoices/overview.html new file mode 100644 index 0000000..8682aa9 --- /dev/null +++ b/invoices/templates/invoices/overview.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} + +{% block title %} +Rechnungswesen +{% endblock %} + + +{% block header_title %} +Rechnungswesen +{% endblock %} + +{% block header_description %} +Angebote, Rechnungen, Mahnungen verwalten +{% endblock %} + +{% block content %} + + + + + +
+ +

+ Zusammenfassung +

+ +

+ folgt +

+ +
+ +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/reminders/add.html b/invoices/templates/invoices/reminders/add.html new file mode 100644 index 0000000..5b22821 --- /dev/null +++ b/invoices/templates/invoices/reminders/add.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% block title %}Rechnungswesen - Mahnungen{% endblock %} + +{% block header_title %} +Mahnung erstellen +{% endblock %} + +{% block header_description %} +Mahnung für einen Kunden erstellen +{% endblock %} + +{% block content %} + + + +
+ +
+ {% csrf_token %} + + {{ form.as_p }} + + + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/reminders/detail.html b/invoices/templates/invoices/reminders/detail.html new file mode 100644 index 0000000..fd33cc3 --- /dev/null +++ b/invoices/templates/invoices/reminders/detail.html @@ -0,0 +1,184 @@ +{% extends "base.html" %} + +{% block title %}Rechnungswesen - Mahnungen{% endblock %} + +{% block header_title %} +Mahnung {{ reminder.reminder_number }} +{% endblock %} + +{% block header_description %} +Details +{% endblock %} + +{% block content %} +
+
+
+
+

Aktionen

+

+ Mahnung verwalten +

+
+
+ {% if reminder.status == "draft" %} + Download + Löschen + {% endif %} + {% if reminder.status == "sent" %} + Download + Stornieren + Zahlung verbuchen + Überfällig + {% endif %} + {% if reminder.status == "paid" or reminder.status == "canceled" or reminder.status == "expired" %} + Download + {% endif %} +
+ +
+
+
+ + +
+ +

Allgemein

+ +
+ +
+
Status
+
{{ reminder.get_status_display }}
+
+ +
+
Level
+
{{ reminder.level }}/3
+
+ + +
+
Mahnungsdatum
+
{{ reminder.issue_date|date:"d.m.Y" }}
+
+ +
+
Neues Zahlungsziel
+
{{ reminder.due_date|date:"d.m.Y" }}
+
+ +
+
Mahngebühr
+
{{ reminder.fee }} €
+
+
+
+ + + +
+ +

Rechnungsinformationen

+ +
+ + + +
+
Kunde
+
{{ reminder.invoice.customer.company_name }}
+
+ +
+
Projekt
+
{{ reminder.invoice.project.name|default:"-" }}
+
+ + +
+
Rechnungsstatus
+
{{ reminder.invoice.get_status_display }}
+
+ + +
+
Rechnungsdatum
+
{{ reminder.invoice.issue_date|date:"d.m.Y" }}
+
+ +
+
Ursprüngliches Zahlungsziel
+
{{ reminder.invoice.due_date|date:"d.m.Y" }}
+
+
+
+ + + +
+ +

Mahnungsadresse

+ +
+ + +
+
Ansprechpartner
+
{{ reminder.invoice.contact_name|default:"-" }}
+ + {% if reminder.invoice.contact_email %} +
{{ reminder.invoice.contact_email }}
+ {% endif %} + + {% if reminder.invoice.contact_phone %} +
{{ reminder.invoice.contact_phone }}
+ {% endif %} +
+ + +
+
Standort
+ +
+ {{ reminder.invoice.location_name|default:"-" }} +
+ +
+ {{ reminder.invoice.location_street }} +
+ +
+ {{ reminder.invoice.location_zip }} {{ reminder.invoice.location_city }} +
+ +
+ {{ reminder.invoice.location_country }} +
+
+ +
+
+ + +
+

Mahnungshinweis

+
+
+
{{ reminder.note|default:"-" }}
+
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/reminders/overview.html b/invoices/templates/invoices/reminders/overview.html new file mode 100644 index 0000000..b2d1755 --- /dev/null +++ b/invoices/templates/invoices/reminders/overview.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block title %}Rechnungswesen - Mahnungen{% endblock %} + +{% block header_title %}Mahnungen{% endblock %} +{% block header_description %}Offene Forderungen verfolgen{% endblock %} + +{% block content %} +{% load django_tables2 %} +
+
+
+
+

Aktionen

+

Neue Mahnung erstellen

+
+ +
+ + Mahnung +
+ +
+
+
+
+
+

Mahnungen

+

Alle Mahnungen mit aktuellem Status

+
+
+ {% render_table table %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/invoices/templates/invoices/reminders/pdf.html b/invoices/templates/invoices/reminders/pdf.html new file mode 100644 index 0000000..0ff2996 --- /dev/null +++ b/invoices/templates/invoices/reminders/pdf.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + + + + + + +
+ + +
+ + {% if company.logo %} + + {% else %} +

+ {{ company.companyname }} +

+ + {% if company.slogan %} +

+ {{ company.slogan }} +

+ {% endif %} + {% endif %} + + + +

+{{ company.name }}
+{{ company.street }} · {{ company.zip }} {{ company.city }} +

+ + +
+ + + +
+ + +
+MAHNUNG +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Mahnungsnummer{{ reminder.reminder_number }}
Mahnungsdatum{{ reminder.issue_date|date:"d.m.Y" }}
Mahnungsstufe{{ reminder.level }}. Mahnung
Ursprüngliches Zahlungsziel{{ reminder.invoice.due_date|date:"d.m.Y" }}
Neues Zahlungsziel{{ reminder.due_date|date:"d.m.Y" }}
Rechnungsnummer{{ reminder.invoice.invoice_number }}
+ + +
+ + +
+ + + +
+ + + + + + + +
+ + +
+Empfänger +
+ + + +
+ +{% if reminder.invoice.customer.type == "company" %} +{{ reminder.invoice.customer.company_name }} +{% else %} +{{ reminder.invoice.contact_name }} +{% endif %} + +
+ + +{% if reminder.invoice.customer.type == "company" %} + +

+z.Hd. {{ reminder.invoice.contact_name }} +

+ +{% endif %} + + +

+ +{{ reminder.invoice.location_street }}
+ +{{ reminder.invoice.location_zip }} +{{ reminder.invoice.location_city }} + +

+ + +
+ + + + + + + + +
+ + +
+ +Mahnung + +
+ + +{% if company.reminder_header_text %} + +

+{{ company.reminder_header_text|linebreaksbr }} +

+ +{% endif %} + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% if previous_reminders or reminder.fee > 0 %} + +{% for old_reminder in previous_reminders %} + + + + + + + + + +{% endfor %} + + + + + + + + + + +{% endif %} + + + + + + +
+Beschreibung + +Betrag +
+Rechnung {{ reminder.invoice.invoice_number }} + +{{ invoice_amount|floatformat:2 }} € +
+ {{ old_reminder.level }}. Mahnung + ({{ old_reminder.issue_date|date:"d.m.Y" }}) + + {{ old_reminder.fee|floatformat:2 }} € +
+ Aktuelle Mahngebühr + + {{ reminder.fee|floatformat:2 }} € +
+ +
+ + + + + + + +
+ + +
+ + +Offener Rechnungsbetrag + + + +{{ invoice_amount|floatformat:2 }} € + + +
+ + + +{% if previous_reminders or reminder.fee > 0 %} + +
+ + +Mahngebühren + + + +{{ total_fees|floatformat:2 }} € + + +
+ +{% endif %} + + + +
+ + +Gesamtforderung + + + + +{{ total_due|floatformat:2 }} € + + + +
+ + +
+ + + + + + +{% if reminder.note %} + +
+ +
+Mahnungshinweis +
+ +
+ +{{ reminder.note|linebreaksbr }} + +
+ +{% endif %} + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/invoices/templates/invoices/tables/customer/detail.html b/invoices/templates/invoices/tables/customer/detail.html new file mode 100644 index 0000000..f423f41 --- /dev/null +++ b/invoices/templates/invoices/tables/customer/detail.html @@ -0,0 +1,218 @@ +
+ + +
+ + + + +
+ +

+ Ansprechpartner +

+ + + {% for contact in item.contacts.all %} + +
+ +
+ {{ contact.first_name }} + {{ contact.last_name }} +
+ + + {% if contact.phone %} + +
+ {{ contact.phone }} +
+ + {% endif %} + + + {% if contact.email %} + +
+ {{ contact.email }} +
+ + {% endif %} + +
+ + + {% empty %} + +
+ Keine Ansprechpartner vorhanden +
+ + {% endfor %} + +
+ + + + + +
+ +

+ Standorte +

+ + + {% for location in item.locations.all %} + +
+ +
+ {{ location.name }} +
+ + +
+ {{ location.street }} +
+ + +
+ {{ location.zip_code }} + {{ location.city }} +
+ + +
+ {{ location.country }} +
+ +
+ + + {% empty %} + +
+ Keine Standorte vorhanden +
+ + {% endfor %} + +
+ + + + + +
+ +

+ Projekte +

+ + + {% for project in item.projects.all %} + +
+ + {{ project.name }} + +
+ + + {% empty %} + +
+ Keine Projekte zugeordnet +
+ + {% endfor %} + +
+ + +
+ + + +
+ + + + + Bearbeiten + + + + + +
+ + {% csrf_token %} + + + + + +
+ + +
+ + +
\ No newline at end of file diff --git a/invoices/templates/invoices/tables/customer/detail_archived.html b/invoices/templates/invoices/tables/customer/detail_archived.html new file mode 100644 index 0000000..1d63b62 --- /dev/null +++ b/invoices/templates/invoices/tables/customer/detail_archived.html @@ -0,0 +1,218 @@ +
+ + +
+ + + + +
+ +

+ Ansprechpartner +

+ + + {% for contact in item.contacts.all %} + +
+ +
+ {{ contact.first_name }} + {{ contact.last_name }} +
+ + + {% if contact.phone %} + +
+ {{ contact.phone }} +
+ + {% endif %} + + + {% if contact.email %} + +
+ {{ contact.email }} +
+ + {% endif %} + +
+ + + {% empty %} + +
+ Keine Ansprechpartner vorhanden +
+ + {% endfor %} + +
+ + + + + +
+ +

+ Standorte +

+ + + {% for location in item.locations.all %} + +
+ +
+ {{ location.name }} +
+ + +
+ {{ location.street }} +
+ + +
+ {{ location.zip_code }} + {{ location.city }} +
+ + +
+ {{ location.country }} +
+ +
+ + + {% empty %} + +
+ Keine Standorte vorhanden +
+ + {% endfor %} + +
+ + + + + +
+ +

+ Projekte +

+ + + {% for project in item.projects.all %} + +
+ + {{ project.name }} + +
+ + + {% empty %} + +
+ Keine Projekte zugeordnet +
+ + {% endfor %} + +
+ + +
+ + + +
+ + + + + Bearbeiten + + + + + +
+ + {% csrf_token %} + + + + + +
+ + +
+ + +
\ No newline at end of file diff --git a/invoices/templates/invoices/tables/invoice_items/actions.html b/invoices/templates/invoices/tables/invoice_items/actions.html new file mode 100644 index 0000000..9476204 --- /dev/null +++ b/invoices/templates/invoices/tables/invoice_items/actions.html @@ -0,0 +1,20 @@ +
+ + {% csrf_token %} + + + + + +
\ No newline at end of file diff --git a/invoices/templates/invoices/tables/invoice_items/service_period.html b/invoices/templates/invoices/tables/invoice_items/service_period.html new file mode 100644 index 0000000..d8fd5d2 --- /dev/null +++ b/invoices/templates/invoices/tables/invoice_items/service_period.html @@ -0,0 +1,7 @@ +{% if record.service_period_from %} + {{ record.service_period_from|date:"d.m.Y" }} +{% endif %} + +{% if record.service_period_to %} + - {{ record.service_period_to|date:"d.m.Y" }} +{% endif %} \ No newline at end of file diff --git a/invoices/templates/invoices/tables/invoice_items/tax_rate.html b/invoices/templates/invoices/tables/invoice_items/tax_rate.html new file mode 100644 index 0000000..32a046f --- /dev/null +++ b/invoices/templates/invoices/tables/invoice_items/tax_rate.html @@ -0,0 +1 @@ +USt. {{ record.unit_tax_rate|floatformat:0 }}% \ No newline at end of file diff --git a/invoices/templates/invoices/tables/invoice_items/total.html b/invoices/templates/invoices/tables/invoice_items/total.html new file mode 100644 index 0000000..5362b8b --- /dev/null +++ b/invoices/templates/invoices/tables/invoice_items/total.html @@ -0,0 +1 @@ +{{ record.calculated_total_net|floatformat:2 }} € \ No newline at end of file diff --git a/invoices/templates/invoices/tables/invoice_items/unit_price.html b/invoices/templates/invoices/tables/invoice_items/unit_price.html new file mode 100644 index 0000000..b853f4d --- /dev/null +++ b/invoices/templates/invoices/tables/invoice_items/unit_price.html @@ -0,0 +1 @@ +{{ record.unit_price_net }} € \ No newline at end of file diff --git a/invoices/templates/invoices/tables/invoices/actions.html b/invoices/templates/invoices/tables/invoices/actions.html new file mode 100644 index 0000000..84d4f42 --- /dev/null +++ b/invoices/templates/invoices/tables/invoices/actions.html @@ -0,0 +1,7 @@ + + + Bearbeiten + + \ No newline at end of file diff --git a/invoices/templates/invoices/tables/items/actions.html b/invoices/templates/invoices/tables/items/actions.html new file mode 100644 index 0000000..fed55fd --- /dev/null +++ b/invoices/templates/invoices/tables/items/actions.html @@ -0,0 +1,35 @@ +
+ + {% csrf_token %} + +
+ + +
+ + + + +
+ + {% csrf_token %} + + + +
+ +
\ No newline at end of file diff --git a/invoices/templates/invoices/tables/items/description.html b/invoices/templates/invoices/tables/items/description.html new file mode 100644 index 0000000..ccb8f8d --- /dev/null +++ b/invoices/templates/invoices/tables/items/description.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/invoices/templates/invoices/tables/items/item_type.html b/invoices/templates/invoices/tables/items/item_type.html new file mode 100644 index 0000000..287b599 --- /dev/null +++ b/invoices/templates/invoices/tables/items/item_type.html @@ -0,0 +1,16 @@ + \ No newline at end of file diff --git a/invoices/templates/invoices/tables/items/price_net.html b/invoices/templates/invoices/tables/items/price_net.html new file mode 100644 index 0000000..a6e4b54 --- /dev/null +++ b/invoices/templates/invoices/tables/items/price_net.html @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/invoices/templates/invoices/tables/items/tax_rate.html b/invoices/templates/invoices/tables/items/tax_rate.html new file mode 100644 index 0000000..3082e3f --- /dev/null +++ b/invoices/templates/invoices/tables/items/tax_rate.html @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/invoices/templates/invoices/tables/items/unit.html b/invoices/templates/invoices/tables/items/unit.html new file mode 100644 index 0000000..2805356 --- /dev/null +++ b/invoices/templates/invoices/tables/items/unit.html @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/invoices/templates/invoices/tables/offer_items/actions.html b/invoices/templates/invoices/tables/offer_items/actions.html new file mode 100644 index 0000000..7c0a8b2 --- /dev/null +++ b/invoices/templates/invoices/tables/offer_items/actions.html @@ -0,0 +1,20 @@ +
+ + {% csrf_token %} + + + + + +
\ No newline at end of file diff --git a/invoices/templates/invoices/tables/offer_items/tax_rate.html b/invoices/templates/invoices/tables/offer_items/tax_rate.html new file mode 100644 index 0000000..32a046f --- /dev/null +++ b/invoices/templates/invoices/tables/offer_items/tax_rate.html @@ -0,0 +1 @@ +USt. {{ record.unit_tax_rate|floatformat:0 }}% \ No newline at end of file diff --git a/invoices/templates/invoices/tables/offer_items/total.html b/invoices/templates/invoices/tables/offer_items/total.html new file mode 100644 index 0000000..5362b8b --- /dev/null +++ b/invoices/templates/invoices/tables/offer_items/total.html @@ -0,0 +1 @@ +{{ record.calculated_total_net|floatformat:2 }} € \ No newline at end of file diff --git a/invoices/templates/invoices/tables/offer_items/unit_price.html b/invoices/templates/invoices/tables/offer_items/unit_price.html new file mode 100644 index 0000000..b853f4d --- /dev/null +++ b/invoices/templates/invoices/tables/offer_items/unit_price.html @@ -0,0 +1 @@ +{{ record.unit_price_net }} € \ No newline at end of file diff --git a/invoices/templates/invoices/tables/offers/actions.html b/invoices/templates/invoices/tables/offers/actions.html new file mode 100644 index 0000000..c2f3c1c --- /dev/null +++ b/invoices/templates/invoices/tables/offers/actions.html @@ -0,0 +1,7 @@ + + + Bearbeiten + + \ No newline at end of file diff --git a/invoices/templates/invoices/tables/reminders/actions.html b/invoices/templates/invoices/tables/reminders/actions.html new file mode 100644 index 0000000..c19259c --- /dev/null +++ b/invoices/templates/invoices/tables/reminders/actions.html @@ -0,0 +1,7 @@ + + + Bearbeiten + + \ No newline at end of file diff --git a/invoices/templates/invoices/tables/reminders/invoice.html b/invoices/templates/invoices/tables/reminders/invoice.html new file mode 100644 index 0000000..f8d5b23 --- /dev/null +++ b/invoices/templates/invoices/tables/reminders/invoice.html @@ -0,0 +1,7 @@ + + + {{ record.invoice.invoice_number }} + + \ No newline at end of file diff --git a/invoices/tests.py b/invoices/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/invoices/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/invoices/urls.py b/invoices/urls.py new file mode 100644 index 0000000..4ddef58 --- /dev/null +++ b/invoices/urls.py @@ -0,0 +1,77 @@ +from django.urls import path +from . import views + +app_name = "invoices" + +urlpatterns = [ + path("", views.invoices_overview, name="overview"), + + + # Kunden + path("customers/", views.customers_overview, name="customers_overview"), + path("customers/add/", views.customers_add, name="customers_add"), + path("customers//", views.customers_detail, name="customers_detail"), + path("customers//edit/", views.customers_edit, name="customers_edit"), + path("customers//archive/", views.customers_archive, name="customers_archive"), + path("customers//reactivate/", views.customers_reactivate, name="customers_reactivate"), + + + # Items + path("items/", views.items_overview, name="items_overview"), + path("items/add/", views.items_add, name="items_add"), + path("items//edit/", views.items_edit, name="items_edit"), + path("items//delete/", views.items_delete, name="items_delete"), + + + # Rechnungen + path("invoices/", views.invoices_invoices_overview, name="invoices_invoices_overview"), + path("invoices//", views.invoices_detail, name="invoices_detail"), + + path("invoices/add/customer/", views.invoice_add_customer, name="invoice_add_customer"), + path("invoices/add/project/", views.invoice_add_project, name="invoice_add_project"), + + path("invoices//items/add/", views.invoice_item_add, name="invoice_item_add"), + path("invoices//items//delete/", views.invoice_item_delete, name="invoice_item_delete"), + + path("invoices//preview/", views.invoices_preview, name="invoices_preview"), + path("invoices//download/", views.invoices_download, name="invoices_download"), + path("invoices//cancel/", views.invoices_cancel, name="invoices_cancel"), + path("invoices//paid/", views.invoices_paid, name="invoices_paid"), + path("invoices//overdue/", views.invoices_overdue, name="invoices_overdue"), + path("invoices//delete/", views.invoices_delete, name="invoices_delete"), + + + # Angebote + path("offers/", views.invoices_offers_overview, name="invoices_offers_overview"), + path("offers//", views.offers_detail, name="offers_detail"), + + path("offers/add/", views.invoices_offers_add, name="invoices_offers_add"), + + path("offers//items/add/", views.offers_item_add, name="offers_item_add"), + path("offers//items//delete/", views.offers_item_delete, name="offers_item_delete"), + + path("offers//preview/", views.offers_preview, name="offers_preview"), + path("offers//download/", views.offers_download, name="offers_download"), + path("offers//accept/", views.offers_accept, name="offers_accept"), + path("offers//reject/", views.offers_reject, name="offers_reject"), + path("offers//expire/", views.offers_expire, name="offers_expire"), + path("offers//delete/", views.offers_delete, name="offers_delete"), + + # Mahnungen + path("reminders/", views.invoices_reminders_overview, name="invoices_reminders_overview"), + path("reminders//", views.reminders_detail, name="reminders_detail"), + path("reminders/add/", views.invoices_reminders_add, name="invoices_reminders_add"), + path("reminders//download/", views.reminders_download, name="reminders_download"), + path("reminders//cancel/", views.reminders_cancel, name="reminders_cancel"), + path("reminders//delete/", views.reminders_delete, name="reminders_delete"), + path("reminders//expire/", views.reminders_expire, name="reminders_expire"), + path("reminders//paid/", views.reminders_paid, name="reminders_paid"), + + + # AJAX + path("ajax/customer//contacts/", views.ajax_contacts, name="ajax_contacts"), + path("ajax/customer//locations/", views.ajax_locations, name="ajax_locations"), + path("ajax/project//invoice-data/", views.ajax_invoicedata, name="ajax_invoicedata"), + path("ajax/invoice//sources/", views.ajax_invoiceitemsources, name="ajax_invoice_item_sources"), + path("ajax/invoice-item-data/", views.ajax_invoice_item_data, name="ajax_invoice_item_data"), +] \ No newline at end of file diff --git a/invoices/views.py b/invoices/views.py new file mode 100644 index 0000000..3264077 --- /dev/null +++ b/invoices/views.py @@ -0,0 +1,2524 @@ +from django.contrib.auth.decorators import login_required +from accounts.decorators import permission_required +from django.shortcuts import render, redirect, get_object_or_404 +from .models import Customer, CustomerContact, CustomerLocation, Item, Invoice, InvoiceItem, Unit, Offer, OfferItem, Reminder +from .forms import CustomerForm, ItemForm, CustomerInvoiceForm, ProjectInvoiceForm, InvoiceItemForm, OfferForm, OfferItemForm, ReminderForm +from datetime import date +from projects.models import Project +from django.http import JsonResponse, FileResponse +from decimal import Decimal +from time_tracking.models import TimeEntry +from finance.models import Expense +from core.models import CompanySetting +from django.views.decorators.http import require_POST +from collections import defaultdict +from django.template.loader import render_to_string +from django.core.files.base import ContentFile +from django.utils import timezone +from weasyprint import HTML +import io +from django.db.models import Prefetch, Sum +from .tables import ItemTable, CustomerTable, CustomerArchivedTable, ReminderTable, InvoiceTable, InvoiceItemTable, OfferTable, OfferItemTable +from django_tables2 import RequestConfig + +def generate_document_number(template, number): + today = date.today() + return template.format( + year=today.strftime("%Y"), + month=today.strftime("%m"), + day=today.strftime("%d"), + number=str(number).zfill(5), + ) + +@login_required +@permission_required("invoices_view") +def invoices_overview(request): + return render(request, "invoices/overview.html") + + + +@login_required +@permission_required("invoices_customers_view") +def customers_overview(request): + customers = Customer.objects.filter(active=True).order_by("company_name") + archived_customers = Customer.objects.filter(active=False).order_by("company_name") + + table = CustomerTable(customers) + RequestConfig( + request, + paginate=False + ).configure(table) + + table_archived = CustomerArchivedTable(archived_customers) + RequestConfig( + request, + paginate=False + ).configure(table_archived) + + return render(request, "invoices/customers/overview.html", { + "table": table, "table_archived": table_archived, + }) + + +@login_required +@permission_required("invoices_customers_add") +def customers_add(request): + if request.method == "POST": + form = CustomerForm(request.POST) + + if form.is_valid(): + form.save() + return redirect("invoices:customers_overview") + + else: + form = CustomerForm() + + return render(request, "invoices/customers/add.html", { + "form": form, + }) + + + +@login_required +@permission_required("invoices_customers_edit") +def customers_edit(request, customer_id): + + customer = get_object_or_404( + Customer.objects.prefetch_related( + "contacts", + "locations", + ), + id=customer_id + ) + + + if request.method != "POST": + return redirect( + "invoices:customers_detail", + customer.id + ) + + + # ========================= + # CUSTOMER SAVE + # ========================= + + form = CustomerForm( + request.POST, + instance=customer + ) + + if form.is_valid(): + form.save() + + + + # ========================= + # CONTACT DELETE + # ========================= + + delete_contact_id = request.POST.get( + "delete_contact_id" + ) + + if delete_contact_id: + + CustomerContact.objects.filter( + id=delete_contact_id, + customer=customer + ).delete() + + return redirect( + "invoices:customers_detail", + customer.id + ) + + + + # ========================= + # CONTACT UPDATE + # ========================= + + for contact in customer.contacts.all(): + + contact.first_name = request.POST.get( + f"contact_first_name_{contact.id}", + contact.first_name + ) + + contact.last_name = request.POST.get( + f"contact_last_name_{contact.id}", + contact.last_name + ) + + contact.email = request.POST.get( + f"contact_email_{contact.id}", + contact.email + ) + + contact.phone = request.POST.get( + f"contact_phone_{contact.id}", + contact.phone + ) + + contact.save() + + + + # ========================= + # NEW CONTACTS + # ========================= + + first_names = request.POST.getlist( + "new_contact_first_name" + ) + + last_names = request.POST.getlist( + "new_contact_last_name" + ) + + emails = request.POST.getlist( + "new_contact_email" + ) + + phones = request.POST.getlist( + "new_contact_phone" + ) + + + for i in range(len(first_names)): + + if not ( + first_names[i].strip() + or last_names[i].strip() + ): + continue + + + CustomerContact.objects.create( + customer=customer, + first_name=first_names[i], + last_name=last_names[i], + email=emails[i], + phone=phones[i], + ) + + + + # ========================= + # LOCATION DELETE + # ========================= + + delete_location_id = request.POST.get( + "delete_location_id" + ) + + if delete_location_id: + + CustomerLocation.objects.filter( + id=delete_location_id, + customer=customer + ).delete() + + return redirect( + "invoices:customers_detail", + customer.id + ) + + + + # ========================= + # LOCATION UPDATE + # ========================= + + for location in customer.locations.all(): + + location.name = request.POST.get( + f"location_name_{location.id}", + location.name + ) + + location.street = request.POST.get( + f"location_street_{location.id}", + location.street + ) + + location.zip_code = request.POST.get( + f"location_zip_{location.id}", + location.zip_code + ) + + location.city = request.POST.get( + f"location_city_{location.id}", + location.city + ) + + location.country = request.POST.get( + f"location_country_{location.id}", + location.country + ) + + location.save() + + + + # ========================= + # NEW LOCATIONS + # ========================= + + names = request.POST.getlist( + "new_location_name" + ) + + streets = request.POST.getlist( + "new_location_street" + ) + + zips = request.POST.getlist( + "new_location_zip" + ) + + cities = request.POST.getlist( + "new_location_city" + ) + + countries = request.POST.getlist( + "new_location_country" + ) + + + for i in range(len(names)): + + if not names[i].strip(): + continue + + + CustomerLocation.objects.create( + customer=customer, + name=names[i], + street=streets[i], + zip_code=zips[i], + city=cities[i], + country=countries[i], + ) + + + return redirect( + "invoices:customers_detail", + customer.id + ) + +@login_required +@permission_required("invoices_customers_archive") +def customers_archive(request, customer_id): + customer = get_object_or_404( + Customer, + id=customer_id + ) + + if request.method == "POST": + customer.active = False + customer.save() + + return redirect( + "invoices:customers_overview" + ) + + +@login_required +@permission_required("invoices_customers_activate") +def customers_reactivate(request, customer_id): + customer = get_object_or_404( + Customer, + id=customer_id + ) + + if request.method == "POST": + customer.active = True + customer.save() + + return redirect( + "invoices:customers_overview" + ) + + +@login_required +@permission_required("invoices_customers_detail_view") +def customers_detail(request, customer_id): + + customer = get_object_or_404( + Customer.objects.prefetch_related( + "contacts", + "locations", + "projects", + ), + id=customer_id + ) + + form = CustomerForm( + instance=customer + ) + + return render( + request, + "invoices/customers/detail.html", + { + "customer": customer, + "form": form, + } + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +@login_required +@permission_required("invoices_items_view") +def items_overview(request): + units = Unit.objects.all() + items = Item.objects.select_related( + "unit" + ).all() + + table = ItemTable(items) + table.types = Item.ItemType.choices + table.units = Unit.objects.order_by("name") + + RequestConfig( + request, + paginate=False + ).configure(table) + + return render( + request, + "invoices/items/overview.html", + { + "table": table, + } + ) + +@login_required +@permission_required("invoices_items_add") +def items_add(request): + if request.method == "POST": + form = ItemForm(request.POST) + + if form.is_valid(): + form.save() + return redirect("invoices:items_overview") + + else: + form = ItemForm() + + return render(request, "invoices/items/add.html", { + "form": form + }) + +@login_required +@permission_required("invoices_items_edit") +def items_edit(request, item_id): + + if request.method != "POST": + return redirect( + "invoices:items_overview" + ) + + + item = get_object_or_404( + Item, + id=item_id + ) + + + form = ItemForm( + request.POST, + instance=item + ) + + + if form.is_valid(): + form.save() + + + return redirect( + "invoices:items_overview" + ) + + + +@login_required +@permission_required("invoices_items_delete") +def items_delete(request,item_id): + + item = get_object_or_404( + Item, + id=item_id + ) + + if request.method == "POST": + item.delete() + + return redirect( + "invoices:items_overview" + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +@login_required +def ajax_contacts(request, customer_id): + + contacts = CustomerContact.objects.filter(customer_id=customer_id) + + data = [ + {"id": c.id, "text": str(c)} + for c in contacts + ] + + return JsonResponse(data, safe=False) + + +@login_required +def ajax_locations(request, customer_id): + + locations = CustomerLocation.objects.filter(customer_id=customer_id) + + data = [ + {"id": l.id, "text": str(l)} + for l in locations + ] + + return JsonResponse(data, safe=False) + + + +@login_required +def ajax_invoicedata(request, project_id): + + project = get_object_or_404( + Project, + id=project_id + ) + + + if project.customer: + + contacts = [ + { + "id": c.id, + "text": str(c) + } + for c in project.customer.contacts.all() + ] + + + locations = [ + { + "id": l.id, + "text": str(l) + } + for l in project.customer.locations.all() + ] + + + return JsonResponse({ + "has_customer": True, + "contacts": contacts, + "locations": locations, + }) + + + return JsonResponse({ + "has_customer": False + }) + + + +@login_required +def ajax_invoiceitemsources(request, invoice_id): + + invoice = get_object_or_404( + Invoice, + id=invoice_id + ) + + + data = { + "has_project": False, + "time_entries": [], + "expenses": [], + } + + + if invoice.project: + + data["has_project"] = True + + + data["time_entries"] = [ + { + "id": t.id, + "text": str(t) + } + for t in invoice.project.time_entries.all() + ] + + + data["expenses"] = [ + { + "id": e.id, + "text": str(e) + } + for e in invoice.project.expenses.all() + ] + + + return JsonResponse(data) + + + +@login_required +def ajax_invoice_item_data(request): + + source = request.GET.get("source") + source_id = request.GET.get("id") + + data = {} + + if source == "item": + + item = get_object_or_404( + Item, + id=source_id + ) + + data = { + "description": item.description, + "quantity": "1.00", + "unit": item.unit.name, + "unit_price_net": str(item.price_net), + "unit_tax_rate": str(item.tax_rate), + } + + + elif source == "time": + + entry = get_object_or_404( + TimeEntry, + id=source_id, + ) + + data = { + "description": ( + f"Arbeitszeit " + f"{entry.start.strftime('%d.%m.%Y')} - " + f"{entry.end.strftime('%d.%m.%Y')}" + ), + + "quantity": str( + entry.duration_hours + ), + + "unit": "Stunden", + + "unit_price_net": str( + entry.project.hourly_rate + ), + } + + + elif source == "expense": + + expense = get_object_or_404( + Expense, + id=source_id + ) + + data = { + "description": str(expense.name), + "quantity": "1.00", + "unit": "Stück", + "unit_price_net": str(expense.amount), + } + + + return JsonResponse(data) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +@login_required +@permission_required("invoices_invoices_view") +def invoices_invoices_overview(request): + + invoices = Invoice.objects.select_related( + "customer", + "project" + ).all() + + table = InvoiceTable(invoices) + + RequestConfig( + request, + paginate=False + ).configure(table) + + return render( + request, + "invoices/invoices/overview.html", + { + "table": table + } + ) + + + + +@login_required +@permission_required("invoices_invoices_customer_add") +def invoice_add_customer(request): + + form = CustomerInvoiceForm(request.POST or None) + + if form.is_valid(): + invoice = form.save(commit=False) + + customer = invoice.customer + contact = form.cleaned_data.get("contact") + location = form.cleaned_data.get("location") + + invoice.tax_number = customer.tax_number + invoice.vat_number = customer.vat_number + + # SNAPSHOT CONTACT + if contact: + invoice.contact_name = f"{contact.first_name} {contact.last_name}" + invoice.contact_email = contact.email + invoice.contact_phone = contact.phone + + # SNAPSHOT LOCATION + if location: + invoice.location_name = location.name + invoice.location_street = location.street + invoice.location_city = location.city + invoice.location_zip = location.zip_code + invoice.location_country = location.country + + invoice.save() + return redirect("invoices:invoices_invoices_overview") + + return render(request, "invoices/invoices/add_customer.html", { + "form": form + }) + + +@login_required +@permission_required("invoices_invoices_project_add") +def invoice_add_project(request): + form = ProjectInvoiceForm(request.POST or None) + if form.is_valid(): + invoice = form.save(commit=False) + + project = form.cleaned_data.get("project") + invoice.project = project + + if project and project.customer: + customer = project.customer + invoice.customer = customer + invoice.tax_number = customer.tax_number + invoice.vat_number = customer.vat_number + contact = form.cleaned_data.get("contact") + if contact: + invoice.contact_name = ( + f"{contact.first_name} {contact.last_name}" + ) + invoice.contact_email = contact.email + invoice.contact_phone = contact.phone + location = form.cleaned_data.get("location") + if location: + invoice.location_name = location.name + invoice.location_street = location.street + invoice.location_zip = location.zip_code + invoice.location_city = location.city + invoice.location_country = location.country + else: + invoice.customer = None + invoice.contact_name = form.cleaned_data.get( + "contact_name_manual", + "" + ) + invoice.contact_email = form.cleaned_data.get( + "contact_email_manual", + "" + ) + invoice.contact_phone = form.cleaned_data.get( + "contact_phone_manual", + "" + ) + invoice.location_name = form.cleaned_data.get( + "location_name_manual", + "" + ) + invoice.location_street = form.cleaned_data.get( + "location_street_manual", + "" + ) + invoice.location_zip = form.cleaned_data.get( + "location_zip_manual", + "" + ) + invoice.location_city = form.cleaned_data.get( + "location_city_manual", + "" + ) + invoice.location_country = form.cleaned_data.get( + "location_country_manual", + "" + ) + invoice.save() + return redirect( + "invoices:invoices_invoices_overview" + ) + return render( + request, + "invoices/invoices/add_project.html", + { + "form": form + } + ) + + + + + + + + + +@login_required +@permission_required("invoices_invoices_invoices_view") +def invoices_detail(request, invoice_id): + + invoice = get_object_or_404( + Invoice.objects.select_related( + "customer", + "project", + ).prefetch_related( + "items__item", + "items__time_entry", + "items__expense", + "reminders", + ), + id=invoice_id + ) + + + items = invoice.items.all() + + + reminders = invoice.reminders.all() + + + total_net = sum( + (i.unit_price_net or Decimal("0.00")) * + (i.quantity or Decimal("0.00")) + for i in items + ) + + + show_service_date = any( + item.service_period_from + for item in items + ) + + table = InvoiceItemTable(items) + + if not show_service_date: + table.columns.hide("service_period") + + if invoice.status != "draft": + table.columns.hide("actions") + + RequestConfig( + request, + paginate=False + ).configure(table) + + return render( + request, + "invoices/invoices/detail.html", + { + "invoice": invoice, + "table": table, + "reminders": reminders, + "total_net": total_net, + "show_service_date": show_service_date, + } + ) + + + + + + + + + +@login_required +@permission_required("invoices_invoices_invoices_item_add") +def invoice_item_add(request, invoice_id): + + invoice = get_object_or_404( + Invoice, + id=invoice_id + ) + + if invoice.status != "draft": + return redirect("invoices:invoices_detail",invoice.id) + + + if request.method == "POST": + + form = InvoiceItemForm( + request.POST, + invoice=invoice + ) + + if form.is_valid(): + + item = form.save( + commit=False + ) + + item.invoice = invoice + item.save() + + + return redirect( + "invoices:invoices_detail", + invoice.id + ) + + + else: + + form = InvoiceItemForm( + invoice=invoice + ) + + + return render( + request, + "invoices/invoices/item_add.html", + { + "form": form, + "invoice": invoice + } + ) + + + +@login_required +@permission_required("invoices_invoices_invoices_preview") +def invoices_preview(request, invoice_id): + + invoice = get_object_or_404( + Invoice.objects.select_related( + "customer", + "project", + ), + id=invoice_id + ) + + if invoice.status != "draft": + return redirect("invoices:invoices_detail",invoice.id) + + items = invoice.items.all() + + # ========================== + # Summen berechnen + # ========================== + total_net = Decimal("0.00") + tax_summary = defaultdict( + lambda: Decimal("0.00") + ) + for item in items: + item_net = Decimal(str(item.calculated_total_net)) + total_net += item_net + tax_rate = Decimal(str(item.unit_tax_rate)) + tax_amount = ( + item_net * + tax_rate / + Decimal("100") + ) + tax_summary[tax_rate] += tax_amount + + total_tax = sum( + tax_summary.values(), + Decimal("0.00") + ) + + total_gross = ( + total_net + + total_tax + ) + + show_service_date = any( + item.service_period_from + for item in items + ) + + # ========================== + # Firma + # ========================== + company = CompanySetting.objects.first() + return render( + request, + "invoices/invoices/preview.html", + { + "invoice": invoice, + "items": items, + + "company": company, + + + # Summen + "total_net": total_net, + "tax_summary": dict(tax_summary), + "total_tax": total_tax, + "total_gross": total_gross, + + "show_service_date": show_service_date, + } + ) + + + + + + +@login_required +@permission_required("invoices_invoices_invoices_download") +def invoices_download(request, invoice_id): + + invoice = get_object_or_404( + Invoice.objects.select_related( + "customer", + "project", + ), + id=invoice_id + ) + + + # ========================== + # Bestehendes PDF zurückgeben + # ========================== + + if invoice.status != "draft" and invoice.pdf_file: + return FileResponse( + invoice.pdf_file.open("rb"), + as_attachment=True, + filename=invoice.pdf_file.name.split("/")[-1], + ) + + + items = invoice.items.all() + + + # ========================== + # Summen + # ========================== + + total_net = Decimal("0.00") + + tax_summary = defaultdict( + lambda: Decimal("0.00") + ) + + + for item in items: + + item_net = Decimal( + str(item.calculated_total_net) + ) + + total_net += item_net + + tax_rate = Decimal( + str(item.unit_tax_rate) + ) + + tax_amount = ( + item_net * + tax_rate / + Decimal("100") + ) + + tax_summary[tax_rate] += tax_amount + + + total_tax = sum( + tax_summary.values(), + Decimal("0.00") + ) + + total_gross = total_net + total_tax + + + company = CompanySetting.objects.first() + number_settings = company.number_settings + invoice.invoice_number = generate_document_number( + number_settings.invoice_format, + number_settings.invoice_next_number, + ) + number_settings.invoice_next_number += 1 + number_settings.save(update_fields=["invoice_next_number"]) + invoice.save(update_fields=["invoice_number"]) + + # ========================== + # PDF HTML + # ========================== + + show_service_date = any( + item.service_period_from + for item in items + ) + + html_string = render_to_string( + "invoices/invoices/pdf.html", + { + "invoice": invoice, + "items": items, + "company": company, + "total_net": total_net, + "tax_summary": dict(tax_summary), + "total_tax": total_tax, + "total_gross": total_gross, + "show_service_date": show_service_date, + } + ) + + + # ========================== + # PDF erstellen + # ========================== + + pdf_file = HTML( + string=html_string, + base_url=request.build_absolute_uri("/") + ).write_pdf() + + + filename = f"Rechnung-{invoice.invoice_number}.pdf" + + + # ========================== + # PDF speichern + # ========================== + + invoice.pdf_file.save( + filename, + ContentFile(pdf_file), + save=False + ) + + invoice.pdf_generated_at = timezone.now() + invoice.status = "sent" + + invoice.save( + update_fields=[ + "pdf_file", + "pdf_generated_at", + "status", + ] + ) + + + + + return FileResponse( + io.BytesIO(pdf_file), + as_attachment=True, + filename=filename + ) + + + + + +@login_required +@permission_required("invoices_invoices_invoices_item_delete") +def invoice_item_delete(request,invoice_id,item_id): + invoice = get_object_or_404( + Invoice.objects.select_related( + "customer", + "project", + ), + id=invoice_id + ) + + item = get_object_or_404( + InvoiceItem, + id=item_id, + invoice_id=invoice_id + ) + + if invoice.status != "draft": + return redirect("invoices:invoices_detail",invoice.id) + + if request.method == "POST": + item.delete() + + return redirect( + "invoices:invoices_detail", + invoice_id=invoice_id + ) + + + + +@login_required +@permission_required("invoices_invoices_invoices_cancel") +def invoices_cancel(request, invoice_id): + + invoice = get_object_or_404( + Invoice.objects.select_related( + "customer", + "project", + ), + id=invoice_id, + ) + + if invoice.status == "canceled" and invoice.pdf_canceled_file: + return FileResponse( + invoice.pdf_canceled_file.open("rb"), + as_attachment=True, + filename=invoice.pdf_canceled_file.name.split("/")[-1], + ) + + company = CompanySetting.objects.first() + number_settings = company.number_settings + + invoice.cancel_number = generate_document_number( + number_settings.cancel_format, + number_settings.cancel_next_number, + ) + number_settings.cancel_next_number += 1 + number_settings.save( + update_fields=[ + "cancel_next_number" + ] + ) + + if invoice.status not in ["sent", "paid"]: + return redirect( + "invoices:invoices_detail", + invoice.id, + ) + + items = invoice.items.all() + + # ========================== + # Summen + # ========================== + + total_net = Decimal("0.00") + + tax_summary = defaultdict( + lambda: Decimal("0.00") + ) + + for item in items: + + item_net = Decimal( + str(item.calculated_total_net) + ) + + total_net += item_net + + tax_rate = Decimal( + str(item.unit_tax_rate) + ) + + tax_amount = ( + item_net * + tax_rate / + Decimal("100") + ) + + tax_summary[tax_rate] += tax_amount + + total_tax = sum( + tax_summary.values(), + Decimal("0.00") + ) + + total_gross = ( + total_net + + total_tax + ) + + company = CompanySetting.objects.first() + + original_status = invoice.status + + show_service_date = any( + item.service_period_from + for item in items + ) + + html_string = render_to_string( + "invoices/invoices/pdf_cancel.html", + { + "invoice": invoice, + "items": items, + "company": company, + "cancel_date": timezone.now(), + "cancel_number": invoice.cancel_number, + "original_status": original_status, + "total_net": total_net, + "tax_summary": dict(tax_summary), + "total_tax": total_tax, + "total_gross": total_gross, + "show_service_date": show_service_date, + }, + ) + + pdf_file = HTML( + string=html_string, + base_url=request.build_absolute_uri("/"), + ).write_pdf() + + filename = ( + f"Stornorechnung-{invoice.cancel_number}.pdf" + ) + + invoice.pdf_canceled_file.save( + filename, + ContentFile(pdf_file), + save=False, + ) + + invoice.pdf_canceled_generated_at = timezone.now() + invoice.status = "canceled" + + invoice.save( + update_fields=[ + "pdf_canceled_file", + "pdf_canceled_generated_at", + "status", + 'cancel_number', + ] + ) + + return FileResponse( + io.BytesIO(pdf_file), + as_attachment=True, + filename=filename, + ) + + + +@login_required +@permission_required("invoices_invoices_invoices_delete") +def invoices_delete(request, invoice_id): + invoice = get_object_or_404( + Invoice, + id=invoice_id, + ) + + if invoice.status != Invoice.Status.DRAFT: + return redirect( + "invoices:invoices_detail", + invoice_id=invoice.id, + ) + + invoice.delete() + return redirect("invoices:invoices_invoices_overview",) + + +@login_required +@permission_required("invoices_invoices_invoices_paid") +def invoices_paid(request, invoice_id): + + invoice = get_object_or_404( + Invoice, + id=invoice_id, + ) + + if invoice.status != "sent": + return redirect( + "invoices:invoices_detail", + invoice_id=invoice.id, + ) + + invoice.status = "paid" + + invoice.save( + update_fields=[ + "status", + ] + ) + + return redirect( + "invoices:invoices_detail", + invoice_id=invoice.id, + ) + + +@login_required +@permission_required("invoices_invoices_invoices_overdue") +def invoices_overdue(request, invoice_id): + + invoice = get_object_or_404( + Invoice, + id=invoice_id, + ) + + today = timezone.now().date() + + if invoice.status != "sent": + return redirect( + "invoices:invoices_detail", + invoice_id=invoice.id, + ) + + if invoice.due_date and invoice.due_date < today: + invoice.status = "overdue" + invoice.save( + update_fields=[ + "status", + ] + ) + + return redirect( + "invoices:invoices_detail", + invoice_id=invoice.id, + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +@login_required +@permission_required("invoices_offers_view") +def invoices_offers_overview(request): + + offers = Offer.objects.select_related("customer",).all() + table = OfferTable(offers) + + RequestConfig( + request, + paginate=False + ).configure(table) + return render( + request, + "invoices/offers/overview.html", + { + "table": table + } + ) + + + + +@login_required +@permission_required("invoices_offers_add") +def invoices_offers_add(request): + + form = OfferForm(request.POST or None) + + if form.is_valid(): + offer = form.save(commit=False) + + customer = offer.customer + contact = form.cleaned_data.get("contact") + location = form.cleaned_data.get("location") + + offer.tax_number = customer.tax_number + offer.vat_number = customer.vat_number + + # SNAPSHOT CONTACT + if contact: + offer.contact_name = f"{contact.first_name} {contact.last_name}" + offer.contact_email = contact.email + offer.contact_phone = contact.phone + + # SNAPSHOT LOCATION + if location: + offer.location_name = location.name + offer.location_street = location.street + offer.location_city = location.city + offer.location_zip = location.zip_code + offer.location_country = location.country + + offer.save() + return redirect("invoices:invoices_offers_overview") + + return render(request, "invoices/offers/add.html", { + "form": form + }) + + + + + + +@login_required +@permission_required("invoices_offers_offers_delete") +def offers_delete(request, offer_id): + offer = get_object_or_404( + Offer, + id=offer_id, + ) + + if offer.status != Offer.Status.DRAFT: + return redirect( + "invoices:offers_detail", + offer_id=offer.id, + ) + + offer.delete() + return redirect("invoices:invoices_offers_overview",) + + + + + + +@login_required +@permission_required("invoices_offers_offers_view") +def offers_detail(request, offer_id): + offer = get_object_or_404( + Offer.objects.select_related( + "customer", + ), + id=offer_id + ) + + + items = offer.items.select_related( + "item", + ) + + + total_net = sum( + (i.unit_price_net or Decimal("0.00")) * + (i.quantity or Decimal("0.00")) + for i in items + ) + + table = OfferItemTable(items) + + table.offer = offer + + if offer.status != "draft": + table.columns.hide("actions") + + RequestConfig( + request, + paginate=False + ).configure(table) + + return render( + request, + "invoices/offers/detail.html", + { + "offer": offer, + "table": table, + "total_net": total_net + } + ) + + + + + + + + + +@login_required +@permission_required("invoices_offers_offers_item_add") +def offers_item_add(request, offer_id): + + offer = get_object_or_404( + Offer, + id=offer_id + ) + + if offer.status != "draft": + return redirect("invoices:offers_detail",offer.id) + + + if request.method == "POST": + + form = OfferItemForm( + request.POST, + offer=offer + ) + + if form.is_valid(): + + item = form.save( + commit=False + ) + + item.offer = offer + item.save() + + + return redirect( + "invoices:offers_detail", + offer.id + ) + + + else: + + form = OfferItemForm( + offer=offer + ) + + + return render( + request, + "invoices/offers/item_add.html", + { + "form": form, + "offer": offer + } + ) + + + + + + +@login_required +@permission_required("invoices_offers_offers_item_delete") +def offers_item_delete(request,offer_id,item_id): + offer = get_object_or_404( + Offer.objects.select_related( + "customer", + ), + id=offer_id + ) + + item = get_object_or_404( + OfferItem, + id=item_id, + offer_id=offer_id + ) + + if offer.status != "draft": + return redirect("invoices:offers_detail",offer.id) + + if request.method == "POST": + item.delete() + + return redirect( + "invoices:offers_detail", + offer_id=offer_id + ) + + + + + + + + + +@login_required +@permission_required("invoices_offers_offers_preview") +def offers_preview(request, offer_id): + + offer = get_object_or_404( + Offer.objects.select_related( + "customer", + ), + id=offer_id + ) + + if offer.status != "draft": + return redirect("invoices:offers_detail",offer.id) + + items = offer.items.all() + + # ========================== + # Summen berechnen + # ========================== + total_net = Decimal("0.00") + tax_summary = defaultdict( + lambda: Decimal("0.00") + ) + for item in items: + item_net = Decimal(str(item.calculated_total_net)) + total_net += item_net + tax_rate = Decimal(str(item.unit_tax_rate)) + tax_amount = ( + item_net * + tax_rate / + Decimal("100") + ) + tax_summary[tax_rate] += tax_amount + + total_tax = sum( + tax_summary.values(), + Decimal("0.00") + ) + + total_gross = ( + total_net + + total_tax + ) + + # ========================== + # Firma + # ========================== + company = CompanySetting.objects.first() + return render( + request, + "invoices/offers/preview.html", + { + "offer": offer, + "items": items, + + "company": company, + + + # Summen + "total_net": total_net, + "tax_summary": dict(tax_summary), + "total_tax": total_tax, + "total_gross": total_gross, + } + ) + + + + + + +@login_required +@permission_required("invoices_offers_offers_download") +def offers_download(request, offer_id): + + offer = get_object_or_404( + Offer.objects.select_related( + "customer", + ), + id=offer_id + ) + + + # ========================== + # Bestehendes PDF zurückgeben + # ========================== + + if offer.status != "draft" and offer.pdf_file: + + return FileResponse( + offer.pdf_file.open("rb"), + as_attachment=True, + filename=offer.pdf_file.name.split("/")[-1], + ) + + + items = offer.items.all() + + + # ========================== + # Summen + # ========================== + + total_net = Decimal("0.00") + + tax_summary = defaultdict( + lambda: Decimal("0.00") + ) + + + for item in items: + + item_net = Decimal( + str(item.calculated_total_net) + ) + + total_net += item_net + + tax_rate = Decimal( + str(item.unit_tax_rate) + ) + + tax_amount = ( + item_net * + tax_rate / + Decimal("100") + ) + + tax_summary[tax_rate] += tax_amount + + + total_tax = sum( + tax_summary.values(), + Decimal("0.00") + ) + + total_gross = total_net + total_tax + + company = CompanySetting.objects.first() + number_settings = company.number_settings + offer.offer_number = generate_document_number( + number_settings.offer_format, + number_settings.offer_next_number, + ) + number_settings.offer_next_number += 1 + number_settings.save(update_fields=["offer_next_number"]) + offer.save(update_fields=["offer_number"]) + + # ========================== + # PDF HTML + # ========================== + + html_string = render_to_string( + "invoices/offers/pdf.html", + { + "offer": offer, + "items": items, + "company": company, + "total_net": total_net, + "tax_summary": dict(tax_summary), + "total_tax": total_tax, + "total_gross": total_gross, + } + ) + + + # ========================== + # PDF erstellen + # ========================== + + pdf_file = HTML( + string=html_string, + base_url=request.build_absolute_uri("/") + ).write_pdf() + + + filename = f"Angebot-{offer.offer_number}.pdf" + + + # ========================== + # PDF speichern + # ========================== + + offer.pdf_file.save( + filename, + ContentFile(pdf_file), + save=False + ) + + offer.pdf_generated_at = timezone.now() + offer.status = "sent" + + offer.save( + update_fields=[ + "pdf_file", + "pdf_generated_at", + "status", + ] + ) + + + return FileResponse( + io.BytesIO(pdf_file), + as_attachment=True, + filename=filename + ) + + + + + + + + +@login_required +@permission_required("invoices_offers_offers_status") +def offers_accept(request, offer_id): + + offer = get_object_or_404( + Offer, + id=offer_id, + ) + + if offer.status != "sent": + return redirect( + "invoices:offers_detail", + offer_id=offer.id, + ) + + offer.status = "accepted" + + offer.save( + update_fields=[ + "status", + ] + ) + + return redirect( + "invoices:offers_detail", + offer_id=offer.id, + ) + +@login_required +@permission_required("invoices_offers_offers_status") +def offers_reject(request, offer_id): + + offer = get_object_or_404( + Offer, + id=offer_id, + ) + + if offer.status != "sent": + return redirect( + "invoices:offers_detail", + offer_id=offer.id, + ) + + offer.status = "rejected" + + offer.save( + update_fields=[ + "status", + ] + ) + + return redirect( + "invoices:offers_detail", + offer_id=offer.id, + ) + + +@login_required +@permission_required("invoices_offers_offers_status") +def offers_expire(request, offer_id): + + offer = get_object_or_404( + Offer, + id=offer_id, + ) + + if offer.status != "sent": + return redirect( + "invoices:offers_detail", + offer_id=offer.id, + ) + + offer.status = "expired" + + offer.save( + update_fields=[ + "status", + ] + ) + + return redirect( + "invoices:offers_detail", + offer_id=offer.id, + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +@login_required +@permission_required("invoices_reminders_view") +def invoices_reminders_overview(request): + + reminders = Reminder.objects.select_related("invoice").all() + + table = ReminderTable(reminders) + + RequestConfig( + request, + paginate=False + ).configure(table) + + return render( + request, + "invoices/reminders/overview.html", + { + "table": table, + } + ) + + +@login_required +@permission_required("invoices_reminders_add") +def invoices_reminders_add(request): + if request.method == "POST": + form = ReminderForm(request.POST) + if form.is_valid(): + reminder = form.save(commit=False) + invoice = reminder.invoice + reminders = invoice.reminders.exclude( + status=Reminder.Status.CANCELED + ).order_by("-level") + + + # ========================== + # Vorhandene Mahnung prüfen + # ========================== + if reminders.exists(): + last_reminder = reminders.first() + if last_reminder.status not in [ + Reminder.Status.EXPIRED, + ]: + return redirect("invoices:invoices_reminders_overview",) + next_level = last_reminder.level + 1 + else: + next_level = 1 + + if next_level > 3: + return redirect( + "invoices:invoices_reminders_overview",) + + + reminder.level = next_level + reminder.status = Reminder.Status.DRAFT + + reminder.save() + + + return redirect( + "invoices:reminders_detail", + reminder_id=reminder.pk, + ) + + else: + form = ReminderForm() + + + return render( + request, + "invoices/reminders/add.html", + { + "form": form, + }, + ) + + + + +@login_required +@permission_required("invoices_reminders_reminders_view") +def reminders_detail(request, reminder_id): + reminder = get_object_or_404( + Reminder.objects.select_related("invoice",), + id=reminder_id + ) + + return render( + request, + "invoices/reminders/detail.html", + { + "reminder": reminder + } + ) + +@login_required +@permission_required("invoices_reminders_reminders_cancel") +def reminders_cancel(request, reminder_id): + + reminder = get_object_or_404( + Reminder, + id=reminder_id, + ) + + if reminder.status != "sent" and reminder.status != "draft": + return redirect( + "invoices:reminders_detail", + reminder_id=reminder.id, + ) + + reminder.status = "canceled" + + reminder.save( + update_fields=[ + "status", + ] + ) + + return redirect( + "invoices:reminders_detail", + reminder_id=reminder.id, + ) + + +@login_required +@permission_required("invoices_reminders_reminders_delete") +def reminders_delete(request, reminder_id): + reminder = get_object_or_404( + Reminder, + id=reminder_id, + ) + + if reminder.status != Reminder.Status.DRAFT: + return redirect( + "invoices:reminders_detail", + reminder_id=reminder.id, + ) + + reminder.delete() + return redirect("invoices:invoices_reminders_overview",) + + +@login_required +@permission_required("invoices_reminders_reminders_download") +def reminders_download(request, reminder_id): + + reminder = get_object_or_404( + Reminder.objects.select_related( + "invoice", + "invoice__customer", + "invoice__project", + ), + id=reminder_id + ) + + + # ========================== + # Bestehendes PDF zurückgeben + # ========================== + + if reminder.pdf_file: + return FileResponse( + reminder.pdf_file.open("rb"), + as_attachment=True, + filename=reminder.pdf_file.name.split("/")[-1], + ) + + + invoice = reminder.invoice + + + # ========================== + # Rechnungsbetrag berechnen + # ========================== + + items = invoice.items.all() + + total_net = Decimal("0.00") + total_tax = Decimal("0.00") + + + for item in items: + + item_net = Decimal( + str(item.calculated_total_net) + ) + + total_net += item_net + + tax_rate = Decimal( + str(item.unit_tax_rate) + ) + + total_tax += ( + item_net * + tax_rate / + Decimal("100") + ) + + + invoice_amount = total_net + total_tax + + # ========================== + # Vorherige Mahnungen + # ========================== + previous_reminders = ( + invoice.reminders + .exclude(id=reminder.id) + .exclude(status=Reminder.Status.CANCELED) + .order_by("level") + ) + + total_previous_fees = sum( + r.fee for r in previous_reminders + ) + + total_due = ( + invoice_amount + + total_previous_fees + + reminder.fee + ) + + total_fees = (total_previous_fees + reminder.fee) + + company = CompanySetting.objects.first() + + + # ========================== + # Mahnungsnummer vergeben + # ========================== + + if not reminder.reminder_number: + + number_settings = company.number_settings + + reminder.reminder_number = generate_document_number( + number_settings.reminder_format, + number_settings.reminder_next_number, + ) + + number_settings.reminder_next_number += 1 + + number_settings.save( + update_fields=[ + "reminder_next_number" + ] + ) + + + reminder.save( + update_fields=[ + "reminder_number", + ] + ) + + + # ========================== + # PDF HTML + # ========================== + + html_string = render_to_string( + "invoices/reminders/pdf.html", + { + "reminder": reminder, + "invoice": invoice, + "company": company, + "invoice_amount": invoice_amount, + "previous_reminders": previous_reminders, + "total_previous_fees": total_previous_fees, + "total_due": total_due, + "total_fees": total_due, + } + ) + + # ========================== + # PDF erstellen + # ========================== + + pdf_file = HTML( + string=html_string, + base_url=request.build_absolute_uri("/") + ).write_pdf() + + + filename = f"Mahnung-{reminder.reminder_number}.pdf" + + + reminder.pdf_file.save( + filename, + ContentFile(pdf_file), + save=False + ) + + + reminder.pdf_generated_at = timezone.now() + reminder.status = Reminder.Status.SENT + + + reminder.save( + update_fields=[ + "pdf_file", + "pdf_generated_at", + "status", + ] + ) + + + return FileResponse( + io.BytesIO(pdf_file), + as_attachment=True, + filename=filename + ) + + + +@login_required +@permission_required("invoices_reminders_reminders_paid") +def reminders_paid(request, reminder_id): + reminder = get_object_or_404( + Reminder, + id=reminder_id, + ) + + if reminder.status != "sent": + return redirect( + "invoices:reminders_detail", + reminder_id=reminder.id, + ) + + reminder.status = "paid" + reminder.save(update_fields=["status",]) + + reminder.invoice.status = "paid" + reminder.invoice.save(update_fields=["status",]) + + return redirect( + "invoices:reminders_detail", + reminder_id=reminder.id, + ) + + +@login_required +@permission_required("invoices_reminders_reminders_expire") +def reminders_expire(request, reminder_id): + reminder = get_object_or_404( + Reminder, + id=reminder_id, + ) + + if reminder.status != "sent": + return redirect( + "invoices:reminders_detail", + reminder_id=reminder.id, + ) + + reminder.status = "expired" + reminder.save(update_fields=["status",]) + + return redirect( + "invoices:reminders_detail", + reminder_id=reminder.id, + ) \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/masterdata/__init__.py b/masterdata/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/masterdata/admin.py b/masterdata/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/masterdata/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/masterdata/apps.py b/masterdata/apps.py new file mode 100644 index 0000000..4ea52ea --- /dev/null +++ b/masterdata/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class MasterdataConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "masterdata" + + def ready(self): + from .loader import autodiscover + autodiscover() \ No newline at end of file diff --git a/masterdata/loader.py b/masterdata/loader.py new file mode 100644 index 0000000..ec9c65b --- /dev/null +++ b/masterdata/loader.py @@ -0,0 +1,26 @@ +import importlib + +from django.apps import apps + + +_loaded = False + + +def autodiscover(): + + global _loaded + + if _loaded: + return + + _loaded = True + + for app in apps.get_app_configs(): + + try: + importlib.import_module( + f"{app.name}.settings_registry" + ) + + except ModuleNotFoundError: + pass \ No newline at end of file diff --git a/masterdata/migrations/__init__.py b/masterdata/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/masterdata/models.py b/masterdata/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/masterdata/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/masterdata/registry.py b/masterdata/registry.py new file mode 100644 index 0000000..c6d6144 --- /dev/null +++ b/masterdata/registry.py @@ -0,0 +1,14 @@ +MASTERDATA = [] + + +def register_setting(group,name,model,setting_type="table"): + + MASTERDATA.append({ + "group": group, + "name": name, + "model": model, + "model_name": model._meta.model_name, + "verbose_name": model._meta.verbose_name, + "verbose_name_plural": model._meta.verbose_name_plural, + "type": setting_type, + }) \ No newline at end of file diff --git a/masterdata/templates/masterdata/add.html b/masterdata/templates/masterdata/add.html new file mode 100644 index 0000000..5308140 --- /dev/null +++ b/masterdata/templates/masterdata/add.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %} +Einstellungen +{% endblock %} + + +{% block header_title %} +{{ title }} hinzufügen +{% endblock %} + +{% block header_description %} +Einstellungen der App und Module bearbeiten +{% endblock %} + +{% block content %} + + +
+ +
+ +{% csrf_token %} + + +{{ form.as_p }} + + + + + +
+ + +
+ + +{% endblock %} \ No newline at end of file diff --git a/masterdata/templates/masterdata/detail.html b/masterdata/templates/masterdata/detail.html new file mode 100644 index 0000000..72f140f --- /dev/null +++ b/masterdata/templates/masterdata/detail.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block title %} +Einstellungen +{% endblock %} + + +{% block header_title %} +{{ title }} bearbeiten +{% endblock %} + +{% block header_description %} +Einstellungen der App und Module bearbeiten +{% endblock %} + + +{% block content %} +
+
+
+
+

Aktionen

+

Einträge hinzufügen

+
+ +
+ + Eintrag +
+ +
+
+
+ + + + +
+

Einträge

+ {% for obj in objects %} +
+ {{ obj }} + +
+ {% csrf_token %} + +
+
+ {% empty %} +
+ Keine Einträge in der Datenbank +
+ {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/masterdata/templates/masterdata/detail_single.html b/masterdata/templates/masterdata/detail_single.html new file mode 100644 index 0000000..d60cd11 --- /dev/null +++ b/masterdata/templates/masterdata/detail_single.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %} +Einstellungen +{% endblock %} + + +{% block header_title %} +{{ title }} bearbeiten +{% endblock %} + +{% block header_description %} +Einstellungen der App und Module bearbeiten +{% endblock %} + + +{% block content %} + +
+ +
+ + {% csrf_token %} + + {{ form.as_p }} + + + + +
+ + +
+ +{% endblock %} \ No newline at end of file diff --git a/masterdata/templates/masterdata/overview.html b/masterdata/templates/masterdata/overview.html new file mode 100644 index 0000000..077a977 --- /dev/null +++ b/masterdata/templates/masterdata/overview.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block title %} +Einstellungen +{% endblock %} + + +{% block header_title %} +Einstellungen +{% endblock %} + +{% block header_description %} +Einstellungen der App und Module bearbeiten +{% endblock %} + + +{% block content %} +{% for group, entries in groups.items %} + + + +
+ +
+

+ {{ group }} +

+
+ + + +
+ +{% endfor %} +{% endblock %} \ No newline at end of file diff --git a/masterdata/tests.py b/masterdata/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/masterdata/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/masterdata/urls.py b/masterdata/urls.py new file mode 100644 index 0000000..bdf0c46 --- /dev/null +++ b/masterdata/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from . import views + + +app_name = "masterdata" + +urlpatterns = [ + path("",views.overview,name="overview"), + path("/",views.setting_detail,name="detail"), + path("/add/",views.setting_add,name="add"), + path("//delete/",views.setting_delete,name="delete"), + path("update/check/", views.update_check, name="update_check"), + path("update/apply/", views.update_apply, name="update_apply"), +] \ No newline at end of file diff --git a/masterdata/views.py b/masterdata/views.py new file mode 100644 index 0000000..f69968a --- /dev/null +++ b/masterdata/views.py @@ -0,0 +1,157 @@ +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib.auth.decorators import login_required +from accounts.decorators import permission_required +from collections import defaultdict +from .registry import MASTERDATA +from django.forms import modelform_factory +from django.apps import apps +from core.forms.base import BaseForm +import subprocess +from django.http import JsonResponse +from django.conf import settings + +def get_registry_entry(model_name): + entry = next( + ( + x for x in MASTERDATA + if x["model_name"] == model_name + ), + None + ) + if not entry: + raise Http404 + return entry + +def create_setting_form(model): + Meta = type( + "Meta", + (), + { + "model": model, + "fields": "__all__", + } + ) + FormClass = type( + f"{model.__name__}Form", + (BaseForm,), + { + "Meta": Meta, + + "__init__": lambda self, *args, **kwargs: ( + BaseForm.__init__(self, *args, **kwargs), + self.apply_style() + )[-1] + } + ) + return FormClass + +@login_required +@permission_required("settings_view") +def overview(request): + groups = defaultdict(list) + for item in MASTERDATA: + groups[item["group"]].append(item) + return render(request,"masterdata/overview.html",{"groups": dict(groups)}) + +@login_required +@permission_required("settings_detail_view") +def setting_detail(request, model_name): + entry = get_registry_entry(model_name) + model = entry["model"] + if entry["type"] == "single": + obj = model.objects.first() + Form = create_setting_form(model) + if request.method == "POST": + form = Form(request.POST,request.FILES,instance=obj) + if form.is_valid(): + form.save() + return redirect("masterdata:detail",model_name=model_name) + else: + form = Form(instance=obj) + return render(request,"masterdata/detail_single.html",{"title": entry["verbose_name_plural"],"form": form,"object": obj,"model_name": model_name,}) + objects = model.objects.all() + return render(request,"masterdata/detail.html",{"title": entry["verbose_name_plural"],"objects": objects,"model_name": model_name,}) + +@login_required +@permission_required("settings_add") +def setting_add(request, model_name): + entry = get_registry_entry(model_name) + model = entry["model"] + if entry["type"] == "single": + if model.objects.exists(): + return redirect("masterdata:detail",model_name=model_name) + Form = create_setting_form(model) + if request.method == "POST": + form = Form(request.POST, request.FILES) + if form.is_valid(): + form.save() + return redirect("masterdata:detail",model_name=model_name) + else: + form = Form() + return render(request,"masterdata/add.html",{"form": form,"title": f"{entry['verbose_name']}",}) + +@login_required +@permission_required("settings_delete") +def setting_delete(request, model_name, pk): + entry = get_registry_entry(model_name) + model = entry["model"] + if entry["type"] == "single": + return redirect("masterdata:detail",model_name=model_name) + obj = get_object_or_404(model,pk=pk) + if request.method == "POST": + obj.delete() + return redirect("masterdata:detail",model_name=model_name) + + + + + + + + + + + + + + + + + + + + + + + +BASE_DIR = settings.BASE_DIR + +def _git(cmd): + r = subprocess.run(cmd, capture_output=True, text=True, cwd=BASE_DIR) + return r.stdout.strip(), r.returncode + +@login_required +@permission_required("settings_update_check") +def update_check(request): + _git(["git", "fetch", "origin"]) + local, _ = _git(["git", "rev-parse", "HEAD"]) + remote, _ = _git(["git", "rev-parse", "origin/main"]) + log, _ = _git(["git", "log", f"{local}..{remote}", "--oneline"]) + return JsonResponse({ + "up_to_date": local == remote, + "local": local[:7], + "remote": remote[:7], + "changes": log.splitlines() if log else [], + }) + +@login_required +@permission_required("settings_update_apply") +def update_apply(request): + if request.method != "POST": + return JsonResponse({"error": "POST required"}, status=405) + _, code = _git(["git", "pull", "origin", "main"]) + if code != 0: + return JsonResponse({"error": "git pull fehlgeschlagen"}, status=500) + _git(["pip", "install", "-r", f"{BASE_DIR}/requirements.txt", "-q"]) + _git(["python", "manage.py", "migrate", "--no-input"]) + return JsonResponse({"success": True}) \ No newline at end of file diff --git a/projects/__init__.py b/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/admin.py b/projects/admin.py new file mode 100644 index 0000000..c36df9a --- /dev/null +++ b/projects/admin.py @@ -0,0 +1,53 @@ +from django.contrib import admin +from .models import Project, ProjectMembership, ProjectContact, ProjectLocation + + +class ProjectMembershipInline(admin.TabularInline): + model = ProjectMembership + extra = 1 + + +@admin.register(Project) +class ProjectAdmin(admin.ModelAdmin): + list_display = ( + "name", + "active", + "created_at", + ) + + search_fields = ( + "name", + ) + + inlines = [ + ProjectMembershipInline + ] + + +@admin.register(ProjectMembership) +class ProjectMembershipAdmin(admin.ModelAdmin): + list_display = ( + "user", + "project", + "role", + ) + + list_filter = ( + "role", + ) + + +@admin.register(ProjectContact) +class ProjectContactAdmin(admin.ModelAdmin): + list_display = ("project", "contact", "created_at") + search_fields = ("project__name", "contact__first_name", "contact__last_name") + autocomplete_fields = ("project", "contact") + list_filter = ("project",) + + +@admin.register(ProjectLocation) +class ProjectLocationAdmin(admin.ModelAdmin): + list_display = ("project", "location", "created_at") + search_fields = ("project__name", "location__name") + autocomplete_fields = ("project", "location") + list_filter = ("project",) \ No newline at end of file diff --git a/projects/apps.py b/projects/apps.py new file mode 100644 index 0000000..3ef44de --- /dev/null +++ b/projects/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + name = 'projects' diff --git a/projects/forms.py b/projects/forms.py new file mode 100644 index 0000000..58ae7b4 --- /dev/null +++ b/projects/forms.py @@ -0,0 +1,102 @@ +from django import forms +from core.forms.base import BaseForm +from invoices.models import CustomerContact, CustomerLocation +from .models import ProjectContact, ProjectLocation, Project, ProjectMembership +from django.contrib.auth import get_user_model +User = get_user_model() + +class ProjectAddForm(BaseForm): + class Meta: + model = Project + fields = [ + "name", + "customer", + "logo", + "color", + "icon", + "hourly_rate", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.apply_style() + +class ProjectForm(BaseForm): + class Meta: + model = Project + fields = [ + "name", + "logo", + "color", + "icon", + "hourly_rate", + "active" + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.apply_style() + +class ProjectAddContactForm(BaseForm): + class Meta: + model = ProjectContact + fields = ["contact"] + + def __init__(self, *args, **kwargs): + project = kwargs.pop("project", None) + super().__init__(*args, **kwargs) + + qs = CustomerContact.objects.none() + + if project and project.customer: + qs = CustomerContact.objects.filter( + customer=project.customer + ).exclude( + project_links__project=project + ) + + self.fields["contact"].queryset = qs + self.apply_style() + +class ProjectAddLocationForm(BaseForm): + class Meta: + model = ProjectLocation + fields = ["location"] + + def __init__(self, *args, **kwargs): + project = kwargs.pop("project", None) + super().__init__(*args, **kwargs) + + qs = CustomerLocation.objects.none() + + if project and project.customer: + qs = CustomerLocation.objects.filter( + customer=project.customer + ).exclude( + project_links__project=project + ) + + self.fields["location"].queryset = qs + self.apply_style() + + +class ProjectAddWorkerForm(BaseForm): + + class Meta: + model = ProjectMembership + + fields = [ + "user", + "role", + ] + + def __init__(self, *args, project=None, **kwargs): + + super().__init__(*args, **kwargs) + + self.apply_style() + + if project: + self.fields["user"].queryset = User.objects.exclude( + project_memberships__project=project + ) \ No newline at end of file diff --git a/projects/migrations/0001_initial.py b/projects/migrations/0001_initial.py new file mode 100644 index 0000000..ab368e7 --- /dev/null +++ b/projects/migrations/0001_initial.py @@ -0,0 +1,71 @@ +# Generated by Django 6.0.6 on 2026-06-23 22:12 + +import django.db.models.deletion +import projects.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('invoices', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('logo', models.ImageField(blank=True, null=True, upload_to=projects.models.logo_upload_path, verbose_name='Logo')), + ('color', models.CharField(default='#2563eb', max_length=20, verbose_name='Farbe')), + ('icon', models.CharField(blank=True, max_length=100, verbose_name='Icon')), + ('active', models.BooleanField(default=True, verbose_name='Status')), + ('hourly_rate', models.DecimalField(decimal_places=2, default=25, max_digits=10, verbose_name='Stundenlohn')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='projects', to='invoices.customer', verbose_name='Kunde')), + ], + ), + migrations.CreateModel( + name='ProjectContact', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')), + ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_links', to='invoices.customercontact', verbose_name='Ansprechpartner')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_contacts', to='projects.project', verbose_name='Projekt')), + ], + options={ + 'unique_together': {('project', 'contact')}, + }, + ), + migrations.CreateModel( + name='ProjectLocation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')), + ('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_links', to='invoices.customerlocation', verbose_name='Standort')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_locations', to='projects.project', verbose_name='Projekt')), + ], + options={ + 'unique_together': {('project', 'location')}, + }, + ), + migrations.CreateModel( + name='ProjectMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('owner', 'Eigentümer'), ('manager', 'Manager'), ('employee', 'Mitarbeiter')], default='employee', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='projects.project')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_memberships', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('project', 'user')}, + }, + ), + ] diff --git a/projects/migrations/__init__.py b/projects/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/projects/models.py b/projects/models.py new file mode 100644 index 0000000..5cee0fe --- /dev/null +++ b/projects/models.py @@ -0,0 +1,131 @@ +from django.db import models +from django.contrib.auth import get_user_model +from invoices.models import Customer, CustomerContact, CustomerLocation +import os + +User = get_user_model() + +def logo_upload_path(instance, filename): + return f"projects/{instance.id}/logo/{filename}" + +class Project(models.Model): + name = models.CharField(max_length=255, verbose_name="Name") + logo = models.ImageField(upload_to=logo_upload_path, blank=True, null=True, verbose_name="Logo") + color = models.CharField(max_length=20, default="#2563eb", verbose_name="Farbe") + icon = models.CharField(max_length=100, blank=True, verbose_name="Icon") + active = models.BooleanField(default=True, verbose_name="Status") + customer = models.ForeignKey( + Customer, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="projects", + verbose_name="Kunde" + ) + hourly_rate = models.DecimalField(max_digits=10,decimal_places=2,default=25,verbose_name="Stundenlohn") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def save(self, *args, **kwargs): + customer_changed = False + + if self.pk: + old_customer_id = ( + Project.objects + .filter(pk=self.pk) + .values_list("customer_id", flat=True) + .first() + ) + + customer_changed = old_customer_id != self.customer_id + + super().save(*args, **kwargs) + + if customer_changed: + self.project_contacts.all().delete() + self.project_locations.all().delete() + + def __str__(self): + return self.name + + +class ProjectMembership(models.Model): + class Role(models.TextChoices): + OWNER = "owner", "Eigentümer" + MANAGER = "manager", "Manager" + EMPLOYEE = "employee", "Mitarbeiter" + + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="memberships") + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="project_memberships") + role = models.CharField(max_length=20, choices=Role.choices, default=Role.EMPLOYEE) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("project", "user") + + def __str__(self): + return f"{self.user} - {self.project} ({self.role})" + + +class ProjectContact(models.Model): + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="project_contacts", + verbose_name="Projekt" + ) + + contact = models.ForeignKey( + CustomerContact, + on_delete=models.CASCADE, + related_name="project_links", + verbose_name="Ansprechpartner" + ) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") + + def clean(self): + super().clean() + + if not self.project_id or not self.location_id: + return + + if self.location.customer_id != self.project.customer_id: + raise ValidationError({ + "location": "Der Ansprechpartner gehört nicht zum Kunden des Projekts." + }) + + class Meta: + unique_together = ("project", "contact") + + +class ProjectLocation(models.Model): + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="project_locations", + verbose_name="Projekt" + ) + + location = models.ForeignKey( + CustomerLocation, + on_delete=models.CASCADE, + related_name="project_links", + verbose_name="Standort" + ) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") + + def clean(self): + super().clean() + + if not self.project_id or not self.location_id: + return + + if self.location.customer_id != self.project.customer_id: + raise ValidationError({ + "location": "Der Standort gehört nicht zum Kunden des Projekts." + }) + + class Meta: + unique_together = ("project", "location") \ No newline at end of file diff --git a/projects/templates/projects/add.html b/projects/templates/projects/add.html new file mode 100644 index 0000000..e515668 --- /dev/null +++ b/projects/templates/projects/add.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block title %} +Projekt - Erstellen +{% endblock %} + +{% block header_title %} +Projekt erstellen +{% endblock %} + +{% block header_description %} +Ein neues Projekt erstellen +{% endblock %} + +{% block content %} + +
+ +
+ {% csrf_token %} + + {{ form.as_p }} + + + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/projects/templates/projects/archived.html b/projects/templates/projects/archived.html new file mode 100644 index 0000000..7a5bfb8 --- /dev/null +++ b/projects/templates/projects/archived.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %} +Projekt - Archiviert +{% endblock %} + +{% block header_title %} +Archivierte Projekte +{% endblock %} + +{% block header_description %} +Abgeschlossene und deaktivierte Projekte +{% endblock %} + +{% block content %} + +{% if memberships %} + +
+ {% for m in memberships %} + +

+ {{ m.project.name }} +

+

+ Rolle: {{ m.get_role_display }} +

+
+ {% endfor %} +
+ +{% else %} + +
+ Keine archivierten Projekte vorhanden. +
+ +{% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/projects/templates/projects/contact_add.html b/projects/templates/projects/contact_add.html new file mode 100644 index 0000000..4a7d302 --- /dev/null +++ b/projects/templates/projects/contact_add.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %} +{{ project.name }} - Informationen +{% endblock %} + +{% block header_title %} +Ansprechpartner hinzufügen +{% endblock %} + +{% block header_description %} +Neuen Ansprechpartner für das Projekt anlegen +{% endblock %} + +{% block content %} + + +
+ +
+ + {% csrf_token %} + + {{ form.as_p }} + +
+ + + + +
+ +
+ +
+ + +{% endblock %} \ No newline at end of file diff --git a/projects/templates/projects/info.html b/projects/templates/projects/info.html new file mode 100644 index 0000000..f83e906 --- /dev/null +++ b/projects/templates/projects/info.html @@ -0,0 +1,249 @@ +{% extends "base.html" %} + +{% block title %} +{{ project.name }} - Informationen +{% endblock %} + +{% block header_title %} +Projektinformationen +{% endblock %} + +{% block header_description %} +Standorte & Ansprechpartner +{% endblock %} + +{% block content %} +
+
+
+
+

Aktionen

+

Standorte und Ansprechpartner hinzufügen

+
+ + + +
+
+
+ + + +
+
+ +

Allgemein

+ +
+ {% csrf_token %} + + +
+ + {% for field in form %} +
+ + + {{ field }} + + {% if field.errors %} +
+ {{ field.errors }} +
+ {% endif %} +
+ {% endfor %} + +
+ + + +
+ +
+
+ + +
+ + +
+ +

+ Mitarbeiter +

+ + {% for worker in workers %} + +
+ +
+
{{ worker.user.get_full_name|default:worker.user.username }}
+
{{ worker.user.email }}
+
Rolle: {{ worker.get_role_display }}
+
+ + {% if worker.role != "owner" %} +
+ {% csrf_token %} + + + +
+ {% endif %} +
+ + {% empty %} +
+ Keine Mitarbeiter vorhanden +
+ {% endfor %} +
+
+ + + + +
+ + +
+ +

+ Standorte +

+ + {% for location in locations %} + +
+
+
+ {{ location.location.name }} +
+ +
+ {{ location.location.street }} +
+ +
+ {{ location.location.zip_code }} {{ location.location.city }} +
+ +
+ {{ location.location.country }} +
+
+ +
+ {% csrf_token %} + + + + + + +
+
+ + {% empty %} + +
+ Keine Standorte vorhanden +
+ + {% endfor %} + +
+ + +
+ +

+ Ansprechpartner +

+ + {% for contact in contacts %} + +
+
+
+ {{ contact.contact.first_name }} {{ contact.contact.last_name }} +
+ + {% if contact.contact.company %} +
+ {{ contact.contact.company }} +
+ {% endif %} + + {% if contact.contact.phone %} +
+ {{ contact.contact.phone }} +
+ {% endif %} + + {% if contact.contact.email %} +
+ {{ contact.contact.email }} +
+ {% endif %} +
+ +
+ {% csrf_token %} + + + + + + +
+ +
+ + {% empty %} + +
+ Keine Ansprechpartner vorhanden +
+ + {% endfor %} + +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/projects/templates/projects/location_add.html b/projects/templates/projects/location_add.html new file mode 100644 index 0000000..3c1e98b --- /dev/null +++ b/projects/templates/projects/location_add.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %} +{{ project.name }} - Informationen +{% endblock %} + +{% block header_title %} +Standort hinzufügen +{% endblock %} + +{% block header_description %} +Neuen Projektstandort anlegen +{% endblock %} + +{% block content %} + +
+ +
+ + {% csrf_token %} + + {{ form.as_p }} + +
+ + + + + +
+ +
+ +
+ + +{% endblock %} \ No newline at end of file diff --git a/projects/templates/projects/overview.html b/projects/templates/projects/overview.html new file mode 100644 index 0000000..b1c6351 --- /dev/null +++ b/projects/templates/projects/overview.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} + +{% block title %} +{{ project.name }} +{% endblock %} + + +{% block header_title %} + {{ project.name }} +{% endblock %} + +{% block header_description %} +Projektübersicht und Module +{% endblock %} + +{% block content %} + + + + + +
+ +

+ Zusammenfassung +

+ +

+ folgt +

+ +
+ +{% endblock %} \ No newline at end of file diff --git a/projects/templates/projects/worker_add.html b/projects/templates/projects/worker_add.html new file mode 100644 index 0000000..5d0093a --- /dev/null +++ b/projects/templates/projects/worker_add.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + + +{% block title %} +{{ project.name }} - Informationen +{% endblock %} + + +{% block header_title %} +Mitarbeiter hinzufügen +{% endblock %} + + +{% block header_description %} +Benutzer zum Projekt hinzufügen +{% endblock %} + + +{% block content %} + +
+ +
+ + {% csrf_token %} + + {{ form.as_p }} + +
+ + + + + +
+ +
+ +
+ + +{% endblock %} \ No newline at end of file diff --git a/projects/tests.py b/projects/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/projects/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/projects/urls.py b/projects/urls.py new file mode 100644 index 0000000..99fcf99 --- /dev/null +++ b/projects/urls.py @@ -0,0 +1,18 @@ +from django.urls import path +from . import views + +app_name = "projects" + +urlpatterns = [ + path("add/", views.project_add, name="add"), + path("archived/", views.projects_archived, name="archived"), + path("/", views.project_overview, name="overview"), + path("/info/", views.project_info, name="info"), + path("/info/edit/",views.project_edit,name="edit"), + path("/info/contact/add/", views.project_contact_add, name="contact_add"), + path("/info/contact//delete/", views.project_contact_delete, name="contact_delete"), + path("/info/location/add/", views.project_location_add, name="location_add"), + path("/info/location//delete/", views.project_location_delete, name="location_delete"), + path("/info/worker/add/", views.project_worker_add, name="worker_add"), + path("/info/worker//delete/", views.project_worker_delete, name="worker_delete"), +] \ No newline at end of file diff --git a/projects/views.py b/projects/views.py new file mode 100644 index 0000000..96a4d21 --- /dev/null +++ b/projects/views.py @@ -0,0 +1,310 @@ +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib.auth.decorators import login_required +from accounts.decorators import permission_required +from .forms import ProjectForm, ProjectAddLocationForm, ProjectAddContactForm, ProjectAddForm, ProjectAddWorkerForm + +from .models import ( + Project, + ProjectMembership, + ProjectContact, + ProjectLocation, +) + +from invoices.models import CustomerContact, CustomerLocation + + + +@login_required +@permission_required("projects_add") +def project_add(request): + form = ProjectAddForm(request.POST or None,request.FILES or None,) + if request.method == "POST" and form.is_valid(): + project = form.save() + ProjectMembership.objects.create(project=project,user=request.user,role=ProjectMembership.Role.OWNER,) + return redirect("projects:overview",project.id) + + return render(request,"projects/add.html",{"form": form,}) + + + +@login_required +@permission_required("projects_archived_view") +def projects_archived(request): + memberships = ProjectMembership.objects.select_related("project").filter(user=request.user,project__active=False) + return render(request,"projects/archived.html",{"memberships": memberships,}) + + + +# --------------------------- +# OVERVIEW +# --------------------------- +@login_required +@permission_required("projects_view") +def project_overview(request, project_id): + + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + return render(request, "projects/overview.html", { + "project": project, + "role": membership.role + }) + + +@login_required +@permission_required("projects_detail_view") +def project_info(request, project_id): + + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + form = ProjectForm( + instance=project + ) + + workers = project.memberships.select_related( + "user" + ).all() + + + return render( + request, + "projects/info.html", + { + "project": project, + "form": form, + + "locations": project.project_locations.select_related( + "location" + ), + + "contacts": project.project_contacts.select_related( + "contact" + ), + + "workers": workers, + } + ) + + +@login_required +@permission_required("projects_edit") +def project_edit(request, project_id): + + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + + if request.method != "POST": + return redirect( + "projects:info", + project.id + ) + + + form = ProjectForm( + request.POST, + request.FILES, + instance=project + ) + + + if form.is_valid(): + form.save() + + + return redirect( + "projects:info", + project.id + ) + +# --------------------------- +# ADD CONTACT TO PROJECT +# --------------------------- +@login_required +@permission_required("projects_contact_add") +def project_contact_add(request, project_id): + + project = get_object_or_404(Project, pk=project_id) + + if request.method == "POST": + form = ProjectAddContactForm( + request.POST, + project=project, + ) + + if form.is_valid(): + contact = form.cleaned_data["contact"] + + ProjectContact.objects.get_or_create( + project=project, + contact=contact + ) + + return redirect("projects:info", project.id) + + else: + form = ProjectAddContactForm(project=project) + + return render(request, "projects/contact_add.html", { + "form": form, + "project": project, + }) + +@login_required +@permission_required("projects_contact_delete") +def project_contact_delete(request, project_id, contact_id): + + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + + if request.method == "POST": + + ProjectContact.objects.filter( + id=contact_id, + project=project + ).delete() + + + return redirect( + "projects:info", + project.id + ) + + + + + + + + + + + +# --------------------------- +# ADD LOCATION TO PROJECT +# --------------------------- +@login_required +@permission_required("projects_location_add") +def project_location_add(request, project_id): + + project = get_object_or_404(Project, pk=project_id) + + if request.method == "POST": + form = ProjectAddLocationForm( + request.POST, + project=project, + ) + + if form.is_valid(): + location = form.cleaned_data["location"] + + ProjectLocation.objects.get_or_create( + project=project, + location=location + ) + + return redirect("projects:info", project.id) + + else: + form = ProjectAddLocationForm(project=project) + + return render(request, "projects/location_add.html", { + "form": form, + "project": project, + }) + +@login_required +@permission_required("projects_location_delete") +def project_location_delete(request, project_id, location_id): + + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + + if request.method == "POST": + + ProjectLocation.objects.filter( + id=location_id, + project=project + ).delete() + + + return redirect( + "projects:info", + project.id + ) + + +@login_required +@permission_required("projects_user_add") +def project_worker_add(request, project_id): + project = get_object_or_404(Project, pk=project_id) + get_object_or_404(ProjectMembership,project=project,user=request.user) + form = ProjectAddWorkerForm(request.POST or None,project=project) + + if request.method == "POST" and form.is_valid(): + membership = form.save(commit=False) + membership.project = project + membership.save() + return redirect("projects:info",project.id) + + return render(request,"projects/worker_add.html",{"form": form,"project": project,}) + + +@login_required +@permission_required("projects_user_delete") +def project_worker_delete(request, project_id, worker_id): + + membership = get_object_or_404( + ProjectMembership, + user=request.user, + project_id=project_id + ) + + project = membership.project + + + worker = get_object_or_404( + ProjectMembership, + id=worker_id, + project=project + ) + + + if request.method == "POST": + + if worker.role != ProjectMembership.Role.OWNER: + worker.delete() + + + return redirect( + "projects:info", + project.id + ) \ No newline at end of file diff --git a/search.py b/search.py new file mode 100644 index 0000000..8492599 --- /dev/null +++ b/search.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 + +from pathlib import Path + +ROOT = Path("/opt/projects") +SEARCH = " :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-2 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-3 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); + } + } + .space-y-4 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); + } + } + .divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } + } + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .overflow-hidden { + overflow: hidden; + } + .overflow-x-auto { + overflow-x: auto; + } + .rounded { + border-radius: 0.25rem; + } + .rounded-2xl { + border-radius: var(--radius-2xl); + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .rounded-lg { + border-radius: var(--radius-lg); + } + .rounded-xl { + border-radius: var(--radius-xl); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .border-r { + border-right-style: var(--tw-border-style); + border-right-width: 1px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-blue-300 { + border-color: var(--color-blue-300); + } + .border-green-300 { + border-color: var(--color-green-300); + } + .border-orange-500 { + border-color: var(--color-orange-500); + } + .border-red-300 { + border-color: var(--color-red-300); + } + .border-slate-300 { + border-color: var(--color-slate-300); + } + .border-slate-700 { + border-color: var(--color-slate-700); + } + .border-white\/20 { + border-color: color-mix(in srgb, #fff 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-white) 20%, transparent); + } + } + .border-yellow-300 { + border-color: var(--color-yellow-300); + } + .bg-black\/40 { + background-color: color-mix(in srgb, #000 40%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-black) 40%, transparent); + } + } + .bg-blue-100 { + background-color: var(--color-blue-100); + } + .bg-green-100 { + background-color: var(--color-green-100); + } + .bg-red-100 { + background-color: var(--color-red-100); + } + .bg-slate-50 { + background-color: var(--color-slate-50); + } + .bg-slate-100 { + background-color: var(--color-slate-100); + } + .bg-slate-200 { + background-color: var(--color-slate-200); + } + .bg-slate-800 { + background-color: var(--color-slate-800); + } + .bg-slate-900 { + background-color: var(--color-slate-900); + } + .bg-white { + background-color: var(--color-white); + } + .bg-yellow-100 { + background-color: var(--color-yellow-100); + } + .object-cover { + object-fit: cover; + } + .p-3 { + padding: calc(var(--spacing) * 3); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .p-10 { + padding: calc(var(--spacing) * 10); + } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .px-5 { + padding-inline: calc(var(--spacing) * 5); + } + .px-8 { + padding-inline: calc(var(--spacing) * 8); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + .pt-4 { + padding-top: calc(var(--spacing) * 4); + } + .pt-6 { + padding-top: calc(var(--spacing) * 6); + } + .pr-8 { + padding-right: calc(var(--spacing) * 8); + } + .text-center { + text-align: center; + } + .text-left { + text-align: left; + } + .text-right { + text-align: right; + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + .text-5xl { + font-size: var(--text-5xl); + line-height: var(--tw-leading, var(--text-5xl--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .tracking-wide { + --tw-tracking: var(--tracking-wide); + letter-spacing: var(--tracking-wide); + } + .text-blue-600 { + color: var(--color-blue-600); + } + .text-blue-800 { + color: var(--color-blue-800); + } + .text-green-600 { + color: var(--color-green-600); + } + .text-green-800 { + color: var(--color-green-800); + } + .text-orange-500 { + color: var(--color-orange-500); + } + .text-orange-600 { + color: var(--color-orange-600); + } + .text-red-500 { + color: var(--color-red-500); + } + .text-red-600 { + color: var(--color-red-600); + } + .text-red-800 { + color: var(--color-red-800); + } + .text-slate-400 { + color: var(--color-slate-400); + } + .text-slate-500 { + color: var(--color-slate-500); + } + .text-slate-600 { + color: var(--color-slate-600); + } + .text-slate-700 { + color: var(--color-slate-700); + } + .text-slate-900 { + color: var(--color-slate-900); + } + .text-white { + color: var(--color-white); + } + .text-yellow-800 { + color: var(--color-yellow-800); + } + .uppercase { + text-transform: uppercase; + } + .placeholder-slate-400 { + &::placeholder { + color: var(--color-slate-400); + } + } + .shadow { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-lg { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-colors { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .duration-200 { + --tw-duration: 200ms; + transition-duration: 200ms; + } + .duration-300 { + --tw-duration: 300ms; + transition-duration: 300ms; + } + .hover\:bg-slate-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-slate-700); + } + } + } + .hover\:text-slate-500 { + &:hover { + @media (hover: hover) { + color: var(--color-slate-500); + } + } + } + .hover\:shadow-lg { + &:hover { + @media (hover: hover) { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + } + .focus\:border-transparent { + &:focus { + border-color: transparent; + } + } + .focus\:ring-2 { + &:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + .focus\:ring-blue-500 { + &:focus { + --tw-ring-color: var(--color-blue-500); + } + } + .focus\:outline-none { + &:focus { + --tw-outline-style: none; + outline-style: none; + } + } + .sm\:grid-cols-2 { + @media (width >= 40rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .sm\:flex-row { + @media (width >= 40rem) { + flex-direction: row; + } + } + .sm\:items-center { + @media (width >= 40rem) { + align-items: center; + } + } + .sm\:justify-between { + @media (width >= 40rem) { + justify-content: space-between; + } + } + .md\:col-span-2 { + @media (width >= 48rem) { + grid-column: span 2 / span 2; + } + } + .md\:grid-cols-2 { + @media (width >= 48rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .md\:grid-cols-3 { + @media (width >= 48rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + .lg\:ml-72 { + @media (width >= 64rem) { + margin-left: calc(var(--spacing) * 72); + } + } + .lg\:hidden { + @media (width >= 64rem) { + display: none; + } + } + .lg\:w-auto { + @media (width >= 64rem) { + width: auto; + } + } + .lg\:translate-x-0 { + @media (width >= 64rem) { + --tw-translate-x: calc(var(--spacing) * 0); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + } + .lg\:grid-cols-2 { + @media (width >= 64rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .lg\:grid-cols-3 { + @media (width >= 64rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + .xl\:grid-cols-3 { + @media (width >= 80rem) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + .xl\:grid-cols-4 { + @media (width >= 80rem) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + } + .dark\:border-slate-600 { + &:where(.dark, .dark *) { + border-color: var(--color-slate-600); + } + } + .dark\:bg-slate-600 { + &:where(.dark, .dark *) { + background-color: var(--color-slate-600); + } + } + .dark\:bg-slate-700 { + &:where(.dark, .dark *) { + background-color: var(--color-slate-700); + } + } + .dark\:bg-slate-800 { + &:where(.dark, .dark *) { + background-color: var(--color-slate-800); + } + } + .dark\:bg-slate-900 { + &:where(.dark, .dark *) { + background-color: var(--color-slate-900); + } + } + .dark\:text-slate-200 { + &:where(.dark, .dark *) { + color: var(--color-slate-200); + } + } + .dark\:text-slate-300 { + &:where(.dark, .dark *) { + color: var(--color-slate-300); + } + } + .dark\:text-white { + &:where(.dark, .dark *) { + color: var(--color-white); + } + } +} +@layer components { + .company_link { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + color: var(--company-color); + } + .company_link:hover { + opacity: 0.8; + } + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-lg); + padding-inline: calc(var(--spacing) * 5); + padding-block: calc(var(--spacing) * 2); + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + transition-property: all; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + --tw-duration: 200ms; + transition-duration: 200ms; + &:hover { + @media (hover: hover) { + --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + } + &:active { + scale: 0.98; + } + &:disabled { + pointer-events: none; + } + &:disabled { + opacity: 50%; + } + } + .btn:hover { + opacity: 0.8; + } + .btn-save { + color: var(--color-white); + background-color: var(--company-color); + } + .btn-delete { + color: var(--color-white); + background-color: #cc4d49; + } + .btn-edit { + color: var(--color-white); + background-color: var(--company-color); + } + .btn-add { + color: var(--color-white); + background-color: var(--company-color); + } + .btn-action { + color: var(--color-white); + background: var(--company-color); + } + .btn-action-start { + color: var(--color-white); + background-color: #059669; + } + .btn-action-stop { + color: var(--color-white); + background-color: #cc4d49; + } +} +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-duration { + syntax: "*"; + inherits: false; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-space-y-reverse: 0; + --tw-divide-y-reverse: 0; + --tw-border-style: solid; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-duration: initial; + } + } +} diff --git a/theme/static_src/src/styles.css b/theme/static_src/src/styles.css new file mode 100644 index 0000000..98b00b3 --- /dev/null +++ b/theme/static_src/src/styles.css @@ -0,0 +1,78 @@ +@import "tailwindcss"; + +/** + * A catch-all path to Django template files, JavaScript, and Python files + * that contain Tailwind CSS classes and will be scanned by Tailwind to generate the final CSS file. + * + * If your final CSS file is not being updated after code changes, you may want to broaden or narrow + * the scope of this path. + */ +@source "../../../**/*.{html,py,js}"; +@variant dark (&:where(.dark, .dark *)); + +@tailwind base; +@tailwind components; +@tailwind utilities; +@layer components { + .company_link { + @apply font-semibold transition-colors; + color: var(--company-color); + } + .company_link:hover { + opacity: 0.8; + } + + + .btn { + @apply inline-flex items-center justify-center + px-5 py-2 + rounded-lg + font-medium + transition-all + duration-200 + shadow-sm + hover:shadow-md + active:scale-[0.98] + disabled:opacity-50 + disabled:pointer-events-none; + } + .btn:hover { + opacity: 0.8; + } + + + .btn-save { + @apply text-white; + background-color: var(--company-color); + } + + .btn-delete { + @apply text-white; + background-color: #cc4d49; + } + + .btn-edit { + @apply text-white; + background-color: var(--company-color); + } + + .btn-add { + @apply text-white; + background-color: var(--company-color); + } + + .btn-action { + @apply text-white; + background: var(--company-color); + } + + .btn-action-start { + @apply text-white; + background-color: #059669; + } + + .btn-action-stop { + @apply text-white; + background-color: #cc4d49; + } +} \ No newline at end of file diff --git a/time_tracking/__init__.py b/time_tracking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/time_tracking/admin.py b/time_tracking/admin.py new file mode 100644 index 0000000..abf28e0 --- /dev/null +++ b/time_tracking/admin.py @@ -0,0 +1,38 @@ +from django.contrib import admin +from .models import TimeEntry + + +@admin.register(TimeEntry) +class TimeEntryAdmin(admin.ModelAdmin): + list_display = ( + "project", + "user", + "entry_type", + "activity", + "start", + "end", + "break_minutes", + ) + + list_filter = ( + "entry_type", + "project", + "user", + ) + + search_fields = ( + "activity", + "project__name", + "user__username", + "user__first_name", + "user__last_name", + ) + + autocomplete_fields = ( + "project", + "user", + ) + + ordering = ( + "-start", + ) \ No newline at end of file diff --git a/time_tracking/apps.py b/time_tracking/apps.py new file mode 100644 index 0000000..327fda6 --- /dev/null +++ b/time_tracking/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TimeTrackingConfig(AppConfig): + name = 'time_tracking' diff --git a/time_tracking/migrations/0001_initial.py b/time_tracking/migrations/0001_initial.py new file mode 100644 index 0000000..efd72b6 --- /dev/null +++ b/time_tracking/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 6.0.6 on 2026-06-23 22:12 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('projects', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TimeEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('entry_type', models.CharField(choices=[('travel', 'Anfahrt'), ('work', 'Arbeitszeit')], default='work', max_length=20, verbose_name='Typ')), + ('activity', models.CharField(blank=True, max_length=255, verbose_name='Tätigkeit')), + ('start', models.DateTimeField(verbose_name='Beginn')), + ('end', models.DateTimeField(blank=True, null=True)), + ('break_minutes', models.PositiveIntegerField(default=0, verbose_name='Pause (Minuten)')), + ('note', models.TextField(blank=True, verbose_name='Notiz')), + ('invoiced', models.BooleanField(default=False, verbose_name='Abgerechnet')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to='projects.project', verbose_name='Projekt')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_entries', to=settings.AUTH_USER_MODEL, verbose_name='Mitarbeiter')), + ], + options={ + 'verbose_name': 'Zeiterfassung', + 'verbose_name_plural': 'Zeiterfassungen', + 'ordering': ['-start'], + }, + ), + ] diff --git a/time_tracking/migrations/__init__.py b/time_tracking/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/time_tracking/models.py b/time_tracking/models.py new file mode 100644 index 0000000..49de3b9 --- /dev/null +++ b/time_tracking/models.py @@ -0,0 +1,105 @@ +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class TimeEntry(models.Model): + + class EntryType(models.TextChoices): + TRAVEL = "travel", "Anfahrt" + WORK = "work", "Arbeitszeit" + + project = models.ForeignKey( + "projects.Project", + on_delete=models.CASCADE, + related_name="time_entries", + verbose_name="Projekt" + ) + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="time_entries", + verbose_name="Mitarbeiter" + ) + + entry_type = models.CharField( + max_length=20, + choices=EntryType.choices, + default=EntryType.WORK, + verbose_name="Typ" + ) + + activity = models.CharField( + max_length=255, + verbose_name="Tätigkeit", + blank=True, + ) + + start = models.DateTimeField( + verbose_name="Beginn" + ) + + end = models.DateTimeField(null=True, blank=True) + + break_minutes = models.PositiveIntegerField( + default=0, + verbose_name="Pause (Minuten)" + ) + + note = models.TextField( + blank=True, + verbose_name="Notiz" + ) + + invoiced = models.BooleanField(default=False,verbose_name="Abgerechnet") + + created_at = models.DateTimeField( + auto_now_add=True + ) + + updated_at = models.DateTimeField( + auto_now=True + ) + + class Meta: + verbose_name = "Zeiterfassung" + verbose_name_plural = "Zeiterfassungen" + ordering = ["-start"] + + def __str__(self): + return f"{self.user} - {self.start:%d.%m.%Y} - {self.duration_hours} Stunden" + + @property + def duration_minutes(self): + + if not self.end: + return 0 + + duration = (self.end - self.start).total_seconds() / 60 + return max(0, int(duration) - self.break_minutes) + + @property + def duration_hours(self): + + if not self.end: + return 0 + + return round(self.duration_minutes / 60, 2) + + @property + def duration_minutes_total(self): + end = self.end or None + if not end: + return 0 + return max(0, int((end - self.start).total_seconds() / 60) - self.break_minutes) + + @property + def duration_hhmm(self): + total_minutes = self.duration_minutes_total + + hours = total_minutes // 60 + minutes = total_minutes % 60 + + return f"{hours:02d}:{minutes:02d}" \ No newline at end of file diff --git a/time_tracking/tables.py b/time_tracking/tables.py new file mode 100644 index 0000000..c86608c --- /dev/null +++ b/time_tracking/tables.py @@ -0,0 +1,91 @@ +import django_tables2 as tables + +from .models import TimeEntry + + +class TimeEntryTable(tables.Table): + + entry_type = tables.TemplateColumn( + template_name="time_tracking/tables/entry_type.html", + verbose_name="Typ", + orderable=False, + ) + + activity = tables.TemplateColumn( + template_name="time_tracking/tables/activity.html", + verbose_name="Aktivität", + orderable=False, + ) + + start = tables.TemplateColumn( + template_name="time_tracking/tables/start.html", + verbose_name="Start", + orderable=False, + ) + + end = tables.TemplateColumn( + template_name="time_tracking/tables/end.html", + verbose_name="Ende", + orderable=False, + ) + + break_minutes = tables.TemplateColumn( + template_name="time_tracking/tables/break.html", + verbose_name="Pause (Min)", + orderable=False, + ) + + duration = tables.Column( + accessor="duration_hhmm", + verbose_name="Dauer", + orderable=False, + ) + + invoiced = tables.TemplateColumn( + template_name="time_tracking/tables/invoiced.html", + verbose_name="Abgerechnet", + orderable=False, + ) + + actions = tables.TemplateColumn( + template_name="time_tracking/tables/actions.html", + verbose_name="Aktionen", + orderable=False, + ) + + + class Meta: + + model = TimeEntry + + template_name = "django_tables2/table.html" + + attrs = { + "class": "w-full text-sm min-w-[1200px]" + } + + fields = ( + "entry_type", + "activity", + "start", + "end", + "break_minutes", + "duration", + "invoiced", + "actions", + ) + + + def __init__(self, *args, types=None, project=None, **kwargs): + + self.types = types + self.project = project + + super().__init__(*args, **kwargs) + + + def before_render(self, request): + + for row in self.rows: + row.record.project = self.project + row.record.types = self.types \ No newline at end of file diff --git a/time_tracking/templates/time_tracking/overview.html b/time_tracking/templates/time_tracking/overview.html new file mode 100644 index 0000000..13c0e97 --- /dev/null +++ b/time_tracking/templates/time_tracking/overview.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block title %} +{{ project.name }} - Zeiterfassung +{% endblock %} + +{% block header_title %} +Zeiterfassung +{% endblock %} + +{% block header_description %} +Zeiten erfassen und verwalten +{% endblock %} + +{% block content %} +{% load django_tables2 %} + +
+
+
+
+

+ Aktionen +

+ +

+ Arbeitszeiten und Anfahrten erfassen +

+
+ +
+ + +
+ {% csrf_token %} + + +
+ + +
+ {% csrf_token %} + + +
+ +
+ +
+ +
+
+ +
+ +
+ +

+ Zeiterfassungen +

+ +

+ Erfasste Zeiten des Projekts +

+ +
+ +
+ {% render_table table %} +
+ +
+ +{% endblock %} \ No newline at end of file diff --git a/time_tracking/templates/time_tracking/tables/actions.html b/time_tracking/templates/time_tracking/tables/actions.html new file mode 100644 index 0000000..38c23c8 --- /dev/null +++ b/time_tracking/templates/time_tracking/tables/actions.html @@ -0,0 +1,30 @@ +
+ +
+ + {% csrf_token %} + + + +
+ +
+ + {% csrf_token %} + + + +
+ +
\ No newline at end of file diff --git a/time_tracking/templates/time_tracking/tables/activity.html b/time_tracking/templates/time_tracking/tables/activity.html new file mode 100644 index 0000000..8c3c8bb --- /dev/null +++ b/time_tracking/templates/time_tracking/tables/activity.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/time_tracking/templates/time_tracking/tables/break.html b/time_tracking/templates/time_tracking/tables/break.html new file mode 100644 index 0000000..bdae3ba --- /dev/null +++ b/time_tracking/templates/time_tracking/tables/break.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/time_tracking/templates/time_tracking/tables/end.html b/time_tracking/templates/time_tracking/tables/end.html new file mode 100644 index 0000000..1d5eddc --- /dev/null +++ b/time_tracking/templates/time_tracking/tables/end.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/time_tracking/templates/time_tracking/tables/entry_type.html b/time_tracking/templates/time_tracking/tables/entry_type.html new file mode 100644 index 0000000..2054d1d --- /dev/null +++ b/time_tracking/templates/time_tracking/tables/entry_type.html @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/time_tracking/templates/time_tracking/tables/invoiced.html b/time_tracking/templates/time_tracking/tables/invoiced.html new file mode 100644 index 0000000..541593d --- /dev/null +++ b/time_tracking/templates/time_tracking/tables/invoiced.html @@ -0,0 +1,5 @@ +{% if record.invoiced %} + Ja +{% else %} + Nein +{% endif %} \ No newline at end of file diff --git a/time_tracking/templates/time_tracking/tables/start.html b/time_tracking/templates/time_tracking/tables/start.html new file mode 100644 index 0000000..35e7db7 --- /dev/null +++ b/time_tracking/templates/time_tracking/tables/start.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/time_tracking/tests.py b/time_tracking/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/time_tracking/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/time_tracking/urls.py b/time_tracking/urls.py new file mode 100644 index 0000000..55aef71 --- /dev/null +++ b/time_tracking/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import views + +app_name = "time_tracking" + +urlpatterns = [ + path("/time_tracking/",views.time_tracking_overview,name="overview"), + path("/time_tracking//edit/",views.time_tracking_edit,name="edit"), + path("/time_tracking//delete/",views.time_tracking_delete,name="delete"), +] \ No newline at end of file diff --git a/time_tracking/views.py b/time_tracking/views.py new file mode 100644 index 0000000..0e35443 --- /dev/null +++ b/time_tracking/views.py @@ -0,0 +1,263 @@ +from django.contrib.auth.decorators import login_required +from accounts.decorators import permission_required +from django.shortcuts import render, get_object_or_404, redirect +from django.utils import timezone +from django.utils.dateparse import parse_datetime +from datetime import datetime +from projects.models import ProjectMembership +from .models import TimeEntry +from django_tables2 import RequestConfig +from .tables import TimeEntryTable + +@login_required +@permission_required("projects_time_tracking_view") +def time_tracking_overview(request, project_id): + + membership = get_object_or_404( + ProjectMembership, + project_id=project_id, + user=request.user + ) + + project = membership.project + + + # ========================== + # POST ACTIONS + # ========================== + + if request.method == "POST": + + action = request.POST.get("action") + + + # -------------------------- + # START TIMER + # -------------------------- + + if action == "start": + + open_entry = TimeEntry.objects.filter( + project=project, + user=request.user, + end__isnull=True + ).first() + + + if not open_entry: + + TimeEntry.objects.create( + project=project, + user=request.user, + entry_type=TimeEntry.EntryType.WORK, + activity="", + start=timezone.now(), + break_minutes=0, + ) + + + return redirect( + "time_tracking:overview", + project.id + ) + + + # -------------------------- + # STOP TIMER + # -------------------------- + + if action == "stop": + + open_entry = TimeEntry.objects.filter( + project=project, + user=request.user, + end__isnull=True + ).order_by("-start").first() + + + if open_entry: + + open_entry.end = timezone.now() + + open_entry.save( + update_fields=[ + "end" + ] + ) + + + return redirect( + "time_tracking:overview", + project.id + ) + + + # -------------------------- + # ADD ENTRY + # -------------------------- + + if action == "add": + + start_str = request.POST.get("start", "") + end_str = request.POST.get("end", "") + + start_dt = None + end_dt = None + + try: + start_dt = datetime.strptime(start_str, "%Y-%m-%dT%H:%M") + start_dt = timezone.make_aware(start_dt) + except (ValueError, TypeError): + pass + + if end_str: + try: + end_dt = datetime.strptime(end_str, "%Y-%m-%dT%H:%M") + end_dt = timezone.make_aware(end_dt) + except (ValueError, TypeError): + pass + + TimeEntry.objects.create( + project=project, + user=request.user, + entry_type=request.POST.get("entry_type"), + activity=request.POST.get("activity"), + start=start_dt or timezone.now(), + end=end_dt, + break_minutes=request.POST.get("break_minutes") or 0, + note=request.POST.get("note", "") + ) + + + return redirect( + "time_tracking:overview", + project.id + ) + + + + # ========================== + # TABLE + # ========================== + + entries = project.time_entries.select_related( + "user" + ).order_by( + "-start" + ) + + + table = TimeEntryTable( + entries, + request=request, + ) + + RequestConfig( + request, + paginate={ + "per_page": 25 + } + ).configure(table) + + + + # ========================== + # RESPONSE + # ========================== + + return render( + request, + "time_tracking/overview.html", + { + "project": project, + "table": table, + "types": TimeEntry.EntryType.choices, + } + ) + + +@login_required +@permission_required("projects_time_tracking_edit") +def time_tracking_edit(request, project_id, entry_id): + + membership = get_object_or_404( + ProjectMembership, + project_id=project_id, + user=request.user + ) + + project = membership.project + + + if request.method != "POST": + return redirect( + "time_tracking:overview", + project.id + ) + + + entry = get_object_or_404( + TimeEntry, + id=entry_id, + project=project + ) + + # Parse datetime fields + start_str = request.POST.get("start", "") + end_str = request.POST.get("end", "") + + try: + # Format: "Y-m-dTH:i" (e.g., "2026-06-27T13:36") + entry.start = datetime.strptime(start_str, "%Y-%m-%dT%H:%M") + entry.start = timezone.make_aware(entry.start) + except (ValueError, TypeError): + pass + + if end_str: + try: + entry.end = datetime.strptime(end_str, "%Y-%m-%dT%H:%M") + entry.end = timezone.make_aware(entry.end) + except (ValueError, TypeError): + entry.end = None + else: + entry.end = None + + entry.entry_type = request.POST.get("entry_type") + entry.activity = request.POST.get("activity") + entry.break_minutes = request.POST.get("break_minutes") or 0 + entry.note = request.POST.get("note", "") + + entry.save() + + + return redirect( + "time_tracking:overview", + project.id + ) + + +@login_required +@permission_required("projects_time_tracking_delete") +def time_tracking_delete(request, project_id, entry_id): + + membership = get_object_or_404( + ProjectMembership, + project_id=project_id, + user=request.user + ) + + project = membership.project + + + if request.method == "POST": + + TimeEntry.objects.filter( + id=entry_id, + project=project + ).delete() + + + return redirect( + "time_tracking:overview", + project.id + ) \ No newline at end of file