diff --git a/app/stiftung/admin.py b/app/stiftung/admin.py index 6b070ee..6a0e9db 100644 --- a/app/stiftung/admin.py +++ b/app/stiftung/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.db.models import Count, Sum from django.urls import reverse +from django.utils import timezone from django.utils.html import format_html from django.utils.safestring import mark_safe @@ -9,7 +10,7 @@ from .models import (AppConfiguration, AuditLog, BackupJob, BankTransaction, CSVImport, Destinataer, DestinataerUnterstuetzung, DokumentLink, Foerderung, Land, LandVerpachtung, Paechter, Person, Rentmeister, StiftungsKonto, UnterstuetzungWiederkehrend, - Verwaltungskosten) + Verwaltungskosten, VierteljahresNachweis) @admin.register(CSVImport) @@ -968,6 +969,194 @@ class HelpBoxAdmin(admin.ModelAdmin): super().save_model(request, obj, form, change) +@admin.register(VierteljahresNachweis) +class VierteljahresNachweisAdmin(admin.ModelAdmin): + list_display = [ + "destinataer", + "jahr", + "quartal", + "status", + "completion_percentage", + "faelligkeitsdatum", + "is_overdue_display", + "eingereicht_am", + "geprueft_von", + ] + list_filter = [ + "jahr", + "quartal", + "status", + "studiennachweis_erforderlich", + "studiennachweis_eingereicht", + "einkommenssituation_bestaetigt", + "vermogenssituation_bestaetigt", + "faelligkeitsdatum", + ] + search_fields = [ + "destinataer__vorname", + "destinataer__nachname", + "destinataer__email", + ] + readonly_fields = [ + "id", + "erstellt_am", + "aktualisiert_am", + "completion_percentage", + "is_overdue_display", + ] + ordering = ["-jahr", "-quartal", "destinataer__nachname"] + + fieldsets = ( + ( + "Grundinformationen", + { + "fields": ( + "destinataer", + "jahr", + "quartal", + "status", + "faelligkeitsdatum", + ) + }, + ), + ( + "Studiennachweis", + { + "fields": ( + "studiennachweis_erforderlich", + "studiennachweis_eingereicht", + "studiennachweis_datei", + "studiennachweis_bemerkung", + ), + "classes": ("collapse",), + }, + ), + ( + "Einkommenssituation", + { + "fields": ( + "einkommenssituation_bestaetigt", + "einkommenssituation_text", + "einkommenssituation_datei", + ), + "classes": ("collapse",), + }, + ), + ( + "Vermögenssituation", + { + "fields": ( + "vermogenssituation_bestaetigt", + "vermogenssituation_text", + "vermogenssituation_datei", + ), + "classes": ("collapse",), + }, + ), + ( + "Weitere Dokumente", + { + "fields": ( + "weitere_dokumente", + "weitere_dokumente_beschreibung", + ), + "classes": ("collapse",), + }, + ), + ( + "Verwaltung & Prüfung", + { + "fields": ( + "interne_notizen", + "eingereicht_am", + "geprueft_am", + "geprueft_von", + ), + "classes": ("collapse",), + }, + ), + ( + "Metadaten", + { + "fields": ( + "id", + "erstellt_am", + "aktualisiert_am", + "completion_percentage", + "is_overdue_display", + ) + }, + ), + ) + + def completion_percentage(self, obj): + """Show completion percentage as colored badge""" + percentage = obj.get_completion_percentage() + if percentage == 100: + color = "success" + elif percentage >= 70: + color = "info" + elif percentage >= 30: + color = "warning" + else: + color = "danger" + + return format_html( + '{} %', + color, + percentage + ) + completion_percentage.short_description = "Fortschritt" + + def is_overdue_display(self, obj): + """Display overdue status with icon""" + if obj.is_overdue(): + return format_html( + ' Ja' + ) + return format_html( + ' Nein' + ) + is_overdue_display.short_description = "Überfällig" + + actions = ["mark_as_approved", "mark_as_needs_revision"] + + def mark_as_approved(self, request, queryset): + """Bulk action to approve submitted confirmations""" + count = 0 + for nachweis in queryset.filter(status="eingereicht"): + nachweis.status = "geprueft" + nachweis.geprueft_am = timezone.now() + nachweis.geprueft_von = request.user + nachweis.save() + count += 1 + + if count: + self.message_user( + request, + f"{count} Nachweise wurden als geprüft und freigegeben markiert." + ) + else: + self.message_user( + request, + "Keine eingereichten Nachweise gefunden.", + level="warning" + ) + mark_as_approved.short_description = "Ausgewählte Nachweise freigeben" + + def mark_as_needs_revision(self, request, queryset): + """Bulk action to mark confirmations as needing revision""" + count = queryset.exclude(status__in=["offen", "nachbesserung"]).update( + status="nachbesserung" + ) + if count: + self.message_user( + request, + f"{count} Nachweise wurden als nachbesserungsbedürftig markiert." + ) + mark_as_needs_revision.short_description = "Nachbesserung erforderlich markieren" + + # Customize admin site admin.site.site_header = "Stiftungsverwaltung Administration" admin.site.site_title = "Stiftungsverwaltung Admin" diff --git a/app/stiftung/forms.py b/app/stiftung/forms.py index 7e0cc19..c7b358d 100644 --- a/app/stiftung/forms.py +++ b/app/stiftung/forms.py @@ -8,7 +8,7 @@ from .models import (BankTransaction, Destinataer, DestinataerNotiz, DestinataerUnterstuetzung, DokumentLink, Foerderung, Land, LandAbrechnung, LandVerpachtung, Paechter, Person, Rentmeister, StiftungsKonto, UnterstuetzungWiederkehrend, - Verwaltungskosten) + Verwaltungskosten, VierteljahresNachweis) class RentmeisterForm(forms.ModelForm): @@ -1381,3 +1381,98 @@ class UserPermissionForm(forms.Form): groups["system"]["permissions"].append((field_name, field, None)) return groups + + +class VierteljahresNachweisForm(forms.ModelForm): + """Form for quarterly confirmations (Vierteljahresnachweise)""" + + class Meta: + model = VierteljahresNachweis + fields = [ + 'studiennachweis_eingereicht', + 'studiennachweis_datei', + 'studiennachweis_bemerkung', + 'einkommenssituation_bestaetigt', + 'einkommenssituation_text', + 'einkommenssituation_datei', + 'vermogenssituation_bestaetigt', + 'vermogenssituation_text', + 'vermogenssituation_datei', + 'weitere_dokumente', + 'weitere_dokumente_beschreibung', + 'interne_notizen', + ] + + widgets = { + 'studiennachweis_eingereicht': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'studiennachweis_datei': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}), + 'studiennachweis_bemerkung': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + 'einkommenssituation_bestaetigt': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'einkommenssituation_text': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Z.B. "Keine Änderungen seit letzter Meldung"'}), + 'einkommenssituation_datei': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}), + 'vermogenssituation_bestaetigt': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'vermogenssituation_text': forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'Z.B. "Keine Änderungen seit letzter Meldung"'}), + 'vermogenssituation_datei': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}), + 'weitere_dokumente': forms.FileInput(attrs={'class': 'form-control', 'accept': '.pdf,.jpg,.jpeg,.png,.doc,.docx'}), + 'weitere_dokumente_beschreibung': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}), + 'interne_notizen': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + } + + labels = { + 'studiennachweis_erforderlich': 'Studiennachweis erforderlich', + 'studiennachweis_eingereicht': 'Studiennachweis eingereicht', + 'studiennachweis_datei': 'Studiennachweis (Datei)', + 'studiennachweis_bemerkung': 'Bemerkung zum Studiennachweis', + 'einkommenssituation_bestaetigt': 'Einkommenssituation bestätigt', + 'einkommenssituation_text': 'Einkommenssituation (Text)', + 'einkommenssituation_datei': 'Einkommenssituation (Datei)', + 'vermogenssituation_bestaetigt': 'Vermögenssituation bestätigt', + 'vermogenssituation_text': 'Vermögenssituation (Text)', + 'vermogenssituation_datei': 'Vermögenssituation (Datei)', + 'weitere_dokumente': 'Weitere Dokumente', + 'weitere_dokumente_beschreibung': 'Beschreibung weitere Dokumente', + 'interne_notizen': 'Interne Notizen (nur für Verwaltung)', + } + + help_texts = { + 'einkommenssituation_text': 'Z.B. "Keine Änderungen seit letzter Meldung" oder Details zu Änderungen', + 'vermogenssituation_text': 'Z.B. "Keine Änderungen seit letzter Meldung" oder Details zu Änderungen', + 'interne_notizen': 'Diese Notizen sind nur für die interne Verwaltung sichtbar', + } + + def clean(self): + cleaned_data = super().clean() + + # Validate that at least one form of confirmation is provided for income situation + einkommenssituation_text = cleaned_data.get('einkommenssituation_text') + einkommenssituation_datei = cleaned_data.get('einkommenssituation_datei') + einkommenssituation_bestaetigt = cleaned_data.get('einkommenssituation_bestaetigt') + + if einkommenssituation_bestaetigt and not einkommenssituation_text and not einkommenssituation_datei: + raise ValidationError( + 'Wenn die Einkommenssituation bestätigt wird, muss entweder ein Text oder eine Datei angegeben werden.' + ) + + # Validate that at least one form of confirmation is provided for asset situation + vermogenssituation_text = cleaned_data.get('vermogenssituation_text') + vermogenssituation_datei = cleaned_data.get('vermogenssituation_datei') + vermogenssituation_bestaetigt = cleaned_data.get('vermogenssituation_bestaetigt') + + if vermogenssituation_bestaetigt and not vermogenssituation_text and not vermogenssituation_datei: + raise ValidationError( + 'Wenn die Vermögenssituation bestätigt wird, muss entweder ein Text oder eine Datei angegeben werden.' + ) + + # Validate study proof if required and marked as submitted + studiennachweis_erforderlich = cleaned_data.get('studiennachweis_erforderlich') + studiennachweis_eingereicht = cleaned_data.get('studiennachweis_eingereicht') + studiennachweis_datei = cleaned_data.get('studiennachweis_datei') + studiennachweis_bemerkung = cleaned_data.get('studiennachweis_bemerkung') + + if studiennachweis_erforderlich and studiennachweis_eingereicht: + if not studiennachweis_datei and not studiennachweis_bemerkung: + raise ValidationError( + 'Wenn der Studiennachweis als eingereicht markiert wird, muss entweder eine Datei oder eine Bemerkung angegeben werden.' + ) + + return cleaned_data diff --git a/app/stiftung/migrations/0030_vierteljahresnachweis.py b/app/stiftung/migrations/0030_vierteljahresnachweis.py new file mode 100644 index 0000000..446d731 --- /dev/null +++ b/app/stiftung/migrations/0030_vierteljahresnachweis.py @@ -0,0 +1,54 @@ +# Generated by Django 5.0.6 on 2025-09-23 19:33 + +import django.core.validators +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stiftung', '0029_alter_destinataer_berufsgruppe_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='VierteljahresNachweis', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('jahr', models.IntegerField(validators=[django.core.validators.MinValueValidator(2020), django.core.validators.MaxValueValidator(2050)], verbose_name='Jahr')), + ('quartal', models.IntegerField(choices=[(1, 'Q1 (Jan-Mär)'), (2, 'Q2 (Apr-Jun)'), (3, 'Q3 (Jul-Sep)'), (4, 'Q4 (Okt-Dez)')], verbose_name='Quartal')), + ('studiennachweis_erforderlich', models.BooleanField(default=True, verbose_name='Studiennachweis erforderlich')), + ('studiennachweis_eingereicht', models.BooleanField(default=False, verbose_name='Studiennachweis eingereicht')), + ('studiennachweis_datei', models.FileField(blank=True, null=True, upload_to='quarterly_proofs/studies/%Y/Q%m/', verbose_name='Studiennachweis (Datei)')), + ('studiennachweis_bemerkung', models.TextField(blank=True, null=True, verbose_name='Bemerkung zum Studiennachweis')), + ('einkommenssituation_bestaetigt', models.BooleanField(default=False, verbose_name='Einkommenssituation bestätigt')), + ('einkommenssituation_text', models.TextField(blank=True, help_text="Z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen", null=True, verbose_name='Einkommenssituation (Text)')), + ('einkommenssituation_datei', models.FileField(blank=True, null=True, upload_to='quarterly_proofs/income/%Y/Q%m/', verbose_name='Einkommenssituation (Datei)')), + ('vermogenssituation_bestaetigt', models.BooleanField(default=False, verbose_name='Vermögenssituation bestätigt')), + ('vermogenssituation_text', models.TextField(blank=True, help_text="Z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen", null=True, verbose_name='Vermögenssituation (Text)')), + ('vermogenssituation_datei', models.FileField(blank=True, null=True, upload_to='quarterly_proofs/assets/%Y/Q%m/', verbose_name='Vermögenssituation (Datei)')), + ('weitere_dokumente', models.FileField(blank=True, null=True, upload_to='quarterly_proofs/additional/%Y/Q%m/', verbose_name='Weitere Dokumente')), + ('weitere_dokumente_beschreibung', models.TextField(blank=True, null=True, verbose_name='Beschreibung weitere Dokumente')), + ('status', models.CharField(choices=[('offen', 'Nachweis ausstehend'), ('teilweise', 'Teilweise eingereicht'), ('eingereicht', 'Vollständig eingereicht'), ('geprueft', 'Geprüft & Freigegeben'), ('nachbesserung', 'Nachbesserung erforderlich'), ('abgelehnt', 'Abgelehnt')], default='offen', max_length=20, verbose_name='Status')), + ('interne_notizen', models.TextField(blank=True, null=True, verbose_name='Interne Notizen (nur für Verwaltung)')), + ('erstellt_am', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')), + ('aktualisiert_am', models.DateTimeField(auto_now=True, verbose_name='Aktualisiert am')), + ('eingereicht_am', models.DateTimeField(blank=True, null=True, verbose_name='Eingereicht am')), + ('geprueft_am', models.DateTimeField(blank=True, null=True, verbose_name='Geprüft am')), + ('faelligkeitsdatum', models.DateField(blank=True, null=True, verbose_name='Fälligkeitsdatum')), + ('destinataer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quartalseinreichungen', to='stiftung.destinataer', verbose_name='Destinatär')), + ('geprueft_von', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Geprüft von')), + ], + options={ + 'verbose_name': 'Vierteljahresnachweis', + 'verbose_name_plural': 'Vierteljahresnachweise', + 'ordering': ['-jahr', '-quartal', 'destinataer__nachname'], + 'indexes': [models.Index(fields=['jahr', 'quartal', 'status'], name='stiftung_vi_jahr_d9607d_idx'), models.Index(fields=['destinataer', 'status'], name='stiftung_vi_destina_957fd8_idx'), models.Index(fields=['faelligkeitsdatum'], name='stiftung_vi_faellig_353e7c_idx')], + 'unique_together': {('destinataer', 'jahr', 'quartal')}, + }, + ), + ] diff --git a/app/stiftung/models.py b/app/stiftung/models.py index 1c6433b..368e8b0 100644 --- a/app/stiftung/models.py +++ b/app/stiftung/models.py @@ -2472,3 +2472,296 @@ class HelpBox(models.Model): return cls.objects.get(page_key=page_key, is_active=True) except cls.DoesNotExist: return None + + +class VierteljahresNachweis(models.Model): + """Quarterly confirmation system for Destinatäre""" + + QUARTAL_CHOICES = [ + (1, "Q1 (Jan-Mär)"), + (2, "Q2 (Apr-Jun)"), + (3, "Q3 (Jul-Sep)"), + (4, "Q4 (Okt-Dez)"), + ] + + STATUS_CHOICES = [ + ("offen", "Nachweis ausstehend"), + ("teilweise", "Teilweise eingereicht"), + ("eingereicht", "Vollständig eingereicht"), + ("geprueft", "Geprüft & Freigegeben"), + ("nachbesserung", "Nachbesserung erforderlich"), + ("abgelehnt", "Abgelehnt"), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + destinataer = models.ForeignKey( + Destinataer, + on_delete=models.CASCADE, + related_name="quartalseinreichungen", + verbose_name="Destinatär" + ) + + # Time period + jahr = models.IntegerField( + verbose_name="Jahr", + validators=[MinValueValidator(2020), MaxValueValidator(2050)] + ) + quartal = models.IntegerField( + choices=QUARTAL_CHOICES, + verbose_name="Quartal" + ) + + # Study proof (if required) + studiennachweis_erforderlich = models.BooleanField( + default=True, + verbose_name="Studiennachweis erforderlich" + ) + studiennachweis_eingereicht = models.BooleanField( + default=False, + verbose_name="Studiennachweis eingereicht" + ) + studiennachweis_datei = models.FileField( + upload_to="quarterly_proofs/studies/%Y/Q%m/", + null=True, + blank=True, + verbose_name="Studiennachweis (Datei)" + ) + studiennachweis_bemerkung = models.TextField( + null=True, + blank=True, + verbose_name="Bemerkung zum Studiennachweis" + ) + + # Income/situation confirmation + einkommenssituation_bestaetigt = models.BooleanField( + default=False, + verbose_name="Einkommenssituation bestätigt" + ) + einkommenssituation_text = models.TextField( + null=True, + blank=True, + verbose_name="Einkommenssituation (Text)", + help_text="Z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen" + ) + einkommenssituation_datei = models.FileField( + upload_to="quarterly_proofs/income/%Y/Q%m/", + null=True, + blank=True, + verbose_name="Einkommenssituation (Datei)" + ) + + # Asset/wealth confirmation + vermogenssituation_bestaetigt = models.BooleanField( + default=False, + verbose_name="Vermögenssituation bestätigt" + ) + vermogenssituation_text = models.TextField( + null=True, + blank=True, + verbose_name="Vermögenssituation (Text)", + help_text="Z.B. 'Keine Änderungen seit letzter Meldung' oder Details zu Änderungen" + ) + vermogenssituation_datei = models.FileField( + upload_to="quarterly_proofs/assets/%Y/Q%m/", + null=True, + blank=True, + verbose_name="Vermögenssituation (Datei)" + ) + + # Additional documents + weitere_dokumente = models.FileField( + upload_to="quarterly_proofs/additional/%Y/Q%m/", + null=True, + blank=True, + verbose_name="Weitere Dokumente" + ) + weitere_dokumente_beschreibung = models.TextField( + null=True, + blank=True, + verbose_name="Beschreibung weitere Dokumente" + ) + + # Review and approval + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default="offen", + verbose_name="Status" + ) + interne_notizen = models.TextField( + null=True, + blank=True, + verbose_name="Interne Notizen (nur für Verwaltung)" + ) + + # Timestamps and tracking + erstellt_am = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am") + aktualisiert_am = models.DateTimeField(auto_now=True, verbose_name="Aktualisiert am") + eingereicht_am = models.DateTimeField( + null=True, + blank=True, + verbose_name="Eingereicht am" + ) + geprueft_am = models.DateTimeField( + null=True, + blank=True, + verbose_name="Geprüft am" + ) + geprueft_von = models.ForeignKey( + "auth.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="Geprüft von" + ) + + # Deadline tracking + faelligkeitsdatum = models.DateField( + null=True, + blank=True, + verbose_name="Fälligkeitsdatum" + ) + + class Meta: + verbose_name = "Vierteljahresnachweis" + verbose_name_plural = "Vierteljahresnachweise" + ordering = ["-jahr", "-quartal", "destinataer__nachname"] + unique_together = ["destinataer", "jahr", "quartal"] # One entry per quarter per person + indexes = [ + models.Index(fields=["jahr", "quartal", "status"]), + models.Index(fields=["destinataer", "status"]), + models.Index(fields=["faelligkeitsdatum"]), + ] + + def __str__(self): + return f"{self.destinataer.get_full_name()} - {self.jahr} Q{self.quartal} ({self.get_status_display()})" + + def get_quarter_display(self): + """Get a nice display name for the quarter""" + quarter_names = { + 1: "Q1 (Januar - März)", + 2: "Q2 (April - Juni)", + 3: "Q3 (Juli - September)", + 4: "Q4 (Oktober - Dezember)" + } + return quarter_names.get(self.quartal, f"Q{self.quartal}") + + def is_complete(self): + """Check if all required documents/confirmations are provided""" + complete = True + + # Check study proof (always required now) + complete &= self.studiennachweis_eingereicht and ( + bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung) + ) + + # Check income situation (either text or file) + complete &= self.einkommenssituation_bestaetigt and ( + bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei) + ) + + # Check asset situation (either text or file) + complete &= self.vermogenssituation_bestaetigt and ( + bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei) + ) + + return complete + + def is_overdue(self): + """Check if the deadline has passed""" + if not self.faelligkeitsdatum: + return False + return timezone.now().date() > self.faelligkeitsdatum and self.status in ["offen", "teilweise"] + + def get_completion_percentage(self): + """Calculate completion percentage""" + total_requirements = 2 # Income and assets always required + completed_requirements = 0 + + # Study proof (if required) + if self.studiennachweis_erforderlich: + total_requirements += 1 + if self.studiennachweis_eingereicht and ( + bool(self.studiennachweis_datei) or bool(self.studiennachweis_bemerkung) + ): + completed_requirements += 1 + + # Income situation + if self.einkommenssituation_bestaetigt and ( + bool(self.einkommenssituation_text) or bool(self.einkommenssituation_datei) + ): + completed_requirements += 1 + + # Asset situation + if self.vermogenssituation_bestaetigt and ( + bool(self.vermogenssituation_text) or bool(self.vermogenssituation_datei) + ): + completed_requirements += 1 + + return int((completed_requirements / total_requirements) * 100) if total_requirements > 0 else 0 + + def save(self, *args, **kwargs): + """Override save to auto-update status and timestamps""" + # Auto-set deadline if not provided (15th of the quarter's second month) + if not self.faelligkeitsdatum: + from datetime import date + quarter_deadlines = { + 1: date(self.jahr, 2, 15), # Q1 deadline: Feb 15 + 2: date(self.jahr, 5, 15), # Q2 deadline: May 15 + 3: date(self.jahr, 8, 15), # Q3 deadline: Aug 15 + 4: date(self.jahr, 11, 15), # Q4 deadline: Nov 15 + } + self.faelligkeitsdatum = quarter_deadlines.get(self.quartal) + + # Auto-update status based on completion + if self.is_complete(): + if self.status == "offen": + self.status = "eingereicht" + self.eingereicht_am = timezone.now() + else: + completion = self.get_completion_percentage() + if completion > 0 and completion < 100 and self.status == "offen": + self.status = "teilweise" + + super().save(*args, **kwargs) + + @classmethod + def get_or_create_for_period(cls, destinataer, jahr, quartal): + """Get or create a quarterly confirmation for a specific period""" + nachweis, created = cls.objects.get_or_create( + destinataer=destinataer, + jahr=jahr, + quartal=quartal, + defaults={ + 'studiennachweis_erforderlich': destinataer.studiennachweis_erforderlich, + 'status': 'offen' + } + ) + return nachweis, created + + @classmethod + def get_current_quarter(cls): + """Get the current quarter based on today's date""" + from datetime import date + today = date.today() + month = today.month + + if month <= 3: + return today.year, 1 + elif month <= 6: + return today.year, 2 + elif month <= 9: + return today.year, 3 + else: + return today.year, 4 + + @classmethod + def get_overdue_confirmations(cls): + """Get all overdue quarterly confirmations""" + from datetime import date + today = date.today() + + return cls.objects.filter( + faelligkeitsdatum__lt=today, + status__in=["offen", "teilweise"] + ).select_related("destinataer") diff --git a/app/stiftung/urls.py b/app/stiftung/urls.py index 645d04f..2e5164a 100644 --- a/app/stiftung/urls.py +++ b/app/stiftung/urls.py @@ -348,4 +348,20 @@ urlpatterns = [ # Gramps integration (probe) path("api/gramps/search/", views.gramps_search_api, name="gramps_search_api"), path("api/gramps/debug/", views.gramps_debug_api, name="gramps_debug_api"), + # Quarterly Confirmations + path( + "quarterly-confirmations/create//", + views.quarterly_confirmation_create, + name="quarterly_confirmation_create", + ), + path( + "quarterly-confirmations//edit/", + views.quarterly_confirmation_edit, + name="quarterly_confirmation_edit", + ), + path( + "quarterly-confirmations//update/", + views.quarterly_confirmation_update, + name="quarterly_confirmation_update", + ), ] diff --git a/app/stiftung/views.py b/app/stiftung/views.py index 1fa1279..074415e 100644 --- a/app/stiftung/views.py +++ b/app/stiftung/views.py @@ -3,7 +3,7 @@ import io import json import os import time -from datetime import datetime +from datetime import datetime, timedelta from decimal import Decimal import requests @@ -24,7 +24,7 @@ from rest_framework.response import Response from .models import (AppConfiguration, CSVImport, Destinataer, DestinataerUnterstuetzung, DokumentLink, Foerderung, Land, LandAbrechnung, LandVerpachtung, Paechter, Person, - StiftungsKonto, UnterstuetzungWiederkehrend) + StiftungsKonto, UnterstuetzungWiederkehrend, VierteljahresNachweis) def get_pdf_generator(): @@ -214,7 +214,7 @@ from stiftung.models import DestinataerNotiz, DestinataerUnterstuetzung from .forms import (DestinataerForm, DestinataerNotizForm, DestinataerUnterstuetzungForm, DokumentLinkForm, FoerderungForm, LandForm, PaechterForm, PersonForm, - UnterstuetzungForm, UnterstuetzungMarkAsPaidForm) + UnterstuetzungForm, UnterstuetzungMarkAsPaidForm, VierteljahresNachweisForm) def home(request): @@ -1233,6 +1233,30 @@ def destinataer_detail(request, pk): destinataer=destinataer ).order_by("-erstellt_am") + # Quarterly confirmations - load for current and next year + from datetime import date + current_year = date.today().year + quarterly_confirmations = VierteljahresNachweis.objects.filter( + destinataer=destinataer, + jahr__in=[current_year, current_year + 1] + ).order_by('-jahr', '-quartal') + + # Create missing quarterly confirmations for current year if destinataer requires study proof + if destinataer.studiennachweis_erforderlich: + for quartal in range(1, 5): # Q1-Q4 + nachweis, created = VierteljahresNachweis.get_or_create_for_period( + destinataer, current_year, quartal + ) + + # Reload to get any newly created confirmations + quarterly_confirmations = VierteljahresNachweis.objects.filter( + destinataer=destinataer, + jahr__in=[current_year, current_year + 1] + ).order_by('-jahr', '-quartal') + + # Generate available years for the add quarter dropdown (current year + next 5 years) + available_years = list(range(current_year, current_year + 6)) + # Alle verfügbaren StiftungsKonten für das Select-Feld laden stiftungskonten = StiftungsKonto.objects.all().order_by("kontoname") @@ -1243,6 +1267,9 @@ def destinataer_detail(request, pk): "unterstuetzungen": unterstuetzungen, "notizen_eintraege": notizen_eintraege, "stiftungskonten": stiftungskonten, + "quarterly_confirmations": quarterly_confirmations, + "available_years": available_years, + "current_year": current_year, } return render(request, "stiftung/destinataer_detail.html", context) @@ -7195,3 +7222,318 @@ def verpachtung_delete(request, pk): 'title': f'Verpachtung {verpachtung.vertragsnummer} löschen', } return render(request, 'stiftung/verpachtung_confirm_delete.html', context) + + +@login_required +def quarterly_confirmation_update(request, pk): + """Update quarterly confirmation for destinataer""" + nachweis = get_object_or_404(VierteljahresNachweis, pk=pk) + + if request.method == "POST": + form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis) + if form.is_valid(): + quarterly_proof = form.save(commit=False) + + # Calculate current status before saving + old_status = nachweis.status + + # Auto-update status based on completion + if quarterly_proof.is_complete(): + if quarterly_proof.status in ['offen', 'teilweise']: + quarterly_proof.status = 'eingereicht' + quarterly_proof.eingereicht_am = timezone.now() + else: + # If not complete, set to teilweise if some fields are filled + has_partial_data = ( + quarterly_proof.einkommenssituation_bestaetigt or + quarterly_proof.vermogenssituation_bestaetigt or + quarterly_proof.studiennachweis_eingereicht + ) + if has_partial_data and quarterly_proof.status == 'offen': + quarterly_proof.status = 'teilweise' + + quarterly_proof.save() + + # Try to create automatic support payment if complete + if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht': + support_payment = create_quarterly_support_payment(quarterly_proof) + if support_payment: + messages.success( + request, + f"Automatische Unterstützung über {support_payment.betrag}€ für {support_payment.destinataer.get_full_name()} wurde erstellt (fällig am {support_payment.faellig_am.strftime('%d.%m.%Y')})." + ) + else: + # Log why payment wasn't created + reasons = [] + if not quarterly_proof.destinataer.vierteljaehrlicher_betrag: + reasons.append("kein vierteljährlicher Betrag hinterlegt") + if not quarterly_proof.destinataer.iban: + reasons.append("keine IBAN hinterlegt") + if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists(): + reasons.append("kein Auszahlungskonto verfügbar") + + if reasons: + messages.warning( + request, + f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}" + ) + + # Debug message to see what happened + status_changed = old_status != quarterly_proof.status + status_msg = f" (Status: {old_status} → {quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})" + + messages.success( + request, + f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} " + f"({nachweis.jahr} Q{nachweis.quartal}) wurde erfolgreich aktualisiert{status_msg}." + ) + return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) + else: + # Add form errors to messages + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"Fehler in {field}: {error}") + + # If GET request or form errors, redirect back to destinataer detail + return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) + + +def create_quarterly_support_payment(nachweis): + """ + Create an automatic support payment when all quarterly requirements are met + """ + destinataer = nachweis.destinataer + + # Check if all requirements are met + if not nachweis.is_complete(): + return None + + # Check if destinataer has required payment info + if not destinataer.vierteljaehrlicher_betrag or destinataer.vierteljaehrlicher_betrag <= 0: + return None + + if not destinataer.iban: + return None + + # Check if a payment for this quarter already exists + quarter_start = datetime(nachweis.jahr, (nachweis.quartal - 1) * 3 + 1, 1).date() + quarter_end = datetime(nachweis.jahr, nachweis.quartal * 3, 1).date() + + existing_payment = DestinataerUnterstuetzung.objects.filter( + destinataer=destinataer, + faellig_am__gte=quarter_start, + faellig_am__lt=quarter_end, + beschreibung__contains=f"Q{nachweis.quartal}/{nachweis.jahr}" + ).first() + + if existing_payment: + return existing_payment + + # Get default payment account + default_konto = destinataer.standard_konto + if not default_konto: + # Try to get any StiftungsKonto + default_konto = StiftungsKonto.objects.first() + if not default_konto: + return None + + # Calculate payment due date (last day of quarter) + # Quarter end months and their last days: + # Q1: March (31), Q2: June (30), Q3: September (30), Q4: December (31) + quarter_end_month = nachweis.quartal * 3 + + if nachweis.quartal == 1: # Q1: January-March (ends March 31) + quarter_end_day = 31 + elif nachweis.quartal == 2: # Q2: April-June (ends June 30) + quarter_end_day = 30 + elif nachweis.quartal == 3: # Q3: July-September (ends September 30) + quarter_end_day = 30 + else: # Q4: October-December (ends December 31) + quarter_end_day = 31 + + payment_due_date = datetime(nachweis.jahr, quarter_end_month, quarter_end_day).date() + + # Create the support payment + payment = DestinataerUnterstuetzung.objects.create( + destinataer=destinataer, + konto=default_konto, + betrag=destinataer.vierteljaehrlicher_betrag, + faellig_am=payment_due_date, + status='geplant', + beschreibung=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} (automatisch erstellt)", + empfaenger_iban=destinataer.iban, + empfaenger_name=destinataer.get_full_name(), + verwendungszweck=f"Vierteljährliche Unterstützung Q{nachweis.quartal}/{nachweis.jahr} - {destinataer.get_full_name()}", + erstellt_am=timezone.now(), + aktualisiert_am=timezone.now() + ) + + return payment + + +@login_required +def quarterly_confirmation_create(request, destinataer_id): + """Create a new quarterly confirmation for a destinataer""" + destinataer = get_object_or_404(Destinataer, pk=destinataer_id) + + if request.method == "POST": + jahr = request.POST.get('jahr') + quartal = request.POST.get('quartal') + + if jahr and quartal: + try: + jahr = int(jahr) + quartal = int(quartal) + + # Check if this quarter already exists + existing = VierteljahresNachweis.objects.filter( + destinataer=destinataer, + jahr=jahr, + quartal=quartal + ).exists() + + if existing: + messages.warning( + request, + f"Quartal {jahr} Q{quartal} existiert bereits für {destinataer.get_full_name()}." + ) + else: + # Create new quarterly confirmation + nachweis = VierteljahresNachweis.objects.create( + destinataer=destinataer, + jahr=jahr, + quartal=quartal, + studiennachweis_erforderlich=True, # Always required now + ) + + # Set deadline (15th of second month of quarter) + deadline_months = {1: 5, 2: 8, 3: 11, 4: 2} # Q1->May, Q2->Aug, Q3->Nov, Q4->Feb(next year) + deadline_month = deadline_months[quartal] + deadline_year = jahr if quartal != 4 else jahr + 1 + + from datetime import date + nachweis.faelligkeitsdatum = date(deadline_year, deadline_month, 15) + nachweis.save() + + messages.success( + request, + f"Quartal {jahr} Q{quartal} wurde erfolgreich für {destinataer.get_full_name()} erstellt." + ) + + except (ValueError, TypeError): + messages.error(request, "Ungültige Jahr- oder Quartalswerte.") + else: + messages.error(request, "Jahr und Quartal müssen angegeben werden.") + + return redirect("stiftung:destinataer_detail", pk=destinataer.pk) + + +@login_required +def quarterly_confirmation_edit(request, pk): + """Standalone edit view for quarterly confirmation""" + nachweis = get_object_or_404(VierteljahresNachweis, pk=pk) + + if request.method == "POST": + form = VierteljahresNachweisForm(request.POST, request.FILES, instance=nachweis) + if form.is_valid(): + quarterly_proof = form.save(commit=False) + + # Calculate current status before saving + old_status = nachweis.status + + # Auto-update status based on completion + if quarterly_proof.is_complete(): + if quarterly_proof.status in ['offen', 'teilweise']: + quarterly_proof.status = 'eingereicht' + quarterly_proof.eingereicht_am = timezone.now() + else: + # If not complete, set to teilweise if some fields are filled + has_partial_data = ( + quarterly_proof.einkommenssituation_bestaetigt or + quarterly_proof.vermogenssituation_bestaetigt or + quarterly_proof.studiennachweis_eingereicht + ) + if has_partial_data and quarterly_proof.status == 'offen': + quarterly_proof.status = 'teilweise' + + quarterly_proof.save() + + # Try to create automatic support payment if complete + if quarterly_proof.is_complete() and quarterly_proof.status == 'eingereicht': + support_payment = create_quarterly_support_payment(quarterly_proof) + if support_payment: + messages.success( + request, + f"Automatische Unterstützung über {support_payment.betrag}€ für {support_payment.destinataer.get_full_name()} wurde erstellt (fällig am {support_payment.faellig_am.strftime('%d.%m.%Y')})." + ) + else: + # Log why payment wasn't created + reasons = [] + if not quarterly_proof.destinataer.vierteljaehrlicher_betrag: + reasons.append("kein vierteljährlicher Betrag hinterlegt") + if not quarterly_proof.destinataer.iban: + reasons.append("keine IBAN hinterlegt") + if not quarterly_proof.destinataer.standard_konto and not StiftungsKonto.objects.exists(): + reasons.append("kein Auszahlungskonto verfügbar") + + if reasons: + messages.warning( + request, + f"Automatische Unterstützung konnte nicht erstellt werden: {', '.join(reasons)}" + ) + + # Debug message to see what happened + status_changed = old_status != quarterly_proof.status + status_msg = f" (Status: {old_status} → {quarterly_proof.status})" if status_changed else f" (Status: {quarterly_proof.status})" + + messages.success( + request, + f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} " + f"({nachweis.jahr} Q{nachweis.quartal}) wurde erfolgreich aktualisiert{status_msg}." + ) + return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) + else: + # Add form errors to messages + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"Fehler in {field}: {error}") + else: + form = VierteljahresNachweisForm(instance=nachweis) + + context = { + 'form': form, + 'nachweis': nachweis, + 'destinataer': nachweis.destinataer, + 'title': f'Vierteljahresnachweis bearbeiten - {nachweis.destinataer.get_full_name()} {nachweis.jahr} Q{nachweis.quartal}', + } + return render(request, 'stiftung/quarterly_confirmation_edit.html', context) + + +@login_required +def quarterly_confirmation_approve(request, pk): + """Approve quarterly confirmation (staff only)""" + if not request.user.is_staff: + messages.error(request, "Sie haben keine Berechtigung für diese Aktion.") + return redirect("stiftung:destinataer_list") + + nachweis = get_object_or_404(VierteljahresNachweis, pk=pk) + + if request.method == "POST": + if nachweis.status == 'eingereicht': + nachweis.status = 'geprueft' + nachweis.geprueft_am = timezone.now() + nachweis.geprueft_von = request.user + nachweis.save() + + messages.success( + request, + f"Vierteljahresnachweis für {nachweis.destinataer.get_full_name()} " + f"({nachweis.jahr} Q{nachweis.quartal}) wurde freigegeben." + ) + else: + messages.error( + request, + "Nur eingereichte Nachweise können freigegeben werden." + ) + + return redirect("stiftung:destinataer_detail", pk=nachweis.destinataer.pk) diff --git a/app/templates/base.html b/app/templates/base.html index b15713c..8c380a3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -25,6 +25,71 @@ --orange-dark: #e8590c; } + /* Global Typography - More Compact */ + html { + font-size: 14px; /* Reduced from default 16px */ + } + + body { + font-size: 0.875rem; /* 12.25px */ + line-height: 1.4; + font-weight: 400; + } + + .h1, .h2, .h3, .h4, .h5, .h6, + h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.3; + margin-bottom: 0.75rem; + } + + .h1, h1 { font-size: 2rem; } + .h2, h2 { font-size: 1.65rem; } + .h3, h3 { font-size: 1.4rem; } + .h4, h4 { font-size: 1.15rem; } + .h5, h5 { font-size: 1rem; } + .h6, h6 { font-size: 0.875rem; } + + /* Compact spacing */ + .container, .container-fluid, .container-lg { + padding-left: 0.75rem; + padding-right: 0.75rem; + } + + .row { + margin-left: -0.5rem; + margin-right: -0.5rem; + } + + .col, .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, + .col-7, .col-8, .col-9, .col-10, .col-11, .col-12, + .col-auto, .col-sm, .col-sm-auto, .col-md, .col-md-auto, + .col-lg, .col-lg-auto, .col-xl, .col-xl-auto { + padding-left: 0.5rem; + padding-right: 0.5rem; + } + + /* Compact margins */ + .mb-1 { margin-bottom: 0.25rem !important; } + .mb-2 { margin-bottom: 0.4rem !important; } + .mb-3 { margin-bottom: 0.75rem !important; } + .mb-4 { margin-bottom: 1rem !important; } + .mb-5 { margin-bottom: 1.5rem !important; } + + .mt-1 { margin-top: 0.25rem !important; } + .mt-2 { margin-top: 0.4rem !important; } + .mt-3 { margin-top: 0.75rem !important; } + .mt-4 { margin-top: 1rem !important; } + .mt-5 { margin-top: 1.5rem !important; } + + .py-1 { padding-top: 0.25rem !important; padding-bottom: 0.25rem !important; } + .py-2 { padding-top: 0.4rem !important; padding-bottom: 0.4rem !important; } + .py-3 { padding-top: 0.75rem !important; padding-bottom: 0.75rem !important; } + + .px-1 { padding-left: 0.25rem !important; padding-right: 0.25rem !important; } + .px-2 { padding-left: 0.4rem !important; padding-right: 0.4rem !important; } + .px-3 { padding-left: 0.75rem !important; padding-right: 0.75rem !important; } + .border-left-primary { border-left: 0.25rem solid var(--racing-green) !important; } @@ -47,7 +112,27 @@ background-image: linear-gradient(180deg, var(--racing-green-light) 10%, var(--racing-green-dark) 100%); } - /* Navigation styling */ + /* Navigation styling - More Compact */ + .navbar { + padding: 0.375rem 0; + } + + .navbar-brand { + font-weight: 700; + font-size: 1.1rem; + color: white !important; + padding: 0.375rem 0; + } + + .navbar-nav .nav-link, + .navbar-nav .nav-link.dropdown-toggle { + padding: 0.375rem 0.5rem !important; + display: inline-flex !important; + align-items: center !important; + white-space: nowrap !important; + font-size: 0.8rem; + } + .navbar-dark .navbar-nav .nav-link { color: rgba(255, 255, 255, 0.9); } @@ -56,31 +141,46 @@ color: var(--orange-light); } - /* Ensure all nav links have consistent alignment */ - .navbar-nav .nav-link, - .navbar-nav .nav-link.dropdown-toggle { - padding: 0.5rem 0.75rem !important; - display: inline-flex !important; - align-items: center !important; - white-space: nowrap !important; + /* Dropdown menus - More Compact */ + .dropdown-menu { + border: 1px solid #dee2e6; + box-shadow: 0 0.25rem 0.5rem rgba(0, 66, 37, 0.15); + font-size: 0.8rem; } - .navbar-brand { - font-weight: 700; - font-size: 1.5rem; - color: white !important; + .dropdown-item { + padding: 0.375rem 0.75rem; + font-size: 0.8rem; } - /* Cards and content */ + .dropdown-item:hover { + background-color: rgba(0, 66, 37, 0.1); + color: var(--racing-green-dark); + } + + .dropdown-header { + font-size: 0.75rem; + padding: 0.25rem 0.75rem; + } + + /* Cards and content - More Compact */ .card { border: 1px solid #e9ecef; - box-shadow: 0 0.15rem 1.75rem 0 rgba(0, 66, 37, 0.1); + box-shadow: 0 0.125rem 1rem 0 rgba(0, 66, 37, 0.08); + margin-bottom: 0.75rem; + } + + .card-body { + padding: 0.75rem; } .card-header { background-color: var(--grey-light); border-bottom: 1px solid #dee2e6; color: var(--racing-green-dark); + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + font-weight: 600; } .card-header.bg-primary { @@ -103,7 +203,107 @@ color: white; } - /* Buttons */ + .card-title { + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 0; + } + + /* Tables - More Compact */ + .table { + font-size: 0.8rem; + margin-bottom: 0.75rem; + } + + .table th { + border-top: none; + font-weight: 600; + color: var(--racing-green-dark); + background-color: var(--grey-light); + padding: 0.5rem; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.025em; + } + + .table td { + padding: 0.5rem; + border-top: 1px solid #dee2e6; + } + + .table-sm th, + .table-sm td { + padding: 0.375rem; + } + + .table-light { + background-color: var(--grey-light); + } + + /* Buttons - More Compact */ + .btn { + font-size: 0.8rem; + padding: 0.375rem 0.75rem; + border-radius: 0.25rem; + font-weight: 500; + line-height: 1.3; + } + + .btn-sm { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 0.2rem; + } + + .btn-lg { + font-size: 0.9rem; + padding: 0.5rem 1rem; + border-radius: 0.3rem; + } + + /* Form Controls - More Compact */ + .form-control, .form-select { + font-size: 0.8rem; + padding: 0.375rem 0.5rem; + line-height: 1.3; + } + + .form-control-sm, .form-select-sm { + font-size: 0.75rem; + padding: 0.25rem 0.375rem; + } + + .form-label { + font-size: 0.8rem; + font-weight: 500; + margin-bottom: 0.25rem; + } + + .form-text { + font-size: 0.7rem; + margin-top: 0.15rem; + } + + .form-check-label { + font-size: 0.8rem; + } + + /* Badges - More Compact */ + .badge { + font-size: 0.7rem; + padding: 0.25em 0.5em; + font-weight: 500; + } + + /* Progress bars */ + .progress { + height: 1rem; + font-size: 0.65rem; + } + + .progress-bar { + line-height: 1rem; + } .btn-primary { background-color: var(--racing-green); border-color: var(--racing-green); @@ -156,14 +356,70 @@ border-color: var(--grey-medium); } - /* Table styling */ - .table th { - border-top: none; - font-weight: 600; - color: var(--racing-green-dark); - background-color: var(--grey-light); + /* Alerts - More Compact */ + .alert { + padding: 0.5rem 0.75rem; + margin-bottom: 0.75rem; + font-size: 0.8rem; } + .alert-success { + background-color: rgba(0, 104, 55, 0.1); + border-color: var(--racing-green-light); + color: var(--racing-green-dark); + } + + .alert-warning { + background-color: rgba(253, 126, 20, 0.1); + border-color: var(--orange-accent); + color: var(--orange-dark); + } + + /* Responsive adjustments for very compact design */ + @media (max-width: 768px) { + html { + font-size: 13px; + } + + body { + font-size: 0.8rem; + } + + .navbar-brand { + font-size: 1rem; + } + + .navbar-nav .nav-link { + font-size: 0.75rem; + padding: 0.25rem 0.375rem !important; + } + + .card-body { + padding: 0.5rem; + } + + .btn { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + } + + .table { + font-size: 0.75rem; + } + + .table th, + .table td { + padding: 0.375rem; + } + } + + @media (min-width: 1400px) { + .container-lg { + max-width: 1400px; + } + } + + /* Table styling */ .table-light { background-color: var(--grey-light); } @@ -190,6 +446,8 @@ .pagination .page-link { color: var(--racing-green); border-color: #dee2e6; + font-size: 0.8rem; + padding: 0.375rem 0.5rem; } .pagination .page-item.active .page-link { @@ -214,42 +472,20 @@ /* Form controls */ .form-control:focus { border-color: var(--racing-green-light); - box-shadow: 0 0 0 0.2rem rgba(0, 66, 37, 0.25); + box-shadow: 0 0 0 0.15rem rgba(0, 66, 37, 0.25); } .form-select:focus { border-color: var(--racing-green-light); - box-shadow: 0 0 0 0.2rem rgba(0, 66, 37, 0.25); - } - - /* Alerts */ - .alert-success { - background-color: rgba(0, 104, 55, 0.1); - border-color: var(--racing-green-light); - color: var(--racing-green-dark); - } - - .alert-warning { - background-color: rgba(253, 126, 20, 0.1); - border-color: var(--orange-accent); - color: var(--orange-dark); + box-shadow: 0 0 0 0.15rem rgba(0, 66, 37, 0.25); } /* Footer */ .sticky-footer { background-color: var(--grey-light) !important; border-top: 1px solid #dee2e6; - } - - /* Dropdown menus */ - .dropdown-menu { - border: 1px solid #dee2e6; - box-shadow: 0 0.5rem 1rem rgba(0, 66, 37, 0.15); - } - - .dropdown-item:hover { - background-color: rgba(0, 66, 37, 0.1); - color: var(--racing-green-dark); + padding: 0.75rem 0; + font-size: 0.8rem; } /* Custom accent colors for specific elements */ @@ -416,12 +652,12 @@
-
+
{% if messages %} -
+
{% for message in messages %} - + + {% if destinataer.studiennachweis_erforderlich %} +
+
+
+
+ Vierteljährliche Nachweise +
+
+ Frist: jeweils 15. des zweiten Quartalsmonats + +
+
+
+
+ {% if quarterly_confirmations %} +
+ + + + + + + + + + + + + + + {% for nachweis in quarterly_confirmations %} + + + + + + + + + + + {% endfor %} + +
ZeitraumStatusFortschrittFälligkeitStudiennachweisEinkommenVermögenAktionen
+ {{ nachweis.jahr }} {{ nachweis.get_quarter_display|truncatechars:3 }} + {% if nachweis.is_overdue %} +
Überfällig + {% endif %} +
+ {% if nachweis.status == 'offen' %} + Ausstehend + {% elif nachweis.status == 'teilweise' %} + Teilweise + {% elif nachweis.status == 'eingereicht' %} + Eingereicht + {% elif nachweis.status == 'geprueft' %} + Freigegeben + {% elif nachweis.status == 'nachbesserung' %} + Nachbesserung + {% elif nachweis.status == 'abgelehnt' %} + Abgelehnt + {% endif %} + + {% with completion=nachweis.get_completion_percentage %} +
+
+ {{ completion }}% +
+
+ {% endwith %} +
+ {% if nachweis.faelligkeitsdatum %} + {{ nachweis.faelligkeitsdatum|date:"d.m.Y" }} + {% else %} + Nicht festgelegt + {% endif %} + + {% if nachweis.studiennachweis_eingereicht %} + {% if nachweis.studiennachweis_datei %} + + + + {% elif nachweis.studiennachweis_bemerkung %} + + {% else %} + + {% endif %} + {% else %} + + {% endif %} + + {% if nachweis.einkommenssituation_bestaetigt %} + {% if nachweis.einkommenssituation_datei %} + + + + {% elif nachweis.einkommenssituation_text %} + + {% else %} + + {% endif %} + {% else %} + + {% endif %} + + {% if nachweis.vermogenssituation_bestaetigt %} + {% if nachweis.vermogenssituation_datei %} + + + + {% elif nachweis.vermogenssituation_text %} + + {% else %} + + {% endif %} + {% else %} + + {% endif %} + +
+ + + + + {% if nachweis.status == 'eingereicht' and user.is_staff %} + + {% endif %} +
+
+
+ + + {% for nachweis in quarterly_confirmations %} + + {% endfor %} + + + + + {% else %} +
+ +
Keine vierteljährlichen Nachweise
+

Nachweise werden automatisch erstellt, wenn Studiennachweise erforderlich sind.

+
+ {% endif %} +
+
+ {% endif %} +
@@ -976,5 +1329,29 @@ document.addEventListener('keydown', function(e) { } } }); + +// Quarterly confirmation functions +function approveQuarterly(nachweisId) { + if (confirm('Möchten Sie diesen Vierteljahresnachweis wirklich freigeben?')) { + fetch(`/quarterly-confirmations/${nachweisId}/approve/`, { + method: 'POST', + headers: { + 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value, + 'Content-Type': 'application/json', + }, + }) + .then(response => { + if (response.ok) { + location.reload(); // Reload to show updated status + } else { + alert('Fehler beim Freigeben des Nachweises.'); + } + }) + .catch(error => { + console.error('Error:', error); + alert('Fehler beim Freigeben des Nachweises.'); + }); + } +} {% endblock %} diff --git a/app/templates/stiftung/quarterly_confirmation_edit.html b/app/templates/stiftung/quarterly_confirmation_edit.html new file mode 100644 index 0000000..7a61faa --- /dev/null +++ b/app/templates/stiftung/quarterly_confirmation_edit.html @@ -0,0 +1,519 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{{ title }} - Stiftungsverwaltung{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+ +
+

+ + Vierteljahresnachweis bearbeiten +

+ + Zurück + +
+ + +
+
+
+
+
+ {{ destinataer.get_full_name }} +
+ {{ nachweis.jahr }} {{ nachweis.get_quarter_display }} +
+
+ + {{ nachweis.get_status_display }} + + {% if nachweis.faelligkeitsdatum %} +
Fällig: {{ nachweis.faelligkeitsdatum|date:"d.m.Y" }} + {% endif %} +
+
+
+
+
+
+

Destinatär: {{ destinataer.get_full_name }}

+

E-Mail: + {% if destinataer.email %} + {{ destinataer.email }} + {% else %} + Nicht angegeben + {% endif %} +

+
+
+

Zeitraum: {{ nachweis.jahr }} {{ nachweis.get_quarter_display }}

+ {% if nachweis.faelligkeitsdatum %} +

Fälligkeitsdatum: {{ nachweis.faelligkeitsdatum|date:"d.m.Y" }}

+ {% endif %} +
+
+
+
+ + +
+ {% csrf_token %} + +
+
+ +
+
+
+ Studiennachweis +
+
+
+
+ {{ form.studiennachweis_eingereicht }} + +
+ +
+ + {{ form.studiennachweis_datei }} + {% if nachweis.studiennachweis_datei %} + + {% endif %} + {% if form.studiennachweis_datei.help_text %} + {{ form.studiennachweis_datei.help_text }} + {% endif %} +
+ +
+ + {{ form.studiennachweis_bemerkung }} + {% if form.studiennachweis_bemerkung.help_text %} + {{ form.studiennachweis_bemerkung.help_text }} + {% endif %} +
+
+
+ + +
+
+
+ Einkommenssituation +
+
+
+
+ {{ form.einkommenssituation_bestaetigt }} + +
+ +
+ + {{ form.einkommenssituation_text }} + {% if form.einkommenssituation_text.help_text %} + {{ form.einkommenssituation_text.help_text }} + {% endif %} +
+ +
+ + {{ form.einkommenssituation_datei }} + {% if nachweis.einkommenssituation_datei %} + + {% endif %} + {% if form.einkommenssituation_datei.help_text %} + {{ form.einkommenssituation_datei.help_text }} + {% endif %} +
+
+
+ + +
+
+
+ Vermögenssituation +
+
+
+
+ {{ form.vermogenssituation_bestaetigt }} + +
+ +
+ + {{ form.vermogenssituation_text }} + {% if form.vermogenssituation_text.help_text %} + {{ form.vermogenssituation_text.help_text }} + {% endif %} +
+ +
+ + {{ form.vermogenssituation_datei }} + {% if nachweis.vermogenssituation_datei %} + + {% endif %} + {% if form.vermogenssituation_datei.help_text %} + {{ form.vermogenssituation_datei.help_text }} + {% endif %} +
+
+
+ + +
+
+
+ Weitere Dokumente (optional) +
+
+
+
+ + {{ form.weitere_dokumente }} + {% if nachweis.weitere_dokumente %} + + {% endif %} + {% if form.weitere_dokumente.help_text %} + {{ form.weitere_dokumente.help_text }} + {% endif %} +
+ +
+ + {{ form.weitere_dokumente_beschreibung }} + {% if form.weitere_dokumente_beschreibung.help_text %} + {{ form.weitere_dokumente_beschreibung.help_text }} + {% endif %} +
+
+
+ + + {% if user.is_staff %} +
+
+
+ Interne Notizen (nur für Verwaltung) +
+
+
+
+ + {{ form.interne_notizen }} + {% if form.interne_notizen.help_text %} + {{ form.interne_notizen.help_text }} + {% endif %} +
+
+
+ {% endif %} + + +
+
+
+ +
+ +
+
+
+
+ + + Ihre Änderungen werden gespeichert und der Status wird automatisch aktualisiert. + +
+
+
+
+
+ + +
+ +
+
+
+ Status & Informationen +
+
+
+

Status: + + {{ nachweis.get_status_display }} + +

+ + {% if nachweis.faelligkeitsdatum %} +

Fälligkeit: {{ nachweis.faelligkeitsdatum|date:"d.m.Y" }}

+ {% endif %} + + {% if nachweis.eingereicht_am %} +

Eingereicht: {{ nachweis.eingereicht_am|date:"d.m.Y H:i" }}

+ {% endif %} + + {% if nachweis.geprueft_am %} +

Geprüft: {{ nachweis.geprueft_am|date:"d.m.Y H:i" }}

+ {% endif %} + + {% if nachweis.geprueft_von %} +

Geprüft von: {{ nachweis.geprueft_von.get_full_name|default:nachweis.geprueft_von.username }}

+ {% endif %} + +

Erstellt: {{ nachweis.erstellt_am|date:"d.m.Y H:i" }}

+

Aktualisiert: {{ nachweis.aktualisiert_am|date:"d.m.Y H:i" }}

+
+
+ + +
+
+
+ Anforderungen +
+
+
+
    +
  • + {% if nachweis.studiennachweis_eingereicht %} + + {% else %} + + {% endif %} + Studiennachweis +
  • + +
  • + {% if nachweis.einkommenssituation_bestaetigt %} + + {% else %} + + {% endif %} + Einkommenssituation +
  • + +
  • + {% if nachweis.vermogenssituation_bestaetigt %} + + {% else %} + + {% endif %} + Vermögenssituation +
  • +
+
+
+ + +
+
+
+ Hilfe +
+
+
+ + Einkommenssituation:
+ Sie können entweder einen kurzen Text eingeben oder eine Datei hochladen.

+ + Unterstützte Dateiformate:
+ PDF, DOC, DOCX, JPG, PNG

+ + Fragen?
+ Wenden Sie sich an die Stiftungsverwaltung. +
+
+
+
+
+
+
+
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} \ No newline at end of file