Phase 4: SEPA-Validierung (schwifty), Globale Suche (Cmd+K) & Jahresbericht-Modul

- SEPA-Export: IBAN/BIC-Validierung via schwifty, Schuldner-Konto aus StiftungsKonto
- Globale Suche: Cmd+K Modal über Destinatäre, Pächter, Ländereien, Förderungen, Dokumente
- Jahresbericht: Vollständige Jahresbilanz mit Einnahmen/Ausgaben/Netto, Unterstützungen,
  Landabrechnungen, Verwaltungskosten nach Kategorie, PDF-Export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SysAdmin Agent
2026-03-11 12:57:36 +00:00
parent a79a0989d6
commit 2be72c3990
8 changed files with 695 additions and 160 deletions

View File

@@ -13,3 +13,4 @@ markdown==3.6
django-otp==1.2.4 django-otp==1.2.4
django-htmx==1.19.0 django-htmx==1.19.0
qrcode[pil]==7.4.2 qrcode[pil]==7.4.2
schwifty==2026.3.0

View File

@@ -343,6 +343,9 @@ urlpatterns = [
# Hilfsbox URLs # Hilfsbox URLs
path("help-box/edit/", views.edit_help_box, name="edit_help_box"), path("help-box/edit/", views.edit_help_box, name="edit_help_box"),
path("help-box/admin/", views.edit_help_box, name="help_boxes_admin"), path("help-box/admin/", views.edit_help_box, name="help_boxes_admin"),
# Phase 4: Globale Suche (Cmd+K)
path("api/suche/", views.globale_suche_api, name="globale_suche_api"),
# API URLs # API URLs
path("api/land-stats/", views.land_stats_api, name="land_stats_api"), path("api/land-stats/", views.land_stats_api, name="land_stats_api"),
path("api/health/", views.health_check, name="health_check"), path("api/health/", views.health_check, name="health_check"),

View File

@@ -131,6 +131,7 @@ from .system import ( # noqa: F401
GrampsClient, GrampsClient,
get_gramps_client, get_gramps_client,
gramps_debug_api, gramps_debug_api,
globale_suche_api,
csv_import_list, csv_import_list,
csv_import_create, csv_import_create,
process_personen_csv, process_personen_csv,

View File

@@ -86,29 +86,86 @@ def bericht_list(request):
return render(request, "stiftung/bericht_list.html", context) return render(request, "stiftung/bericht_list.html", context)
@login_required def _jahresbericht_context(jahr):
def jahresbericht_generate(request, jahr): """Phase 4: Aggregiert alle Daten für den Jahresbericht."""
"""Generate annual report for a specific year""" from stiftung.models import (
# Get data for the year DestinataerUnterstuetzung, LandAbrechnung, Verwaltungskosten,
foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("person")
verpachtungen = LandVerpachtung.objects.filter(
pachtbeginn__year__lte=jahr, pachtende__year__gte=jahr
).select_related("land", "paechter")
# Calculate statistics
total_foerderungen = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
total_pachtzins = (
verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
) )
context = { # Förderungen (legacy)
foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("destinataer", "person")
total_foerderungen_legacy = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
# Unterstützungen (Phase 2 neue Pipeline)
unterstuetzungen = DestinataerUnterstuetzung.objects.filter(
faellig_am__year=jahr
).exclude(status="storniert").select_related("destinataer", "konto")
unterstuetzungen_ausgezahlt = unterstuetzungen.filter(
status__in=["ausgezahlt", "abgeschlossen"]
)
total_unterstuetzungen = unterstuetzungen_ausgezahlt.aggregate(total=Sum("betrag"))["total"] or 0
# Gesamtausgaben Förderung
total_ausgaben_foerderung = total_foerderungen_legacy + total_unterstuetzungen
# Verpachtungen
verpachtungen = LandVerpachtung.objects.filter(
pachtbeginn__year__lte=jahr
).filter(
Q(pachtende__isnull=True) | Q(pachtende__year__gte=jahr)
).select_related("land", "paechter")
total_pachtzins = verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
# Landabrechnungen für das Jahr
landabrechnungen = LandAbrechnung.objects.filter(
abrechnungsjahr=jahr
).select_related("land")
pacht_vereinnahmt = landabrechnungen.aggregate(total=Sum("pacht_vereinnahmt"))["total"] or 0
grundsteuer_gesamt = landabrechnungen.aggregate(total=Sum("grundsteuer_betrag"))["total"] or 0
# Verwaltungskosten
verwaltungskosten_qs = Verwaltungskosten.objects.filter(datum__year=jahr)
total_verwaltungskosten = verwaltungskosten_qs.aggregate(total=Sum("betrag"))["total"] or 0
verwaltungskosten_nach_kategorie = (
verwaltungskosten_qs
.values("kategorie")
.annotate(summe=Sum("betrag"), anzahl=Count("id"))
.order_by("-summe")
)
# Gesamtbilanz
total_einnahmen = pacht_vereinnahmt if pacht_vereinnahmt else total_pachtzins
total_ausgaben = total_ausgaben_foerderung + total_verwaltungskosten
netto = total_einnahmen - total_ausgaben
return {
"jahr": jahr, "jahr": jahr,
"foerderungen": foerderungen,
"verpachtungen": verpachtungen,
"total_foerderungen": total_foerderungen,
"total_pachtzins": total_pachtzins,
"title": f"Jahresbericht {jahr}", "title": f"Jahresbericht {jahr}",
"foerderungen": foerderungen,
"total_foerderungen_legacy": total_foerderungen_legacy,
"unterstuetzungen": unterstuetzungen,
"unterstuetzungen_ausgezahlt": unterstuetzungen_ausgezahlt,
"total_unterstuetzungen": total_unterstuetzungen,
"total_ausgaben_foerderung": total_ausgaben_foerderung,
"verpachtungen": verpachtungen,
"total_pachtzins": total_pachtzins,
"landabrechnungen": landabrechnungen,
"pacht_vereinnahmt": pacht_vereinnahmt,
"grundsteuer_gesamt": grundsteuer_gesamt,
"verwaltungskosten_nach_kategorie": verwaltungskosten_nach_kategorie,
"total_verwaltungskosten": total_verwaltungskosten,
"total_einnahmen": total_einnahmen,
"total_ausgaben": total_ausgaben,
"netto": netto,
# Rückwärtskompatibilität
"total_foerderungen": total_ausgaben_foerderung,
} }
@login_required
def jahresbericht_generate(request, jahr):
"""Phase 4: Jahresbericht mit aggregierten Finanzdaten."""
context = _jahresbericht_context(jahr)
return render(request, "stiftung/jahresbericht.html", context) return render(request, "stiftung/jahresbericht.html", context)
@@ -124,30 +181,12 @@ def jahresbericht_generate_redirect(request):
@login_required @login_required
def jahresbericht_pdf(request, jahr): def jahresbericht_pdf(request, jahr):
"""Generate PDF version of annual report""" """Phase 4: PDF-Export des Jahresberichts."""
from django.http import HttpResponse from django.http import HttpResponse
from django.template.loader import render_to_string from django.template.loader import render_to_string
from weasyprint import HTML from weasyprint import HTML
# Get data for the year context = _jahresbericht_context(jahr)
foerderungen = Foerderung.objects.filter(jahr=jahr).select_related("person")
verpachtungen = LandVerpachtung.objects.filter(
pachtbeginn__year__lte=jahr, pachtende__year__gte=jahr
).select_related("land", "paechter")
# Calculate statistics
total_foerderungen = foerderungen.aggregate(total=Sum("betrag"))["total"] or 0
total_pachtzins = (
verpachtungen.aggregate(total=Sum("pachtzins_pauschal"))["total"] or 0
)
context = {
"jahr": jahr,
"foerderungen": foerderungen,
"verpachtungen": verpachtungen,
"total_foerderungen": total_foerderungen,
"total_pachtzins": total_pachtzins,
}
# Render HTML # Render HTML
html_string = render_to_string("stiftung/jahresbericht.html", context) html_string = render_to_string("stiftung/jahresbericht.html", context)

View File

@@ -2137,3 +2137,94 @@ from stiftung.models import GeschichteSeite, GeschichteBild
from stiftung.forms import GeschichteSeiteForm, GeschichteBildForm from stiftung.forms import GeschichteSeiteForm, GeschichteBildForm
# =============================================================================
# Phase 4: GLOBALE SUCHE (Cmd+K)
# =============================================================================
@login_required
def globale_suche_api(request):
"""Phase 4: AJAX-Endpunkt für globale Suche über alle Bereiche."""
from django.http import JsonResponse
from stiftung.models import (
Destinataer, Paechter, Land, LandVerpachtung, Foerderung,
)
from stiftung.models.dokumente import DokumentDatei
q = request.GET.get("q", "").strip()
if len(q) < 2:
return JsonResponse({"results": []})
results = []
# Destinatäre
for d in Destinataer.objects.filter(
Q(vorname__icontains=q) | Q(nachname__icontains=q) | Q(email__icontains=q)
)[:5]:
results.append({
"typ": "Destinatär",
"titel": d.get_full_name(),
"untertitel": d.email or "",
"url": f"/destinataere/{d.pk}/",
"icon": "fas fa-user",
})
# Pächter
for p in Paechter.objects.filter(
Q(vorname__icontains=q) | Q(nachname__icontains=q) | Q(email__icontains=q)
)[:5]:
results.append({
"typ": "Pächter",
"titel": p.get_full_name(),
"untertitel": p.email or "",
"url": f"/paechter/{p.pk}/",
"icon": "fas fa-user-tie",
})
# Ländereien
for l in Land.objects.filter(
Q(gemeinde__icontains=q) | Q(gemarkung__icontains=q) | Q(lfd_nr__icontains=q)
)[:5]:
results.append({
"typ": "Länderei",
"titel": str(l),
"untertitel": l.gemeinde or "",
"url": f"/laendereien/{l.pk}/",
"icon": "fas fa-map",
})
# Förderungen suche über Destinatär-Name oder Bemerkungen
for f in Foerderung.objects.filter(
Q(destinataer__vorname__icontains=q)
| Q(destinataer__nachname__icontains=q)
| Q(bemerkungen__icontains=q)
).select_related("destinataer", "person")[:5]:
empfaenger = (
f.destinataer.get_full_name() if f.destinataer
else (f.person.get_full_name() if f.person else "Unbekannt")
)
results.append({
"typ": "Förderung",
"titel": f"{empfaenger} ({f.jahr})",
"untertitel": f"{f.betrag} · {f.get_status_display()}",
"url": f"/foerderungen/{f.pk}/",
"icon": "fas fa-gift",
})
# Dokumente (DMS)
try:
for dok in DokumentDatei.objects.filter(
Q(titel__icontains=q) | Q(beschreibung__icontains=q)
)[:5]:
results.append({
"typ": "Dokument",
"titel": dok.titel,
"untertitel": dok.get_kontext_display(),
"url": f"/dms/{dok.pk}/",
"icon": "fas fa-file-alt",
})
except Exception:
pass
return JsonResponse({"results": results, "query": q})

View File

@@ -1768,9 +1768,14 @@ def unterstuetzung_abschliessen(request, pk):
@login_required @login_required
def sepa_xml_export(request): def sepa_xml_export(request):
"""2c: SEPA pain.001 XML-Export für freigegebene Zahlungen.""" """Phase 4: SEPA pain.001 XML-Export mit schwifty IBAN/BIC-Validierung."""
from xml.etree.ElementTree import Element, SubElement, tostring from xml.etree.ElementTree import Element, SubElement, tostring
import xml.dom.minidom import xml.dom.minidom
try:
from schwifty import IBAN as SchwiftyIBAN, BIC as SchwiftyBIC
schwifty_available = True
except ImportError:
schwifty_available = False
zahlungen = DestinataerUnterstuetzung.objects.filter( zahlungen = DestinataerUnterstuetzung.objects.filter(
status="freigegeben" status="freigegeben"
@@ -1780,10 +1785,32 @@ def sepa_xml_export(request):
messages.warning(request, "Keine freigegebenen Zahlungen für SEPA-Export vorhanden.") messages.warning(request, "Keine freigegebenen Zahlungen für SEPA-Export vorhanden.")
return redirect("stiftung:zahlungs_pipeline") return redirect("stiftung:zahlungs_pipeline")
# IBAN/BIC-Validierung mit schwifty
validierungsfehler = []
if schwifty_available:
for zahlung in zahlungen:
iban_raw = (zahlung.empfaenger_iban or zahlung.destinataer.iban or "").replace(" ", "")
if not iban_raw:
validierungsfehler.append(
f"{zahlung.destinataer.get_full_name()}: Keine IBAN hinterlegt"
)
continue
try:
SchwiftyIBAN(iban_raw)
except Exception:
validierungsfehler.append(
f"{zahlung.destinataer.get_full_name()}: Ungültige IBAN '{iban_raw}'"
)
if validierungsfehler:
for fehler in validierungsfehler:
messages.error(request, f"SEPA-Validierungsfehler: {fehler}")
return redirect("stiftung:zahlungs_pipeline")
heute = date.today() heute = date.today()
msg_id = f"STIFTUNG-{heute.strftime('%Y%m%d%H%M%S')}" msg_id = f"STIFTUNG-{heute.strftime('%Y%m%d%H%M%S')}"
nb_of_txs = zahlungen.count() nb_of_txs = zahlungen.count()
ctrl_sum = str(sum(z.betrag for z in zahlungen)) ctrl_sum = f"{sum(z.betrag for z in zahlungen):.2f}"
root = Element("Document", { root = Element("Document", {
"xmlns": "urn:iso:std:iso:20022:tech:xsd:pain.001.001.03", "xmlns": "urn:iso:std:iso:20022:tech:xsd:pain.001.001.03",
@@ -1810,18 +1837,34 @@ def sepa_xml_export(request):
dbtr = SubElement(pmt_inf, "Dbtr") dbtr = SubElement(pmt_inf, "Dbtr")
SubElement(dbtr, "Nm").text = "van Hees-Theyssen-Vogel'sche Stiftung" SubElement(dbtr, "Nm").text = "van Hees-Theyssen-Vogel'sche Stiftung"
# Schuldner-IBAN aus aktivem Stiftungskonto
if zahlungen.first() and zahlungen.first().konto and zahlungen.first().konto.iban:
dbtr_acct = SubElement(pmt_inf, "DbtrAcct")
dbtr_acct_id = SubElement(dbtr_acct, "Id")
SubElement(dbtr_acct_id, "IBAN").text = zahlungen.first().konto.iban.replace(" ", "")
dbtr_agt = SubElement(pmt_inf, "DbtrAgt")
fin_instn_id = SubElement(dbtr_agt, "FinInstnId")
bic_val = zahlungen.first().konto.bic.strip()
if schwifty_available and bic_val:
try:
bic_val = str(SchwiftyBIC(bic_val))
except Exception:
pass
SubElement(fin_instn_id, "BIC").text = bic_val or "NOTPROVIDED"
for zahlung in zahlungen: for zahlung in zahlungen:
cdt_trf = SubElement(pmt_inf, "CdtTrfTxInf") cdt_trf = SubElement(pmt_inf, "CdtTrfTxInf")
pmt_id_el = SubElement(cdt_trf, "PmtId") pmt_id_el = SubElement(cdt_trf, "PmtId")
SubElement(pmt_id_el, "EndToEndId").text = str(zahlung.id)[:35] SubElement(pmt_id_el, "EndToEndId").text = str(zahlung.id)[:35]
amt = SubElement(cdt_trf, "Amt") amt = SubElement(cdt_trf, "Amt")
instd_amt = SubElement(amt, "InstdAmt", {"Ccy": "EUR"}) instd_amt = SubElement(amt, "InstdAmt", {"Ccy": "EUR"})
instd_amt.text = str(zahlung.betrag) instd_amt.text = f"{zahlung.betrag:.2f}"
cdtr = SubElement(cdt_trf, "Cdtr") cdtr = SubElement(cdt_trf, "Cdtr")
SubElement(cdtr, "Nm").text = (zahlung.empfaenger_name or zahlung.destinataer.get_full_name())[:70] SubElement(cdtr, "Nm").text = (zahlung.empfaenger_name or zahlung.destinataer.get_full_name())[:70]
cdtr_acct = SubElement(cdt_trf, "CdtrAcct") cdtr_acct = SubElement(cdt_trf, "CdtrAcct")
cdtr_id = SubElement(cdtr_acct, "Id") cdtr_id = SubElement(cdtr_acct, "Id")
SubElement(cdtr_id, "IBAN").text = (zahlung.empfaenger_iban or zahlung.destinataer.iban or "").replace(" ", "") iban_clean = (zahlung.empfaenger_iban or zahlung.destinataer.iban or "").replace(" ", "")
SubElement(cdtr_id, "IBAN").text = iban_clean
rmt_inf = SubElement(cdt_trf, "RmtInf") rmt_inf = SubElement(cdt_trf, "RmtInf")
SubElement(rmt_inf, "Ustrd").text = (zahlung.verwendungszweck or zahlung.beschreibung or "Stiftungsunterstützung")[:140] SubElement(rmt_inf, "Ustrd").text = (zahlung.verwendungszweck or zahlung.beschreibung or "Stiftungsunterstützung")[:140]

View File

@@ -777,5 +777,223 @@
</script> </script>
{% block javascript %}{% endblock %} {% block javascript %}{% endblock %}
<!-- Phase 4: Globale Suche (Cmd+K) -->
<div id="global-search-overlay" style="display:none; position:fixed; inset:0; z-index:9999; background:rgba(0,0,0,0.5);" onclick="closeGlobalSearch()">
</div>
<div id="global-search-modal" style="display:none; position:fixed; top:15%; left:50%; transform:translateX(-50%); z-index:10000; width:min(600px, 90vw); background:#fff; border-radius:0.75rem; box-shadow:0 20px 60px rgba(0,0,0,0.3); overflow:hidden;">
<div style="padding:0.75rem 1rem; border-bottom:1px solid #e9ecef; display:flex; align-items:center; gap:0.5rem;">
<i class="fas fa-search" style="color:#6c757d;"></i>
<input id="global-search-input" type="text" placeholder="Suche über alle Bereiche..." autocomplete="off"
style="flex:1; border:none; outline:none; font-size:1rem; background:transparent; color:#212529;">
<kbd style="font-size:0.75rem; padding:2px 6px; background:#f8f9fa; border:1px solid #dee2e6; border-radius:4px; color:#6c757d;">Esc</kbd>
</div>
<div id="global-search-results" style="max-height:420px; overflow-y:auto; padding:0.5rem 0;">
<div class="px-3 py-4 text-center text-muted" id="global-search-hint">
<i class="fas fa-search fa-2x mb-2 d-block" style="opacity:0.3;"></i>
Mindestens 2 Zeichen eingeben …
</div>
</div>
</div>
<!-- Suche-Trigger in Topbar -->
<style>
#global-search-btn {
background: none;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 4px 10px;
color: #6c757d;
font-size: 0.85rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
transition: border-color 0.15s, color 0.15s;
}
#global-search-btn:hover {
border-color: var(--racing-green);
color: var(--racing-green);
}
.search-result-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
cursor: pointer;
text-decoration: none;
color: #212529;
transition: background 0.1s;
}
.search-result-item:hover, .search-result-item.highlighted {
background: #f0f7f4;
color: var(--racing-green-dark);
}
.search-result-icon {
width: 32px; height: 32px;
border-radius: 50%;
background: var(--racing-green);
color: white;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
font-size: 0.8rem;
}
.search-result-typ {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6c757d;
}
.search-group-label {
padding: 0.25rem 1rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #adb5bd;
font-weight: 600;
border-top: 1px solid #f0f0f0;
margin-top: 0.25rem;
}
.search-group-label:first-child { border-top: none; margin-top: 0; }
</style>
<script>
(function() {
const overlay = document.getElementById('global-search-overlay');
const modal = document.getElementById('global-search-modal');
const input = document.getElementById('global-search-input');
const resultsEl = document.getElementById('global-search-results');
const hintEl = document.getElementById('global-search-hint');
let debounceTimer = null;
let currentHighlight = -1;
let resultLinks = [];
function openGlobalSearch() {
overlay.style.display = 'block';
modal.style.display = 'block';
input.focus();
input.select();
}
window.closeGlobalSearch = function() {
overlay.style.display = 'none';
modal.style.display = 'none';
};
// Cmd+K / Ctrl+K öffnet Suche
document.addEventListener('keydown', function(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (modal.style.display === 'block') {
closeGlobalSearch();
} else {
openGlobalSearch();
}
}
if (e.key === 'Escape' && modal.style.display === 'block') {
closeGlobalSearch();
}
// Keyboard navigation
if (modal.style.display === 'block' && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
resultLinks = Array.from(resultsEl.querySelectorAll('.search-result-item'));
if (!resultLinks.length) return;
resultLinks.forEach(l => l.classList.remove('highlighted'));
if (e.key === 'ArrowDown') currentHighlight = Math.min(currentHighlight + 1, resultLinks.length - 1);
else currentHighlight = Math.max(currentHighlight - 1, 0);
if (currentHighlight >= 0) {
resultLinks[currentHighlight].classList.add('highlighted');
resultLinks[currentHighlight].scrollIntoView({ block: 'nearest' });
}
}
if (modal.style.display === 'block' && e.key === 'Enter') {
resultLinks = Array.from(resultsEl.querySelectorAll('.search-result-item'));
if (currentHighlight >= 0 && resultLinks[currentHighlight]) {
window.location.href = resultLinks[currentHighlight].href;
} else if (resultLinks.length === 1) {
window.location.href = resultLinks[0].href;
}
}
});
// Prevent modal click from closing
modal.addEventListener('click', function(e) { e.stopPropagation(); });
input.addEventListener('input', function() {
clearTimeout(debounceTimer);
currentHighlight = -1;
const q = input.value.trim();
if (q.length < 2) {
hintEl.style.display = '';
resultsEl.innerHTML = '';
resultsEl.appendChild(hintEl);
return;
}
debounceTimer = setTimeout(function() { doSearch(q); }, 200);
});
function doSearch(q) {
fetch("{% url 'stiftung:globale_suche_api' %}?q=" + encodeURIComponent(q), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => renderResults(data.results, q))
.catch(() => {});
}
function renderResults(results, q) {
resultsEl.innerHTML = '';
if (!results || !results.length) {
const empty = document.createElement('div');
empty.className = 'px-3 py-4 text-center text-muted';
empty.innerHTML = '<i class="fas fa-search-minus fa-2x mb-2 d-block" style="opacity:0.3;"></i>Keine Ergebnisse für „' + escapeHtml(q) + '"';
resultsEl.appendChild(empty);
return;
}
// Group by type
const grouped = {};
results.forEach(r => {
if (!grouped[r.typ]) grouped[r.typ] = [];
grouped[r.typ].push(r);
});
Object.entries(grouped).forEach(([typ, items]) => {
const label = document.createElement('div');
label.className = 'search-group-label';
label.textContent = typ;
resultsEl.appendChild(label);
items.forEach(item => {
const a = document.createElement('a');
a.className = 'search-result-item';
a.href = item.url;
a.innerHTML = `
<div class="search-result-icon"><i class="${item.icon}"></i></div>
<div style="min-width:0;">
<div style="font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escapeHtml(item.titel)}</div>
${item.untertitel ? '<div class="search-result-typ">' + escapeHtml(item.untertitel) + '</div>' : ''}
</div>`;
a.addEventListener('click', closeGlobalSearch);
resultsEl.appendChild(a);
});
});
}
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Inject search button into topbar
document.addEventListener('DOMContentLoaded', function() {
const topbarActions = document.querySelector('.topbar-actions');
if (topbarActions) {
const btn = document.createElement('button');
btn.id = 'global-search-btn';
btn.onclick = openGlobalSearch;
btn.innerHTML = '<i class="fas fa-search"></i> Suche <kbd style="font-size:0.7rem; padding:1px 4px; background:#f8f9fa; border:1px solid #dee2e6; border-radius:3px; margin-left:4px;">⌘K</kbd>';
topbarActions.insertBefore(btn, topbarActions.firstChild);
}
});
})();
</script>
</body> </body>
</html> </html>

View File

@@ -10,6 +10,7 @@
margin: 0; margin: 0;
padding: 20px; padding: 20px;
color: #333; color: #333;
font-size: 14px;
} }
.header { .header {
text-align: center; text-align: center;
@@ -17,129 +18,200 @@
padding-bottom: 20px; padding-bottom: 20px;
margin-bottom: 30px; margin-bottom: 30px;
} }
.header h1 { .header h1 { color: #2c3e50; margin: 0; font-size: 2.2em; }
color: #2c3e50; .header .subtitle { color: #7f8c8d; font-size: 1.1em; margin-top: 8px; }
margin: 0; .section { margin-bottom: 30px; page-break-inside: avoid; }
font-size: 2.5em;
}
.header .subtitle {
color: #7f8c8d;
font-size: 1.2em;
margin-top: 10px;
}
.section {
margin-bottom: 30px;
page-break-inside: avoid;
}
.section h2 { .section h2 {
color: #34495e; color: #1a4a2e;
border-bottom: 2px solid #3498db; border-bottom: 2px solid #2c7a4b;
padding-bottom: 10px; padding-bottom: 8px;
margin-bottom: 20px; margin-bottom: 16px;
font-size: 1.2em;
} }
.section h3 { color: #34495e; font-size: 1em; margin-top: 16px; margin-bottom: 8px; }
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 20px; gap: 16px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.stat-card { .stat-card {
background: #f8f9fa; background: #f8f9fa;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 16px;
text-align: center; text-align: center;
} }
.stat-card .value { .stat-card .value { font-size: 1.6em; font-weight: bold; color: #1a4a2e; }
font-size: 2em; .stat-card .label { color: #7f8c8d; margin-top: 4px; font-size: 0.85em; }
font-weight: bold; .bilanz-grid {
color: #2c3e50; display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
} }
.stat-card .label { .bilanz-card {
color: #7f8c8d; border-radius: 8px;
margin-top: 5px; padding: 16px;
text-align: center;
} }
.bilanz-card.einnahmen { background: #d4edda; border: 1px solid #c3e6cb; }
.bilanz-card.ausgaben { background: #f8d7da; border: 1px solid #f5c6cb; }
.bilanz-card.netto-positiv { background: #d1ecf1; border: 1px solid #bee5eb; }
.bilanz-card.netto-negativ { background: #fff3cd; border: 1px solid #ffeeba; }
.bilanz-card .value { font-size: 1.5em; font-weight: bold; }
.bilanz-card .label { font-size: 0.85em; margin-top: 4px; color: #555; }
table { table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
margin-bottom: 20px; margin-bottom: 16px;
}
th, td {
border: 1px solid #dee2e6;
padding: 12px;
text-align: left;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #2c3e50;
}
tr:nth-child(even) {
background-color: #f8f9fa;
}
.amount {
text-align: right;
font-family: 'Courier New', monospace;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9em; font-size: 0.9em;
}
th, td { border: 1px solid #dee2e6; padding: 8px 10px; text-align: left; }
th { background-color: #f0f7f4; font-weight: 600; color: #1a4a2e; }
tr:nth-child(even) { background-color: #f8f9fa; }
.amount { text-align: right; font-family: 'Courier New', monospace; }
.status-badge {
padding: 3px 7px;
border-radius: 4px;
font-size: 0.8em;
font-weight: 500; font-weight: 500;
} }
.status-beantragt { background-color: #fff3cd; color: #856404; } .status-beantragt { background-color: #fff3cd; color: #856404; }
.status-genehmigt { background-color: #d1ecf1; color: #0c5460; } .status-genehmigt { background-color: #d1ecf1; color: #0c5460; }
.status-ausgezahlt { background-color: #d4edda; color: #155724; } .status-ausgezahlt, .status-abgeschlossen { background-color: #d4edda; color: #155724; }
.status-abgelehnt { background-color: #f8d7da; color: #721c24; } .status-abgelehnt, .status-storniert { background-color: #f8d7da; color: #721c24; }
.status-storniert { background-color: #e2e3e5; color: #383d41; } .status-geplant, .status-faellig { background-color: #e2e3e5; color: #383d41; }
.footer { .footer {
margin-top: 40px; margin-top: 40px;
padding-top: 20px; padding-top: 16px;
border-top: 1px solid #dee2e6; border-top: 1px solid #dee2e6;
text-align: center; text-align: center;
color: #7f8c8d; color: #7f8c8d;
font-size: 0.9em; font-size: 0.85em;
} }
@media print { @media print {
body { margin: 0; padding: 15px; } body { margin: 0; padding: 10px; }
.section { page-break-inside: avoid; } .section { page-break-inside: avoid; }
.no-print { display: none; }
}
.print-btn {
display: inline-block;
padding: 8px 20px;
background: #1a4a2e;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9em;
text-decoration: none;
margin-right: 8px;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="header"> <!-- Aktionsleiste (nur Bildschirm, nicht Druck) -->
<h1>Stiftung Jahresbericht {{ jahr }}</h1> <div class="no-print" style="margin-bottom: 20px; display: flex; align-items: center; gap: 12px;">
<div class="subtitle">Jahresübersicht über Förderungen und Verpachtungen</div> <a href="{% url 'stiftung:bericht_list' %}" style="color: #1a4a2e;">&#8592; Berichte</a>
<div class="subtitle">Erstellt am {{ "now"|date:"d.m.Y" }}</div> <span style="color: #dee2e6;">|</span>
<a href="{% url 'stiftung:jahresbericht_pdf' jahr=jahr %}" class="print-btn">
PDF herunterladen
</a>
<button onclick="window.print()" class="print-btn" style="background: #34495e;">
Drucken
</button>
</div> </div>
<!-- Executive Summary --> <!-- Kopfzeile -->
<div class="header">
<h1>Jahresbericht {{ jahr }}</h1>
<div class="subtitle">van Hees-Theyssen-Vogel'sche Familienstiftung</div>
<div class="subtitle">Erstellt am {% now "d.m.Y" %}</div>
</div>
<!-- 1. Gesamtübersicht / Bilanz -->
<div class="section"> <div class="section">
<h2>Zusammenfassung</h2> <h2>1. Jahresbilanz {{ jahr }}</h2>
<div class="bilanz-grid">
<div class="bilanz-card einnahmen">
<div class="value">&#8364;{{ total_einnahmen|floatformat:2 }}</div>
<div class="label">Einnahmen (Pacht)</div>
</div>
<div class="bilanz-card ausgaben">
<div class="value">&#8364;{{ total_ausgaben|floatformat:2 }}</div>
<div class="label">Ausgaben gesamt</div>
</div>
<div class="bilanz-card {% if netto >= 0 %}netto-positiv{% else %}netto-negativ{% endif %}">
<div class="value">{% if netto >= 0 %}+{% endif %}&#8364;{{ netto|floatformat:2 }}</div>
<div class="label">Nettosaldo</div>
</div>
</div>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
<div class="value">€{{ total_foerderungen|floatformat:2 }}</div> <div class="value">&#8364;{{ total_ausgaben_foerderung|floatformat:2 }}</div>
<div class="label">Gesamtförderungen</div> <div class="label">Förderausgaben</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="value">€{{ total_pachtzins|floatformat:2 }}</div> <div class="value">&#8364;{{ total_verwaltungskosten|floatformat:2 }}</div>
<div class="label">Gesamtpachtzins</div> <div class="label">Verwaltungskosten</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="value">{{ foerderungen.count }}</div> <div class="value">&#8364;{{ pacht_vereinnahmt|floatformat:2 }}</div>
<div class="label">Förderungen</div> <div class="label">Pacht vereinnahmt</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="value">{{ verpachtungen.count }}</div> <div class="value">&#8364;{{ grundsteuer_gesamt|floatformat:2 }}</div>
<div class="label">Aktive Verpachtungen</div> <div class="label">Grundsteuer</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Förderungen Section --> <!-- 2. Unterstützungen (Zahlungs-Pipeline) -->
{% if unterstuetzungen %}
<div class="section">
<h2>2. Unterstützungszahlungen {{ jahr }}</h2>
<p style="color: #666; margin-bottom: 12px;">
{{ unterstuetzungen.count }} Unterstützung(en) geplant/ausgezahlt &middot;
{{ unterstuetzungen_ausgezahlt.count }} überwiesen (&#8364;{{ total_unterstuetzungen|floatformat:2 }})
</p>
<table>
<thead>
<tr>
<th>Destinatär</th>
<th>Betrag</th>
<th>Fällig am</th>
<th>Status</th>
<th>Verwendungszweck</th>
</tr>
</thead>
<tbody>
{% for u in unterstuetzungen %}
<tr>
<td>{{ u.destinataer.get_full_name }}</td>
<td class="amount">&#8364;{{ u.betrag|floatformat:2 }}</td>
<td>{{ u.faellig_am|date:"d.m.Y" }}</td>
<td>
<span class="status-badge status-{{ u.status }}">{{ u.get_status_display }}</span>
</td>
<td>{{ u.beschreibung|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr style="font-weight: bold; background: #f0f7f4;">
<td>Summe ausgezahlt</td>
<td class="amount">&#8364;{{ total_unterstuetzungen|floatformat:2 }}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
{% endif %}
<!-- 3. Förderungen (legacy Foerderung-Modell) -->
{% if foerderungen %} {% if foerderungen %}
<div class="section"> <div class="section">
<h2>Förderungen im Jahr {{ jahr }}</h2> <h2>3. Förderungen {{ jahr }}</h2>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -151,77 +223,144 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for foerderung in foerderungen %} {% for f in foerderungen %}
<tr> <tr>
<td>{{ foerderung.person.get_full_name }}</td>
<td>{{ foerderung.get_kategorie_display }}</td>
<td class="amount">€{{ foerderung.betrag|floatformat:2 }}</td>
<td> <td>
<span class="status-badge status-{{ foerderung.status }}"> {% if f.destinataer %}{{ f.destinataer.get_full_name }}
{{ foerderung.get_status_display }} {% elif f.person %}{{ f.person.get_full_name }}
</span> {% else %}{% endif %}
</td> </td>
<td>{{ foerderung.antragsdatum|date:"d.m.Y" }}</td> <td>{{ f.get_kategorie_display }}</td>
<td class="amount">&#8364;{{ f.betrag|floatformat:2 }}</td>
<td>
<span class="status-badge status-{{ f.status }}">{{ f.get_status_display }}</span>
</td>
<td>{{ f.antragsdatum|date:"d.m.Y" }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot>
<tr style="font-weight: bold; background: #f0f7f4;">
<td colspan="2">Summe</td>
<td class="amount">&#8364;{{ total_foerderungen_legacy|floatformat:2 }}</td>
<td colspan="2"></td>
</tr>
</tfoot>
</table> </table>
</div> </div>
{% endif %} {% endif %}
<!-- Verpachtungen Section --> <!-- 4. Grundstücksverwaltung -->
{% if verpachtungen %}
<div class="section"> <div class="section">
<h2>Aktive Verpachtungen im Jahr {{ jahr }}</h2> <h2>4. Grundstücksverwaltung</h2>
{% if verpachtungen %}
<h3>Aktive Verpachtungen</h3>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Länderei</th> <th>Länderei</th>
<th>Pächter</th> <th>Pächter</th>
<th>Vertragsnummer</th>
<th>Verpachtete Fläche</th> <th>Verpachtete Fläche</th>
<th>Jährlicher Pachtzins</th> <th>Jahrespachtzins</th>
<th>Pachtende</th> <th>Pachtende</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for verpachtung in verpachtungen %} {% for v in verpachtungen %}
<tr> <tr>
<td>{{ verpachtung.land }}</td> <td>{{ v.land }}</td>
<td>{{ verpachtung.paechter.get_full_name }}</td> <td>{{ v.paechter.get_full_name }}</td>
<td>{{ verpachtung.vertragsnummer }}</td> <td class="amount">{{ v.verpachtete_flaeche|floatformat:0 }} qm</td>
<td>{{ verpachtung.verpachtete_flaeche|floatformat:2 }} qm</td> <td class="amount">&#8364;{{ v.pachtzins_pauschal|floatformat:2 }}</td>
<td class="amount">€{{ verpachtung.pachtzins_jaehrlich|floatformat:2 }}</td> <td>{% if v.pachtende %}{{ v.pachtende|date:"d.m.Y" }}{% else %}unbefristet{% endif %}</td>
<td>{{ verpachtung.pachtende|date:"d.m.Y" }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot>
<tr style="font-weight: bold; background: #f0f7f4;">
<td colspan="3">Gesamtpachtzins (kalkuliert)</td>
<td class="amount">&#8364;{{ total_pachtzins|floatformat:2 }}</td>
<td></td>
</tr>
</tfoot>
</table>
{% endif %}
{% if landabrechnungen %}
<h3>Landabrechnungen {{ jahr }}</h3>
<table>
<thead>
<tr>
<th>Länderei</th>
<th>Pacht vereinnahmt</th>
<th>Umlagen</th>
<th>Grundsteuer</th>
<th>Sonstige Einnahmen</th>
</tr>
</thead>
<tbody>
{% for a in landabrechnungen %}
<tr>
<td>{{ a.land }}</td>
<td class="amount">&#8364;{{ a.pacht_vereinnahmt|floatformat:2 }}</td>
<td class="amount">&#8364;{{ a.umlagen_vereinnahmt|floatformat:2 }}</td>
<td class="amount">&#8364;{{ a.grundsteuer_betrag|floatformat:2 }}</td>
<td class="amount">&#8364;{{ a.sonstige_einnahmen|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr style="font-weight: bold; background: #f0f7f4;">
<td>Summe</td>
<td class="amount">&#8364;{{ pacht_vereinnahmt|floatformat:2 }}</td>
<td></td>
<td class="amount">&#8364;{{ grundsteuer_gesamt|floatformat:2 }}</td>
<td></td>
</tr>
</tfoot>
</table>
{% endif %}
{% if not verpachtungen and not landabrechnungen %}
<p style="color: #999;">Keine Verpachtungs- oder Abrechnungsdaten für {{ jahr }} vorhanden.</p>
{% endif %}
</div>
<!-- 5. Verwaltungskosten -->
{% if verwaltungskosten_nach_kategorie %}
<div class="section">
<h2>5. Verwaltungskosten {{ jahr }}</h2>
<table>
<thead>
<tr>
<th>Kategorie</th>
<th>Anzahl</th>
<th>Betrag</th>
</tr>
</thead>
<tbody>
{% for k in verwaltungskosten_nach_kategorie %}
<tr>
<td>{{ k.kategorie|capfirst }}</td>
<td>{{ k.anzahl }}</td>
<td class="amount">&#8364;{{ k.summe|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr style="font-weight: bold; background: #f0f7f4;">
<td colspan="2">Gesamt</td>
<td class="amount">&#8364;{{ total_verwaltungskosten|floatformat:2 }}</td>
</tr>
</tfoot>
</table> </table>
</div> </div>
{% endif %} {% endif %}
<!-- Financial Summary -->
<div class="section">
<h2>Finanzielle Übersicht</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="value">€{{ total_foerderungen|floatformat:2 }}</div>
<div class="label">Ausgaben (Förderungen)</div>
</div>
<div class="stat-card">
<div class="value">€{{ total_pachtzins|floatformat:2 }}</div>
<div class="label">Einnahmen (Pachtzins)</div>
</div>
<div class="stat-card">
<div class="value">€{{ total_pachtzins|add:total_foerderungen|floatformat:2 }}</div>
<div class="label">Netto-Position</div>
</div>
</div>
</div>
<div class="footer"> <div class="footer">
<p>Dieser Bericht wurde automatisch generiert von der Stiftungsverwaltung.</p> <p>Jahresbericht {{ jahr }} &mdash; automatisch generiert von der Stiftungsverwaltung</p>
<p>Bei Fragen wenden Sie sich bitte an die Verwaltung.</p> <p>van Hees-Theyssen-Vogel'sche Familienstiftung &middot; Vertraulich</p>
</div> </div>
</body> </body>
</html> </html>