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

Verbesserungen an der Filter- und Dashboard-Funktionalität

- Aktualisierung der CSS- und JS-Dateien zur Unterstützung neuer Filterfunktionen.
- Entfernen von Dashboard-Filteranzeigen aus der Liste, um die Benutzeroberfläche zu vereinfachen.
- Einführung eines neuen Mehrfachfilter-Layouts in der Filteransicht, das eine bessere Benutzererfahrung bietet.
- Anpassungen an der Dashboard-Beschreibung zur Klarstellung der Filterverhalten.
- Verbesserung der Zusammenfassungsanzeige, um aktive Filter klarer darzustellen.
- Erweiterung der Tests zur Sicherstellung der korrekten Funktionalität der neuen Filterlogik und der Dashboard-Integration.
- Sicherstellung, dass alte Dashboard-Links auf die neuen Filterparameter umgeleitet werden.
gc-server3 пре 1 недеља
родитељ
комит
3b0bb87594

+ 37 - 10
README.md

@@ -149,7 +149,8 @@ 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.
+Die Wiedervorlagestaffelung beeinflusst derzeit nur die Darstellung im Dashboard;
+beim Öffnen der Liste wird diese Stapelung nicht als Datumsfilter übernommen.
 Alte Dashboardadressen mit `page=Wiedervorlage` wechseln zum Standort mit
 Wiedervorlagestapelung; bestehende Listenlinks bleiben gültig.
 
@@ -165,15 +166,18 @@ Die standardmäßig ausgeschaltete Checkbox „Nur Rechnungen“ (`only_invoices
 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.
+verlinkten Listen; dort wird dafür `RechnungGutschrift=Rechnung` gesetzt.
+
+Dashboardlinks verwenden die normalen Listenfilter: Standort setzt `Mandant` und `Filiale`,
+Altersstaffel setzt `Staffel`, Verursacher setzt den Namen in `Verursacher`. Gleichnamige
+Verursacher werden deshalb gemeinsam dargestellt. Ein Abteilungssegment ergänzt `Abteilung`.
+Alle übernommenen Werte sind in den vorhandenen Filterfeldern sichtbar, änderbar und
+entfernbar. Es wird kein zusätzlicher Dashboardfilter erzeugt. Gruppen ohne Wert verwenden
+die reguläre Auswahl „Ohne Wert“ (`__empty__`), die NULL und leere Zeichenketten umfasst.
+Textfelder behalten ihre Teiltextsuche, Auswahlfelder vergleichen die Schlüssel exakt.
+Alte Listenlinks mit `dashboard_segment` werden in normale Listenparameter umgewandelt
+und auf diese URL weitergeleitet. Es gibt keinen versteckten Dashboardfilter und keine
+separate Dashboardfilter-Anzeige mehr. Bereits gesetzte normale Filter haben Vorrang.
 
 ## Darstellung der Forderungsliste
 
@@ -182,6 +186,29 @@ Bearbeitung und Wiedervorlage gegliedert. Beim Scrollen der Seite dockt der Tabe
 unter der Navigation an; bei schmalen Fenstern lässt sich die Tabelle seitlich verschieben.
 Kommentare und Antragsinformationen werden je Beleg über „Anzeigen“ eingeblendet.
 
+Auswahl- und Textfelder unterstützen mehrere Werte in suchbaren Dropdowns mit Checkboxen.
+Textfelder erlauben zusätzlich freie Suchbegriffe, die mit Enter übernommen werden.
+Änderungen werden beim Schließen des Dropdowns angewendet; einzelne Werte können direkt
+entfernt werden. Während die Liste aktualisiert wird, erscheinen ein Wartesymbol und
+„Filter werden angewendet …“; sämtliche Filtereingaben sind bis zum Abschluss gesperrt.
+Auch bei einem abgebrochenen oder fehlgeschlagenen Aufruf wird die Sperre aufgehoben.
+Innerhalb eines Feldes gilt ODER, zwischen Feldern UND. Datumsgrenzen
+bleiben einzelne Werte. Vorschläge beachten alle anderen Filter,
+aber nicht die eigene Feldauswahl. Sie stammen ausschließlich aus berechtigten Forderungen.
+
+Die GET-Parameternamen bleiben unverändert, beispielsweise `Filiale=10,20`. Jeder Einzelwert
+wird URL-kodiert, bevor die Werte mit Kommas verbunden und als GET-Parameter kodiert werden.
+Die Trennkommas bleiben dabei sichtbar (`Mandant=1,2`), auch beim Filtern und Nachladen.
+Ein Komma im Wert erscheint dadurch als `%252C` in der fertigen URL, beispielsweise
+`Kunde=M%25C3%25BCller%252C%2520GmbH,Meier`. Nach dem normalen GET-Dekodieren zuerst
+an Kommas trennen und danach jeden Einzelwert genau einmal dekodieren. Einfache bestehende
+Einzelwert-Links bleiben gültig. Leere Einträge und doppelte Werte werden entfernt.
+
+`GET /app/forderungen/liste/vorschlaege` nimmt die Listenfilter sowie `field` und `search`
+entgegen. Die authentifizierte Antwort enthält höchstens 50 eindeutige, sortierte
+`items` mit `value` und `label` sowie `has_more`. Bei weiteren Treffern muss die Suche
+eingegrenzt werden. Ungültige Felder oder Dashboardfilter ergeben HTTP 400.
+
 Die Fußleiste zeigt Anzahl und offenen Gesamtbetrag aller gefilterten Treffer, unabhängig
 von den bereits nachgeladenen Zeilen. Aktive Filter lassen sich dort aufklappen.
 Auch ohne Treffer bleiben die Filter bedienbar. Die gemeinsamen Gestaltungsregeln liegen

+ 18 - 74
app/dashboard_segment.py

@@ -1,6 +1,7 @@
-"""Validated exact dashboard selections; independent of navigation and authentication."""
-from datetime import date, timedelta
+"""Decode legacy dashboard links into the regular editable list filters."""
+from datetime import date
 import json
+from .query_filter import EMPTY_FILTER_VALUE, encode_filter_values
 
 VIEWS = ('Altersstaffel', 'Standort', 'Verursacher', 'Wiedervorlage')
 DASHBOARD_VIEWS = VIEWS[:-1]  # Old Wiedervorlage list selections remain valid.
@@ -66,79 +67,22 @@ 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
+def segment_filters(segment, person_values=None):
+    """Translate old dashboard URLs into editable list filters, never hidden predicates."""
+    if not segment:
+        return {}
+    values = {}
     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']})"
+    if view == 'Standort':
+        values.update(Mandant=[key['client']], Filiale=[key['site']])
+    elif view == 'Altersstaffel':
+        values['Staffel'] = [key]
     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}'
+        values['Verursacher'] = person_values if person_values is not None else [key.get('text', key.get('id'))]
     if 'department' in segment:
-        text += ' · Abteilung: ' + (segment['department'] or 'Ohne Abteilung')
+        values['Abteilung'] = [segment['department']]
     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
+        values['RechnungGutschrift'] = ['Rechnung']
+    # Both the old Wiedervorlage rubric and follow_up stacks are deliberately ignored.
+    return {name: encode_filter_values([EMPTY_FILTER_VALUE if value in (None, '') else value for value in items])
+            for name, items in values.items()}

+ 20 - 12
app/forderung_dashboard.py

@@ -4,12 +4,13 @@ import hashlib
 from collections import Counter
 from datetime import date, datetime, timedelta
 from decimal import Decimal
-from urllib.parse import urlencode
 from zoneinfo import ZoneInfo
 
 from .dashboard_segment import DASHBOARD_VIEWS, pack_segment
 from .forderung_data import read_invoices
 from .jinja_templates import number_format
+from .navigation import list_url
+from .query_filter import EMPTY_FILTER_VALUE, encode_filter_values
 
 BERLIN = ZoneInfo("Europe/Berlin")
 STACKS = ("abteilung", "wiedervorlage")
@@ -63,12 +64,21 @@ def follow_up_bands(today):
 
 
 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
+    filters = {}
+    def select(name, value):
+        filters[name] = encode_filter_values([EMPTY_FILTER_VALUE if value is None or value == '' else value])
+    if view == "Standort":
+        select("Mandant", key["client"])
+        select("Filiale", key["site"])
+    elif view == "Altersstaffel":
+        select("Staffel", key)
+    elif view == "Verursacher":
+        select("Verursacher", key["text"])
+    if segment is not ... and stack == "abteilung":
+        select("Abteilung", segment)
     if only_invoices:
-        selection["only_invoices"] = True
-    return "/app/forderungen/liste?" + urlencode({"dashboard_segment": pack_segment(selection)})
+        select("RechnungGutschrift", "Rechnung")
+    return list_url(filters)
 
 
 def build_dashboard(db, username, now=None, only_invoices=False, stack="abteilung"):
@@ -108,8 +118,8 @@ def build_dashboard(db, username, now=None, only_invoices=False, stack="abteilun
         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"))}
+        person_name = _key(row.get("Verursacher_Text"))
+        person = {"text": person_name}
         definitions = [
             (
                 "Altersstaffel",
@@ -123,7 +133,7 @@ def build_dashboard(db, username, now=None, only_invoices=False, stack="abteilun
                 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),
+            ("Verursacher", person, person_name or "Ohne Verursacher", 0),
         ]
         for view, key, label, order in definitions:
             identity = pack_segment(key)
@@ -155,9 +165,7 @@ def build_dashboard(db, username, now=None, only_invoices=False, stack="abteilun
         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":
+                if view == "Standort":
                     label += " · Standort-ID " + (group["key"]["site"] or "ohne ID")
             segments = [
                 dict(

+ 32 - 0
app/forderung_data.py

@@ -1,6 +1,38 @@
 """One authorized projection for list results and dashboard aggregates."""
 from sqlalchemy import text
 from .jinja_templates import templates
+from .query_filter import EMPTY_FILTER_VALUE, EMPTY_FILTER_LABEL
+
+
+def read_filter_suggestions(db, username, field, clauses, values, search="", *, selected=None):
+    # Reuse the authorized projection, including local changes to its WHERE clause.
+    query = templates.env.get_template('forderungen/liste/queries/forderungen_liste.sql').render()
+    query = query.rsplit('ORDER BY', 1)[0]
+    raw_key, raw_label, sort = (f"F.[{column}]" for column in (field.key_column, field.value_column, field.sort_column))
+    key = f"COALESCE(NULLIF(CAST({raw_key} AS nvarchar(4000)), ''), '{EMPTY_FILTER_VALUE}')"
+    label = (f"CASE WHEN NULLIF(CAST({raw_key} AS nvarchar(4000)), '') IS NULL THEN '{EMPTY_FILTER_LABEL}' "
+             f"ELSE CAST({raw_label} AS nvarchar(4000)) END")
+    limit = 'TOP 51 ' if selected is None else ''
+    query = query.replace('SELECT F.*', f'SELECT {limit}{key} AS value, MIN({label}) AS label, MIN({sort}) AS sort_value', 1)
+    predicates = list(clauses)
+    values = dict(values, auth_username=username)
+    if selected is not None:
+        placeholders = []
+        for index, value in enumerate(selected):
+            parameter = f'selected_{index}'
+            placeholders.append(':' + parameter)
+            values[parameter] = value
+        if not placeholders:
+            return []
+        predicates.append(f"{key} IN ({', '.join(placeholders)})")
+    if search:
+        predicates.append(f"(CAST({key} AS nvarchar(max)) LIKE :suggest_search OR CAST({label} AS nvarchar(max)) LIKE :suggest_search)")
+        values['suggest_search'] = f'%{search}%'
+    if predicates:
+        query = query.replace('AND 1 = 1', 'AND 1 = 1 AND ' + ' AND '.join(predicates))
+    query += f' GROUP BY {key} ORDER BY MIN({sort}), {key}'
+    rows = db.execute(text(query), values).fetchall()
+    return [{"value": str(row[0]), "label": str(row[1] if row[1] is not None else row[0])} for row in rows]
 
 
 def read_invoices(db, username, clauses=(), values=None):

+ 9 - 4
app/navigation.py

@@ -3,7 +3,7 @@ import re
 from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
 
 from .query_filter import import_filter_config
-from .dashboard_segment import parse_segment, pack_segment
+from .dashboard_segment import parse_segment, segment_filters
 
 LIST_PATH = '/app/forderungen/liste'
 DETAIL_PREFIX = '/app/forderungen/details/'
@@ -38,16 +38,21 @@ def _local_parts(value):
 
 def list_url(filters=None, focus: str | None = None) -> str:
     filters = filters or {}
-    params = {f.name: filters[f.name] for f in import_filter_config('forderungen')
+    config = import_filter_config('forderungen')
+    multi_fields = {f.name for f in config if f.filter_type in {'select', 'text'}}
+    params = {f.name: filters[f.name] for f in config
               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']))
+            params = {**segment_filters(parse_segment(filters['dashboard_segment'])), **params}
         except ValueError:
             pass  # Untrusted return/login URLs must never carry malformed selections.
     if valid_focus(focus):
         params['focus'] = focus
-    return LIST_PATH + ('?' + urlencode(params) if params else '')
+    # Only separators remain literal; encoded commas inside values stay %252C.
+    query = '&'.join(urlencode({name: value}, safe=',' if name in multi_fields else '')
+                     for name, value in params.items())
+    return LIST_PATH + ('?' + query if query else '')
 
 
 def list_return_url(value: str | None, primary_key: str) -> str:

+ 52 - 0
app/query_filter.py

@@ -1,5 +1,47 @@
 import csv
 from dataclasses import dataclass, field
+from urllib.parse import quote, unquote
+
+EMPTY_FILTER_VALUE = "__empty__"
+EMPTY_FILTER_LABEL = "Ohne Wert"
+
+
+def decode_filter_values(value: str | None) -> list[str]:
+    """Split the GET-decoded parameter before decoding individual values."""
+    return list(dict.fromkeys(decoded for part in (value or "").split(",")
+                              if (decoded := unquote(part).strip())))
+
+
+def encode_filter_values(values) -> str:
+    return ",".join(quote(value, safe="") for value in dict.fromkeys(
+        str(value).strip() for value in values) if value)
+
+
+def filter_clauses(config, params, exclude=None):
+    clauses, values = [], {}
+    for f in config:
+        current = params.get(f.name, f.default_value)
+        if f.name == exclude or f.filter_type == "hidden" or not current:
+            continue
+        parameter = f"filter_{f.name.lower()}"
+        if f.filter_type == "date":
+            operator = "<=" if "bis" in f.name.lower() else ">="
+            clauses.append(f"F.[{f.key_column}] {operator} :{parameter}")
+            values[parameter] = current
+            continue
+        parts = decode_filter_values(current)
+        terms = []
+        for index, part in enumerate(parts):
+            key = parameter if len(parts) == 1 else f"{parameter}_{index}"
+            if part == EMPTY_FILTER_VALUE:
+                terms.append(f"NULLIF(F.[{f.key_column}], '') IS NULL")
+                continue
+            operator = "=" if f.filter_type == "select" else "LIKE"
+            terms.append(f"F.[{f.key_column}] {operator} :{key}")
+            values[key] = part if f.filter_type == "select" else f"%{part}%"
+        if terms:
+            clauses.append("(" + " OR ".join(terms) + ")")
+    return clauses, values
 
 
 @dataclass
@@ -16,6 +58,16 @@ class FilterConfig:
     options: dict[str, str] | None = field(default_factory=dict)
     current_value: str | None = None
 
+    @property
+    def selected_values(self) -> list[str]:
+        return decode_filter_values(self.current_value)
+
+    @property
+    def selected_items(self) -> list[dict[str, str]]:
+        options = {str(key): str(value) for key, value in (self.options or {}).items()}
+        options[EMPTY_FILTER_VALUE] = EMPTY_FILTER_LABEL
+        return [{"value": value, "label": options.get(value, value)} for value in self.selected_values]
+
     @property
     def query_param(self) -> str:
         if self.current_value is None or self.current_value == "":

+ 46 - 44
app/routes.py

@@ -6,13 +6,14 @@ from datetime import datetime, timedelta
 from decimal import Decimal
 from pathlib import Path
 from typing import Annotated
-from urllib.parse import unquote, urlencode
+from urllib.parse import urlencode
 
 from docxtpl import DocxTemplate
 from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
 from fastapi.responses import (
     FileResponse,
     HTMLResponse,
+    JSONResponse,
     RedirectResponse,
     StreamingResponse,
 )
@@ -20,22 +21,20 @@ from sqlalchemy import text
 from sqlalchemy.orm import Session
 
 from app.file_storage import file_primary_key, get_file, get_files
-from app.query_filter import import_filter_config
+from app.query_filter import import_filter_config, filter_clauses
 
 from .auth import clear_session_cookie, safe_next_url, set_session_cookie
 from .comment_submission import submit_comment
 from .config import settings
 from .dashboard_segment import (
     DASHBOARD_VIEWS,
-    pack_segment,
     parse_segment,
-    segment_label,
-    segment_sql,
+    segment_filters,
 )
 from .db import get_session
 from .docuware_search import get_invoice_pdf_by_document_no
 from .forderung_dashboard import STACKS, build_dashboard
-from .forderung_data import read_invoices
+from .forderung_data import read_invoices, read_filter_suggestions
 from .forderung_history import HistoryScope, history_context, section_context
 from .forderung_summary import get_forderung_summary
 from .jinja_templates import templates
@@ -178,6 +177,22 @@ def select_get(request: Request):
     return templates.TemplateResponse(request, "base/select.html", {"request": request})
 
 
+def _list_filter_params(request, config, db, username):
+    params = {f.name: request.query_params[f.name] for f in config if request.query_params.get(f.name, "") != ""}
+    try:
+        legacy = parse_segment(request.query_params.get("dashboard_segment"))
+    except ValueError as error:
+        raise HTTPException(400, "Ungültiger Dashboardlink") from error
+    names = None
+    if legacy and legacy["view"] == "Verursacher" and "id" in legacy["key"] and "Verursacher" not in params:
+        columns, rows = read_invoices(db, username, ["F.[Verursacher_Benutzer_ID] = :legacy_person"],
+                                     {"legacy_person": legacy["key"]["id"]})
+        names = list(dict.fromkeys(row[columns.index("Verursacher_Text")] for row in rows))
+        if not names:
+            names = [legacy["key"]["id"]]
+    return {**segment_filters(legacy, names), **params}
+
+
 @router.get("/app/forderungen/liste", response_class=HTMLResponse)
 def forderungen_liste(
     request: Request, db: Session = Depends(get_session), limit: int = 100, page: int = 0, focus: str | None = None
@@ -185,17 +200,7 @@ def forderungen_liste(
     principal = current_principal(request)
     forderung_filter_config = import_filter_config("forderungen")
 
-    params = {
-        f.name: request.query_params[f.name]
-        for f in forderung_filter_config
-        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)
+    params = _list_filter_params(request, forderung_filter_config, db, principal.username)
     if not valid_focus(focus):
         focus = None
     if page == 0:
@@ -203,25 +208,16 @@ def forderungen_liste(
         if request.url.path + ("?" + request.url.query if request.url.query else "") != canonical:
             return RedirectResponse(url=canonical)
 
-    query_clauses = []
-    query_values = {"auth_username": principal.username}
+    query_clauses, query_values = filter_clauses(forderung_filter_config, params)
     for f in forderung_filter_config:
-        f.current_value = unquote(params.get(f.name, f.default_value))
-        if f.filter_type == "hidden" or not f.current_value:
-            continue
-        parameter = f"filter_{f.name.lower()}"
-        if f.filter_type == "date":
-            operator = "<=" if "bis" in f.name.lower() else ">="
-            query_clauses.append(f"F.[{f.key_column}] {operator} :{parameter}")
-            query_values[parameter] = f.current_value
-        else:
-            query_clauses.append(f"F.[{f.key_column}] LIKE :{parameter}")
-            query_values[parameter] = f"%{f.current_value}%"
+        f.current_value = params.get(f.name, f.default_value)
 
-    if selection:
-        clauses, values = segment_sql(selection)
-        query_clauses.extend(clauses)
-        query_values.update(values)
+    selected_options = {}
+    if page == 0:
+        for f in forderung_filter_config:
+            if f.filter_type == "select" and f.key_column != f.value_column and f.selected_values:
+                items = read_filter_suggestions(db, principal.username, f, [], {}, selected=f.selected_values)
+                selected_options[f.name] = {item['value']: item['label'] for item in items}
     col_names, q = read_invoices(db, principal.username, query_clauses, query_values)
 
     filters = {}
@@ -244,7 +240,7 @@ def forderungen_liste(
             }
         else:
             filters[f.name] = {}
-        f.options = filters[f.name]
+        f.options = {**selected_options.get(f.name, {}), **filters[f.name]}
 
     summary = {
         "Beleg_Offen": sum((Decimal(str(r[col_names.index("Beleg_Offen")] or 0)) for r in q), Decimal(0)),
@@ -264,7 +260,7 @@ def forderungen_liste(
 
     defaults = {}
     for f in forderung_filter_config:
-        defaults[f.name] = unquote(request.query_params.get(f.name, f.default_value))
+        defaults[f.name] = params.get(f.name, f.default_value)
         f.current_value = defaults[f.name]
 
     if page > 0 and not q_limit:
@@ -291,18 +287,24 @@ def forderungen_liste(
             "focus": focus if page == 0 else None,
             "focus_missing": bool(page == 0 and focus and not focus_found),
             "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", **({"HX-Push-Url": list_url(params, focus)} if page == 0 else {})},
     )
 
 
+@router.get("/app/forderungen/liste/vorschlaege")
+def forderungen_vorschlaege(request: Request, field: str, search: str = "", db: Session = Depends(get_session)):
+    principal = current_principal(request)
+    config = import_filter_config("forderungen")
+    target = next((f for f in config if f.name == field and f.filter_type in {"select", "text"}), None)
+    if target is None:
+        raise HTTPException(400, "Ungültiges Vorschlagsfeld")
+    params = _list_filter_params(request, config, db, principal.username)
+    clauses, values = filter_clauses(config, params, exclude=field)
+    items = read_filter_suggestions(db, principal.username, target, clauses, values, search)
+    return JSONResponse({"items": items[:50], "has_more": len(items) > 50}, headers={"Cache-Control": "no-store"})
+
+
 @router.get("/app/forderungen/details/{primary_key}", response_class=HTMLResponse)
 def forderungen_details(request: Request, primary_key: str, db: Session = Depends(get_session)):
     principal = current_principal(request)

+ 20 - 1
static/assets/css/list.css

@@ -5,12 +5,31 @@ body:has(.gc-list-page) { max-width: 1840px; }
 .gc-list-page * { min-width: 0; }
 .gc-list-page h1 { font-size: 1.75rem; font-weight: 700; margin: 0; }
 .gc-list-filter-content { padding: 1rem; }
-.gc-list-filters { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.25rem; }
+.gc-list-filters { position: relative; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.25rem; }
+.gc-filter-loading { display: none; position: absolute; inset: 0; z-index: 1040; align-items: center; justify-content: center; background: #ffffffb3; cursor: wait; }
+.gc-list-filters.htmx-request .gc-filter-loading { display: flex; }
+.gc-list-filters.htmx-request .gc-filter-group { opacity: .55; }
+.gc-filter-loading-message { display: flex; align-items: center; gap: .75rem; padding: .85rem 1rem; border: 1px solid #b1bcc7; border-radius: 6px; background: #fff; color: #182630; box-shadow: 0 3px 12px #18263020; }
+.gc-filter-spinner { width: 1.5rem; height: 1.5rem; flex-shrink: 0; border: 3px solid #dce2e7; border-top-color: #354f63; border-radius: 50%; animation: gc-filter-spin .8s linear infinite; }
+@keyframes gc-filter-spin { to { transform: rotate(360deg); } }
+@media (prefers-reduced-motion: reduce) { .gc-filter-spinner { animation: none; } }
 .gc-filter-group { margin: 0; padding: 0; border: 0; }
 .gc-filter-group + .gc-filter-group { padding-left: 1.25rem; border-left: 1px solid #dce2e7; }
 .gc-filter-group legend { float: none; width: auto; font-size: 1rem; font-weight: 600; margin: 0 0 .75rem; }
 .gc-filter-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .8rem; }
 .gc-filter-field label { display: block; font-size: 1rem; color: #52616f; margin-bottom: .3rem; }
+.gc-multifilter { position: relative; }
+.gc-filter-toggle { width: 100%; text-align: left; background-color: #fff; }
+.gc-filter-chips { display: flex; flex-wrap: wrap; gap: .3rem; }
+.gc-filter-chip { border: 1px solid #b1bcc7; border-radius: 5px; background: #edf2f5; color: #182630; padding: .2rem .4rem; margin-bottom: .3rem; font-size: .9375rem; overflow-wrap: anywhere; text-align: left; }
+.gc-filter-dropdown { position: absolute; top: 100%; left: 0; z-index: 1030; width: max(100%, 20rem); max-width: calc(100vw - 3rem); max-height: 70vh; overflow-y: auto; padding: .75rem; background: #fff; border: 1px solid #b1bcc7; border-radius: 6px; box-shadow: 0 4px 18px #18263030; }
+.gc-filter-options { max-height: 15rem; overflow-y: auto; margin-top: .5rem; }
+.gc-filter-options label { display: flex; align-items: baseline; gap: .5rem; padding: .35rem; color: #182630; overflow-wrap: anywhere; cursor: pointer; }
+.gc-filter-options label:focus-within { outline: 2px solid #526b7e; outline-offset: -2px; }
+.gc-filter-options input { flex-shrink: 0; }
+.gc-filter-status { margin: .5rem 0; font-size: .9375rem; }
+.gc-filter-dropdown-actions { display: flex; justify-content: space-between; gap: .5rem; }
+.gc-filter-dropdown .btn { border: 1px solid #b1bcc7; }
 .gc-list-page :is(.form-control, .form-select) { font-size: 1.0625rem; min-height: 42px; border-color: #b1bcc7; border-radius: 6px; }
 .gc-list-page :is(.form-control, .form-select):focus { border-color: #526b7e; box-shadow: 0 0 0 3px #72889b33; }
 .gc-list-page input[type="date"]::-webkit-datetime-edit-year-field,

+ 176 - 0
static/assets/js/list-filters.js

@@ -0,0 +1,176 @@
+/* Only the named hidden input participates in GET/HTMX serialization. */
+(() => {
+  if (window.gcListFiltersInstalled) return;
+  window.gcListFiltersInstalled = true;
+  const states = new WeakMap();
+  let active;
+  let restoreFocus;
+  const encode = value => encodeURIComponent(value).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
+  const queryString = params => {
+    const multiFields = new Set([...document.querySelectorAll('.gc-multifilter')].map(root => root.dataset.field));
+    return [...params].map(([name, value]) => {
+      const pair = new URLSearchParams([[name, value]]).toString();
+      // %252C belongs to a value; only the encoded separators are replaced.
+      return multiFields.has(name) ? pair.replace(/%2C/gi, ',') : pair;
+    }).join('&');
+  };
+  document.addEventListener('htmx:configRequest', event => {
+    const config = event.detail;
+    if (config.verb !== 'get') return;
+    const url = new URL(config.path, location.href);
+    if (url.origin !== location.origin || url.pathname !== '/app/forderungen/liste') return;
+    for (const [name, value] of Object.entries(config.parameters)) {
+      url.searchParams.delete(name);
+      for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(name, item);
+    }
+    const query = queryString(url.searchParams);
+    config.path = url.pathname + (query ? `?${query}` : '') + url.hash;
+    config.parameters = {};
+  });
+  const state = root => {
+    if (!states.has(root)) states.set(root, {
+      selected: new Map(JSON.parse(root.dataset.selected).map(item => [item.value, item.label])),
+      input: root.querySelector('input[type=hidden]'),
+      panel: root.querySelector('.gc-filter-dropdown'),
+      toggle: root.querySelector('.gc-filter-toggle'),
+      search: root.querySelector('.gc-filter-search'),
+      status: root.querySelector('.gc-filter-status'),
+      items: [], sequence: 0,
+      original: JSON.stringify(JSON.parse(root.dataset.selected).map(item => item.value)),
+    });
+    return states.get(root);
+  };
+  function render(root, renderOptions = true) {
+    const s = state(root);
+    const chips = root.querySelector('.gc-filter-chips');
+    chips.replaceChildren();
+    for (const [value, label] of s.selected) {
+      const chip = document.createElement('button');
+      chip.type = 'button'; chip.className = 'gc-filter-chip'; chip.dataset.remove = value;
+      chip.textContent = `${label} ×`; chip.setAttribute('aria-label', `${label} entfernen`);
+      chips.append(chip);
+    }
+    s.toggle.textContent = s.selected.size ? 'Auswahl bearbeiten' : 'Alle';
+    if (!renderOptions) return;
+    const options = root.querySelector('.gc-filter-options');
+    const focused = options.contains(document.activeElement) ? document.activeElement.value : null;
+    options.replaceChildren();
+    const items = new Map(s.items.map(item => [item.value, item.label]));
+    for (const [value, label] of s.selected) if (!items.has(value)) items.set(value, label);
+    for (const [value, label] of items) {
+      const row = document.createElement('label');
+      const check = document.createElement('input');
+      check.type = 'checkbox'; check.value = value; check.checked = s.selected.has(value);
+      check.dataset.option = ''; check.dataset.label = label;
+      row.append(check, document.createTextNode(label)); options.append(row);
+    }
+    if (focused !== null) [...options.querySelectorAll('input')].find(input => input.value === focused)?.focus();
+  }
+  function commit(root) {
+    const s = state(root);
+    const signature = JSON.stringify([...s.selected.keys()]);
+    if (signature === s.original) return;
+    const value = [...s.selected.keys()].map(encode).join(',');
+    s.original = signature;
+    s.input.value = value;
+    root.dataset.selected = JSON.stringify([...s.selected].map(([value, label]) => ({value, label})));
+    restoreFocus = s.toggle.id;
+    s.input.dispatchEvent(new Event('change', { bubbles: true }));
+  }
+  function close(root, focus = false) {
+    const s = state(root);
+    clearTimeout(s.timer); s.controller?.abort(); ++s.sequence;
+    s.panel.hidden = true; s.toggle.setAttribute('aria-expanded', 'false');
+    if (active === root) active = undefined;
+    if (focus) s.toggle.focus();
+    commit(root);
+  }
+  async function load(root) {
+    const s = state(root);
+    s.controller?.abort(); s.controller = new AbortController();
+    const sequence = ++s.sequence;
+    s.status.textContent = 'Vorschläge werden geladen …';
+    const params = new URLSearchParams(new FormData(root.closest('form')));
+    params.set('field', root.dataset.field); params.set('search', s.search.value);
+    try {
+      const response = await fetch(`/app/forderungen/liste/vorschlaege?${queryString(params)}`, {
+        signal: s.controller.signal, headers: { Accept: 'application/json' }, cache: 'no-store',
+      });
+      if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) throw new Error();
+      const data = await response.json();
+      if (sequence !== s.sequence || s.panel.hidden || !root.isConnected) return;
+      s.items = data.items;
+      // Refresh selected labels without changing their values or order.
+      for (const item of s.items) if (s.selected.has(item.value)) s.selected.set(item.value, item.label);
+      render(root);
+      s.status.textContent = data.has_more ? 'Weitere Treffer vorhanden. Bitte Suche eingrenzen.' :
+        data.items.length ? '' : 'Keine passenden Vorschläge.';
+    } catch (error) {
+      if (error.name !== 'AbortError' && sequence === s.sequence) s.status.textContent = 'Vorschläge konnten nicht geladen werden. Suche erneut eingeben.';
+    }
+  }
+  function add(root) {
+    const s = state(root), value = s.search.value.trim();
+    if (root.dataset.free !== 'true' || !value) return;
+    s.selected.set(value, value); s.search.value = ''; render(root); load(root); s.search.focus();
+  }
+  document.addEventListener('click', event => {
+    const root = event.target.closest('.gc-multifilter');
+    if (!root) return;
+    const s = state(root);
+    if (event.target.closest('.gc-filter-toggle')) {
+      if (!s.panel.hidden) { close(root, true); return; }
+      if (active && active !== root) close(active);
+      active = root; s.panel.hidden = false; s.toggle.setAttribute('aria-expanded', 'true');
+      render(root); s.search.focus(); load(root);
+      s.panel.style.transform = '';
+      const bounds = s.panel.getBoundingClientRect();
+      s.panel.style.transform = `translateX(${Math.max(8 - bounds.left, Math.min(0, innerWidth - 8 - bounds.right))}px)`;
+    } else if (event.target.closest('[data-remove]')) {
+      s.selected.delete(event.target.closest('[data-remove]').dataset.remove); render(root);
+      close(root, true);
+    } else if (event.target.closest('.gc-filter-clear')) {
+      s.selected.clear(); render(root); close(root, true);
+    } else if (event.target.closest('.gc-filter-done')) close(root, true);
+    else if (event.target.closest('.gc-filter-add')) add(root);
+  });
+  document.addEventListener('change', event => {
+    const root = event.target.closest('.gc-multifilter');
+    if (!root || event.target === state(root).input) return;
+    event.stopPropagation();
+    if (event.target.matches('[data-option]')) {
+      const s = state(root), check = event.target;
+      if (check.checked) s.selected.set(check.value, check.dataset.label); else s.selected.delete(check.value);
+      // Keep the focused checkbox in place while updating the chips.
+      render(root, false);
+    }
+  }, true);
+  document.addEventListener('input', event => {
+    if (!event.target.matches('.gc-filter-search')) return;
+    const root = event.target.closest('.gc-multifilter'), s = state(root);
+    clearTimeout(s.timer); s.controller?.abort(); ++s.sequence;
+    s.timer = setTimeout(() => load(root), 250);
+  });
+  document.addEventListener('keydown', event => {
+    const root = event.target.closest('.gc-multifilter');
+    if (!root) return;
+    if (event.key === 'Escape' && !state(root).panel.hidden) { event.preventDefault(); close(root, true); }
+    if (event.key === 'Enter' && event.target.matches('.gc-filter-search')) { event.preventDefault(); add(root); }
+    if (event.key === 'ArrowDown' && event.target.matches('.gc-filter-search')) {
+      event.preventDefault(); root.querySelector('[data-option]')?.focus();
+    }
+  });
+  document.addEventListener('pointerdown', event => {
+    if (active && !active.contains(event.target)) close(active);
+  });
+  document.addEventListener('focusin', event => {
+    if (active && !active.contains(event.target)) close(active);
+  });
+  document.addEventListener('htmx:afterSettle', () => {
+    if (restoreFocus) { document.getElementById(restoreFocus)?.focus({ preventScroll: true }); restoreFocus = undefined; }
+    if (active && !active.isConnected) { state(active).controller?.abort(); active = undefined; }
+  });
+  document.addEventListener('htmx:beforeHistorySave', () => {
+    if (active) close(active);
+  });
+})();

+ 2 - 1
templates/base/base.html

@@ -27,12 +27,13 @@
   <link href="/static/assets/css/app.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?v=2" 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/navigation.js" defer></script>
   <script src="/static/assets/js/list.js" defer></script>
+  <script src="/static/assets/js/list-filters.js?v=2" defer></script>
   <script src="/static/assets/js/layout.js" defer></script>
   <script src="/static/assets/js/verursacher.js" defer></script>
   <script src="/static/assets/js/wiedervorlage.js" defer></script>

+ 0 - 5
templates/base/liste.html

@@ -4,11 +4,6 @@
 {% block content %}
 <div id="gc-list-state" data-list-url="{{ list_url }}" data-focus="{{ focus or '' }}" hidden></div>
 <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 %}
 <p class="gc-return-status" role="status">Die betreffende Rechnung ist in der aktualisierten Ergebnisliste nicht mehr enthalten. Ihre Filter wurden beibehalten.</p>
 {% endif %}

+ 29 - 17
templates/base/liste_filter.html

@@ -1,31 +1,43 @@
 {% macro filter_field(f) %}
 <div class="gc-filter-field">
+  {% if f.filter_type == 'date' %}
   <label for="{{ f.name }}">{{ f.text }}</label>
-  {% if f.filter_type == 'select' %}
-  <select id="{{ f.name }}" name="{{ f.name }}" class="form-select">
-    <option value="">Alle</option>
-    {% set selected = namespace(found=false) %}
-    {% for key, value in f.options.items() %}
-      {% set matches = key|string == f.current_value|string %}
-      {% if matches %}{% set selected.found = true %}{% endif %}
-      <option value="{{ key }}"{% if matches %} selected{% endif %}>{{ value }}</option>
-    {% endfor %}
-    {% if f.current_value and not selected.found %}
-    <option value="{{ f.current_value }}" selected>{{ f.current_value }}</option>
-    {% endif %}
-  </select>
-  {% elif f.filter_type == 'date' %}
   <input type="date" class="form-control" id="{{ f.name }}" name="{{ f.name }}" value="{{ f.current_value|date_format2 }}">
   {% else %}
-  <input type="text" class="form-control" id="{{ f.name }}" name="{{ f.name }}" value="{{ f.current_value or '' }}">
+  <div class="gc-multifilter" data-field="{{ f.name }}" data-free="{{ 'true' if f.filter_type == 'text' else 'false' }}"
+       data-selected='{{ f.selected_items|tojson }}'>
+    <label id="{{ f.name }}-label" for="{{ f.name }}-toggle">{{ f.text }}</label>
+    <input type="hidden" id="{{ f.name }}" name="{{ f.name }}" value="{{ f.current_value or '' }}">
+    <div class="gc-filter-chips" aria-label="Ausgewählte Werte für {{ f.text }}">
+      {% for item in f.selected_items %}
+      <button type="button" class="gc-filter-chip" data-remove="{{ item.value }}" aria-label="{{ item.label }} entfernen">{{ item.label }} ×</button>
+      {% endfor %}
+    </div>
+    <button type="button" id="{{ f.name }}-toggle" class="form-select gc-filter-toggle" aria-expanded="false"
+            aria-controls="{{ f.name }}-panel" aria-haspopup="dialog">{{ 'Auswahl bearbeiten' if f.selected_values else 'Alle' }}</button>
+    <div class="gc-filter-dropdown" id="{{ f.name }}-panel" role="dialog" aria-labelledby="{{ f.name }}-label" hidden>
+      <input type="search" class="form-control gc-filter-search" aria-label="{{ f.text }} durchsuchen" autocomplete="off"
+             placeholder="{{ 'Suchbegriff eingeben, mit Enter übernehmen' if f.filter_type == 'text' else 'Vorschläge durchsuchen' }}">
+      {% if f.filter_type == 'text' %}<button type="button" class="btn gc-filter-add">Suchbegriff übernehmen</button>{% endif %}
+      <div class="gc-filter-options"></div>
+      <p class="gc-filter-status" role="status" aria-live="polite"></p>
+      <div class="gc-filter-dropdown-actions">
+        <button type="button" class="btn gc-filter-clear">Auswahl leeren</button>
+        <button type="button" class="btn gc-filter-done">Übernehmen</button>
+      </div>
+    </div>
+  </div>
   {% endif %}
 </div>
 {% endmacro %}
 {% set organisation = ['Mandant', 'Filiale', 'Abteilung', 'Verursacher'] %}
 {% set beleg = ['RechnungGutschrift', 'Fahrzeug', 'Kunde', 'Rechnungsnummer', 'RechnungsdatumVon', 'RechnungsdatumBis'] %}
 <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">
-  {% if dashboard_segment %}<input type="hidden" name="dashboard_segment" value="{{ dashboard_segment }}">{% endif %}
+      hx-params="*" hx-trigger="change" hx-push-url="true" hx-swap="innerHTML" hx-target="body"
+      hx-indicator="#liste-filter" hx-disabled-elt="#liste-filter fieldset" hx-sync="this:drop">
+  <div class="gc-filter-loading" role="status" aria-live="polite" aria-atomic="true">
+    <span class="gc-filter-loading-message"><span class="gc-filter-spinner" aria-hidden="true"></span>Filter werden angewendet …</span>
+  </div>
   {% for group in ['Organisation', 'Kunde und Beleg', 'Bearbeitung und Wiedervorlage'] %}
   <fieldset class="gc-filter-group">
     <legend>{{ group }}</legend>

+ 1 - 1
templates/forderungen/dashboard/dashboard.html

@@ -44,7 +44,7 @@
     </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>
+  <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. Die Listenlinks öffnen die gesamte Gruppe; die Wiedervorlagestaffel wird dabei nicht als Filter übernommen.{% 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>

+ 3 - 9
templates/forderungen/liste/summary.html

@@ -8,18 +8,12 @@
   <div class="gc-summary-inner">
     <div><span>Treffer</span> <strong id="list-count">{{ summary.Anzahl }}</strong></div>
     <details class="gc-active-filters">
-      <summary>Aktive Filter ({{ active.filters|length + (1 if dashboard_segment else 0) }})</summary>
+      <summary>Aktive Filter ({{ active.filters|length }})</summary>
       <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 %}
-        {% set display = namespace(value=f.current_value) %}
-        {% for key, value in f.options.items() %}
-          {% if key|string == f.current_value|string %}{% set display.value = value %}{% endif %}
-        {% endfor %}
-        <div><strong>{{ f.text }}</strong><span>{{ display.value }}</span></div>
+        <div><strong>{{ f.text }}</strong><span>{% if f.filter_type == 'date' %}{{ f.current_value }}{% else %}{{ f.selected_items|map(attribute='label')|join(', ') }}{% endif %}</span></div>
         {% else %}
-        {% if not dashboard_segment %}<p>Keine zusätzlichen Filter aktiv.</p>{% endif %}
+        <p>Keine zusätzlichen Filter aktiv.</p>
         {% endfor %}
         <a href="#liste-filter" hx-boost="false">Zu den Filtern ↑</a>
       </div>

+ 112 - 26
tests/test_dashboard.py

@@ -9,10 +9,12 @@ from decimal import Decimal
 from unittest.mock import patch
 from urllib.parse import parse_qs, urlencode, urlsplit
 
-from app.dashboard_segment import pack_segment, parse_segment, segment_sql
+from app.dashboard_segment import pack_segment, parse_segment
 from app.forderung_dashboard import BERLIN, build_dashboard, color
 from app.navigation import list_return_url, normalize_page_url
 from tests import test_verursacher as fixture
+from tests.test_forderung_list import invoice
+from app.query_filter import decode_filter_values
 
 
 class DashboardTests(unittest.TestCase):
@@ -24,7 +26,7 @@ class DashboardTests(unittest.TestCase):
         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))
+        needed = set(invoice()._fields) | 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 = (
@@ -38,7 +40,7 @@ class DashboardTests(unittest.TestCase):
             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);
+            CREATE TABLE dbo.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;
@@ -64,6 +66,23 @@ class DashboardTests(unittest.TestCase):
         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'"
         )
+        # The production query now reads a DWH view. Model that view in SQLite;
+        # keep the detail overlays live so update/authorization tests still run real SQL.
+        overlays = {
+            'Verursacher_Benutzer_ID': 'COALESCE(D.Verursacher_Benutzer_ID, F.Verursacher_Benutzer_ID)',
+            'Verursacher_Text': 'COALESCE(B.Name, D.Verursacher_Benutzer_ID, F.Verursacher_Text)',
+            'Wiedervorlage': 'COALESCE(D.Wiedervorlage, F.Beleg_Faellig_Datum)',
+            'Bearbeitet': "COALESCE(D.Bearbeitet, 'N')",
+            'Abwarten': "CASE WHEN D.Reklamation='J' THEN 'Rekl.' WHEN D.Versicherung='J' THEN 'Vers.' "
+                        "WHEN D.Rechtsanwalt='J' THEN 'RA' WHEN D.Mahnen_aussetzen='J' THEN 'N' ELSE '' END",
+        }
+        projection = ', '.join(f'{overlays.get(name, "F.[" + name + "]")} AS [{name}]' for name in columns)
+        self.db.connection.execute(
+            'CREATE VIEW dbo.Forderungen_Liste AS SELECT ' + projection +
+            ' FROM Forderungen_Kopf F LEFT JOIN Forderungen_Details D ON F.Client_DB=D.Client_DB AND F.Beleg_Nr=D.Beleg_Nr'
+            ' LEFT JOIN Benutzer_Rolle B ON D.Verursacher_Benutzer_ID=B.Benutzer_ID'
+            ' LEFT JOIN Garantie2 G ON F.Client_DB=G.Client_DB AND F.Beleg_Nr=G.Beleg_Nr'
+        )
         self.db.connection.commit()
         execute = self.db.execute
 
@@ -75,6 +94,7 @@ class DashboardTests(unittest.TestCase):
                 )
             query = (
                 query.replace("isnull(", "ifnull(")
+                .replace("nvarchar(max)", "TEXT")
                 .replace("dateadd(day, 4, getdate())", "'2026-11-06'")
                 .replace("getdate()", "'2026-11-02'")
             )
@@ -99,7 +119,10 @@ class DashboardTests(unittest.TestCase):
                         return rows
 
                 return SQLRows()
-            return execute(query, values)
+            result = execute(query, values)
+            if ' AS value, MIN(' in query:
+                result.rows = [tuple(row.values()) for row in result.rows]
+            return result
 
         adapter = patch.object(self.db, "execute", side_effect=dialect)
         adapter.start()
@@ -131,11 +154,12 @@ class DashboardTests(unittest.TestCase):
             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"])
+                    params = parse_qs(urlsplit(item["url"]).query)
+                    self.assertEqual(params['RechnungGutschrift'], ['Rechnung'])
+                    self.assertNotIn('dashboard_segment', params)
                     response = self.client.get(item["url"])
                     self.assertIn('id="list-count">' + str(item["count"]), response.text)
-                    self.assertIn("Nur Rechnungen", response.text)
+                    self.assertIn('name="RechnungGutschrift" value="Rechnung"', response.text)
         self.db.connection.execute(
             "UPDATE dbo.Forderungen_Kopf SET Rechnung_Gutschrift='Rechnungskorrektur' WHERE Beleg_Nr='CR'"
         )
@@ -170,9 +194,9 @@ class DashboardTests(unittest.TestCase):
                         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)
+                        self.assertNotIn('name="dashboard_segment"', response.text)
+                        self.assertNotIn("Dashboardfilter entfernen", response.text)
+                        self.assertIn("Aktive Filter (", response.text)
                         from app.jinja_templates import number_format
 
                         self.assertIn(number_format(Decimal(segment["amount"])) + " €", response.text)
@@ -215,8 +239,11 @@ class DashboardTests(unittest.TestCase):
         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]['url'], group['url'])
+        params = parse_qs(urlsplit(group['url']).query)
+        self.assertEqual(decode_filter_values(params['Staffel'][0]), ['< 4 Wochen'])
+        self.assertNotIn('WiedervorlageVon', params)
+        self.assertNotIn('WiedervorlageBis', params)
         self.assertEqual(group["segments"][0]["id"], "soon")
 
     def test_follow_up_stack_roundtrips_for_all_rubrics_and_invoice_scope(self):
@@ -227,12 +254,15 @@ class DashboardTests(unittest.TestCase):
                 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)
+                        params = parse_qs(urlsplit(segment['url']).query)
+                        self.assertNotIn('dashboard_segment', params)
+                        self.assertNotIn('WiedervorlageVon', params)
+                        self.assertNotIn('WiedervorlageBis', params)
+                        self.assertNotIn('Abteilung', params)
+                        self.assertEqual(segment['url'], group['url'])
                         response = self.client.get(segment["url"])
                         self.assertEqual(response.status_code, 200)
-                        self.assertIn('id="list-count">' + str(segment["count"]), response.text)
+                        self.assertIn('id="list-count">' + str(group["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")
@@ -251,16 +281,17 @@ class DashboardTests(unittest.TestCase):
         )
         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)
+        self.assertIn('id="list-count">5', response.text)
+        self.assertEqual(urlsplit(str(response.url)).query, '')
         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"])
+    def test_same_names_share_normal_filter_but_clients_remain_separate(self):
+        groups = [group for group in self.data()["views"]["Verursacher"] if group["label"] == 'Alice']
+        self.assertEqual(len(groups), 1)
+        self.assertEqual(groups[0]['count'], 2)
+        self.assertEqual(parse_qs(urlsplit(groups[0]['url']).query), {'Verursacher': ['Alice']})
         self.db.connection.execute("INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','2','10','Service')")
         groups = self.data()["views"]["Standort"]
         self.assertEqual(len(groups), 2)
@@ -295,7 +326,62 @@ class DashboardTests(unittest.TestCase):
         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)
+        self.assertIn('id="list-count">4', partial.text)
+
+    def test_dashboard_filters_can_be_changed_without_hidden_constraints(self):
+        from app.navigation import list_url
+        group = self.data()['views']['Standort'][0]
+        segment = next(item for item in group['segments'] if item['department'] == 'Service')
+        params = {name: values[0] for name, values in parse_qs(urlsplit(segment['url']).query).items()}
+        self.assertEqual(params, {'Mandant': '1', 'Filiale': '10', 'Abteilung': 'Service'})
+        response = self.client.get(segment['url'])
+        self.assertIn('id="list-count">4', response.text)
+        self.assertIn('data-remove="Service"', response.text)
+        params.pop('Abteilung')
+        response = self.client.get(list_url(params))
+        self.assertIn('id="list-count">5', response.text)
+        self.assertNotIn('name="dashboard_segment"', response.text)
+        params['Staffel'] = '%3C%204%20Wochen'
+        response = self.client.get(list_url(params))
+        self.assertIn('id="list-count">1', response.text)
+
+    def test_old_dashboard_link_redirects_to_visible_filters(self):
+        legacy = pack_segment({'view': 'Standort', 'key': {'client': '1', 'site': '10'}, 'department': 'Service'})
+        response = self.client.get('/app/forderungen/liste', params={'dashboard_segment': legacy}, follow_redirects=False)
+        self.assertEqual(response.status_code, 307)
+        self.assertEqual(response.headers['location'], '/app/forderungen/liste?Mandant=1&Filiale=10&Abteilung=Service')
+        response = self.client.get(response.headers['location'])
+        self.assertIn('id="list-count">4', response.text)
+        self.assertIn('data-remove="Service"', response.text)
+        self.assertNotIn('dashboard_segment', response.text)
+        self.assertNotIn('Dashboardfilter', response.text)
+
+    def test_old_person_id_is_translated_to_normal_name_filter(self):
+        legacy = pack_segment({'view': 'Verursacher', 'key': {'id': 'alice'}, 'only_invoices': True})
+        response = self.client.get('/app/forderungen/liste', params={'dashboard_segment': legacy})
+        params = parse_qs(urlsplit(str(response.url)).query)
+        self.assertEqual(params, {'Verursacher': ['Alice'], 'RechnungGutschrift': ['Rechnung']})
+        self.assertIn('id="list-count">2', response.text)
+        self.assertIn('data-remove="Alice"', response.text)
+        self.assertNotIn('dashboard_segment', response.text)
+
+    def test_explicit_filters_override_old_link_and_login_recovers_visible_filters(self):
+        legacy = pack_segment({'view': 'Standort', 'key': {'client': '1', 'site': '10'}, 'department': 'Service'})
+        response = self.client.get('/app/forderungen/liste', params={'dashboard_segment': legacy, 'Filiale': '20'})
+        self.assertEqual(parse_qs(urlsplit(str(response.url)).query)['Filiale'], ['20'])
+        self.assertIn('id="list-count">0', response.text)
+        self.client.cookies.clear()
+        response = self.client.get('/app/forderungen/liste', params={'dashboard_segment': legacy}, headers={'HX-Request': 'true'})
+        target = parse_qs(urlsplit(response.headers['HX-Redirect']).query)['next'][0]
+        self.assertEqual(target, '/app/forderungen/liste?Mandant=1&Filiale=10&Abteilung=Service')
+
+    def test_new_links_preserve_names_with_commas_and_spaces(self):
+        self.db.connection.execute("UPDATE dbo.Benutzer_Rolle SET Name='Link, Florian' WHERE Benutzer_ID='alice'")
+        group = next(item for item in self.data()['views']['Verursacher'] if item['label'] == 'Link, Florian')
+        self.assertIn('Verursacher=Link%252C%2520Florian', group['url'])
+        response = self.client.get(group['url'])
+        self.assertIn('id="list-count">1', response.text)
+        self.assertIn('data-remove="Link, Florian"', response.text)
 
     def test_forged_segment_cannot_expand_invoice_access(self):
         raw = pack_segment({"view": "Standort", "key": {"client": "2", "site": "10"}})
@@ -306,9 +392,9 @@ class DashboardTests(unittest.TestCase):
 
     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]
+        filters = parse_qs(urlsplit(url).query)
         returned = list_return_url(url, "1_R1")
-        self.assertEqual(parse_qs(urlsplit(returned).query)["dashboard_segment"], [selection])
+        self.assertEqual(parse_qs(urlsplit(returned).query), {**filters, 'focus': ['1_R1']})
         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
@@ -318,7 +404,7 @@ class DashboardTests(unittest.TestCase):
         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.assertIn("F.[Client_DB] = :filter_mandant", sql)
         self.assertEqual(values["filter_kunde"], "%Test%")
         self.client.cookies.clear()
         response = self.client.get(url, headers={"HX-Request": "true"})

+ 4 - 4
tests/test_forderung_list.py

@@ -111,13 +111,13 @@ class ForderungListTests(unittest.TestCase):
     def test_empty_results_preserve_selected_filter_and_query_parameters(self):
         response = self.client.get("/app/forderungen/liste?Filiale=10&Kunde=Nichtgefunden")
         self.assertEqual(response.status_code, 200)
-        self.assertIn('<option value="10" selected>10</option>', response.text)
+        self.assertIn('data-remove="10"', response.text)
         self.assertIn('value="Nichtgefunden"', response.text)
         sql, params = self.db.calls[-1]
         self.assertEqual(params["auth_username"], "tester")
-        self.assertEqual(params["filter_filiale"], "%10%")
+        self.assertEqual(params["filter_filiale"], "10")
         self.assertEqual(params["filter_kunde"], "%Nichtgefunden%")
-        self.assertIn("F.[Standort_ID] LIKE :filter_filiale", sql)
+        self.assertIn("F.[Standort_ID] = :filter_filiale", sql)
 
     def test_summary_uses_all_matches_and_paging_preserves_filter_form(self):
         self.db.rows = [invoice(n) for n in range(103)]
@@ -194,7 +194,7 @@ class ForderungListTests(unittest.TestCase):
     def test_missing_focus_preserves_filters_and_explains_missing_row(self):
         response = self.client.get("/app/forderungen/liste?Filiale=10&focus=1_R404")
         self.assertIn("nicht mehr enthalten", response.text)
-        self.assertIn('<option value="10" selected>10</option>', response.text)
+        self.assertIn('data-remove="10"', response.text)
         self.assertIn("Keine Forderungen", response.text)
 
     def test_detail_links_keep_filters_including_on_loaded_fragments(self):

+ 228 - 0
tests/test_list_filters.py

@@ -0,0 +1,228 @@
+"""Real predicate and suggestion queries on SQLite with only TOP/type syntax adapted."""
+import re
+import sqlite3
+import unittest
+from collections import namedtuple
+from datetime import date
+from types import SimpleNamespace
+from urllib.parse import parse_qs, urlencode, urlsplit
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app.db import get_session
+from app.navigation import list_return_url, list_url
+from app.query_filter import decode_filter_values, encode_filter_values
+from app.routes import router
+from tests.test_forderung_list import invoice
+
+
+class FilterEncodingTests(unittest.TestCase):
+    def test_separator_commas_remain_literal(self):
+        self.assertEqual(list_url({'Mandant': '1,2'}), '/app/forderungen/liste?Mandant=1,2')
+        encoded = encode_filter_values(['Müller, GmbH', 'Meier'])
+        url = list_url({'Kunde': encoded})
+        self.assertTrue(url.endswith('GmbH,Meier'))
+        self.assertIn('%252C', url)
+        self.assertNotIn('%2C', url)
+        self.assertEqual(decode_filter_values(parse_qs(urlsplit(url).query)['Kunde'][0]), ['Müller, GmbH', 'Meier'])
+        self.assertTrue(list_return_url(url, '1_R1').endswith('GmbH,Meier&focus=1_R1'))
+
+    def test_special_values_survive_get_and_return_navigation(self):
+        values = ['Müller, GmbH', '100% Service', '"A&B"', 'A+B', '%2C']
+        encoded = encode_filter_values(values)
+        url = list_return_url(list_url({'Kunde': encoded}), '1_R1')
+        parameter = parse_qs(urlsplit(url).query)['Kunde'][0]
+        self.assertEqual(decode_filter_values(parameter), values)
+        self.assertIn('%252C', url)
+
+    def test_normalization_and_legacy_single_values(self):
+        self.assertEqual(decode_filter_values('Muster'), ['Muster'])
+        self.assertEqual(decode_filter_values(', A, A,,B ,'), ['A', 'B'])
+        self.assertEqual(encode_filter_values([' A ', '', 'A', 'B']), 'A,B')
+
+
+class SQLiteListDB:
+    def __init__(self):
+        self.connection = sqlite3.connect(':memory:', check_same_thread=False)
+        self.connection.execute("ATTACH DATABASE ':memory:' AS dbo")
+        fields = invoice()._fields
+        amounts = {'Beleg_Betrag', 'Beleg_Offen', 'Kunde_offen_gesamt'}
+        self.connection.execute('CREATE TABLE dbo.Forderungen_Liste (' +
+                                ','.join(f'[{name}] {"REAL" if name in amounts else "TEXT"}' for name in fields) +
+                                ', Hauptbetrieb_ID TEXT)')
+        self.connection.execute('CREATE TABLE dbo.Benutzer_Rechte (Benutzer_ID TEXT, Hauptbetrieb_ID TEXT, Standort_ID TEXT, Abteilung TEXT)')
+        self.connection.executemany('INSERT INTO dbo.Benutzer_Rechte VALUES (?,?,?,?)',
+                                    [('tester', '1', '10', 'SC'), ('tester', '1', '20', 'SC')])
+        self.calls = []
+
+    def add(self, number, **changes):
+        record = invoice(number, **changes)
+        self.connection.execute('INSERT INTO dbo.Forderungen_Liste VALUES (' + ','.join('?' for _ in record) + ",?)",
+                                (*(value.isoformat() if isinstance(value, date) else value for value in record), '1'))
+
+    def execute(self, query, values):
+        sql = str(query)
+        self.calls.append((sql, dict(values)))
+        top = re.search(r'SELECT TOP (\d+)', sql)
+        if top:
+            sql = re.sub(r'SELECT TOP \d+', 'SELECT', sql, count=1) + ' LIMIT ' + top[1]
+        sql = sql.replace('nvarchar(max)', 'TEXT')
+        cursor = self.connection.execute(sql, values)
+        columns = [item[0] for item in cursor.description]
+        record = namedtuple('Result', columns)
+        rows = []
+        for raw in cursor.fetchall():
+            data = dict(zip(columns, raw))
+            for name in ('Beleg_Datum', 'Beleg_Faellig_Datum', 'Wiedervorlage'):
+                if data.get(name):
+                    data[name] = date.fromisoformat(data[name])
+            rows.append(record(**data))
+        return SimpleNamespace(keys=lambda: columns, fetchall=lambda: rows)
+
+
+class MultiFilterTests(unittest.TestCase):
+    def setUp(self):
+        self.db = SQLiteListDB()
+        self.addCleanup(self.db.connection.close)
+        self.app = FastAPI()
+        self.app.include_router(router)
+        self.app.dependency_overrides[get_session] = lambda: self.db
+
+        @self.app.middleware('http')
+        async def principal(request, call_next):
+            request.state.principal = SimpleNamespace(username='tester', name='Tester', role='Buchhaltung', csrf='csrf')
+            return await call_next(request)
+
+        self.client = TestClient(self.app)
+        self.addCleanup(self.client.close)
+        self.db.add(1, Kunde_Text='Müller, GmbH')
+        self.db.add(2, Standort_ID='20', Standort_Name='München', Kunde_Text='Meier')
+        self.db.add(3, Kunde_Text='Anderer Kunde')
+        self.db.add(4, Standort_ID='100', Kunde_Text='Geheimer Kunde')
+
+    def test_or_within_fields_and_between_fields_and_exact_select(self):
+        response = self.client.get('/app/forderungen/liste', params={'Filiale': '10,20', 'Kunde': 'Müller,Meier'})
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('id="list-count">2', response.text)
+        sql, values = self.db.calls[-1]
+        self.assertIn('(F.[Standort_ID] = :filter_filiale_0 OR F.[Standort_ID] = :filter_filiale_1)', sql)
+        self.assertEqual(values['filter_kunde_0'], '%Müller%')
+        response = self.client.get('/app/forderungen/liste?Filiale=1')
+        self.assertIn('id="list-count">0', response.text)
+
+    def test_encoded_separators_redirect_to_literal_commas(self):
+        response = self.client.get('/app/forderungen/liste?Mandant=1%2C2', follow_redirects=False)
+        self.assertEqual(response.status_code, 307)
+        self.assertEqual(response.headers['location'], '/app/forderungen/liste?Mandant=1,2')
+        response = self.client.get(response.headers['location'])
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('data-list-url="/app/forderungen/liste?Mandant=1,2"', response.text)
+
+    def test_htmx_response_sets_canonical_address_even_for_old_clients(self):
+        response = self.client.get('/app/forderungen/liste?Mandant=1%2C2', headers={'HX-Request': 'true'})
+        self.assertEqual(response.headers['HX-Push-Url'], '/app/forderungen/liste?Mandant=1,2')
+        self.assertIn('/static/assets/js/list-filters.js?v=2', response.text)
+
+    def test_paging_does_not_push_a_new_address(self):
+        for number in range(110):
+            self.db.add(100 + number)
+        response = self.client.get('/app/forderungen/liste?page=1&Mandant=1%2C2', headers={'HX-Request': 'true'})
+        self.assertEqual(response.status_code, 200)
+        self.assertNotIn('HX-Push-Url', response.headers)
+
+    def test_dates_remain_inclusive(self):
+        response = self.client.get('/app/forderungen/liste?RechnungsdatumVon=2026-10-01&RechnungsdatumBis=2026-10-01')
+        self.assertIn('id="list-count">3', response.text)
+        sql, _ = self.db.calls[-1]
+        self.assertIn('F.[Beleg_Datum] >= :filter_rechnungsdatumvon', sql)
+        self.assertIn('F.[Beleg_Datum] <= :filter_rechnungsdatumbis', sql)
+
+    def test_comma_value_survives_render_and_detail_return(self):
+        value = encode_filter_values(['Müller, GmbH', 'Meier'])
+        response = self.client.get(list_url({'Kunde': value}))
+        self.assertIn('id="list-count">2', response.text)
+        self.assertIn('data-remove="Müller, GmbH"', response.text)
+        self.assertIn('value="M%C3%BCller%2C%20GmbH,Meier"', response.text)
+        self.assertIn('Müller, GmbH, Meier', response.text)
+
+    def test_suggestions_ignore_own_filter_and_keep_other_filters_and_rights(self):
+        response = self.client.get('/app/forderungen/liste/vorschlaege',
+                                   params={'field': 'Kunde', 'Kunde': 'Müller', 'Filiale': '10'})
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual({item['value'] for item in response.json()['items']}, {'Müller, GmbH', 'Anderer Kunde'})
+        sql, values = self.db.calls[-1]
+        self.assertNotIn('filter_kunde', sql)
+        self.assertEqual(values['auth_username'], 'tester')
+        self.assertEqual(values['filter_filiale'], '10')
+        self.assertEqual(response.headers['cache-control'], 'no-store')
+
+    def test_suggestions_search_labels_and_limit_distinct_values(self):
+        response = self.client.get('/app/forderungen/liste/vorschlaege?field=Filiale&search=München')
+        self.assertEqual(response.json()['items'], [{'value': '20', 'label': 'München'}])
+        for number in range(60):
+            self.db.add(100 + number, Kunde_Text=f'Firma {number:02}')
+        self.db.add(999, Kunde_Text='Firma 00')
+        data = self.client.get('/app/forderungen/liste/vorschlaege?field=Kunde&search=Firma').json()
+        self.assertEqual(len(data['items']), 50)
+        self.assertTrue(data['has_more'])
+        self.assertEqual(data['items'][0]['value'], 'Firma 00')
+
+    def test_selected_labels_survive_empty_results(self):
+        response = self.client.get('/app/forderungen/liste?Filiale=20&Kunde=Unbekannt')
+        self.assertIn('id="list-count">0', response.text)
+        self.assertIn('München entfernen', response.text)
+        self.assertIn('data-remove="20"', response.text)
+
+    def test_empty_selection_is_visible_and_can_be_combined_with_other_values(self):
+        self.db.add(5, Verursacher_Text=None)
+        self.db.add(6, Verursacher_Text='')
+        response = self.client.get('/app/forderungen/liste?Verursacher=__empty__')
+        self.assertIn('id="list-count">2', response.text)
+        self.assertIn('Ohne Wert entfernen', response.text)
+        response = self.client.get('/app/forderungen/liste?Verursacher=__empty__,dki')
+        self.assertIn('id="list-count">5', response.text)
+        data = self.client.get('/app/forderungen/liste/vorschlaege?field=Verursacher').json()
+        self.assertEqual(sum(item['value'] == '__empty__' for item in data['items']), 1)
+        self.assertIn({'value': '__empty__', 'label': 'Ohne Wert'}, data['items'])
+
+    def test_suggestions_retain_dashboard_segment(self):
+        from app.dashboard_segment import pack_segment
+        segment = pack_segment({'view': 'Standort', 'key': {'client': '1', 'site': '20'}})
+        data = self.client.get('/app/forderungen/liste/vorschlaege',
+                               params={'field': 'Kunde', 'dashboard_segment': segment}).json()
+        self.assertEqual(data['items'], [{'value': 'Meier', 'label': 'Meier'}])
+
+    def test_screenshot_dashboard_link_becomes_three_selected_filters(self):
+        from app.dashboard_segment import pack_segment
+        self.db.connection.execute("INSERT INTO dbo.Benutzer_Rechte VALUES ('tester','1','60','GA')")
+        self.db.add(88, Client_DB='2', Hauptbetrieb_Name='AAM', Standort_ID='60', Standort_Name='AAM', Abteilung='GA')
+        legacy = pack_segment({'department': 'GA', 'key': {'client': '2', 'site': '60'}, 'view': 'Standort'})
+        response = self.client.get('/app/forderungen/liste', params={'dashboard_segment': legacy})
+        expected = '/app/forderungen/liste?Mandant=2&Filiale=60&Abteilung=GA'
+        self.assertEqual(response.history[-1].headers['location'], expected)
+        self.assertTrue(str(response.url).endswith(expected))
+        self.assertEqual(response.headers['HX-Push-Url'], expected)
+        self.assertIn('id="list-count">1', response.text)
+        self.assertEqual(response.text.count('aria-label="AAM entfernen"'), 2)
+        self.assertIn('data-remove="GA"', response.text)
+        self.assertNotIn('dashboard_segment', response.text)
+        self.assertNotIn('Dashboardfilter', response.text)
+
+    def test_invalid_empty_injection_and_dashboard_suggestions(self):
+        path = '/app/forderungen/liste/vorschlaege'
+        for field in ('Unknown', 'RechnungsdatumVon', 'Kunde]; DROP TABLE X;--'):
+            self.assertEqual(self.client.get(path, params={'field': field}).status_code, 400)
+        self.assertEqual(self.client.get(path, params={'field': 'Kunde', 'dashboard_segment': 'invalid'}).status_code, 400)
+        data = self.client.get(path, params={'field': 'Kunde', 'search': "'; DROP TABLE X;--"}).json()
+        self.assertEqual(data, {'items': [], 'has_more': False})
+        sql, values = self.db.calls[-1]
+        self.assertNotIn('DROP TABLE', sql)
+        self.assertIn('DROP TABLE', values['suggest_search'])
+
+    def test_suggestions_require_principal(self):
+        app = FastAPI()
+        app.include_router(router)
+        app.dependency_overrides[get_session] = lambda: self.db
+        with TestClient(app) as client:
+            self.assertIn(client.get('/app/forderungen/liste/vorschlaege?field=Kunde').status_code, (401, 403))