Просмотр исходного кода

Füge Verursacher- und Wiedervorlage-Buttons hinzu

- Implementiere JavaScript für die Bearbeitung von Verursachern und Wiedervorlagen.
- Füge modale Dialoge für die Bearbeitung von Verursachern und Wiedervorlagen in den HTML-Vorlagen hinzu.
- Aktualisiere SQL-Abfragen, um Verursacherinformationen zu integrieren.
- Erstelle Tests für die neuen Funktionen zur Sicherstellung der Funktionalität und Fehlerbehandlung.
- Verbessere die Benutzeroberfläche durch neue Schaltflächen und Statusanzeigen für die Modalitäten.
gc-server3 2 недель назад
Родитель
Сommit
86989f858c

+ 33 - 0
README.md

@@ -161,6 +161,39 @@ technische Nachladeseiten sind keine Login-Rücksprungziele. Unterbrochene Schre
 werden nicht automatisch wiederholt; ungespeicherte Eingaben werden nicht gesichert.
 
 
+## Verursacher ändern
+
+Der Stift neben dem Verursacher öffnet in Liste und Detailseite dasselbe Auswahlmodal.
+Die Benutzer stammen aus `dbo.Benutzer_Rolle` (`Benutzer_ID`, `Name`, `Rolle_Name`)
+und lassen sich nach Rolle filtern. Mehrere Rollen erzeugen keine doppelten Benutzer
+oder Rechnungszeilen. Jeder Benutzer mit Belegzugriff darf die Zuordnung ändern.
+
+Gespeichert wird ausschließlich `dbo.Forderungen_Details.Verursacher_Benutzer_ID`.
+„Auf ursprünglichen Verursacher zurücksetzen“ entfernt diese Überschreibung.
+Auf der Detailseite steht die aktuelle Zuordnung im Bereich „Bearbeitung“ oberhalb
+der Wiedervorlage; in den Grunddaten bleibt der ursprüngliche Verursacher sichtbar.
+Das Modal speichert unabhängig vom übrigen Formular und erhält dessen ungespeicherte
+Eingaben sowie Kommentarentwürfe. Die normalen Speicherbuttons ändern diese Zuordnung
+nicht. In der Liste führt das Speichern zu einem frischen Aufruf mit denselben Filtern
+und Fokus auf die Rechnung; entfällt sie durch die Filter, erscheint ein Hinweis.
+
+Bei abgelaufener Anmeldung wird nach dem Login die vollständige Liste beziehungsweise
+Detailseite geöffnet. Eine unterbrochene Zuordnungsänderung wird nicht automatisch
+wiederholt. Es sind keine neuen Datenbanktabellen oder Migrationen erforderlich.
+
+## Wiedervorlage in der Liste ändern
+
+Das Wiedervorlagedatum ist ein Button mit einem Datumsmodal. „Heute“ und „Heute + 7 Tage“
+setzen das Eingabefeld anhand des aktuellen Datums in Europe/Berlin; erst „Speichern“
+übernimmt die Änderung. Ein fehlendes Datum lässt sich über „Festlegen“ ergänzen.
+Die Änderung schreibt ausschließlich `Forderungen_Details.Wiedervorlage` und den Kommentar
+`WV auf dd.mm.yyyy angepasst` mit dem angemeldeten Benutzer und Berliner Zeitstempel.
+Datum und Kommentar werden gemeinsam in einer Transaktion gespeichert. Ein unverändertes
+Datum erzeugt keinen weiteren Kommentar. Danach wird die Liste mit den bestehenden Filtern
+frisch geladen und die Rechnung fokussiert; passt sie nicht mehr zum Filter, erscheint
+der entsprechende Hinweis. Nach einem Login wird ein unterbrochener Speichervorgang nicht
+automatisch wiederholt.
+
 ## Dokumente im Kommentarbereich
 
 Über die Büroklammer oder durch Ablegen auf dem Kommentarfeld können mehrere Dateien

+ 21 - 0
app/navigation.py

@@ -7,6 +7,8 @@ from .query_filter import import_filter_config
 LIST_PATH = '/app/forderungen/liste'
 DETAIL_PREFIX = '/app/forderungen/details/'
 DETAIL_PAGE = re.compile(r'^/app/forderungen/details/([^/]+)(?:/(?:kommentare|buchungen))?/?$')
+ASSIGNEE_MODAL = re.compile(r'^/app/forderungen/(liste|details)/([^/]+)/verursacher/?$')
+FOLLOW_UP_MODAL = re.compile(r'^/app/forderungen/liste/([^/]+)/wiedervorlage/?$')
 
 
 def valid_focus(value: str | None) -> bool:
@@ -85,6 +87,25 @@ def normalize_page_url(value: str | None, default='/select', current_url: str |
         return default
     params = dict(parse_qsl(parts.query))
     current = _current_parts(current_url)
+    follow_up = FOLLOW_UP_MODAL.fullmatch(unquote(parts.path))
+    if follow_up and valid_focus(follow_up[1]):
+        value = params.get('return_to')
+        if value is None and current and current.path == LIST_PATH:
+            value = current.path + ('?' + current.query if current.query else '')
+        return list_return_url(value, follow_up[1])
+    modal = ASSIGNEE_MODAL.fullmatch(unquote(parts.path))
+    if modal and valid_focus(modal[2]):
+        primary_key = modal[2]
+        value = params.get('return_to')
+        if modal[1] == 'liste':
+            if value is None and current and current.path == LIST_PATH:
+                value = current.path + ('?' + current.query if current.query else '')
+            return list_return_url(value, primary_key)
+        if value is None and current:
+            previous = DETAIL_PAGE.fullmatch(unquote(current.path))
+            if previous and previous[1] == primary_key:
+                value = dict(parse_qsl(current.query)).get('return_to')
+        return DETAIL_PREFIX + quote(primary_key, safe='') + '?' + urlencode({'return_to': list_return_url(value, primary_key)})
     if parts.path == LIST_PATH:
         focus = params.get('focus')
         if not focus and current and current.path == LIST_PATH:

+ 4 - 0
app/routes.py

@@ -39,8 +39,12 @@ from .permissions import (
     has_forderung_access,
     load_entitlements,
 )
+from .verursacher import router as verursacher_router
+from .wiedervorlage import router as wiedervorlage_router
 
 router = APIRouter()
+router.include_router(verursacher_router)
+router.include_router(wiedervorlage_router)
 logger = logging.getLogger(__name__)
 
 

+ 128 - 0
app/verursacher.py

@@ -0,0 +1,128 @@
+"""Change the manual invoice assignee independently of the workflow form."""
+import json
+import logging
+from urllib.parse import quote, urlencode
+
+from fastapi import APIRouter, Depends, Form, HTTPException, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from .db import get_session
+from .jinja_templates import templates
+from .navigation import DETAIL_PREFIX, list_return_url, valid_focus
+from .permissions import current_principal, ensure_forderung_access
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+USERS_SQL = text("""
+SELECT DISTINCT Benutzer_ID,
+       MAX(NULLIF(Name, '')) OVER (PARTITION BY Benutzer_ID) AS Name,
+       Rolle_Name
+FROM dbo.Benutzer_Rolle
+WHERE NULLIF(LTRIM(RTRIM(Benutzer_ID)), '') IS NOT NULL
+ORDER BY Name, Benutzer_ID, Rolle_Name
+""")
+STATE_SQL = text("""
+SELECT F.Client_DB, F.Beleg_Nr, F.Verursacher_Text AS Original_Verursacher_Text,
+       F.Verursacher_Benutzer_ID AS Original_Verursacher_Benutzer_ID,
+       FD.Verursacher_Benutzer_ID AS Override_Verursacher_Benutzer_ID,
+       COALESCE(B.Name, FD.Verursacher_Benutzer_ID, F.Verursacher_Text) AS Verursacher_Text
+FROM dbo.Forderungen_Kopf F
+LEFT JOIN dbo.Forderungen_Details FD ON FD.Client_DB = F.Client_DB AND FD.Beleg_Nr = F.Beleg_Nr
+LEFT JOIN (
+    SELECT Benutzer_ID, MAX(NULLIF(Name, '')) AS Name
+    FROM dbo.Benutzer_Rolle GROUP BY Benutzer_ID
+) B ON B.Benutzer_ID = FD.Verursacher_Benutzer_ID
+WHERE F.Client_DB = :Client_DB AND F.Beleg_Nr = :Beleg_Nr
+""")
+
+
+def _context(request, primary_key, db):
+    if not valid_focus(primary_key):
+        raise HTTPException(400, 'Ungültiger Primärschlüssel')
+    tenant, number = primary_key.split('_', 1)
+    pkey = {'Client_DB': tenant, 'Beleg_Nr': number}
+    ensure_forderung_access(db, current_principal(request), tenant, number)
+    state = db.execute(STATE_SQL, pkey).mappings().first()
+    if state is None:
+        raise HTTPException(404, 'Forderung nicht gefunden')
+    users = {}
+    for row in db.execute(USERS_SQL).mappings().all():
+        identity = row['Benutzer_ID']
+        user = users.setdefault(identity, {'id': identity, 'name': row['Name'] or identity, 'roles': []})
+        if row['Rolle_Name'] and row['Rolle_Name'] not in user['roles']:
+            user['roles'].append(row['Rolle_Name'])
+    users = sorted(users.values(), key=lambda user: (user['name'].casefold(), user['id']))
+    roles = sorted({role for user in users for role in user['roles']}, key=str.casefold)
+    detail = request.url.path.startswith(DETAIL_PREFIX)
+    return_url = list_return_url(request.query_params.get('return_to'), primary_key)
+    page_url = DETAIL_PREFIX + quote(primary_key, safe='') + '?' + urlencode({'return_to': return_url}) if detail else return_url
+    selected = state['Override_Verursacher_Benutzer_ID'] or state['Original_Verursacher_Benutzer_ID'] or ''
+    return dict(pkey=pkey, state=state, users=users, roles=roles, selected=selected,
+                detail=detail, primary_key=primary_key, page_url=page_url, return_url=return_url,
+                modal_url=request.url.path + '?' + urlencode({'return_to': return_url}), role_filter='', error='')
+
+
+def _render(request, context, status_code=200):
+    return templates.TemplateResponse(request, 'forderungen/verursacher/modal.html', context,
+                                      status_code=status_code, headers={'Cache-Control': 'no-store'})
+
+
+@router.get('/app/forderungen/liste/{primary_key}/verursacher', response_class=HTMLResponse)
+@router.get('/app/forderungen/details/{primary_key}/verursacher', response_class=HTMLResponse)
+def edit_verursacher(request: Request, primary_key: str, db: Session = Depends(get_session)):
+    context = _context(request, primary_key, db)
+    if request.headers.get('HX-Request', '').lower() != 'true':
+        return RedirectResponse(context['page_url'], status_code=303)
+    return _render(request, context)
+
+
+@router.post('/app/forderungen/liste/{primary_key}/verursacher', response_class=HTMLResponse)
+@router.post('/app/forderungen/details/{primary_key}/verursacher', response_class=HTMLResponse)
+def save_verursacher(request: Request, primary_key: str, action: str = Form('assign'),
+                     benutzer_id: str = Form(''), rolle: str = Form(''), db: Session = Depends(get_session)):
+    context = _context(request, primary_key, db)
+    context['selected'] = benutzer_id
+    context['role_filter'] = rolle if rolle in context['roles'] else ''
+    user = next((user for user in context['users'] if user['id'] == benutzer_id), None)
+    if action not in {'assign', 'reset'} or (action == 'assign' and user is None):
+        context['error'] = 'Bitte einen gültigen Benutzer auswählen.'
+        return _render(request, context, 422)
+    value = None if action == 'reset' else benutzer_id
+    values = dict(context['pkey'], Verursacher_Benutzer_ID=value)
+    try:
+        # Serializable key-range lock prevents concurrent creation of the same detail record.
+        existing = db.execute(text("""
+            SELECT Verursacher_Benutzer_ID FROM dbo.Forderungen_Details WITH (UPDLOCK, HOLDLOCK)
+            WHERE Client_DB = :Client_DB AND Beleg_Nr = :Beleg_Nr
+        """), context['pkey']).first()
+        if existing is None:
+            if value is not None:
+                db.execute(text("""
+                    INSERT INTO dbo.Forderungen_Details (Client_DB, Beleg_Nr, Verursacher_Benutzer_ID)
+                    VALUES (:Client_DB, :Beleg_Nr, :Verursacher_Benutzer_ID)
+                """), values)
+        else:
+            db.execute(text("""
+                UPDATE dbo.Forderungen_Details SET Verursacher_Benutzer_ID = :Verursacher_Benutzer_ID
+                WHERE Client_DB = :Client_DB AND Beleg_Nr = :Beleg_Nr
+            """), values)
+        db.commit()
+    except Exception:
+        db.rollback()
+        logger.exception('Verursacher konnte für %s nicht gespeichert werden', primary_key)
+        context['error'] = 'Der Verursacher konnte nicht gespeichert werden. Bitte erneut versuchen.'
+        return _render(request, context, 503)
+
+    if request.headers.get('HX-Request', '').lower() != 'true':
+        return RedirectResponse(context['page_url'], status_code=303)
+    if not context['detail']:
+        return HTMLResponse('', headers={'HX-Redirect': context['return_url'], 'Cache-Control': 'no-store'})
+    context['effective_name'] = context['state']['Original_Verursacher_Text'] if value is None else user['name']
+    context['oob'] = True
+    return templates.TemplateResponse(
+        request, 'forderungen/verursacher/detail_field.html', context,
+        headers={'HX-Trigger-After-Swap': json.dumps({'gc:verursacherSaved': {}}), 'Cache-Control': 'no-store'},
+    )

+ 123 - 0
app/wiedervorlage.py

@@ -0,0 +1,123 @@
+"""List follow-up editing, with its audit comment committed atomically."""
+from datetime import date, datetime, timedelta
+import logging
+import re
+from urllib.parse import urlencode
+from zoneinfo import ZoneInfo
+
+from fastapi import APIRouter, Depends, Form, HTTPException, Request
+from fastapi.responses import HTMLResponse, RedirectResponse
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from .db import get_session
+from .jinja_templates import templates
+from .navigation import list_return_url, valid_focus
+from .permissions import current_principal, ensure_forderung_access
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+BERLIN = ZoneInfo('Europe/Berlin')
+
+
+def _now_berlin():
+    return datetime.now(BERLIN)
+
+
+def _as_date(value):
+    if value is None:
+        return None
+    if isinstance(value, datetime):
+        return value.date()
+    if isinstance(value, date):
+        return value
+    return date.fromisoformat(str(value)[:10])
+
+
+def _context(request, primary_key, db):
+    if not valid_focus(primary_key):
+        raise HTTPException(400, 'Ungültiger Primärschlüssel')
+    tenant, number = primary_key.split('_', 1)
+    pkey = {'Client_DB': tenant, 'Beleg_Nr': number}
+    ensure_forderung_access(db, current_principal(request), tenant, number)
+    state = db.execute(text("""
+        SELECT F.Beleg_Nr, F.Beleg_Faellig_Datum,
+               COALESCE(FD.Wiedervorlage, F.Beleg_Faellig_Datum) AS Wiedervorlage
+        FROM dbo.Forderungen_Kopf F
+        LEFT JOIN dbo.Forderungen_Details FD ON FD.Client_DB = F.Client_DB AND FD.Beleg_Nr = F.Beleg_Nr
+        WHERE F.Client_DB = :Client_DB AND F.Beleg_Nr = :Beleg_Nr
+    """), pkey).mappings().first()
+    if state is None:
+        raise HTTPException(404, 'Forderung nicht gefunden')
+    today = _now_berlin().date()
+    current = _as_date(state['Wiedervorlage'])
+    return_url = list_return_url(request.query_params.get('return_to'), primary_key)
+    return dict(pkey=pkey, state=state, today=today.isoformat(),
+                next_week=(today + timedelta(days=7)).isoformat(),
+                selected=current.isoformat() if current else '', error='',
+                return_url=return_url,
+                modal_url=request.url.path + '?' + urlencode({'return_to': return_url}))
+
+
+def _render(request, context, status_code=200):
+    return templates.TemplateResponse(request, 'forderungen/wiedervorlage/modal.html', context,
+                                      status_code=status_code, headers={'Cache-Control': 'no-store'})
+
+
+@router.get('/app/forderungen/liste/{primary_key}/wiedervorlage', response_class=HTMLResponse)
+def edit_wiedervorlage(request: Request, primary_key: str, db: Session = Depends(get_session)):
+    context = _context(request, primary_key, db)
+    if request.headers.get('HX-Request', '').lower() != 'true':
+        return RedirectResponse(context['return_url'], status_code=303)
+    return _render(request, context)
+
+
+@router.post('/app/forderungen/liste/{primary_key}/wiedervorlage', response_class=HTMLResponse)
+def save_wiedervorlage(request: Request, primary_key: str, wiedervorlage: str = Form(''),
+                      db: Session = Depends(get_session)):
+    context = _context(request, primary_key, db)
+    context['selected'] = wiedervorlage
+    try:
+        if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', wiedervorlage):
+            raise ValueError
+        selected = date.fromisoformat(wiedervorlage)
+    except ValueError:
+        context['error'] = 'Bitte ein gültiges Wiedervorlagedatum auswählen.'
+        return _render(request, context, 422)
+
+    try:
+        existing = db.execute(text("""
+            SELECT Wiedervorlage FROM dbo.Forderungen_Details WITH (UPDLOCK, HOLDLOCK)
+            WHERE Client_DB = :Client_DB AND Beleg_Nr = :Beleg_Nr
+        """), context['pkey']).mappings().first()
+        current = _as_date(existing['Wiedervorlage'] if existing and existing['Wiedervorlage'] is not None
+                           else context['state']['Beleg_Faellig_Datum'])
+        # Repeated submissions of an unchanged date must not duplicate the audit comment.
+        if selected != current:
+            values = dict(context['pkey'], Wiedervorlage=selected)
+            if existing is None:
+                db.execute(text("""
+                    INSERT INTO dbo.Forderungen_Details (Client_DB, Beleg_Nr, Wiedervorlage)
+                    VALUES (:Client_DB, :Beleg_Nr, :Wiedervorlage)
+                """), values)
+            else:
+                db.execute(text("""
+                    UPDATE dbo.Forderungen_Details SET Wiedervorlage = :Wiedervorlage
+                    WHERE Client_DB = :Client_DB AND Beleg_Nr = :Beleg_Nr
+                """), values)
+            db.execute(text("""
+                INSERT INTO dbo.Forderungen_Kommentar (Client_DB, Beleg_Nr, [Timestamp], Benutzer_ID, Kommentar)
+                VALUES (:Client_DB, :Beleg_Nr, :Timestamp, :Benutzer_ID, :Kommentar)
+            """), dict(context['pkey'], Timestamp=_now_berlin().replace(tzinfo=None),
+                      Benutzer_ID=current_principal(request).username,
+                      Kommentar=f'WV auf {selected.strftime("%d.%m.%Y")} angepasst'))
+        db.commit()
+    except Exception:
+        db.rollback()
+        logger.exception('Wiedervorlage konnte für %s nicht gespeichert werden', primary_key)
+        context['error'] = 'Datum und Kommentar konnten nicht gespeichert werden. Bitte erneut versuchen.'
+        return _render(request, context, 503)
+
+    if request.headers.get('HX-Request', '').lower() == 'true':
+        return HTMLResponse('', headers={'HX-Redirect': context['return_url'], 'Cache-Control': 'no-store'})
+    return RedirectResponse(context['return_url'], status_code=303)

+ 16 - 0
static/assets/css/app.css

@@ -68,6 +68,22 @@ body {
 .gc-document-badge--M2 { background: #f7d8bf; border-color: #e98751; }
 .gc-document-badge--M3 { background: #f5dddd; border-color: #b30808; }
 .gc-section-title .icon { flex-shrink: 0; width: 1.25rem; height: 1.25rem; font-size: 1.25rem; vertical-align: -.1em; }
+.gc-assignee-value { display: flex; align-items: flex-start; gap: .5rem; }
+.gc-assignee-value > :first-child { min-width: 0; overflow-wrap: anywhere; }
+.gc-assignee-value .gc-verursacher-edit, .gc-list-page .gc-email-btn { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; padding: .25rem; width: 30px; height: 30px; color: #354f63; background: #fff; border: 1px solid #72889b; border-radius: 6px; }
+.gc-editor-modal { --cui-modal-width: 560px; font-size: 1.0625rem; }
+.gc-editor-modal .modal-header { background: var(--gc-brand-action); color: #fff; }
+.gc-editor-modal .modal-header .btn-close { filter: invert(1) grayscale(1) brightness(2); }
+.gc-editor-modal :is(.form-select, .form-control, .btn) { font-size: inherit; }
+.gc-editor-modal .btn-primary { --cui-btn-bg: #72889b; --cui-btn-border-color: #72889b; --cui-btn-color: #101b24; --cui-btn-hover-bg: #b1bcc7; --cui-btn-hover-border-color: #72889b; --cui-btn-hover-color: #101b24; --cui-btn-active-bg: #b1bcc7; --cui-btn-active-border-color: #72889b; --cui-btn-active-color: #101b24; --cui-btn-disabled-bg: #72889b; --cui-btn-disabled-border-color: #72889b; --cui-btn-disabled-color: #101b24; }
+.gc-editor-modal :is(.btn-link, .btn-outline-secondary) { --cui-btn-color: #354f63; }
+.gc-date-suggestions { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .75rem; }
+.gc-date-status { margin: .75rem 0 0; }
+.gc-date-status:empty { display: none; }
+.gc-assignee-original { color: #52616f; }
+.gc-assignee-error { color: #b30808; }
+.gc-assignee-status { margin: .75rem 0 0; }
+.gc-assignee-status:empty { display: none; }
 
 html, body { min-height: 100%; background: #f4f6fb; color: #111827; }
 body { overflow-y: auto; }

+ 5 - 0
static/assets/css/details.css

@@ -160,4 +160,9 @@ body:has(.gc-detail-page) { max-width: 1840px; }
 .gc-detail-page .gc-upload-error { display: block; color: #b30808; }
 .gc-detail-page .gc-upload-status { margin: 0; overflow-wrap: anywhere; }
 .gc-detail-page .gc-upload-status:empty, .gc-detail-page .gc-upload-queue:empty { display: none; }
+.gc-detail-page .gc-assignee-field { border-top: 1px solid #dce2e7; padding-top: 1rem; }
+.gc-detail-page .gc-assignee-label { display: block; color: #52616f; margin-bottom: .5rem; }
+.gc-detail-page .gc-assignee-value { align-items: center; }
+.gc-detail-page .gc-assignee-value > span { flex: 1; }
+.gc-detail-page .gc-verursacher-edit { padding: .25rem; }
 .gc-detail-page .message-input.gc-drop-active { outline: 3px solid #72889b; background: #edf1f4; }

+ 3 - 0
static/assets/css/list.css

@@ -52,6 +52,9 @@ body:has(.gc-list-page) { max-width: 1840px; }
 .gc-list-badge-warning, .gc-list-badge-1 { background: #fff3bd; border-color: #c8af45; }
 .gc-list-badge-2 { background: #f5dddd; border-color: #b30808; }
 .gc-list-badge-0 { background: #e6eedc; border-color: #92aa78; }
+.gc-wiedervorlage-edit { gap: .3rem; cursor: pointer; }
+.gc-wiedervorlage-edit:hover { filter: brightness(.96); }
+.gc-wiedervorlage-edit .icon { width: 1rem; height: 1rem; flex-shrink: 0; }
 .gc-list-page .btn { font-size: 1rem; border-radius: 6px; }
 .gc-list-page .gc-comment-toggle { display: inline-flex; align-items: center; gap: .4rem; border: 1px solid #b1bcc7; background: #fff; color: #354f63; padding: .4rem .5rem; }
 .gc-comment-toggle[aria-expanded="true"] { background: #e6edf2; }

+ 84 - 0
static/assets/js/verursacher.js

@@ -0,0 +1,84 @@
+/* A single modal serves both list rows and the independent detail field. */
+(() => {
+  let trigger;
+  const initialized = new WeakSet();
+  const host = () => document.getElementById('verursacher-modal');
+  function init(root = document) {
+    const form = root.matches?.('[data-gc-verursacher-form]') ? root : root.querySelector?.('[data-gc-verursacher-form]');
+    if (!form || initialized.has(form)) return;
+    initialized.add(form);
+    const role = form.querySelector('#verursacher-role'), user = form.querySelector('#verursacher-user');
+    const options = Array.from(user.options).map(option => option.cloneNode(true));
+    const status = form.querySelector('.gc-assignee-status'), save = form.querySelector('[data-gc-assign]');
+    const update = () => { save.disabled = !user.value; };
+    const filterUsers = () => {
+      const previous = user.value;
+      user.replaceChildren(...options.filter(option => !option.value || !role.value || JSON.parse(option.dataset.roles).includes(role.value)).map(option => option.cloneNode(true)));
+      user.value = previous;
+      if (user.selectedIndex < 0) user.value = '';
+      status.textContent = previous && !user.value ? 'Die bisherige Auswahl gehört nicht zur gewählten Rolle. Bitte einen Benutzer auswählen.' : '';
+      if (user.options.length === 1) status.textContent = 'Keine Benutzer für diese Rolle verfügbar.';
+      update();
+    };
+    role.addEventListener('change', filterUsers);
+    user.addEventListener('change', update);
+    form.addEventListener('submit', event => {
+      if (form.dataset.sending === 'true') { event.preventDefault(); event.stopImmediatePropagation(); }
+    }, true);
+    if (role.value) filterUsers();
+    update();
+    if (host()?.classList.contains('show')) role.focus();
+  }
+  document.addEventListener('htmx:beforeRequest', event => {
+    if (event.detail.elt.matches('.gc-verursacher-edit')) {
+      trigger = event.detail.elt;
+      const content = document.getElementById('verursacher-modal-content');
+      content.innerHTML = '<div class="modal-header"><h2 class="modal-title fs-5" id="verursacher-modal-title">Verursacher ändern</h2><button type="button" class="btn-close" data-coreui-dismiss="modal" aria-label="Schließen"></button></div><div class="modal-body" role="status">Benutzer werden geladen …</div>';
+      coreui.Modal.getOrCreateInstance(host()).show();
+    }
+    if (event.detail.elt.matches('[data-gc-verursacher-form]')) {
+      event.detail.elt.dataset.sending = 'true';
+      event.detail.elt.setAttribute('aria-busy', 'true');
+      event.detail.elt.querySelector('.gc-assignee-status').textContent = 'Wird gespeichert …';
+      event.detail.elt.querySelectorAll('select').forEach(select => { select.disabled = true; });
+    }
+  });
+  document.addEventListener('htmx:beforeSwap', event => {
+    if (event.detail.target?.id === 'verursacher-modal-content' && [422, 503].includes(event.detail.xhr.status)) {
+      // Authentication/service errors can be JSON; only swap our own modal form.
+      if (event.detail.xhr.responseText.includes('data-gc-verursacher-form')) {
+        event.detail.shouldSwap = true; event.detail.isError = false;
+      }
+    }
+  });
+  document.addEventListener('htmx:afterRequest', event => {
+    const form = event.detail.elt;
+    if (!form.matches('[data-gc-verursacher-form]')) return;
+    form.dataset.sending = 'false'; form.removeAttribute('aria-busy');
+    form.querySelectorAll('select').forEach(select => { select.disabled = false; });
+  });
+  function requestError(event) {
+    if (event.detail.target?.id !== 'verursacher-modal-content' && !event.detail.elt?.matches('.gc-verursacher-edit, [data-gc-verursacher-form]')) return;
+    const content = document.getElementById('verursacher-modal-content');
+    if (!content) return;
+    let message = content.querySelector('.gc-assignee-status, [role=status]');
+    if (!message) { message = document.createElement('p'); message.setAttribute('role', 'alert'); content.append(message); }
+    message.textContent = 'Die Anfrage konnte nicht abgeschlossen werden. Bitte Verbindung und Zugriffsrechte prüfen und erneut versuchen.';
+  }
+  document.addEventListener('htmx:responseError', requestError);
+  document.addEventListener('htmx:sendError', requestError);
+  document.addEventListener('gc:verursacherSaved', () => {
+    coreui.Modal.getInstance(host())?.hide();
+  });
+  document.addEventListener('hidden.coreui.modal', event => {
+    if (event.target !== host()) return;
+    // The detail button is replaced out-of-band after a successful save.
+    const focus = trigger?.isConnected ? trigger : document.querySelector('#workflow-verursacher .gc-verursacher-edit');
+    focus?.focus({preventScroll: true});
+  });
+  document.addEventListener('shown.coreui.modal', event => {
+    if (event.target === host()) document.getElementById('verursacher-role')?.focus();
+  });
+  document.addEventListener('DOMContentLoaded', () => init());
+  document.addEventListener('htmx:load', event => init(event.detail.elt));
+})();

+ 61 - 0
static/assets/js/wiedervorlage.js

@@ -0,0 +1,61 @@
+/* The date shortcuts only edit the form; saving requires an explicit submit. */
+(() => {
+  let trigger;
+  const host = () => document.getElementById('wiedervorlage-modal');
+  const content = () => document.getElementById('wiedervorlage-modal-content');
+  document.addEventListener('click', event => {
+    const button = event.target.closest('[data-gc-date]');
+    if (!button || !button.closest('[data-gc-wiedervorlage-form]')) return;
+    const field = document.getElementById('wiedervorlage-date');
+    field.value = button.dataset.gcDate;
+    field.dispatchEvent(new Event('input', {bubbles: true}));
+    field.focus();
+  });
+  document.addEventListener('submit', event => {
+    if (event.target.matches('[data-gc-wiedervorlage-form]') && event.target.dataset.sending === 'true') {
+      event.preventDefault(); event.stopImmediatePropagation();
+    }
+  }, true);
+  document.addEventListener('htmx:beforeRequest', event => {
+    if (event.detail.elt.matches('.gc-wiedervorlage-edit')) {
+      trigger = event.detail.elt;
+      content().innerHTML = '<div class="modal-header"><h2 class="modal-title fs-5" id="wiedervorlage-modal-title">Wiedervorlage ändern</h2><button type="button" class="btn-close" data-coreui-dismiss="modal" aria-label="Schließen"></button></div><div class="modal-body" role="status">Datum wird geladen …</div>';
+      coreui.Modal.getOrCreateInstance(host()).show();
+    }
+    const form = event.detail.elt;
+    if (!form.matches('[data-gc-wiedervorlage-form]')) return;
+    form.dataset.sending = 'true'; form.setAttribute('aria-busy', 'true');
+    form.querySelector('[type=date]').disabled = true;
+    form.querySelector('.gc-date-status').textContent = 'Wird gespeichert …';
+  });
+  document.addEventListener('htmx:beforeSwap', event => {
+    if (event.detail.target?.id === 'wiedervorlage-modal-content' && [422, 503].includes(event.detail.xhr.status)
+        && event.detail.xhr.responseText.includes('data-gc-wiedervorlage-form')) {
+      event.detail.shouldSwap = true; event.detail.isError = false;
+    }
+  });
+  document.addEventListener('htmx:afterRequest', event => {
+    const form = event.detail.elt;
+    if (!form.matches('[data-gc-wiedervorlage-form]')) return;
+    form.dataset.sending = 'false'; form.removeAttribute('aria-busy');
+    form.querySelector('[type=date]').disabled = false;
+  });
+  function requestError(event) {
+    if (event.detail.target?.id !== 'wiedervorlage-modal-content' && !event.detail.elt?.matches('.gc-wiedervorlage-edit, [data-gc-wiedervorlage-form]')) return;
+    const message = content()?.querySelector('.gc-date-status, [role=status]');
+    if (message) message.textContent = 'Die Anfrage konnte nicht abgeschlossen werden. Bitte Verbindung und Zugriffsrechte prüfen und erneut versuchen.';
+  }
+  document.addEventListener('htmx:responseError', requestError);
+  document.addEventListener('htmx:sendError', requestError);
+  document.addEventListener('hidden.coreui.modal', event => {
+    if (event.target === host() && trigger?.isConnected) trigger.focus({preventScroll: true});
+  });
+  document.addEventListener('shown.coreui.modal', event => {
+    if (event.target === host()) document.getElementById('wiedervorlage-date')?.focus();
+  });
+  document.addEventListener('htmx:load', event => {
+    if (host()?.classList.contains('show') && event.detail.elt.closest?.('#wiedervorlage-modal')) {
+      document.getElementById('wiedervorlage-date')?.focus();
+    }
+  });
+})();

+ 12 - 0
templates/base/base.html

@@ -32,6 +32,8 @@
   <script src="/static/assets/js/navigation.js" defer></script>
   <script src="/static/assets/js/list.js" 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>
 
   <!-- Favicon icon -->
   <link rel="icon" href="/static/assets/images/favicon.ico" type="image/x-icon">
@@ -74,6 +76,16 @@
   {% include 'forderungen/liste/summary.html' %}
   {% endif %}
 
+<div class="modal fade gc-editor-modal" id="verursacher-modal" tabindex="-1" aria-labelledby="verursacher-modal-title" aria-hidden="true">
+  <div class="modal-dialog modal-dialog-centered">
+    <div class="modal-content" id="verursacher-modal-content"></div>
+  </div>
+</div>
+<div class="modal fade gc-editor-modal" id="wiedervorlage-modal" tabindex="-1" aria-labelledby="wiedervorlage-modal-title" aria-hidden="true">
+  <div class="modal-dialog modal-dialog-centered">
+    <div class="modal-content" id="wiedervorlage-modal-content"></div>
+  </div>
+</div>
 </body>
 
 </html>

+ 1 - 1
templates/base/liste.html

@@ -5,7 +5,7 @@
 <div id="gc-list-state" data-list-url="{{ list_url }}" data-focus="{{ focus or '' }}" hidden></div>
 <h1>Forderungen</h1>
 {% if focus_missing %}
-<p class="gc-return-status" role="status">Die zuvor geöffnete 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 %}
 <section class="gc-card" aria-labelledby="list-filter-title">
   <h2 class="gc-section-title" id="list-filter-title"><i class="icon cil-filter" aria-hidden="true"></i> Filter</h2>

+ 5 - 0
templates/forderungen/details/details_formular.html

@@ -187,6 +187,11 @@
       </div>
     </div>
   </div>
+  {% with primary_key=row.Client_DB|string ~ '_' ~ row.Beleg_Nr|string,
+          effective_name=row.Verursacher_Aktuell_Text|default(row.Verursacher_Text),
+          modal_url=detail_url ~ '/verursacher?return_to=' ~ (return_url|urlencode) %}
+    {% include 'forderungen/verursacher/detail_field.html' %}
+  {% endwith %}
   <div class="col-12 gc-follow-up">
     <div class="row mb-3">
       <label for="Wiedervorlage" class="col-sm-3 col-form-label">Wiedervorlage</label>

+ 6 - 0
templates/forderungen/details/queries/forderungen_details.sql

@@ -1,4 +1,6 @@
 SELECT [F].*
+     , COALESCE([B].[Name], [FD].[Verursacher_Benutzer_ID], [F].[Verursacher_Text]) AS [Verursacher_Aktuell_Text]
+     , COALESCE([FD].[Verursacher_Benutzer_ID], [F].[Verursacher_Benutzer_ID]) AS [Verursacher_Aktuell_Benutzer_ID]
      , round([Beleg_offen] * 0.19, 2) AS [Mwst_Betrag_berechnet]
      , [K].[Kunde_Name]
      , [K].[Kunde_Nachname]
@@ -69,6 +71,10 @@ FROM [dbo].[Forderungen_Kopf] [F]
          LEFT JOIN [dbo].[Fahrzeuge] FZ ON F.[Fahrzeug_Nr] = FZ.[Fahrzeug_Nr] AND [F].[Client_DB] = [FZ].[Client_DB]
          LEFT JOIN [dbo].[Kunden] [K2] ON [FZ].[Fahrzeug_Kunde_Nr] = [K2].[Kunde_Nr] AND [FZ].[Client_DB] = [K2].[Client_DB]
          LEFT JOIN [dbo].[Forderungen_Details] [FD] ON [F].[Beleg_Nr] = [FD].[Beleg_Nr] AND [F].[Client_DB] = [FD].[Client_DB] 
+         LEFT JOIN (
+             SELECT [Benutzer_ID], MAX(NULLIF([Name], '')) AS [Name]
+             FROM [dbo].[Benutzer_Rolle] GROUP BY [Benutzer_ID]
+         ) [B] ON [FD].[Verursacher_Benutzer_ID] = [B].[Benutzer_ID]
          LEFT JOIN [dbo].[Versicherungen] [V] ON [FD].[Vers_Adresse_ID] = [V].[Vers_Adresse_ID]
 WHERE [F].[Client_DB] = :client_db
 AND [F].[Beleg_Nr] = :beleg_nr

+ 22 - 8
templates/forderungen/liste/liste_zeile.html

@@ -2,6 +2,7 @@
 {% set has_comments = row.Kommentar_Fibu or row.Kommentar_Abteilung or row.Antrag_Nr %}
 {% set primary_key = row.Client_DB|string ~ '_' ~ row.Beleg_Nr|string %}
 {% set origin = list_url|default('/app/forderungen/liste') %}
+{% from 'forderungen/verursacher/button.html' import edit_button %}
 {% set row_return_url = origin ~ ('&' if '?' in origin else '?') ~ 'focus=' ~ (primary_key|urlencode) %}
 <tr class="gc-invoice-row" data-primary-key="{{ primary_key }}" tabindex="-1">
   <td>
@@ -10,10 +11,13 @@
   </td>
 
   <td>
+    <div class="gc-assignee-value">
     <a href="/app/forderungen/liste?Verursacher={{ row.Verursacher_Text|urlencode }}"
       class="fw-semibold gc-link">
       {{ row.Verursacher_Text }}
     </a>
+    {{ edit_button('/app/forderungen/liste/' ~ (primary_key|urlencode) ~ '/verursacher?return_to=' ~ (origin|urlencode), primary_key) }}
+    </div>
     {% if row.Verursacher_Text == '--N.N.' %}
     <span class="gc-list-badge gc-list-badge-warning ms-1">offen</span>
     {% endif %}
@@ -35,11 +39,16 @@
     <a class="gc-title" href="/app/forderungen/liste?Kunde={{ row.Kunde_Text|urlencode }}">
       {{ row.Kunde_Text }}
     </a>
-    <div class="gc-subtitle">
-      {{ row.Besitzer_Kunde_Text or '' }}
       {% if row.Kunde_Email %}
-      · <a href="mailto:{{ row.Kunde_Email }}?subject={{ row.Beleg_Nr }}" hx-disable="true">E-Mail</a>
+      &nbsp;
+      <a href="mailto:{{ row.Kunde_Email }}?subject={{ row.Beleg_Nr }}" hx-disable="true" class="btn btn-outline-secondary gc-email-btn"
+        aria-label="E-Mail an {{ row.Kunde_Text }} senden" title="E-Mail senden">
+        <i class="icon cil-envelope-closed" aria-hidden="true"></i>
+      </a>
       {% endif %}
+
+    <div class="gc-subtitle">
+      {{ row.Besitzer_Kunde_Text or '' }}
     </div>
   </td>
 
@@ -74,9 +83,14 @@
   </td>
 
   <td class="text-end">
-    {% if row.Wiedervorlage %}
-    <span class="gc-list-badge gc-list-badge-{{ row.Wiedervorlage_Stufe }}">{{ row.Wiedervorlage|date_format }}</span>
-    {% endif %}
+    <button type="button" class="gc-list-badge gc-list-badge-{{ row.Wiedervorlage_Stufe }} gc-wiedervorlage-edit"
+      hx-get="/app/forderungen/liste/{{ primary_key|urlencode }}/wiedervorlage?return_to={{ origin|urlencode }}"
+      hx-target="#wiedervorlage-modal-content" hx-swap="innerHTML" hx-push-url="false"
+      hx-sync="#wiedervorlage-modal:replace" aria-haspopup="dialog" aria-controls="wiedervorlage-modal"
+      aria-label="Wiedervorlage für Beleg {{ row.Beleg_Nr }} ändern" title="Wiedervorlage ändern">
+      <i class="icon cil-calendar" aria-hidden="true"></i>
+      {{ row.Wiedervorlage|date_format if row.Wiedervorlage else 'Festlegen' }}
+    </button>
   </td>
 
   <td>
@@ -89,11 +103,11 @@
 <tr class="gc-comment-row" id="{{ comments_id }}" hidden>
   <td colspan="11">
     <div class="gc-row-comments">
-      <section aria-label="Finanzbuchhaltung – {{ row.Beleg_Nr }}">
+      <section aria-label="Finanzbuchhaltung - {{ row.Beleg_Nr }}">
         <h3>Finanzbuchhaltung</h3>
         <p>{{ row.Kommentar_Fibu or 'Kein Kommentar vorhanden.' }}</p>
       </section>
-      <section aria-label="Abteilung – {{ row.Beleg_Nr }}">
+      <section aria-label="Abteilung - {{ row.Beleg_Nr }}">
         <h3>Abteilung</h3>
         {% if row.Antrag_Nr %}
         <p>{{ row.Antrag_Art }} {{ row.Antrag_Nr }} vom {{ row.Antrag_Datum|date_format }}<br>{{ row.Antrag_Benutzer_ID }}, Status: {{ row.Antrag_Status }}</p>

+ 6 - 2
templates/forderungen/liste/queries/forderungen_liste.sql

@@ -13,7 +13,8 @@ SELECT [F].[Client_DB]
      , [F].[Beleg_offen]
      , [F].[Kunde_offen_gesamt]
      , [F].[Beleg_Datum]
-     , [F].[Verursacher_Text]
+     , isnull([FD].[Verursacher_Benutzer_ID], [F].[Verursacher_Benutzer_ID]) AS Verursacher_Benutzer_ID
+     , COALESCE([B].[Name], [FD].[Verursacher_Benutzer_ID], [F].[Verursacher_Text]) AS Verursacher_Text
      , [F].[Kunde_Text]
      , [F].[Beleg_Text]
      , [F].[Abteilung]
@@ -22,7 +23,6 @@ SELECT [F].[Client_DB]
      , [F].[Mahnstufe]
      , [F].[Forderungsart]
      --, [F].[Abwarten]
-     , [F].[Verursacher_Benutzer_ID]
      , F.[Besitzer_Kunde_Text]
      , F.[Beleg_Betrag]
      , F.[Kunde_gesamt_Bezug]
@@ -56,6 +56,10 @@ SELECT [F].[Client_DB]
      , [G].[Letzte_Beantragung]
 FROM [dbo].[Forderungen_Kopf] [F]
          LEFT JOIN [dbo].[Forderungen_Details] [FD] ON [F].[Client_DB] = [FD].[Client_DB] AND [F].[Beleg_Nr] = [FD].[Beleg_Nr]
+         LEFT JOIN (
+             SELECT [Benutzer_ID], MAX(NULLIF([Name], '')) AS [Name]
+             FROM [dbo].[Benutzer_Rolle] GROUP BY [Benutzer_ID]
+         ) [B] ON [FD].[Verursacher_Benutzer_ID] = [B].[Benutzer_ID]
          LEFT JOIN [dbo].[Forderungen_Kommentar_letzte] [FK] ON [F].[Client_DB] = [FK].[Client_DB] AND [F].[Beleg_Nr] = [FK].[Beleg_Nr]
          LEFT JOIN [Garantie2] [G] ON [F].[Client_DB] = [G].[Client_DB] AND [F].[Beleg_Nr] = [G].[Beleg_Nr]
 ) AS F

+ 8 - 0
templates/forderungen/verursacher/button.html

@@ -0,0 +1,8 @@
+{% macro edit_button(modal_url, primary_key) %}
+<button type="button" class="btn btn-outline-secondary gc-verursacher-edit"
+  hx-get="{{ modal_url }}" hx-target="#verursacher-modal-content" hx-swap="innerHTML" hx-push-url="false"
+  hx-sync="#verursacher-modal:replace" aria-haspopup="dialog" aria-controls="verursacher-modal"
+  aria-label="Verursacher für Beleg {{ primary_key }} ändern" title="Verursacher ändern">
+  <i class="icon cil-pencil" aria-hidden="true"></i>
+</button>
+{% endmacro %}

+ 8 - 0
templates/forderungen/verursacher/detail_field.html

@@ -0,0 +1,8 @@
+{% from 'forderungen/verursacher/button.html' import edit_button %}
+<div class="col-12 gc-assignee-field" id="workflow-verursacher"{% if oob %} hx-swap-oob="outerHTML"{% endif %}>
+  <span class="gc-assignee-label" id="workflow-verursacher-label">Verursacher</span>
+  <div class="gc-assignee-value" role="group" aria-labelledby="workflow-verursacher-label">
+    <span>{{ effective_name or 'Nicht zugeordnet' }}</span>
+    {{ edit_button(modal_url, primary_key) }}
+  </div>
+</div>

+ 34 - 0
templates/forderungen/verursacher/modal.html

@@ -0,0 +1,34 @@
+<div class="modal-header">
+  <h2 class="modal-title fs-5" id="verursacher-modal-title">Verursacher ändern</h2>
+  <button type="button" class="btn-close" data-coreui-dismiss="modal" aria-label="Schließen"></button>
+</div>
+<form id="verursacher-form" method="post" action="{{ modal_url }}" hx-post="{{ modal_url }}"
+  hx-target="#verursacher-modal-content" hx-swap="innerHTML" hx-push-url="false"
+  hx-sync="this:drop" hx-disabled-elt="find button" data-gc-verursacher-form>
+  <input type="hidden" name="_csrf" value="{{ request.state.principal.csrf }}">
+  <div class="modal-body">
+    <p>Beleg <strong>{{ state.Beleg_Nr }}</strong></p>
+    <p>Aktueller Verursacher: <strong>{{ state.Verursacher_Text or 'Nicht zugeordnet' }}</strong></p>
+    <p class="gc-assignee-original">Ursprünglich: {{ state.Original_Verursacher_Text or 'Nicht zugeordnet' }}</p>
+    {% if error %}<p class="gc-assignee-error" role="alert">{{ error }}</p>{% endif %}
+    <label class="form-label" for="verursacher-role">Rolle</label>
+    <select class="form-select mb-3" id="verursacher-role" name="rolle">
+      <option value="">Alle Rollen</option>
+      {% for role in roles %}<option value="{{ role }}"{% if role == role_filter %} selected{% endif %}>{{ role }}</option>{% endfor %}
+    </select>
+    <label class="form-label" for="verursacher-user">Benutzer</label>
+    <select class="form-select" name="benutzer_id" id="verursacher-user" required>
+      <option value="">Bitte auswählen</option>
+      {% for user in users %}
+      <option value="{{ user.id }}" data-roles="{{ user.roles|tojson|forceescape }}"{% if user.id == selected %} selected{% endif %}>{{ user.name }} ({{ user.id }})</option>
+      {% endfor %}
+    </select>
+    {% if not users %}<p role="status">Keine Benutzer verfügbar.</p>{% endif %}
+    <p class="gc-assignee-status" role="status" aria-live="polite"></p>
+  </div>
+  <div class="modal-footer">
+    <button type="submit" class="btn btn-outline-secondary" name="action" value="reset" formnovalidate>Auf ursprünglichen Verursacher zurücksetzen</button>
+    <button type="button" class="btn btn-link" data-coreui-dismiss="modal">Abbrechen</button>
+    <button type="submit" class="btn btn-primary" name="action" value="assign" data-gc-assign>Speichern</button>
+  </div>
+</form>

+ 24 - 0
templates/forderungen/wiedervorlage/modal.html

@@ -0,0 +1,24 @@
+<div class="modal-header">
+  <h2 class="modal-title fs-5" id="wiedervorlage-modal-title">Wiedervorlage ändern</h2>
+  <button type="button" class="btn-close" data-coreui-dismiss="modal" aria-label="Schließen"></button>
+</div>
+<form id="wiedervorlage-form" method="post" action="{{ modal_url }}" hx-post="{{ modal_url }}"
+  hx-target="#wiedervorlage-modal-content" hx-swap="innerHTML" hx-push-url="false"
+  hx-sync="this:drop" hx-disabled-elt="find button" data-gc-wiedervorlage-form>
+  <input type="hidden" name="_csrf" value="{{ request.state.principal.csrf }}">
+  <div class="modal-body">
+    <p>Beleg <strong>{{ state.Beleg_Nr }}</strong></p>
+    {% if error %}<p class="gc-assignee-error" role="alert">{{ error }}</p>{% endif %}
+    <label class="form-label" for="wiedervorlage-date">Wiedervorlage</label>
+    <input type="date" class="form-control" id="wiedervorlage-date" name="wiedervorlage" value="{{ selected }}" required>
+    <div class="gc-date-suggestions">
+      <button type="button" class="btn btn-outline-secondary" data-gc-date="{{ today }}">Heute</button>
+      <button type="button" class="btn btn-outline-secondary" data-gc-date="{{ next_week }}">Heute + 7 Tage</button>
+    </div>
+    <p class="gc-date-status" role="status" aria-live="polite"></p>
+  </div>
+  <div class="modal-footer">
+    <button type="button" class="btn btn-link" data-coreui-dismiss="modal">Abbrechen</button>
+    <button type="submit" class="btn btn-primary">Speichern</button>
+  </div>
+</form>

+ 154 - 0
tests/test_verursacher.py

@@ -0,0 +1,154 @@
+"""Production assignee handlers and SQL, with SQL Server lock hints adapted for SQLite."""
+import re
+import unittest
+from unittest.mock import patch
+from urllib.parse import parse_qs, urlencode, urlsplit
+
+from app.navigation import normalize_page_url
+from app.verursacher import STATE_SQL
+from tests import test_forderung_history as fixture
+
+
+class VerursacherTests(unittest.TestCase):
+    def setUp(self):
+        self.fixture = fixture.HistoryTests()
+        self.fixture.setUp()
+        self.addCleanup(self.fixture.doCleanups)
+        self.db, self.client = self.fixture.db, self.fixture.client
+        self.db.connection.executescript("""
+            ALTER TABLE dbo.Forderungen_Kopf ADD Verursacher_Benutzer_ID TEXT;
+            ALTER TABLE dbo.Forderungen_Kopf ADD Verursacher_Text TEXT;
+            UPDATE dbo.Forderungen_Kopf SET Verursacher_Benutzer_ID = 'original', Verursacher_Text = 'Original Person';
+            CREATE TABLE dbo.Benutzer_Rolle (Benutzer_ID TEXT, Name TEXT, Rolle_Name TEXT);
+            INSERT INTO dbo.Benutzer_Rolle VALUES
+                ('original', 'Original Person', 'Service'),
+                ('alice', 'Alice', 'Service'), ('alice', 'Alice', 'Buchhaltung'),
+                ('bob', 'Bob', 'Leitung'), ('id-only', NULL, NULL), ('', 'Invalid', 'Service');
+            CREATE TABLE dbo.Forderungen_Details (
+                Client_DB TEXT, Beleg_Nr TEXT, Verursacher_Benutzer_ID TEXT,
+                Bearbeitet TEXT DEFAULT 'N', Versicherung TEXT DEFAULT 'N', Wiedervorlage TEXT,
+                PRIMARY KEY (Client_DB, Beleg_Nr)
+            );
+        """)
+        self.db.connection.commit()
+        execute = self.db.execute
+        def adapted(query, values=None):
+            sql = re.sub(r'\s+WITH \(UPDLOCK, HOLDLOCK\)', '', str(query))
+            result = execute(sql, values)
+            result.all = lambda: result.rows
+            return result
+        mocked = patch.object(self.db, 'execute', side_effect=adapted)
+        mocked.start(); self.addCleanup(mocked.stop)
+        self.db.rollback = self.db.connection.rollback
+        self.origin = '/app/forderungen/liste?Filiale=10&Verursacher=Original'
+
+    def url(self, view='details', key='1_R1'):
+        return f'/app/forderungen/{view}/{key}/verursacher?' + urlencode({'return_to': self.origin})
+
+    def get(self, view='details', key='1_R1'):
+        return self.client.get(self.url(view, key), headers={'HX-Request': 'true'})
+
+    def post(self, identity='alice', action='assign', view='details', key='1_R1', **extra):
+        return self.client.post(self.url(view, key), data={
+            'benutzer_id': identity, 'action': action, **extra,
+        }, headers={'HX-Request': 'true', 'X-CSRF-Token': 'csrf'})
+
+    def row(self):
+        return self.db.connection.execute('SELECT * FROM dbo.Forderungen_Details WHERE Client_DB = ? AND Beleg_Nr = ?', ('1', 'R1')).fetchone()
+
+    def test_modal_deduplicates_people_but_keeps_all_roles_and_preselection(self):
+        response = self.get()
+        self.assertEqual(response.status_code, 200)
+        self.assertNotIn('<html', response.text)
+        self.assertEqual(response.text.count('value="alice"'), 1)
+        self.assertNotIn('value="" data-roles=', response.text)
+        self.assertIn('value="original" data-roles=', response.text)
+        self.assertIn('selected>Original Person', response.text)
+        self.assertIn('id-only (id-only)', response.text)
+        for role in ['Service', 'Buchhaltung', 'Leitung']:
+            self.assertIn(role, response.text)
+        self.assertNotIn('Invalid', response.text)
+
+    def test_assignment_creates_record_and_returns_only_detail_field(self):
+        response = self.post()
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('hx-swap-oob="outerHTML"', response.text)
+        self.assertIn('Alice', response.text)
+        self.assertNotIn('id="formular"', response.text)
+        self.assertNotIn('HX-Redirect', response.headers)
+        self.assertIn('gc:verursacherSaved', response.headers['HX-Trigger-After-Swap'])
+        self.assertEqual(self.row()[2:5], ('alice', 'N', 'N'))
+        self.assertEqual(self.db.commits, 1)
+        self.assertIn('Alice', self.get().text)
+        self.assertIn('Original Person', self.get().text)
+
+    def test_change_and_reset_preserve_other_fields_and_original(self):
+        self.db.connection.execute("INSERT INTO dbo.Forderungen_Details VALUES ('1', 'R1', 'alice', 'N', 'J', '2027-01-01')")
+        self.db.connection.commit()
+        self.post('bob')
+        self.assertEqual(self.row()[2:], ('bob', 'N', 'J', '2027-01-01'))
+        reset = self.post('', 'reset')
+        self.assertEqual(self.row()[2:], (None, 'N', 'J', '2027-01-01'))
+        self.assertIn('Original Person', reset.text)
+        source = self.db.connection.execute("SELECT Verursacher_Text FROM dbo.Forderungen_Kopf WHERE Client_DB='1' AND Beleg_Nr='R1'").fetchone()
+        self.assertEqual(source[0], 'Original Person')
+
+    def test_reset_without_record_does_not_create_one(self):
+        self.assertEqual(self.post('', 'reset').status_code, 200)
+        self.assertIsNone(self.row())
+
+    def test_list_success_refreshes_filters_and_focus(self):
+        response = self.post(view='liste')
+        self.assertEqual(response.status_code, 200)
+        destination = response.headers['HX-Redirect']
+        self.assertEqual(urlsplit(destination).path, '/app/forderungen/liste')
+        self.assertEqual(parse_qs(urlsplit(destination).query), {'Filiale': ['10'], 'Verursacher': ['Original'], 'focus': ['1_R1']})
+
+    def test_invalid_selection_and_database_failure_keep_modal_selection(self):
+        self.assertEqual(self.post('not-a-user').status_code, 422)
+        self.assertIsNone(self.row())
+        self.assertEqual(self.db.commits, 0)
+        with patch.object(self.db, 'commit', side_effect=RuntimeError('offline')), self.assertLogs('app.verursacher', level='ERROR'):
+            response = self.post('alice', rolle='Buchhaltung')
+        self.assertEqual(response.status_code, 503)
+        self.assertIn('selected>Alice', response.text)
+        self.assertIn('value="Buchhaltung" selected', response.text)
+        self.assertIsNone(self.row())
+
+    def test_rights_csrf_and_immutable_target(self):
+        self.assertEqual(self.get(key='1_R2').status_code, 404)
+        self.assertEqual(self.post(key='1_R2').status_code, 404)
+        self.assertEqual(self.client.post(self.url(), data={'benutzer_id': 'alice'}).status_code, 403)
+        self.assertEqual(self.post(Client_DB='2', Beleg_Nr='R2').status_code, 200)
+        self.assertEqual(self.row()[2], 'alice')
+        self.assertEqual(self.db.connection.execute('SELECT COUNT(*) FROM dbo.Forderungen_Details').fetchone()[0], 1)
+
+    def test_multiple_roles_do_not_multiply_state_rows(self):
+        self.post()
+        result = self.db.execute(STATE_SQL, {'Client_DB': '1', 'Beleg_Nr': 'R1'}).all()
+        self.assertEqual(len(result), 1)
+        self.assertEqual(result[0].Verursacher_Text, 'Alice')
+        self.assertEqual(result[0].Original_Verursacher_Text, 'Original Person')
+
+    def test_direct_request_returns_full_page_and_rejects_external_return(self):
+        for view in ['liste', 'details']:
+            response = self.client.get(self.url(view), follow_redirects=False)
+            self.assertEqual(response.status_code, 303)
+            self.assertNotIn('/verursacher', response.headers['location'])
+        url = '/app/forderungen/liste/1_R1/verursacher?return_to=https://evil.test'
+        self.assertEqual(self.client.get(url, follow_redirects=False).headers['location'], '/app/forderungen/liste?focus=1_R1')
+
+    def test_login_recovery_from_both_modal_views_and_no_write_replay(self):
+        self.client.cookies.clear()
+        for view in ['liste', 'details']:
+            response = self.post(view=view)
+            login = response.headers['HX-Redirect']
+            target = parse_qs(urlsplit(login).query)['next'][0]
+            self.assertNotIn('/verursacher', target)
+            self.assertEqual(target, normalize_page_url(self.url(view)))
+            self.assertIn('/app/forderungen/'+view, target)
+        self.assertIsNone(self.row())
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 170 - 0
tests/test_wiedervorlage.py

@@ -0,0 +1,170 @@
+"""Follow-up date and audit comment are one protected transaction."""
+from datetime import date, datetime
+import unittest
+from unittest.mock import patch
+from urllib.parse import parse_qs, urlencode, urlsplit
+
+from app.navigation import normalize_page_url
+from app.wiedervorlage import BERLIN, _as_date
+from tests import test_verursacher as fixture
+
+
+class WiedervorlageTests(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
+        self.db.connection.executescript("""
+            ALTER TABLE dbo.Forderungen_Kopf ADD Beleg_Faellig_Datum TEXT;
+            UPDATE dbo.Forderungen_Kopf SET Beleg_Faellig_Datum = '2026-10-15';
+        """)
+        self.db.connection.commit()
+        self.origin = '/app/forderungen/liste?Filiale=10&WiedervorlageBis=2026-10-31'
+        execute = self.db.execute
+        def adapt_dates(query, values=None):
+            values = {key: value.isoformat() if isinstance(value, (date, datetime)) else value
+                      for key, value in (values or {}).items()}
+            return execute(query, values)
+        adapter = patch.object(self.db, 'execute', side_effect=adapt_dates)
+        adapter.start(); self.addCleanup(adapter.stop)
+        self.now = datetime(2026, 10, 30, 0, 5, 12, tzinfo=BERLIN)
+        clock = patch('app.wiedervorlage._now_berlin', return_value=self.now)
+        clock.start(); self.addCleanup(clock.stop)
+
+    def url(self, key='1_R1'):
+        return f'/app/forderungen/liste/{key}/wiedervorlage?' + urlencode({'return_to': self.origin})
+
+    def get(self, key='1_R1'):
+        return self.client.get(self.url(key), headers={'HX-Request': 'true'})
+
+    def post(self, value='2026-11-06', key='1_R1', **extra):
+        return self.client.post(self.url(key), data={'wiedervorlage': value, **extra},
+                                headers={'HX-Request': 'true', 'X-CSRF-Token': 'csrf'})
+
+    def row(self):
+        return self.fixture.row()
+
+    def comments(self):
+        return self.db.connection.execute("SELECT Client_DB, Beleg_Nr, Benutzer_ID, Kommentar, Timestamp FROM dbo.Forderungen_Kommentar WHERE Kommentar LIKE 'WV auf %'").fetchall()
+
+    def test_modal_prefills_list_fallback_and_berlin_shortcuts_without_writing(self):
+        response = self.get()
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('value="2026-10-15"', response.text)
+        self.assertIn('data-gc-date="2026-10-30"', response.text)
+        self.assertIn('data-gc-date="2026-11-06"', response.text)
+        self.assertNotIn('<html', response.text)
+        self.assertIsNone(self.row()); self.assertEqual(self.comments(), [])
+        self.assertEqual(self.db.commits, 0)
+
+    def test_shortcuts_cross_year_and_leap_day(self):
+        for now, expected in [(datetime(2026, 12, 28, tzinfo=BERLIN), '2027-01-04'),
+                              (datetime(2028, 2, 22, tzinfo=BERLIN), '2028-02-29')]:
+            with patch('app.wiedervorlage._now_berlin', return_value=now):
+                self.assertIn('data-gc-date="'+expected+'"', self.get().text)
+
+    def test_insert_date_comment_actor_timestamp_and_filtered_focus(self):
+        response = self.post(Benutzer_ID='other', Client_DB='2', Beleg_Nr='R2')
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(self.row()[2:], (None, 'N', 'N', '2026-11-06'))
+        comments = self.comments()
+        self.assertEqual(len(comments), 1)
+        self.assertEqual(comments[0][:4], ('1', 'R1', 'tester', 'WV auf 06.11.2026 angepasst'))
+        self.assertEqual(datetime.fromisoformat(comments[0][4]), self.now.replace(tzinfo=None))
+        self.assertEqual(self.db.commits, 1)
+        destination = response.headers['HX-Redirect']
+        self.assertEqual(parse_qs(urlsplit(destination).query),
+                         {'Filiale': ['10'], 'WiedervorlageBis': ['2026-10-31'], 'focus': ['1_R1']})
+
+    def test_update_preserves_other_fields_and_preselects_saved_date(self):
+        self.db.connection.execute("INSERT INTO dbo.Forderungen_Details VALUES ('1','R1','alice','N','J','2026-10-20')")
+        self.db.connection.commit()
+        self.assertIn('value="2026-10-20"', self.get().text)
+        self.post()
+        self.assertEqual(self.row()[2:], ('alice', 'N', 'J', '2026-11-06'))
+        self.assertIn('value="2026-11-06"', self.get().text)
+
+    def test_missing_date_can_be_assigned(self):
+        self.db.connection.execute("UPDATE dbo.Forderungen_Kopf SET Beleg_Faellig_Datum = NULL WHERE Client_DB='1' AND Beleg_Nr='R1'")
+        self.db.connection.commit()
+        self.assertIn('value="" required', self.get().text)
+        self.assertEqual(self.post().status_code, 200)
+        self.assertEqual(self.row()[-1], '2026-11-06')
+        self.assertEqual(len(self.comments()), 1)
+
+    def test_same_date_and_repeated_submission_do_not_duplicate_comments(self):
+        self.post('2026-10-15')
+        self.assertIsNone(self.row()); self.assertEqual(self.comments(), [])
+        self.post(); self.post()
+        self.assertEqual(len(self.comments()), 1)
+
+    def test_invalid_or_empty_dates_never_write(self):
+        for value in ['', '2026-02-30', '2026-13-01', '30.10.2026', '20261030', "x'; DROP TABLE dbo.Forderungen_Details"]:
+            with self.subTest(value=value):
+                response = self.post(value)
+                self.assertEqual(response.status_code, 422)
+                self.assertIn('gültiges Wiedervorlagedatum', response.text)
+        self.assertIsNone(self.row()); self.assertEqual(self.comments(), [])
+        self.assertEqual(self.db.commits, 0)
+
+    def test_comment_failure_rolls_back_existing_date_and_allows_retry(self):
+        self.db.connection.execute("INSERT INTO dbo.Forderungen_Details VALUES ('1','R1','alice','N','J','2026-10-20')")
+        self.db.connection.commit()
+        execute = self.db.execute
+        def failing(query, values=None):
+            if 'INSERT INTO dbo.Forderungen_Kommentar' in str(query):
+                raise RuntimeError('comment unavailable')
+            return execute(query, values)
+        with patch.object(self.db, 'execute', side_effect=failing), self.assertLogs('app.wiedervorlage', level='ERROR'):
+            response = self.post()
+        self.assertEqual(response.status_code, 503)
+        self.assertNotIn('HX-Redirect', response.headers)
+        self.assertIn('value="2026-11-06"', response.text)
+        self.assertEqual(self.row()[2:], ('alice', 'N', 'J', '2026-10-20'))
+        self.assertEqual(self.comments(), [])
+        self.post()
+        self.assertEqual(len(self.comments()), 1)
+
+    def test_commit_failure_rolls_back_new_record_and_comment(self):
+        with patch.object(self.db, 'commit', side_effect=RuntimeError('offline')), self.assertLogs('app.wiedervorlage', level='ERROR'):
+            response = self.post()
+        self.assertEqual(response.status_code, 503)
+        self.assertIsNone(self.row()); self.assertEqual(self.comments(), [])
+
+    def test_rights_csrf_and_native_redirect(self):
+        self.assertEqual(self.get('1_R2').status_code, 404)
+        self.assertEqual(self.post(key='1_R2').status_code, 404)
+        self.assertEqual(self.client.post(self.url(), data={'wiedervorlage':'2026-11-06'}).status_code, 403)
+        native = self.client.get(self.url(), follow_redirects=False)
+        self.assertEqual(native.status_code, 303)
+        self.assertNotIn('/wiedervorlage', native.headers['location'])
+        self.assertIsNone(self.row())
+        native_save = self.client.post(self.url(), data={'wiedervorlage':'2026-11-06'},
+                                       headers={'X-CSRF-Token':'csrf'}, follow_redirects=False)
+        self.assertEqual(native_save.status_code, 303)
+        self.assertNotIn('/wiedervorlage', native_save.headers['location'])
+        self.assertEqual(len(self.comments()), 1)
+
+    def test_session_expiry_normalizes_full_list_without_replaying_post(self):
+        self.client.cookies.clear()
+        response = self.post()
+        target = parse_qs(urlsplit(response.headers['HX-Redirect']).query)['next'][0]
+        self.assertNotIn('/wiedervorlage', target)
+        self.assertEqual(target, normalize_page_url(self.url()))
+        self.assertIsNone(self.row()); self.assertEqual(self.comments(), [])
+        self.assertEqual(normalize_page_url('/app/forderungen/liste/1_R1/wiedervorlage',
+                         current_url='https://proxy.test'+self.origin), target)
+        self.assertEqual(normalize_page_url('/app/forderungen/liste/1_R1/wiedervorlage?return_to=https://evil.test'),
+                         '/app/forderungen/liste?focus=1_R1')
+
+    def test_database_date_types(self):
+        value = datetime(2026, 10, 30, 12, 30)
+        self.assertEqual(_as_date(value), value.date())
+        self.assertEqual(_as_date(value.date()), value.date())
+        self.assertEqual(_as_date('2026-10-30 00:00:00'), value.date())
+        self.assertIsNone(_as_date(None))
+
+
+if __name__ == '__main__':
+    unittest.main()