Преглед изворни кода

Füge Dashboard-Funktionalität hinzu und entferne veraltete SQL-Abfragen

- Neue Dashboard-Seite mit KPIs und Diagrammen implementiert.
- Dashboard-Filter hinzugefügt, um die Anzeige von Rechnungen zu steuern.
- Dashboard-Status und Fehlerbehandlung verbessert.
- Alte SQL-Abfragen für Betriebe, Altersstaffeln und Verursacher entfernt.
- Tests für das Dashboard und die Filterlogik hinzugefügt.
- CSS- und JS-Dateien für das Dashboard eingebunden.
gc-server3 пре 1 недеља
родитељ
комит
0158a876f8

+ 43 - 0
README.md

@@ -132,6 +132,49 @@ nicht mehr akzeptiert. Standardmäßig ist `DEBUG_AUTH=false`; außerhalb der Um
 `development`, `debug` und `test` verweigert die Anwendung den Start mit aktivem Debug-Login.
 `development`, `debug` und `test` verweigert die Anwendung den Start mit aktivem Debug-Login.
 `COOKIE_SECURE=false` ist nur für lokale HTTP-Tests gedacht; bei HTTPS auf `true` setzen.
 `COOKIE_SECURE=false` ist nur für lokale HTTP-Tests gedacht; bei HTTPS auf `true` setzen.
 
 
+## Forderungsdashboard
+
+Das Dashboard verwendet dieselbe berechtigte Belegabfrage wie die Liste. Anzahl und
+`Beleg_offen` enthalten auch Gutschriften, negative Beträge und Nullbeträge; jeder Beleg
+wird anhand von Mandant und Belegnummer einmal gezählt. Die Berechtigungsprüfung erfolgt
+vor der Aggregation über Benutzer, Hauptbetrieb, Standort und Abteilung. Beträge werden
+mit Dezimalarithmetik summiert. An den Browser gehen nur berechtigte Aggregate.
+
+Die Rubriken Altersstaffel, Standort und Verursacher zeigen horizontale Balken mit
+Umschalter zwischen Betrag und Anzahl. Die Stapelung lässt sich zwischen Abteilung und
+Wiedervorlage wählen (`stack=abteilung|wiedervorlage`). Negative Beträge liegen links der
+Null. Verursacherüberschreibungen und die effektive Wiedervorlage werden wie in der Liste
+berücksichtigt. Für die Staffelung gilt zuerst `Forderungen_Details.Wiedervorlage`,
+ersatzweise `Beleg_Faellig_Datum`. Maßgeblich ist der Berliner Kalendertag:
+„Überfällig“ liegt vor heute, „In 3 Tagen fällig“ umfasst heute bis heute + 3 Tage,
+„Noch nicht fällig“ heute + 4 bis einschließlich heute + 14 Tage und „zurückgestellt“
+alles danach. Fehlen beide Datumsfelder, bleibt der Beleg separat als „Ohne Datum“ sichtbar.
+Die verwendeten Datumsgrenzen stehen konkret im Listenlink und wandern nicht nachträglich.
+Alte Dashboardadressen mit `page=Wiedervorlage` wechseln zum Standort mit
+Wiedervorlagestapelung; bestehende Listenlinks bleiben gültig.
+
+Mouseover zeigt beide Kennzahlen für Segment und Gruppe; Antippen fixiert die Anzeige,
+Escape schließt sie. „Liste öffnen“ startet einen neuen Tab. Die aufklappbare Datentabelle
+bietet dieselben Werte und Links per Tastatur, auch für Beträge von null Euro.
+Suche und Diagrammseiten mit jeweils 20 Gruppen ändern nicht die Gesamtkennzahlen.
+`page=Altersstaffel|Standort|Verursacher`, `metric=betrag|anzahl` und `stack` bestimmen
+die initiale Ansicht; Standard ist „Nach Standort“. Chart.js 4.6.1 wird erst beim Dashboardaufruf geladen; bei einem
+Ladefehler bleibt die Datentabelle verfügbar. Es gibt keine neuen Schreibaktionen.
+
+Die standardmäßig ausgeschaltete Checkbox „Nur Rechnungen“ (`only_invoices=true`) begrenzt
+Kennzahlen, Diagramme und Datentabelle exakt auf `Rechnung_Gutschrift = 'Rechnung'`.
+Rechnungen mit negativem oder null offenem Betrag bleiben enthalten. Die Auswahl bleibt
+bei Rubrik- und Kennzahlwechseln sowie beim Neuladen erhalten und gilt auch für sämtliche
+verlinkten Listen; deren Dashboardfilter trägt dafür das boolesche Feld `only_invoices`.
+
+Der zusätzliche Listenparameter `dashboard_segment` enthält eine URL-kodierte JSON-Auswahl
+mit `view`, `key` und optional `department` beziehungsweise `follow_up` mit konkreten
+Datumsgrenzen. Gruppen werden exakt mit gebundenen Parametern
+gefiltert; fehlende Werte stehen ausdrücklich als `null`. Ungültige Auswahlen werden mit
+HTTP 400 abgelehnt. Normale Listenfilter wirken zusätzlich und behalten ihre bisherige
+Teiltextsuche. Der Dashboardfilter zählt als ein aktiver Filter, ist separat entfernbar
+und bleibt bei Nachladen, Detailnavigation, Rückkehr, Bearbeitungsmodals und Login erhalten.
+
 ## Darstellung der Forderungsliste
 ## Darstellung der Forderungsliste
 
 
 Die Filter bleiben vollständig sichtbar und sind in Organisation, Kunde und Beleg sowie
 Die Filter bleiben vollständig sichtbar und sind in Organisation, Kunde und Beleg sowie

+ 144 - 0
app/dashboard_segment.py

@@ -0,0 +1,144 @@
+"""Validated exact dashboard selections; independent of navigation and authentication."""
+from datetime import date, timedelta
+import json
+
+VIEWS = ('Altersstaffel', 'Standort', 'Verursacher', 'Wiedervorlage')
+DASHBOARD_VIEWS = VIEWS[:-1]  # Old Wiedervorlage list selections remain valid.
+
+
+def _date_key(key):
+    if key is None:
+        return
+    if not isinstance(key, dict) or set(key) != {'from', 'to'} or not any(key.values()):
+        raise ValueError('Ungültiger Datumsfilter')
+    for value in key.values():
+        if value is not None and (not isinstance(value, str) or date.fromisoformat(value).isoformat() != value):
+            raise ValueError('Ungültiges Datum')
+    if key['from'] and key['to'] and key['from'] > key['to']:
+        raise ValueError('Ungültiger Zeitraum')
+    if key['to'] == '9999-12-31':
+        raise ValueError('Zeitraum außerhalb des unterstützten Bereichs')
+
+
+def _value(value):
+    if value is not None and (not isinstance(value, str) or len(value) > 500
+                              or any(ord(char) < 32 or ord(char) == 127 or 0xd800 <= ord(char) <= 0xdfff for char in value)):
+        raise ValueError('Ungültiger Gruppenschlüssel')
+
+
+def parse_segment(raw):
+    if not raw:
+        return None
+    if not isinstance(raw, str) or len(raw) > 4096:
+        raise ValueError('Ungültiger Dashboardfilter')
+    try:
+        segment = json.loads(raw)
+    except (ValueError, TypeError, RecursionError) as error:
+        raise ValueError('Ungültiger Dashboardfilter') from error
+    if (not isinstance(segment, dict) or set(segment) - {'view', 'key', 'department', 'only_invoices', 'follow_up'}
+            or not {'view', 'key'} <= set(segment) or segment['view'] not in VIEWS):
+        raise ValueError('Ungültiger Dashboardfilter')
+    key, view = segment['key'], segment['view']
+    if 'only_invoices' in segment and type(segment['only_invoices']) is not bool:
+        raise ValueError('Ungültiger Rechnungsfilter')
+    if 'department' in segment:
+        _value(segment['department'])
+    if 'follow_up' in segment:
+        _date_key(segment['follow_up'])
+    if view == 'Altersstaffel':
+        _value(key)
+    elif view == 'Standort':
+        if not isinstance(key, dict) or set(key) != {'client', 'site'} or not key['client']:
+            raise ValueError('Ungültiger Standortfilter')
+        _value(key['client']); _value(key['site'])
+    elif view == 'Verursacher':
+        if not isinstance(key, dict) or set(key) not in ({'id'}, {'text'}):
+            raise ValueError('Ungültiger Verursacherfilter')
+        _value(next(iter(key.values())))
+        if 'id' in key and not key['id']:
+            raise ValueError('Benutzer-ID fehlt')
+    else:
+        _date_key(key)
+    return segment
+
+
+def pack_segment(segment):
+    return json.dumps(segment, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
+
+
+def segment_sql(segment):
+    clauses, values = [], {}
+    def dates(key, prefix):
+        if key is None:
+            clauses.append('F.[Wiedervorlage] IS NULL')
+            return
+        if key['from']:
+            clauses.append(f'F.[Wiedervorlage] >= :{prefix}_from')
+            values[prefix + '_from'] = key['from']
+        if key['to']:
+            clauses.append(f'F.[Wiedervorlage] < :{prefix}_until')
+            values[prefix + '_until'] = (date.fromisoformat(key['to']) + timedelta(days=1)).isoformat()
+    def exact(column, value, parameter):
+        if value is None or value == '':
+            clauses.append(f"NULLIF(F.[{column}], '') IS NULL")
+        else:
+            clauses.append(f'F.[{column}] = :{parameter}')
+            values[parameter] = value
+    key, view = segment['key'], segment['view']
+    if view == 'Altersstaffel':
+        exact('Staffel_Text', key, 'ds_age')
+    elif view == 'Standort':
+        exact('Client_DB', key['client'], 'ds_client')
+        exact('Standort_ID', key['site'], 'ds_site')
+    elif view == 'Verursacher':
+        if 'id' in key:
+            exact('Verursacher_Benutzer_ID', key['id'], 'ds_person')
+        else:
+            exact('Verursacher_Benutzer_ID', None, 'ds_person')
+            exact('Verursacher_Text', key['text'], 'ds_person_text')
+    else:
+        dates(key, 'ds')
+    if 'follow_up' in segment:
+        dates(segment['follow_up'], 'ds_follow')
+    if 'department' in segment:
+        exact('Abteilung', segment['department'], 'ds_department')
+    if segment.get('only_invoices'):
+        exact('Rechnung_Gutschrift', 'Rechnung', 'ds_invoice_type')
+    return clauses, values
+
+
+def segment_label(segment, rows=()):
+    key, view = segment['key'], segment['view']
+    sample = rows[0] if rows else {}
+    if view == 'Altersstaffel':
+        label = key or 'Ohne Altersstaffel'
+    elif view == 'Standort':
+        label = f"{sample.get('Standort_Name') or 'Standort'} · {key['site'] or 'ohne ID'} ({sample.get('Hauptbetrieb_Name') or 'Mandant ' + key['client']})"
+    elif view == 'Verursacher':
+        label = sample.get('Verursacher_Text') or key.get('id') or key.get('text') or 'Ohne Verursacher'
+        if key.get('id') and label != key['id']:
+            label += ' (' + key['id'] + ')'
+    elif key is None:
+        label = 'Ohne Datum'
+    else:
+        fmt = lambda value: date.fromisoformat(value).strftime('%d.%m.%Y')
+        if key['from'] == key['to']:
+            label = 'Am ' + fmt(key['from'])
+        else:
+            label = ' / '.join(filter(None, ['Ab ' + fmt(key['from']) if key['from'] else '',
+                                             'Bis ' + fmt(key['to']) if key['to'] else '']))
+    text = f'{view}: {label}'
+    if 'department' in segment:
+        text += ' · Abteilung: ' + (segment['department'] or 'Ohne Abteilung')
+    if segment.get('only_invoices'):
+        text += ' · Nur Rechnungen'
+    if 'follow_up' in segment:
+        period = segment['follow_up']
+        if period is None:
+            text += ' · Wiedervorlage: Ohne Datum'
+        else:
+            fmt = lambda value: date.fromisoformat(value).strftime('%d.%m.%Y')
+            text += ' · Wiedervorlage: ' + ' / '.join(filter(None, [
+                'Ab ' + fmt(period['from']) if period['from'] else '',
+                'Bis ' + fmt(period['to']) if period['to'] else '']))
+    return text

+ 119 - 0
app/forderung_dashboard.py

@@ -0,0 +1,119 @@
+"""Aggregate authorized invoices by rubric and selectable stack dimension."""
+from datetime import date, datetime, timedelta
+from decimal import Decimal
+from collections import Counter
+import hashlib
+from urllib.parse import urlencode
+from zoneinfo import ZoneInfo
+
+from .dashboard_segment import pack_segment, DASHBOARD_VIEWS
+from .forderung_data import read_invoices
+from .jinja_templates import number_format
+
+BERLIN = ZoneInfo('Europe/Berlin')
+STACKS = ('abteilung', 'wiedervorlage')
+
+
+def _key(value):
+    return None if value is None or str(value) == '' else str(value)
+
+
+def _date(value):
+    return value.date() if isinstance(value, datetime) else value if isinstance(value, date) else date.fromisoformat(str(value)[:10]) if value else None
+
+
+def color(department):
+    hue = int.from_bytes(hashlib.sha256((department or '').encode()).digest()[:2], 'big') % 360
+    return f'hsl({hue}, 23%, 70%)'
+
+
+def follow_up_bands(today):
+    return [
+        dict(id='overdue', label='Überfällig', color='#d4aaaa', key={'from': None, 'to': (today-timedelta(days=1)).isoformat()}),
+        dict(id='soon', label='In 3 Tagen fällig', color='#e0cd9c', key={'from': today.isoformat(), 'to': (today+timedelta(days=3)).isoformat()}),
+        dict(id='not_due', label='Noch nicht fällig', color='#b4c5b5', key={'from': (today+timedelta(days=4)).isoformat(), 'to': (today+timedelta(days=14)).isoformat()}),
+        dict(id='deferred', label='zurückgestellt', color='#b2bccd', key={'from': (today+timedelta(days=15)).isoformat(), 'to': None}),
+        dict(id='undated', label='Ohne Datum', color='#c5c5c5', key=None),
+    ]
+
+
+def _url(view, key, segment=..., only_invoices=False, stack='abteilung'):
+    selection = {'view': view, 'key': key}
+    if segment is not ...:
+        selection['department' if stack == 'abteilung' else 'follow_up'] = segment
+    if only_invoices:
+        selection['only_invoices'] = True
+    return '/app/forderungen/liste?' + urlencode({'dashboard_segment': pack_segment(selection)})
+
+
+def build_dashboard(db, username, now=None, only_invoices=False, stack='abteilung'):
+    if stack not in STACKS:
+        raise ValueError('Ungültige Stapelung')
+    now = now or datetime.now(BERLIN)
+    today = now.astimezone(BERLIN).date()
+    if only_invoices:
+        columns, source = read_invoices(db, username, ['F.[Rechnung_Gutschrift] = :dashboard_invoice_type'], {'dashboard_invoice_type': 'Rechnung'})
+    else:
+        columns, source = read_invoices(db, username)
+    groups = {view: {} for view in DASHBOARD_VIEWS}
+    total, departments, observed = Decimal(0), set(), set()
+    bands = follow_up_bands(today)
+    for values in source:
+        row = dict(zip(columns, values))
+        department = _key(row.get('Abteilung'))
+        departments.add(department)
+        if stack == 'abteilung':
+            segment_id = department
+        else:
+            when = _date(row.get('Wiedervorlage') or row.get('Beleg_Faellig_Datum'))
+            bucket = 4 if when is None else 0 if when < today else 1 if when <= today+timedelta(days=3) else 2 if when <= today+timedelta(days=14) else 3
+            segment_id = bands[bucket]['id']
+        observed.add(segment_id)
+        amount = Decimal(str(row['Beleg_offen'] or 0))
+        total += amount
+        age = _key(row.get('Staffel_Text'))
+        site = {'client': str(row['Client_DB']), 'site': _key(row.get('Standort_ID'))}
+        person_id = _key(row.get('Verursacher_Benutzer_ID'))
+        person = {'id': person_id} if person_id else {'text': _key(row.get('Verursacher_Text'))}
+        definitions = [
+            ('Altersstaffel', age, age or 'Ohne Altersstaffel', int(row['Beleg_Tage_offen']) if age and row.get('Beleg_Tage_offen') is not None else 10**12),
+            ('Standort', site, f"{row.get('Standort_Name') or site['site'] or 'Ohne Standort'} ({row.get('Hauptbetrieb_Name') or 'Mandant ' + site['client']})", 0),
+            ('Verursacher', person, row.get('Verursacher_Text') or person_id or 'Ohne Verursacher', 0),
+        ]
+        for view, key, label, order in definitions:
+            identity = pack_segment(key)
+            group = groups[view].setdefault(identity, dict(key=key, label=label, order=order, count=0, amount=Decimal(0), segments={}))
+            group['order'] = min(group['order'], order)
+            group['count'] += 1
+            group['amount'] += amount
+            segment = group['segments'].setdefault(segment_id, dict(count=0, amount=Decimal(0)))
+            segment['count'] += 1
+            segment['amount'] += amount
+    items = [dict(id=dep, label=dep or 'Ohne Abteilung', color=color(dep), key=dep)
+             for dep in sorted(departments, key=lambda value: (value is None, value or ''))] if stack == 'abteilung' else [band for band in bands if band['id'] != 'undated' or 'undated' in observed]
+
+    def serialized(amount):
+        return str(amount.quantize(Decimal('0.01')))
+    output = {}
+    for view, mapping in groups.items():
+        result = []
+        labels = Counter(group['label'].casefold() for group in mapping.values())
+        for group in mapping.values():
+            label = group['label']
+            if labels[label.casefold()] > 1:
+                if view == 'Verursacher':
+                    label += ' (' + (group['key'].get('id') or 'ohne Benutzer-ID') + ')'
+                elif view == 'Standort':
+                    label += ' · Standort-ID ' + (group['key']['site'] or 'ohne ID')
+            segments = [dict(id=item['id'], department=item['id'] if stack == 'abteilung' else None,
+                             label=item['label'], count=group['segments'][item['id']]['count'],
+                             amount=serialized(group['segments'][item['id']]['amount']), color=item['color'],
+                             url=_url(view, group['key'], item['key'], only_invoices, stack))
+                        for item in items if item['id'] in group['segments']]
+            result.append(dict(label=label, order=group['order'], count=group['count'], amount=serialized(group['amount']),
+                               segments=segments, url=_url(view, group['key'], only_invoices=only_invoices)))
+        output[view] = result
+    return dict(count=len(source), amount=serialized(total), amount_display=number_format(total), views=output,
+                only_invoices=only_invoices, stack=stack, stack_label='Abteilung' if stack == 'abteilung' else 'Wiedervorlage',
+                stack_items=[{name: value for name, value in item.items() if name != 'key'} for item in items],
+                generated=now.astimezone(BERLIN).strftime('%d.%m.%Y %H:%M'))

+ 19 - 0
app/forderung_data.py

@@ -0,0 +1,19 @@
+"""One authorized projection for list results and dashboard aggregates."""
+from sqlalchemy import text
+from .jinja_templates import templates
+
+
+def read_invoices(db, username, clauses=(), values=None):
+    query = templates.env.get_template('forderungen/liste/queries/forderungen_liste.sql').render()
+    if clauses:
+        query = query.replace('AND 1 = 1', 'AND 1 = 1 AND ' + ' AND '.join(clauses))
+    result = db.execute(text(query), dict(values or {}, auth_username=username))
+    columns = list(result.keys())
+    tenant, number = columns.index('Client_DB'), columns.index('Beleg_Nr')
+    seen, rows = set(), []
+    for row in result.fetchall():
+        identity = (str(row[tenant]), str(row[number]))
+        if identity not in seen:
+            rows.append(row)
+            seen.add(identity)
+    return columns, rows

+ 6 - 0
app/navigation.py

@@ -3,6 +3,7 @@ import re
 from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
 from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
 
 
 from .query_filter import import_filter_config
 from .query_filter import import_filter_config
+from .dashboard_segment import parse_segment, pack_segment
 
 
 LIST_PATH = '/app/forderungen/liste'
 LIST_PATH = '/app/forderungen/liste'
 DETAIL_PREFIX = '/app/forderungen/details/'
 DETAIL_PREFIX = '/app/forderungen/details/'
@@ -39,6 +40,11 @@ def list_url(filters=None, focus: str | None = None) -> str:
     filters = filters or {}
     filters = filters or {}
     params = {f.name: filters[f.name] for f in import_filter_config('forderungen')
     params = {f.name: filters[f.name] for f in import_filter_config('forderungen')
               if f.name in filters and filters[f.name] not in ('', None)}
               if f.name in filters and filters[f.name] not in ('', None)}
+    if filters.get('dashboard_segment'):
+        try:
+            params['dashboard_segment'] = pack_segment(parse_segment(filters['dashboard_segment']))
+        except ValueError:
+            pass  # Untrusted return/login URLs must never carry malformed selections.
     if valid_focus(focus):
     if valid_focus(focus):
         params['focus'] = focus
         params['focus'] = focus
     return LIST_PATH + ('?' + urlencode(params) if params else '')
     return LIST_PATH + ('?' + urlencode(params) if params else '')

+ 39 - 14
app/routes.py

@@ -3,6 +3,7 @@ import logging
 import re
 import re
 import secrets
 import secrets
 from datetime import datetime, timedelta
 from datetime import datetime, timedelta
+from decimal import Decimal
 from pathlib import Path
 from pathlib import Path
 from typing import Annotated
 from typing import Annotated
 from urllib.parse import unquote, urlencode
 from urllib.parse import unquote, urlencode
@@ -27,6 +28,9 @@ from .comment_submission import submit_comment
 from .db import get_session
 from .db import get_session
 from .docuware_search import get_invoice_pdf_by_document_no
 from .docuware_search import get_invoice_pdf_by_document_no
 from .forderung_history import HistoryScope, history_context, section_context
 from .forderung_history import HistoryScope, history_context, section_context
+from .forderung_data import read_invoices
+from .forderung_dashboard import build_dashboard, STACKS
+from .dashboard_segment import DASHBOARD_VIEWS, parse_segment, pack_segment, segment_sql, segment_label
 from .forderung_summary import get_forderung_summary
 from .forderung_summary import get_forderung_summary
 from .jinja_templates import templates
 from .jinja_templates import templates
 from .ldap import DirectoryUnavailable, DirectoryUser, ldap_authenticate, normalize_username
 from .ldap import DirectoryUnavailable, DirectoryUser, ldap_authenticate, normalize_username
@@ -168,6 +172,12 @@ def forderungen_liste(request: Request, db: Session = Depends(get_session), limi
         for f in forderung_filter_config
         for f in forderung_filter_config
         if request.query_params.get(f.name, "") != ""
         if request.query_params.get(f.name, "") != ""
     }
     }
+    try:
+        selection = parse_segment(request.query_params.get('dashboard_segment'))
+    except ValueError as error:
+        raise HTTPException(400, 'Ungültiger Dashboardfilter') from error
+    if selection:
+        params['dashboard_segment'] = pack_segment(selection)
     if not valid_focus(focus):
     if not valid_focus(focus):
         focus = None
         focus = None
     if page == 0:
     if page == 0:
@@ -190,14 +200,11 @@ def forderungen_liste(request: Request, db: Session = Depends(get_session), limi
             query_clauses.append(f"F.[{f.key_column}] LIKE :{parameter}")
             query_clauses.append(f"F.[{f.key_column}] LIKE :{parameter}")
             query_values[parameter] = f"%{f.current_value}%"
             query_values[parameter] = f"%{f.current_value}%"
 
 
-    query = templates.TemplateResponse(request, "forderungen/liste/queries/forderungen_liste.sql", {}).body.decode(
-        "utf-8"
-    )
-    if query_clauses:
-        query = query.replace("AND 1 = 1", "AND 1 = 1 AND " + " AND ".join(query_clauses))
-    q_res = db.execute(text(query), query_values)
-    col_names = list(q_res.keys())
-    q = q_res.fetchall()
+    if selection:
+        clauses, values = segment_sql(selection)
+        query_clauses.extend(clauses)
+        query_values.update(values)
+    col_names, q = read_invoices(db, principal.username, query_clauses, query_values)
 
 
     filters = {}
     filters = {}
     for f in forderung_filter_config:
     for f in forderung_filter_config:
@@ -212,13 +219,13 @@ def forderungen_liste(request: Request, db: Session = Depends(get_session), limi
             for row in q
             for row in q
         ]
         ]
         if filter_temp:
         if filter_temp:
-            filters[f.name] = {item[1]: item[2] for item in sorted(list(set(filter_temp))) if item[1]}
+            filters[f.name] = {item[1]: item[2] for item in sorted(set(filter_temp), key=lambda item: tuple(str(value or '') for value in item)) if item[1]}
         else:
         else:
             filters[f.name] = {}
             filters[f.name] = {}
         f.options = filters[f.name]
         f.options = filters[f.name]
 
 
     summary = {
     summary = {
-        "Beleg_offen": sum([r[col_names.index("Beleg_offen")] for r in q]),
+        "Beleg_offen": sum((Decimal(str(r[col_names.index("Beleg_offen")] or 0)) for r in q), Decimal(0)),
         "Anzahl": len(q),
         "Anzahl": len(q),
     }
     }
 
 
@@ -262,6 +269,9 @@ def forderungen_liste(request: Request, db: Session = Depends(get_session), limi
             "focus": focus if page == 0 else None,
             "focus": focus if page == 0 else None,
             "focus_missing": bool(page == 0 and focus and not focus_found),
             "focus_missing": bool(page == 0 and focus and not focus_found),
             "filter_config": forderung_filter_config,
             "filter_config": forderung_filter_config,
+            "dashboard_segment": params.get('dashboard_segment'),
+            "dashboard_segment_label": segment_label(selection, [dict(zip(col_names, q[0]))] if q else []) if selection else None,
+            "without_dashboard_segment": list_url({key: value for key, value in params.items() if key != 'dashboard_segment'}),
         },
         },
         headers={"Cache-Control": "no-store"},
         headers={"Cache-Control": "no-store"},
     )
     )
@@ -408,10 +418,25 @@ def convert_primary_key(area: str, primary_key: str) -> dict[str, str]:
 
 
 
 
 @router.get("/app/forderungen/dashboard", response_class=HTMLResponse)
 @router.get("/app/forderungen/dashboard", response_class=HTMLResponse)
-def forderungen_dashboard(request: Request, db: Session = Depends(get_session)):
-    current_principal(request)
-    context = {"summary": False}
-    return templates.TemplateResponse(request, "forderungen/dashboard/dashboard.html", context)
+def forderungen_dashboard(request: Request, db: Session = Depends(get_session), page: str = 'Standort', metric: str = 'betrag', only_invoices: bool = False, stack: str = 'abteilung'):
+    principal = current_principal(request)
+    if page == 'Wiedervorlage' and metric in {'betrag', 'anzahl'} and stack in STACKS:
+        params = dict(page='Standort', metric=metric, stack='wiedervorlage')
+        if only_invoices:
+            params['only_invoices'] = 'true'
+        return RedirectResponse('/app/forderungen/dashboard?' + urlencode(params), status_code=303)
+    if page not in DASHBOARD_VIEWS or metric not in {'betrag', 'anzahl'} or stack not in STACKS:
+        raise HTTPException(422, 'Ungültige Dashboardansicht')
+    context = dict(summary=False, dashboard_view=page, dashboard_metric=metric, dashboard_stack=stack, dashboard_only_invoices=only_invoices, dashboard=None, dashboard_error=None)
+    status_code = 200
+    try:
+        context['dashboard'] = build_dashboard(db, principal.username, only_invoices=only_invoices, stack=stack)
+    except Exception:
+        logger.exception('Forderungsdashboard konnte nicht geladen werden')
+        context['dashboard_error'] = 'Das Dashboard konnte nicht geladen werden. Bitte erneut versuchen.'
+        status_code = 503
+    return templates.TemplateResponse(request, 'forderungen/dashboard/dashboard.html', context,
+                                      status_code=status_code, headers={'Cache-Control': 'no-store'})
 
 
 
 
 # TODO
 # TODO

+ 54 - 0
static/assets/css/dashboard.css

@@ -0,0 +1,54 @@
+/* Authorized Forderungen overview; isolated from list and detail layouts. */
+body:has(.gc-dashboard-page) { max-width: 1840px; }
+.gc-dashboard-page { font-size: 1.0625rem; }
+.gc-dashboard-page h1 { margin: 0; font-size: 1.75rem; }
+.gc-dashboard-heading { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: .5rem; scroll-margin-top: calc(var(--gc-header-height) + 1rem); }
+.gc-dashboard-heading p { margin: 0; color: #52616f; }
+.gc-dashboard-scope label { display: inline-flex; align-items: center; gap: .5rem; cursor: pointer; }
+.gc-dashboard-scope input[type=checkbox] { width: 1.1rem; height: 1.1rem; accent-color: #72889b; }
+.gc-dashboard-kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
+.gc-dashboard-kpi { padding: 1.25rem; display: flex; flex-direction: column; gap: .3rem; border-top: 4px solid #72889b; }
+.gc-dashboard-kpi strong { font-size: clamp(1.5rem, 2vw, 2rem); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+.gc-dashboard-kpi span { color: #52616f; }
+.gc-dashboard-tabs { display: flex; flex-wrap: wrap; gap: .5rem; padding: 1rem; border-bottom: 1px solid #dce2e7; }
+.gc-dashboard-tabs a, .gc-dashboard-metrics button { padding: .5rem .75rem; border: 1px solid #b1bcc7; border-radius: 6px; color: #354f63; background: #fff; text-decoration: none; }
+.gc-dashboard-tabs a[aria-current], .gc-dashboard-metrics button[aria-pressed=true] { color: #101b24; background: #c3ccd4; border-color: #72889b; font-weight: 600; }
+.gc-dashboard-controls { padding: 1rem; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 1rem; }
+.gc-dashboard-control-label { display: block; margin-bottom: .3rem; }
+.gc-dashboard-metric-options { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem; }
+.gc-dashboard-controls .gc-dashboard-scope label { display: inline-flex; margin: 0; }
+.gc-dashboard-controls label { display: block; margin-bottom: .3rem; }
+.gc-dashboard-controls :is(input, select) { font-size: inherit; width: min(100%, 22rem); }
+.gc-dashboard-metrics { display: flex; flex-wrap: wrap; gap: .5rem; }
+.gc-dashboard-description, .gc-dashboard-status { padding: 0 1rem; margin: .5rem 0; color: #52616f; }
+.gc-dashboard-legend { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; list-style: none; margin: .5rem 0 1rem; padding: 0 1rem; }
+.gc-dashboard-legend li { display: inline-flex; align-items: center; gap: .4rem; overflow-wrap: anywhere; }
+.gc-dashboard-swatch { width: 1rem; height: 1rem; flex-shrink: 0; border: 1px solid #72889b; border-radius: 3px; }
+.gc-dashboard-chart { position: relative; margin: 0 1rem; min-width: 0; }
+.gc-dashboard-chart canvas { max-width: 100%; }
+.gc-dashboard-chart-error, .gc-dashboard-error { padding: 1rem; }
+.gc-dashboard-pagination { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: .75rem; padding: 1rem; }
+.gc-dashboard-page .btn { font-size: inherit; }
+.gc-dashboard-table { border-top: 1px solid #dce2e7; }
+.gc-dashboard-table summary { padding: 1rem; cursor: pointer; font-weight: 600; }
+.gc-dashboard-table-scroll { overflow-x: auto; }
+.gc-dashboard-table .table { margin: 0; min-width: 620px; }
+.gc-dashboard-table :is(th, td) { padding: .7rem 1rem; vertical-align: top; }
+.gc-dashboard-table thead th { background: #edf2f5; color: #182630; }
+.gc-dashboard-table thead { position: static; }
+.gc-dashboard-table th { font-size: inherit; }
+.gc-dashboard-table :is(th, td):nth-child(2), .gc-dashboard-table :is(th, td):nth-child(3) { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
+.gc-dashboard-table .gc-dashboard-group-row th { background: #f4f6f8; font-weight: 700; }
+.gc-dashboard-table td:first-child { padding-left: 2rem; }
+.gc-dashboard-page a { color: #354f63; }
+.gc-dashboard-popover { position: fixed; z-index: 1040; width: min(26rem, calc(100vw - 2rem)); padding: 1rem; background: #fff; border: 1px solid #72889b; border-radius: 9px; box-shadow: 0 4px 20px #18263033; max-height: calc(100dvh - 2rem); overflow-y: auto; }
+.gc-dashboard-popover h3 { font-size: 1.125rem; padding-right: 2rem; overflow-wrap: anywhere; }
+.gc-dashboard-popover p { margin: .5rem 0; }
+.gc-dashboard-popover a { display: inline-block; margin-top: .5rem; padding: .5rem .75rem; border-radius: 6px; border: 1px solid #72889b; background: #edf2f5; color: #354f63; }
+.gc-dashboard-popover button { position: absolute; top: .5rem; right: .5rem; background: #fff; border: 0; font-size: 1.5rem; }
+.gc-dashboard-page :is(button, a, input, summary, [tabindex]):focus-visible { outline: 3px solid #354f63; outline-offset: 3px; }
+@media (max-width: 575.98px) {
+  .gc-dashboard-kpis { grid-template-columns: 1fr; }
+  .gc-dashboard-controls > div { width: 100%; }
+  .gc-dashboard-chart { margin: 0 .25rem; }
+}

+ 164 - 0
static/assets/js/dashboard.js

@@ -0,0 +1,164 @@
+/* Accessible chart, table and exact list drill-downs share the same authorized aggregates. */
+(() => {
+  let current, dispose = () => {};
+  let library;
+  function loadChart() {
+    if (window.Chart) return Promise.resolve();
+    if (!library) library = new Promise((resolve, reject) => {
+      const script = document.createElement('script');
+      script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.6.1/dist/chart.umd.min.js';
+      script.onload = resolve; script.onerror = reject; document.head.append(script);
+    });
+    return library;
+  }
+  function init() {
+    const page = document.querySelector('.gc-dashboard-page');
+    if (page === current) return;
+    dispose(); dispose = () => {}; current = page;
+    const source = page?.querySelector('#dashboard-data');
+    if (!source) return;
+    const data = JSON.parse(source.textContent), report = page.querySelector('.gc-dashboard-report');
+    const canvas = page.querySelector('canvas'), box = page.querySelector('.gc-dashboard-chart');
+    const popup = page.querySelector('#dashboard-popover'), search = page.querySelector('#dashboard-search');
+    const status = page.querySelector('.gc-dashboard-status'), tbody = page.querySelector('#dashboard-table-body');
+    const previous = page.querySelector('[data-dashboard-prev]'), next = page.querySelector('[data-dashboard-next]');
+    const events = new AbortController(), signal = events.signal;
+    const collator = new Intl.Collator('de', {sensitivity: 'base'});
+    const count = value => new Intl.NumberFormat('de-DE').format(value);
+    const money = value => {
+      const [whole, fraction = '00'] = String(value).split('.');
+      return whole.replace(/\B(?=(\d{3})+(?!\d))/g, '.') + ',' + fraction.padEnd(2, '0') + ' €';
+    };
+    const element = (tag, text, className) => {
+      const node = document.createElement(tag); if (text !== undefined) node.textContent = text;
+      if (className) node.className = className; return node;
+    };
+    const listLink = (url, description) => {
+      const link = element('a', 'Liste öffnen'); link.href = url; link.target = '_blank'; link.rel = 'noopener';
+      link.setAttribute('aria-label', `Liste öffnen – ${description} (neuer Tab)`); return link;
+    };
+    let view = report.dataset.dashboardView, metric = report.dataset.dashboardMetric, index = 0;
+    let chart, visible = [], pinned = false, hideTimer, loading = !window.Chart;
+    const listen = (target, type, callback) => target.addEventListener(type, callback, {signal});
+    function closePopup() { clearTimeout(hideTimer); pinned = false; popup.hidden = true; }
+    function scheduleHide() { if (!pinned) { clearTimeout(hideTimer); hideTimer = setTimeout(closePopup, 350); } }
+    function showPopup(group, segment, event, pin = false) {
+      if (!segment || (pinned && !pin)) return;
+      clearTimeout(hideTimer); pinned = pin; popup.replaceChildren();
+      const close = element('button', '×'); close.type = 'button'; close.setAttribute('aria-label', 'Details schließen');
+      close.addEventListener('click', () => { closePopup(); canvas.focus({preventScroll: true}); });
+      popup.append(close, element('h3', group.label), element('p', `${data.stack_label}: ${segment.label}`),
+        element('p', `Anzahl: ${count(segment.count)}`), element('p', `Offener Betrag: ${money(segment.amount)}`),
+        element('hr'), element('p', `Gesamte Gruppe: ${count(group.count)} Belege · ${money(group.amount)}`),
+        listLink(segment.url, `${group.label}, ${segment.label}`));
+      popup.hidden = false;
+      const rect = canvas.getBoundingClientRect(), pointX = rect.left + (event.x || 0), pointY = rect.top + (event.y || 0);
+      popup.style.left = Math.max(16, Math.min(pointX + 12, innerWidth - popup.offsetWidth - 16)) + 'px';
+      popup.style.top = Math.max(16, Math.min(pointY + 12, innerHeight - popup.offsetHeight - 16)) + 'px';
+      if (pin) close.focus({preventScroll: true});
+    }
+    function segmentAt(hit) {
+      const group = visible[hit.index], department = data.stack_items[hit.datasetIndex];
+      return [group, group?.segments.find(segment => segment.id === department.id)];
+    }
+    function drawChart() {
+      if (!visible.length || !window.Chart) {
+        chart?.destroy(); chart = null; canvas.hidden = true; box.style.height = '0px';
+        page.querySelector('.gc-dashboard-chart-error').hidden = !visible.length || !!window.Chart || loading;
+        if (visible.length && !window.Chart && !loading) page.querySelector('.gc-dashboard-table').open = true;
+        return;
+      }
+      page.querySelector('.gc-dashboard-chart-error').hidden = true; canvas.hidden = false;
+      box.style.height = `${Math.max(240, visible.length * 47 + 55)}px`;
+      const datasets = data.stack_items.map(department => ({label: department.label, backgroundColor: department.color,
+        borderColor: '#72889b', borderWidth: 1, borderRadius: 2, maxBarThickness: 30,
+        data: visible.map(group => { const segment = group.segments.find(item => item.id === department.id);
+          return segment ? (metric === 'anzahl' ? segment.count : Number(segment.amount)) : 0; })}));
+      const config = {type: 'bar', data: {labels: visible.map(group => group.label), datasets}, options: {
+        indexAxis: 'y', responsive: true, maintainAspectRatio: false, animation: false,
+        plugins: {legend: {display: false}, tooltip: {enabled: false}},
+        scales: {x: {stacked: true, beginAtZero: true, min: metric === 'anzahl' ? 0 : undefined,
+          title: {display: true, text: metric === 'anzahl' ? 'Anzahl der Belege' : 'Offener Betrag (€)', font: {size: 17}},
+          ticks: {precision: metric === 'anzahl' ? 0 : undefined, font: {size: 15}, callback: value => metric === 'anzahl' ? count(value) : new Intl.NumberFormat('de-DE', {notation: 'compact'}).format(value)}},
+          y: {stacked: true, grid: {display: false}, ticks: {autoSkip: false, font: {size: 16}, callback: function(value) {
+            const label = this.getLabelForValue(value), width = innerWidth < 600 ? 17 : 32;
+            return label.length <= width ? label : [label.slice(0, width), label.length > width*2 ? label.slice(width, width*2-1)+'…' : label.slice(width)];
+          }}}},
+        onHover: (event, hits) => { canvas.style.cursor = hits.length ? 'pointer' : 'default';
+          if (hits.length) showPopup(...segmentAt(hits[0]), event); else scheduleHide(); },
+        onClick: (event, hits) => { if (hits.length) showPopup(...segmentAt(hits[0]), event, true); },
+      }};
+      if (chart) { chart.data = config.data; chart.options = config.options; chart.update(); }
+      else chart = new Chart(canvas, config);
+    }
+    function renderTable() {
+      tbody.replaceChildren();
+      for (const group of visible) {
+        const total = element('tr', undefined, 'gc-dashboard-group-row'), heading = element('th', group.label);
+        heading.scope = 'row'; const linkCell = element('td'); linkCell.append(listLink(group.url, group.label));
+        total.append(heading, element('td', count(group.count)), element('td', money(group.amount)), linkCell); tbody.append(total);
+        for (const segment of group.segments) {
+          const row = element('tr'), link = element('td'); link.append(listLink(segment.url, `${group.label}, ${segment.label}`));
+          row.append(element('td', segment.label), element('td', count(segment.count)), element('td', money(segment.amount)), link); tbody.append(row);
+        }
+      }
+    }
+    function render() {
+      closePopup();
+      let groups = data.views[view].filter(group => group.label.toLocaleLowerCase('de').includes(search.value.trim().toLocaleLowerCase('de')));
+      groups = groups.slice().sort((a, b) => {
+        const difference = view === 'Altersstaffel' ? a.order-b.order : metric === 'anzahl' ? b.count-a.count : Number(b.amount)-Number(a.amount);
+        return difference || collator.compare(a.label, b.label) || a.url.localeCompare(b.url);
+      });
+      const pages = Math.max(1, Math.ceil(groups.length / 20)); index = Math.min(index, pages-1);
+      visible = groups.slice(index*20, (index+1)*20);
+      status.textContent = groups.length ? `${index*20+1}–${Math.min((index+1)*20, groups.length)} von ${count(groups.length)} Gruppen` : data.count ? 'Keine Gruppen für die Suche gefunden.' : 'Keine zugänglichen Forderungen vorhanden.';
+      page.querySelector('[data-dashboard-pages]').textContent = `Seite ${index+1} von ${pages}`;
+      previous.disabled = index === 0; next.disabled = index >= pages-1;
+      report.querySelectorAll('a[data-dashboard-view]').forEach(link => {
+        if (link.dataset.dashboardView === view) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current');
+        link.href = `?page=${encodeURIComponent(link.dataset.dashboardView)}&metric=${metric}&stack=${data.stack}${data.only_invoices ? '&only_invoices=true' : ''}`;
+      });
+      report.querySelectorAll('button[data-dashboard-metric]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.dashboardMetric === metric)));
+      page.querySelector('#dashboard-scope [name=page]').value = view;
+      page.querySelector('#dashboard-scope [name=metric]').value = metric;
+      canvas.setAttribute('aria-label', `Forderungen nach ${view}, gestapelt nach ${data.stack_label}, ${metric === 'anzahl' ? 'Anzahl' : 'offener Betrag'}; vollständige Werte in der Datentabelle`);
+      renderTable(); drawChart();
+    }
+    function updateUrl() { const url = new URL(location.href); url.searchParams.set('page', view); url.searchParams.set('metric', metric); url.searchParams.set('stack', data.stack); history.replaceState(history.state, '', url); }
+    for (const link of report.querySelectorAll('a[data-dashboard-view]')) listen(link, 'click', event => {
+      if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
+      event.preventDefault(); event.stopPropagation(); view = link.dataset.dashboardView; search.value = ''; index = 0; updateUrl(); render();
+    });
+    for (const button of report.querySelectorAll('button[data-dashboard-metric]')) listen(button, 'click', () => {
+      metric = button.dataset.dashboardMetric; index = 0; updateUrl(); render();
+    });
+    listen(search, 'input', () => { index = 0; render(); });
+    listen(previous, 'click', () => { index--; render(); }); listen(next, 'click', () => { index++; render(); });
+    listen(canvas, 'mouseleave', scheduleHide); listen(popup, 'mouseenter', () => clearTimeout(hideTimer));
+    listen(popup, 'mouseleave', scheduleHide); listen(popup, 'focusin', () => clearTimeout(hideTimer));
+    listen(document, 'keydown', event => { if (event.key === 'Escape' && !popup.hidden) { const focused = popup.contains(document.activeElement); closePopup(); if (focused) canvas.focus({preventScroll: true}); } });
+    listen(document, 'pointerdown', event => { if (!popup.contains(event.target) && event.target !== canvas) closePopup(); });
+    listen(window, 'resize', closePopup); listen(window, 'scroll', closePopup);
+    canvas.tabIndex = 0;
+    const legend = page.querySelector('.gc-dashboard-legend');
+    for (const department of data.stack_items) { const item = element('li'), swatch = element('span', '', 'gc-dashboard-swatch'); swatch.style.background = department.color; swatch.setAttribute('aria-hidden', 'true'); item.append(swatch, document.createTextNode(department.label)); legend.append(item); }
+    dispose = () => { events.abort(); clearTimeout(hideTimer); chart?.destroy(); popup.hidden = true; };
+    render();
+    if (loading && data.count) loadChart().then(() => { loading = false; if (current === page) drawChart(); })
+      .catch(() => { loading = false; if (current === page) drawChart(); });
+  }
+  document.addEventListener('DOMContentLoaded', init);
+  document.addEventListener('change', event => {
+    if (event.target.matches('#dashboard-only-invoices, #dashboard-stack')) {
+      const form = document.getElementById('dashboard-scope');
+      if (event.target.id === 'dashboard-stack') form.querySelector('[name=stack]').value = event.target.value;
+      form.requestSubmit();
+    }
+  });
+  document.addEventListener('htmx:load', init);
+  document.addEventListener('htmx:historyRestore', init);
+  document.addEventListener('htmx:beforeCleanupElement', event => {
+    if (current && (event.detail.elt === current || event.detail.elt.contains?.(current))) { dispose(); dispose = () => {}; current = undefined; }
+  });
+})();

+ 2 - 0
templates/base/base.html

@@ -28,6 +28,8 @@
 
 
   <link href="/static/assets/css/details.css" rel="stylesheet">
   <link href="/static/assets/css/details.css" rel="stylesheet">
   <link href="/static/assets/css/list.css" rel="stylesheet">
   <link href="/static/assets/css/list.css" rel="stylesheet">
+  <link href="/static/assets/css/dashboard.css" rel="stylesheet">
+  <script src="/static/assets/js/dashboard.js" defer></script>
   <script src="/static/assets/js/attachments.js" defer></script>
   <script src="/static/assets/js/attachments.js" defer></script>
   <script src="/static/assets/js/navigation.js" defer></script>
   <script src="/static/assets/js/navigation.js" defer></script>
   <script src="/static/assets/js/list.js" defer></script>
   <script src="/static/assets/js/list.js" defer></script>

+ 5 - 0
templates/base/liste.html

@@ -4,6 +4,11 @@
 {% block content %}
 {% block content %}
 <div id="gc-list-state" data-list-url="{{ list_url }}" data-focus="{{ focus or '' }}" hidden></div>
 <div id="gc-list-state" data-list-url="{{ list_url }}" data-focus="{{ focus or '' }}" hidden></div>
 <h1>Forderungen</h1>
 <h1>Forderungen</h1>
+{% if dashboard_segment %}
+<div class="gc-return-status" role="status"><strong>Dashboardfilter:</strong> {{ dashboard_segment_label }}
+  <a class="ms-2" href="{{ without_dashboard_segment }}">Dashboardfilter entfernen</a>
+</div>
+{% endif %}
 {% if focus_missing %}
 {% if focus_missing %}
 <p class="gc-return-status" role="status">Die betreffende Rechnung ist in der aktualisierten Ergebnisliste nicht mehr enthalten. Ihre Filter wurden beibehalten.</p>
 <p class="gc-return-status" role="status">Die betreffende Rechnung ist in der aktualisierten Ergebnisliste nicht mehr enthalten. Ihre Filter wurden beibehalten.</p>
 {% endif %}
 {% endif %}

+ 1 - 0
templates/base/liste_filter.html

@@ -25,6 +25,7 @@
 {% set beleg = ['RechnungGutschrift', 'Fahrzeug', 'Kunde', 'Rechnungsnummer', 'RechnungsdatumVon', 'RechnungsdatumBis'] %}
 {% set beleg = ['RechnungGutschrift', 'Fahrzeug', 'Kunde', 'Rechnungsnummer', 'RechnungsdatumVon', 'RechnungsdatumBis'] %}
 <form class="gc-list-filters" id="liste-filter" hx-get="/app/forderungen/liste"
 <form class="gc-list-filters" id="liste-filter" hx-get="/app/forderungen/liste"
       hx-params="*" hx-trigger="change" hx-push-url="true" hx-swap="innerHTML" hx-target="body">
       hx-params="*" hx-trigger="change" hx-push-url="true" hx-swap="innerHTML" hx-target="body">
+  {% if dashboard_segment %}<input type="hidden" name="dashboard_segment" value="{{ dashboard_segment }}">{% endif %}
   {% for group in ['Organisation', 'Kunde und Beleg', 'Bearbeitung und Wiedervorlage'] %}
   {% for group in ['Organisation', 'Kunde und Beleg', 'Bearbeitung und Wiedervorlage'] %}
   <fieldset class="gc-filter-group">
   <fieldset class="gc-filter-group">
     <legend>{{ group }}</legend>
     <legend>{{ group }}</legend>

+ 65 - 121
templates/forderungen/dashboard/dashboard.html

@@ -1,124 +1,68 @@
-{% extends "base/base.html" %}
+{% extends 'base/base.html' %}
+{% block title %}Forderungen | Dashboard{% endblock %}
+{% block page_class %}gc-dashboard-page{% endblock %}
 {% block content %}
 {% block content %}
+<div class="gc-dashboard-heading"><h1>Forderungsdashboard</h1>
+{% if dashboard %}<p>Stand: {{ dashboard.generated }} · Europe/Berlin</p>{% endif %}</div>
 
 
-<br>
-<br>
-
-<h3>Forderungen</h3>
-
-<br>
-
-<ul class="nav nav-pills">
-  <li class="nav-item">
-    <a class="nav-link" aria-current="page" href="?page=Altersstaffel">nach Altersstaffel</a>
-  </li>
-  <li class="nav-item">
-    <a class="nav-link active" href="?page=Standort">nach Standort</a>
-  </li>
-  <li class="nav-item">
-    <a class="nav-link" href="?page=Verursacher">nach Verursacher</a>
-  </li>
-  <li class="nav-item">
-    <a class="nav-link disabled" aria-disabled="true">nach Wiedervorlage</a>
-  </li>
-</ul>
-
-<br>
-<br>
-
-<div class="col-8">
-  <canvas id="myChart"></canvas>
+{% if dashboard_error %}
+<div class="gc-card gc-dashboard-error" role="alert"><p>{{ dashboard_error }}</p>
+<a href="/app/forderungen/dashboard?page={{ dashboard_view }}&amp;metric={{ dashboard_metric }}&amp;stack={{ dashboard_stack }}{% if dashboard_only_invoices %}&amp;only_invoices=true{% endif %}">Erneut laden</a></div>
+{% else %}
+<div class="gc-dashboard-kpis">
+  <section class="gc-card gc-dashboard-kpi"><span>Anzahl der Belege</span><strong>{{ dashboard.count }}</strong></section>
+  <section class="gc-card gc-dashboard-kpi"><span>Offener Betrag</span><strong>{{ dashboard.amount_display }} €</strong></section>
 </div>
 </div>
-
-<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
-
-<script>
-  let CHART_COLORS = {
-    red: 'rgb(255, 99, 132)',
-    orange: 'rgb(255, 159, 64)',
-    yellow: 'rgb(255, 205, 86)',
-    green: 'rgb(75, 192, 192)',
-    blue: 'rgb(54, 162, 235)',
-    purple: 'rgb(153, 102, 255)',
-    grey: 'rgb(201, 203, 207)'
-  };
-
-  let data = {
-    labels: [
-      'MM',
-      'KRU',
-      'ULM',
-      'LL',
-      'GZ',
-      'AAM',
-    ],
-    datasets: [{
-      label: 'Verkauf',
-      data: [12, 19, 3, 5, 2, 3],
-      borderWidth: 1,
-      backgroundColor: CHART_COLORS.red
-    },
-    {
-      label: 'Service',
-      data: [12, 19, 3, 5, 2, 3],
-      borderWidth: 1,
-      backgroundColor: CHART_COLORS.blue
-    },
-    {
-      label: 'TZ',
-      data: [12, 19, 3, 5, 2, 3],
-      borderWidth: 1,
-      backgroundColor: CHART_COLORS.green
-    }, {
-      label: 'Sonstige',
-      data: [12, 19, 3, 5, 2, 3],
-      borderWidth: 1,
-      backgroundColor: CHART_COLORS.yellow
-    }]
-  };
-
-  let myChart = document.getElementById('myChart')
-
-  let myBarChart = new Chart(myChart, {
-    type: 'bar',
-    data: {
-      labels: data.labels,
-      datasets: data.datasets
-    },
-    options: {
-      plugins: {
-        title: {
-          display: false,
-          text: 'Chart.js Bar Chart - Stacked'
-        },
-      },
-      responsive: true,
-      scales: {
-        x: {
-          stacked: true,
-        },
-        y: {
-          stacked: true
-        }
-      }
-    }
-  });
-
-  myChart.onclick = function (e) {
-    var slice = myBarChart.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
-    if (!slice.length) return; // return if not clicked on slice
-    console.log(slice);
-    var label = myBarChart.data.labels[slice[0].index];
-    var ids = {
-      'MM': '10',
-      'KRU': '30',
-      'ULM': '40',
-      'LL': '50',
-      'GZ': '55',
-      'AAM': '60',
-    };
-    window.open('/app/forderungen/liste/?Standort=' + ids[label]);
-  }
-</script>
-
-{% endblock %}
+<section class="gc-card gc-dashboard-report" hx-history="false" aria-labelledby="dashboard-title" data-dashboard-view="{{ dashboard_view }}" data-dashboard-metric="{{ dashboard_metric }}">
+  <h2 class="gc-section-title" id="dashboard-title"><i class="icon cil-chart" aria-hidden="true"></i> Auswertung der Forderungen</h2>
+  <nav class="gc-dashboard-tabs" aria-label="Auswertung nach">
+    {% for view in ['Standort', 'Altersstaffel', 'Verursacher'] %}
+    <a href="?page={{ view }}&amp;metric={{ dashboard_metric }}&amp;stack={{ dashboard_stack }}{% if dashboard_only_invoices %}&amp;only_invoices=true{% endif %}" data-dashboard-view="{{ view }}"{% if view == dashboard_view %} aria-current="page"{% endif %}>Nach {{ view }}</a>
+    {% endfor %}
+  </nav>
+  <div class="gc-dashboard-controls">
+    <div class="gc-dashboard-metric-settings" role="group" aria-labelledby="dashboard-metric-label">
+      <span class="gc-dashboard-control-label" id="dashboard-metric-label">Kennzahl</span>
+      <div class="gc-dashboard-metric-options">
+        <div class="gc-dashboard-metrics">
+          <button type="button" data-dashboard-metric="betrag" aria-pressed="{{ 'true' if dashboard_metric == 'betrag' else 'false' }}">Offener Betrag</button>
+          <button type="button" data-dashboard-metric="anzahl" aria-pressed="{{ 'true' if dashboard_metric == 'anzahl' else 'false' }}">Anzahl</button>
+        </div>
+<form class="gc-dashboard-scope" id="dashboard-scope" method="get" action="/app/forderungen/dashboard" hx-boost="false">
+  <input type="hidden" name="page" value="{{ dashboard_view }}">
+  <input type="hidden" name="metric" value="{{ dashboard_metric }}">
+  <input type="hidden" name="stack" value="{{ dashboard_stack }}">
+  <label for="dashboard-only-invoices"><input type="checkbox" id="dashboard-only-invoices" name="only_invoices" value="true"{% if dashboard_only_invoices %} checked{% endif %}> Nur Rechnungen</label>
+</form>
+      </div>
+    </div>
+    <div><label for="dashboard-stack">Balken stapeln nach</label>
+      <select class="form-select" id="dashboard-stack" form="dashboard-scope">
+        <option value="abteilung"{% if dashboard_stack == 'abteilung' %} selected{% endif %}>Abteilung</option>
+        <option value="wiedervorlage"{% if dashboard_stack == 'wiedervorlage' %} selected{% endif %}>Wiedervorlage</option>
+      </select>
+    </div>
+    <div><label for="dashboard-search">Suchen</label><input class="form-control" type="search" id="dashboard-search" placeholder="Name der Gruppe …"></div>
+  </div>
+  <p class="gc-dashboard-description">{% if dashboard_stack == 'wiedervorlage' %}Die Farben zeigen die Wiedervorlagestaffeln: überfällig, heute bis in 3 Tagen, in 4 bis 14 Tagen und danach zurückgestellt. Ohne Wiedervorlage gilt das Beleg-Fälligkeitsdatum.{% else %}Die Farben zeigen die Abteilungen.{% endif %} Bewegen Sie den Mauszeiger auf ein Segment oder tippen Sie es an. Die Datentabelle bietet dieselben Werte und Listenlinks.</p>
+  <ul class="gc-dashboard-legend" aria-label="{{ 'Wiedervorlagestaffeln' if dashboard_stack == 'wiedervorlage' else 'Abteilungen' }}"></ul>
+  <p class="gc-dashboard-status" role="status" aria-live="polite"></p>
+  <div class="gc-dashboard-chart"><canvas id="dashboard-chart" role="img" aria-label="Forderungen nach Abteilung; vollständige Werte in der Datentabelle"></canvas></div>
+  <p class="gc-dashboard-chart-error" role="alert" hidden>Das Diagramm ist nicht verfügbar. Alle Werte und Listenlinks finden Sie in der Datentabelle.</p>
+  <div class="gc-dashboard-pagination" role="group" aria-label="Diagrammseiten">
+    <button type="button" class="btn btn-outline-secondary" data-dashboard-prev>Vorherige</button><span data-dashboard-pages></span>
+    <button type="button" class="btn btn-outline-secondary" data-dashboard-next>Nächste</button>
+  </div>
+  <details class="gc-dashboard-table"><summary>Datentabelle und Listenlinks anzeigen</summary>
+    <div class="gc-dashboard-table-scroll" tabindex="0" role="region" aria-label="Dashboarddaten, horizontal scrollbar">
+      <table class="table"><caption class="visually-hidden">Kennzahlen der angezeigten Diagrammseite</caption>
+        <thead><tr><th scope="col">Gruppe / {{ 'Wiedervorlage' if dashboard_stack == 'wiedervorlage' else 'Abteilung' }}</th><th scope="col">Anzahl</th><th scope="col">Offener Betrag</th><th scope="col">Forderungen</th></tr></thead>
+        <tbody id="dashboard-table-body"></tbody>
+      </table>
+    </div>
+  </details>
+</section>
+<div class="gc-dashboard-popover" id="dashboard-popover" role="dialog" aria-label="Details zum Diagrammsegment" hidden></div>
+<script type="application/json" id="dashboard-data">{{ dashboard|tojson }}</script>
+{% endif %}
+{% endblock %}

+ 0 - 21
templates/forderungen/dashboard/queries/dashboard_betriebe.sql

@@ -1,21 +0,0 @@
--- Nach Betrieben
-SELECT [Dim1_Sortierung]
-     , [Dim2_Sortierung]
-     , [Dim1]
-     , [Dim2]
-     , sum([Kennzahl1]) AS [Kennzahl1]
-     , sum([Kennzahl2]) AS [Kennzahl2]
-FROM (SELECT [Standort_ID] AS [Dim1_Sortierung]
-           , CASE
-                 WHEN [Bereich] = 'Verkauf' THEN 1
-                 WHEN [Bereich] = 'Service' THEN 2
-                 ELSE 9
-             END AS [Dim2_Sortierung]
-           , [Standort_Name] AS [Dim1]
-           , isnull([Bereich], 'Sonstige') AS [Dim2]
-           , [offen] AS [Kennzahl1]
-           , 1 AS [Kennzahl2]
-      FROM [Forderungen]
-      WHERE [Rechnung_Gutschrift] = 'Rechnung') [V1]
-GROUP BY [Dim1_Sortierung], [Dim2_Sortierung], [Dim1], [Dim2]
-ORDER BY 1, 2

+ 0 - 21
templates/forderungen/dashboard/queries/dashboard_staffel.sql

@@ -1,21 +0,0 @@
--- Nach Altersstaffel
-SELECT [Dim1_Sortierung]
-     , [Dim2_Sortierung]
-     , [Dim1]
-     , [Dim2]
-     , sum([Kennzahl1]) AS [Kennzahl1]
-     , sum([Kennzahl2]) AS [Kennzahl2]
-FROM (SELECT [Staffel] AS [Dim1_Sortierung]
-           , CASE
-                 WHEN [Bereich] = 'Verkauf' THEN 1
-                 WHEN [Bereich] = 'Service' THEN 2
-                 ELSE 9
-             END AS [Dim2_Sortierung]
-           , [Staffel] AS [Dim1]
-           , isnull([Bereich], 'Sonstige') AS [Dim2]
-           , [offen] AS [Kennzahl1]
-           , 1 AS [Kennzahl2]
-      FROM [Forderungen]
-      WHERE [Rechnung_Gutschrift] = 'Rechnung') [V1]
-GROUP BY [Dim1_Sortierung], [Dim2_Sortierung], [Dim1], [Dim2]
-ORDER BY 1, 2

+ 0 - 18
templates/forderungen/dashboard/queries/dashboard_verursacher.sql

@@ -1,18 +0,0 @@
--- Nach Verursacher
-SELECT [Dim1_Sortierung]
-     , [Dim2_Sortierung]
-     , [Dim1]
-     , [Dim2]
-     , sum([Kennzahl1]) AS [Kennzahl1]
-     , sum([Kennzahl2]) AS [Kennzahl2]
-FROM (SELECT [Standort_ID] AS [Dim1_Sortierung]
-           , [Verursacher] AS [Dim2_Sortierung]
-           , [Standort_Name] AS [Dim1]
-           , [Verursacher] AS [Dim2]
-           , [offen] AS [Kennzahl1]
-           , 1 AS [Kennzahl2]
-      FROM [Forderungen]
-      WHERE [Rechnung_Gutschrift] = 'Rechnung') [V1]
-GROUP BY [Dim1_Sortierung], [Dim2_Sortierung], [Dim1], [Dim2]
-HAVING sum([Kennzahl2]) >= 10
-ORDER BY 1, 2

+ 4 - 2
templates/forderungen/liste/summary.html

@@ -8,8 +8,10 @@
   <div class="gc-summary-inner">
   <div class="gc-summary-inner">
     <div><span>Treffer</span> <strong id="list-count">{{ summary.Anzahl }}</strong></div>
     <div><span>Treffer</span> <strong id="list-count">{{ summary.Anzahl }}</strong></div>
     <details class="gc-active-filters">
     <details class="gc-active-filters">
-      <summary>Aktive Filter ({{ active.filters|length }})</summary>
+      <summary>Aktive Filter ({{ active.filters|length + (1 if dashboard_segment else 0) }})</summary>
       <div class="gc-active-filter-panel">
       <div class="gc-active-filter-panel">
+        {% if dashboard_segment %}<div><strong>Dashboard</strong><span>{{ dashboard_segment_label }}</span></div>
+        <a href="{{ without_dashboard_segment }}">Dashboardfilter entfernen</a>{% endif %}
         {% for f in active.filters %}
         {% for f in active.filters %}
         {% set display = namespace(value=f.current_value) %}
         {% set display = namespace(value=f.current_value) %}
         {% for key, value in f.options.items() %}
         {% for key, value in f.options.items() %}
@@ -17,7 +19,7 @@
         {% endfor %}
         {% endfor %}
         <div><strong>{{ f.text }}</strong><span>{{ display.value }}</span></div>
         <div><strong>{{ f.text }}</strong><span>{{ display.value }}</span></div>
         {% else %}
         {% else %}
-        <p>Keine zusätzlichen Filter aktiv.</p>
+        {% if not dashboard_segment %}<p>Keine zusätzlichen Filter aktiv.</p>{% endif %}
         {% endfor %}
         {% endfor %}
         <a href="#liste-filter" hx-boost="false">Zu den Filtern ↑</a>
         <a href="#liste-filter" hx-boost="false">Zu den Filtern ↑</a>
       </div>
       </div>

+ 295 - 0
tests/test_dashboard.py

@@ -0,0 +1,295 @@
+"""Dashboard and exact drill-down contracts on the shared production SQL projection."""
+from datetime import datetime
+from collections import namedtuple
+from decimal import Decimal
+import json
+import re
+import unittest
+from unittest.mock import patch
+from urllib.parse import parse_qs, urlsplit, urlencode
+
+from app.dashboard_segment import parse_segment, pack_segment, segment_sql
+from app.forderung_dashboard import build_dashboard, BERLIN, color
+from app.navigation import list_return_url, normalize_page_url
+from tests import test_verursacher as fixture
+
+
+class DashboardTests(unittest.TestCase):
+    def setUp(self):
+        self.fixture = fixture.VerursacherTests()
+        self.fixture.setUp()
+        self.addCleanup(self.fixture.doCleanups)
+        self.client, self.db = self.fixture.client, self.fixture.db
+        from app.jinja_templates import templates
+        sql = templates.env.get_template('forderungen/liste/queries/forderungen_liste.sql').render()
+        needed = set(re.findall(r'(?:\[F\]|F)\.\[([^]]+)\]', sql))
+        existing = {row[1] for row in self.db.connection.execute('PRAGMA dbo.table_info(Forderungen_Kopf)')}
+        for name in needed - existing:
+            kind = 'INTEGER' if name == 'Beleg_Tage_offen' else 'REAL' if name in {'Beleg_Betrag','Kunde_offen_gesamt'} else 'TEXT'
+            self.db.connection.execute(f'ALTER TABLE dbo.Forderungen_Kopf ADD [{name}] {kind}')
+        self.db.connection.executescript("""
+            ALTER TABLE dbo.Forderungen_Details ADD Rechtsanwalt TEXT;
+            ALTER TABLE dbo.Forderungen_Details ADD Reklamation TEXT;
+            ALTER TABLE dbo.Forderungen_Details ADD Mahnen_aussetzen TEXT;
+            CREATE TABLE dbo.Forderungen_Kommentar_letzte (Client_DB TEXT, Beleg_Nr TEXT, Kommentar_Fibu TEXT, Kommentar_Abteilung TEXT, Begründung TEXT);
+            CREATE TABLE Garantie2 (Client_DB TEXT, Beleg_Nr TEXT, Antrag_Art TEXT, Antrag_Nr TEXT, Antrag_Datum TEXT, Antrag_Benutzer_ID TEXT, Antrag_Status TEXT, Letzte_Beantragung TEXT);
+            UPDATE dbo.Forderungen_Kopf SET Standort_Name='ULM', Hauptbetrieb_Name='AHR',
+                Staffel_Text='> 12 Wochen', Beleg_Tage_offen=100, Rechnung_Gutschrift='Rechnung',
+                Beleg_Faellig_Datum='2026-11-02', Beleg_Betrag=Beleg_offen, Kunde_offen_gesamt=100;
+            UPDATE dbo.Forderungen_Kopf SET Hauptbetrieb_ID='2', Hauptbetrieb_Name='AAM' WHERE Client_DB='2';
+            UPDATE dbo.Forderungen_Kopf SET Staffel_Text='< 4 Wochen', Beleg_Tage_offen=10 WHERE Beleg_Nr='R1';
+            INSERT INTO dbo.Forderungen_Details (Client_DB,Beleg_Nr,Verursacher_Benutzer_ID,Wiedervorlage) VALUES
+                ('1','R1','alice','2026-11-01'), ('1','R3',NULL,'2026-11-02 13:30:00'),
+                ('1','X1','bob','2026-11-09 13:30:00'), ('1','NV',NULL,'2026-11-10');
+            UPDATE dbo.Benutzer_Rolle SET Name='Alice' WHERE Benutzer_ID='bob';
+            INSERT INTO Garantie2 (Client_DB,Beleg_Nr,Antrag_Nr) VALUES ('1','R1','A'),('1','R1','B');
+            INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','10','Service');
+            INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','10','');
+        """)
+        columns = [row[1] for row in self.db.connection.execute('PRAGMA dbo.table_info(Forderungen_Kopf)')]
+        select = ','.join("'CR'" if name == 'Beleg_Nr' else '-40' if name == 'Beleg_offen' else '['+name+']' for name in columns)
+        self.db.connection.execute('INSERT INTO dbo.Forderungen_Kopf SELECT '+select+" FROM dbo.Forderungen_Kopf WHERE Client_DB='1' AND Beleg_Nr='NV'")
+        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Rechnung_Gutschrift='Gutschrift', Abteilung='', Staffel_Text=NULL, Verursacher_Benutzer_ID=NULL, Verursacher_Text=NULL, Beleg_Faellig_Datum=NULL WHERE Beleg_Nr='CR'")
+        self.db.connection.commit()
+        execute = self.db.execute
+        def dialect(query, values=None):
+            query = str(query)
+            for column in ['Kommentar_Fibu', 'Kommentar_Abteilung']:
+                query = query.replace(f"left(isnull([FK].[{column}], ''), 50)", f"substr(ifnull([FK].[{column}], ''), 1, 50)")
+            query = query.replace('isnull(', 'ifnull(').replace('dateadd(day, 4, getdate())', "'2026-11-06'").replace('getdate()', "'2026-11-02'")
+            if query.strip().startswith('SELECT F.* FROM'):
+                self.db.calls.append((query, values))
+                cursor = self.db.connection.execute(query, values or {})
+                columns = [item[0] for item in cursor.description]
+                record = namedtuple('DashboardInvoice', columns)
+                rows = []
+                for raw in cursor.fetchall():
+                    row = dict(zip(columns, raw))
+                    for name in ['Beleg_Datum','Beleg_Faellig_Datum','Wiedervorlage']:
+                        if row.get(name): row[name] = datetime.fromisoformat(row[name])
+                    rows.append(record(**row))
+                class SQLRows:
+                    def keys(self): return columns
+                    def fetchall(self): return rows
+                return SQLRows()
+            return execute(query, values)
+        adapter = patch.object(self.db, 'execute', side_effect=dialect)
+        adapter.start(); self.addCleanup(adapter.stop)
+        self.now = datetime(2026, 11, 2, 10, tzinfo=BERLIN)
+
+    def data(self):
+        return build_dashboard(self.db, 'tester', self.now)
+
+    def test_totals_rights_duplicate_roles_and_duplicate_join_rows(self):
+        data = self.data()
+        self.assertEqual(data['count'], 5)
+        self.assertEqual(data['amount'], '200.00')
+        self.assertEqual(data['amount_display'], '200,00')
+        for groups in data['views'].values():
+            self.assertEqual(sum(group['count'] for group in groups), 5)
+            self.assertEqual(sum(Decimal(group['amount']) for group in groups), Decimal(200))
+        sql, values = self.db.calls[-1]
+        self.assertIn('WHERE EXISTS', sql)
+        self.assertEqual(values['auth_username'], 'tester')
+        self.assertTrue(all('AAM' not in group['label'] for group in data['views']['Standort']))
+
+    def test_only_invoices_filters_all_aggregates_and_drilldowns(self):
+        data = build_dashboard(self.db, 'tester', self.now, only_invoices=True)
+        self.assertEqual(data['count'], 4)
+        self.assertEqual(data['amount'], '240.00')
+        self.assertEqual(self.data()['count'], 5)
+        for groups in data['views'].values():
+            self.assertEqual(sum(group['count'] for group in groups), 4)
+            for group in groups:
+                for item in [group, *group['segments']]:
+                    raw = parse_qs(urlsplit(item['url']).query)['dashboard_segment'][0]
+                    self.assertTrue(parse_segment(raw)['only_invoices'])
+                    response = self.client.get(item['url'])
+                    self.assertIn('id="list-count">'+str(item['count']), response.text)
+                    self.assertIn('Nur Rechnungen', response.text)
+        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Rechnung_Gutschrift='Rechnungskorrektur' WHERE Beleg_Nr='CR'")
+        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Beleg_offen=-100 WHERE Client_DB='1' AND Beleg_Nr='R1'")
+        negative = build_dashboard(self.db, 'tester', self.now, only_invoices=True)
+        self.assertEqual(negative['count'], 4)
+        self.assertEqual(negative['amount'], '40.00')
+
+    def test_invoice_checkbox_url_state_and_strict_segment_flag(self):
+        response = self.client.get('/app/forderungen/dashboard?page=Verursacher&metric=anzahl&only_invoices=true')
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('name="only_invoices" value="true" checked', response.text)
+        self.assertIn('name="page" value="Verursacher"', response.text)
+        self.assertIn('name="metric" value="anzahl"', response.text)
+        self.assertIn('240,00', response.text)
+        unchecked = self.client.get('/app/forderungen/dashboard')
+        self.assertNotIn('name="only_invoices" value="true" checked', unchecked.text)
+        self.assertIn('200,00', unchecked.text)
+        self.assertIn('data-dashboard-view="Standort" data-dashboard-metric=', unchecked.text)
+        for invalid in ['true', 1, None]:
+            with self.assertRaises(ValueError):
+                parse_segment(pack_segment({'view':'Altersstaffel','key':None,'only_invoices':invalid}))
+
+    def test_all_segments_round_trip_to_exact_list_count_and_amount(self):
+        for view, groups in self.data()['views'].items():
+            for group in groups:
+                for segment in group['segments']:
+                    with self.subTest(view=view, label=group['label'], department=segment['department']):
+                        response = self.client.get(segment['url'])
+                        self.assertEqual(response.status_code, 200)
+                        count = int(re.search(r'id="list-count">(\d+)', response.text)[1])
+                        self.assertEqual(count, segment['count'])
+                        self.assertIn('name="dashboard_segment"', response.text)
+                        self.assertIn('Dashboardfilter entfernen', response.text)
+                        self.assertIn('Aktive Filter (1)', response.text)
+                        from app.jinja_templates import number_format
+                        self.assertIn(number_format(Decimal(segment['amount']))+' €', response.text)
+
+    def test_follow_up_boundaries_and_null_group(self):
+        cases = [('2026-11-01', 'overdue'), ('2026-11-02', 'soon'), ('2026-11-05 23:30:00', 'soon'),
+                 ('2026-11-06', 'not_due'), ('2026-11-16 23:30:00', 'not_due'), ('2026-11-17', 'deferred')]
+        for value, expected in cases:
+            with self.subTest(date=value):
+                self.db.connection.execute("UPDATE dbo.Forderungen_Details SET Wiedervorlage=? WHERE Beleg_Nr='R1'", (value,))
+                data = build_dashboard(self.db, 'tester', self.now, stack='wiedervorlage')
+                group = next(group for group in data['views']['Altersstaffel'] if group['label']=='< 4 Wochen')
+                self.assertEqual(group['segments'][0]['id'], expected)
+                response = self.client.get(group['segments'][0]['url'])
+                self.assertIn('id="list-count">1', response.text)
+        ids = [item['id'] for item in data['stack_items']]
+        self.assertEqual(ids, ['overdue','soon','not_due','deferred','undated'])
+        self.assertNotIn('Wiedervorlage', data['views'])
+
+    def test_follow_up_falls_back_to_document_due_date(self):
+        self.db.connection.execute("UPDATE dbo.Forderungen_Details SET Wiedervorlage=NULL WHERE Beleg_Nr='R1'")
+        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Beleg_Faellig_Datum='2026-11-17' WHERE Client_DB='1' AND Beleg_Nr='R1'")
+        data = build_dashboard(self.db, 'tester', self.now, stack='wiedervorlage')
+        group = next(group for group in data['views']['Altersstaffel'] if group['label']=='< 4 Wochen')
+        self.assertEqual(group['segments'][0]['id'], 'deferred')
+        self.assertIn('id="list-count">1', self.client.get(group['segments'][0]['url']).text)
+
+    def test_follow_up_year_boundary_and_berlin_calendar_day(self):
+        self.db.connection.execute("UPDATE dbo.Forderungen_Details SET Wiedervorlage='2027-01-01' WHERE Beleg_Nr='R1'")
+        now = datetime.fromisoformat('2026-12-28T23:30:00+00:00')
+        groups = build_dashboard(self.db, 'tester', now, stack='wiedervorlage')['views']['Altersstaffel']
+        group = next(group for group in groups if group['label']=='< 4 Wochen')
+        selection = parse_segment(parse_qs(urlsplit(group['segments'][0]['url']).query)['dashboard_segment'][0])
+        self.assertEqual(selection['follow_up'], {'from':'2026-12-29','to':'2027-01-01'})
+        self.assertEqual(group['segments'][0]['id'], 'soon')
+
+    def test_follow_up_stack_roundtrips_for_all_rubrics_and_invoice_scope(self):
+        for only in [False, True]:
+            data = build_dashboard(self.db, 'tester', self.now, only_invoices=only, stack='wiedervorlage')
+            for groups in data['views'].values():
+                self.assertEqual(sum(group['count'] for group in groups), data['count'])
+                self.assertEqual(sum(Decimal(group['amount']) for group in groups), Decimal(data['amount']))
+                for group in groups:
+                    for segment in group['segments']:
+                        selection = parse_segment(parse_qs(urlsplit(segment['url']).query)['dashboard_segment'][0])
+                        self.assertIn('follow_up', selection)
+                        self.assertNotIn('department', selection)
+                        response = self.client.get(segment['url'])
+                        self.assertEqual(response.status_code, 200)
+                        self.assertIn('id="list-count">'+str(segment['count']), response.text)
+
+    def test_stack_selection_and_legacy_follow_up_links(self):
+        response = self.client.get('/app/forderungen/dashboard?stack=wiedervorlage&metric=anzahl&only_invoices=true')
+        self.assertEqual(response.status_code, 200)
+        self.assertNotIn('data-dashboard-view="Wiedervorlage"', response.text)
+        self.assertIn('value="wiedervorlage" selected', response.text)
+        self.assertIn('name="stack" value="wiedervorlage"', response.text)
+        self.assertEqual(self.client.get('/app/forderungen/dashboard?stack=bad').status_code, 422)
+        old = self.client.get('/app/forderungen/dashboard?page=Wiedervorlage&metric=anzahl&only_invoices=true', follow_redirects=False)
+        self.assertEqual(old.status_code, 303)
+        params = parse_qs(urlsplit(old.headers['location']).query)
+        self.assertEqual(params, {'page':['Standort'], 'metric':['anzahl'], 'stack':['wiedervorlage'], 'only_invoices':['true']})
+        legacy = pack_segment({'view':'Wiedervorlage','key':{'from':'2026-11-02','to':'2026-11-09'}})
+        response = self.client.get('/app/forderungen/liste', params={'dashboard_segment':legacy})
+        self.assertIn('id="list-count">2', response.text)
+        for bad in [True, 'tomorrow', {'from':'2026-11-02','to':'2026-11-01'}]:
+            with self.assertRaises(ValueError):
+                parse_segment(pack_segment({'view':'Altersstaffel','key':None,'follow_up':bad}))
+
+    def test_same_names_different_user_ids_and_clients_remain_separate(self):
+        groups = [group for group in self.data()['views']['Verursacher'] if group['label'].startswith('Alice (')]
+        self.assertEqual(len(groups), 2)
+        self.assertNotEqual(groups[0]['label'], groups[1]['label'])
+        self.assertNotEqual(groups[0]['url'], groups[1]['url'])
+        self.db.connection.execute("INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','2','10','Service')")
+        groups = self.data()['views']['Standort']
+        self.assertEqual(len(groups), 2)
+        for group in groups:
+            self.assertEqual(self.client.get(group['url']).status_code, 200)
+        self.assertEqual({group['label'] for group in groups}, {'ULM (AHR)', 'ULM (AAM)'})
+
+    def test_decimal_sum_and_zero_amount_segments_still_have_links(self):
+        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Beleg_offen=0.1 WHERE Client_DB='1' AND Beleg_Nr='R1'")
+        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Beleg_offen=0.2 WHERE Client_DB='1' AND Beleg_Nr='X1'")
+        data = self.data()
+        self.assertEqual(data['amount'], '10.30')
+        zero = build_dashboard(self.db, 'tester', self.now, stack='wiedervorlage')
+        self.assertTrue(any(segment['amount']=='0.00' and segment['url'] for group in zero['views']['Verursacher'] for segment in group['segments']))
+        self.assertEqual(color('Service'), color('Service'))
+
+    def test_exact_department_filter_does_not_match_prefixes(self):
+        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Abteilung='Service2' WHERE Beleg_Nr='CR'")
+        self.db.connection.execute("INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','10','Service2')")
+        group = self.data()['views']['Standort'][0]
+        segment = next(segment for segment in group['segments'] if segment['department']=='Service')
+        exact = self.client.get(segment['url'])
+        partial = self.client.get('/app/forderungen/liste?Abteilung=Service')
+        self.assertIn('id="list-count">4', exact.text)
+        self.assertIn('id="list-count">5', partial.text)
+
+    def test_forged_segment_cannot_expand_invoice_access(self):
+        raw = pack_segment({'view':'Standort','key':{'client':'2','site':'10'}})
+        response = self.client.get('/app/forderungen/liste', params={'dashboard_segment':raw})
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('id="list-count">0', response.text)
+        self.assertIn('0,00 €', response.text)
+
+    def test_segment_preserved_through_detail_return_login_and_normal_filters(self):
+        url = self.data()['views']['Standort'][0]['url']
+        selection = parse_qs(urlsplit(url).query)['dashboard_segment'][0]
+        returned = list_return_url(url, '1_R1')
+        self.assertEqual(parse_qs(urlsplit(returned).query)['dashboard_segment'], [selection])
+        detail = '/app/forderungen/details/1_R1?' + urlencode({'return_to': returned})
+        recovered = normalize_page_url('/app/forderungen/details/1_R1/kommentare', current_url='https://proxy.test'+detail)
+        self.assertEqual(recovered, detail)
+        response = self.client.get(url+'&Kunde=Test&page=1&limit=1')
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('hx-include="#liste-filter"', response.text)
+        sql, values = self.db.calls[-1]
+        self.assertIn('F.[Client_DB] = :ds_client', sql)
+        self.assertEqual(values['filter_kunde'], '%Test%')
+        self.client.cookies.clear()
+        response = self.client.get(url, headers={'HX-Request':'true'})
+        target = parse_qs(urlsplit(response.headers['HX-Redirect']).query)['next'][0]
+        self.assertEqual(target, url)
+
+    def test_bad_segments_are_rejected_before_data_query(self):
+        invalid = ['not-json', '{}', '{"view":"Standort","key":{}}', '{"view":"Wiedervorlage","key":{"from":"x","to":null}}',
+                   '{"view":"Altersstaffel","key":null,"sql":"1=1"}', '{"view":"Verursacher","key":{"id":null}}',
+                   '{"view":"Standort","key":{"client":true,"site":"10"}}']
+        for value in invalid:
+            with self.subTest(value=value):
+                self.db.calls.clear()
+                response = self.client.get('/app/forderungen/liste', params={'dashboard_segment':value})
+                self.assertEqual(response.status_code, 400)
+                self.assertFalse(self.db.calls)
+
+    def test_empty_error_and_invalid_dashboard_states(self):
+        response = self.client.get('/app/forderungen/dashboard?page=Standort&metric=anzahl')
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Forderungen | Dashboard', response.text)
+        self.assertIn('data-dashboard-metric="anzahl"', response.text)
+        self.assertEqual(self.client.get('/app/forderungen/dashboard?page=bad').status_code, 422)
+        self.db.connection.execute("DELETE FROM dbo.Benutzer_Rechte WHERE Benutzer_ID='tester'")
+        data = self.data()
+        self.assertEqual(data['count'], 0); self.assertEqual(data['amount'], '0.00')
+        with patch('app.forderung_dashboard.read_invoices', side_effect=RuntimeError('offline')), self.assertLogs('app.routes',level='ERROR'):
+            response = self.client.get('/app/forderungen/dashboard')
+        self.assertEqual(response.status_code, 503)
+        self.assertIn('nicht geladen', response.text)
+        self.assertNotIn('Anzahl der Belege', response.text)
+
+if __name__ == '__main__':
+    unittest.main()