Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-06-auftraege-11-woms-auftraege.md
ClaudeandClaude Opus 5.5 9d82fc579f Umsetzungspläne Aufträge und Rechnungen: Übersicht und 12 Strang-Pläne
Parallele Stränge L0–L7 (Portal), W0–W2 (WOMS), Z (Integration und
Kontrolle); Vertragsfragen der Planrunde entschieden (Abschnitt
„Nachträge nach der Planrunde“ in der Übersicht).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:49:15 +00:00

154 KiB
Raw Permalink Blame History

AB-UI in WOMS – Umsetzungsplan (Strang W2)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Admins sehen in WOMS beim Kunden die Auftragsbestätigungen (AB) als Liste mit Status und Aktionen und legen sie in einem Dialog mit vier Schritten an (Projekt, Auftrag, Zahlung und Termine, Vorschau mit Entwurf oder Senden). Eine AB ohne Projekt lässt sich in der Liste nachträglich einem Projekt zuordnen. Im Dialog erscheinen zwei nicht blockierende Hinweise (fehlende Steuernummer; Papierfassung bei Aufträgen vor Ort mit Privatpersonen). Dazu kommen das Feld „Kundenart“ in der Kunden-Übersicht und die „Steuernummer“ in den Finanz-Einstellungen.

Architecture: Alle Regeln (Eingaben prüfen, Cent und Minuten, Status und Aktionen, Texte, Fehlerklassen, Doppelklick-Sperre) liegen als reine Funktionen in src/lib/auftragView.js und zwei kleinen Helfern (kundenFelder.js, auftragPdf.js) und sind mit node --test getestet. AuftragListe und AuftragDialog (mit den Schritt-Dateien) sind dünne React-Schichten darüber; sie rufen nur die financeApi.js-Funktionen aus W0 auf. Gestaltung nach den Regeln des weißen Designs (Bausteine aus src/components/ui, Dialog, eigene Datei auftrag.css, nichts in global.css).

Tech Stack: React 18, Vite 5, reines CSS mit Container Queries, Tests node --test mit esbuild-Lader (tests/unit/load.mjs, Muster tests/unit/financeView.test.mjs), Prüfwerkzeug des Redesigns (/root/reports/ticketsystem-design-2026-10-04/werkzeug, playwright-core) nur lesend.

Spec: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md (Abschnitte 1 und 2; Abschnitt 5 und 6 für Felder und Endpunkte).

Übersicht/Verträge: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md (V2 Konstanten, V3 AuftragTeam, V4 Steuernummer, V5 Endpunkte, V8 Komponenten und der Abschnitt „Nachträge nach der Planrunde“ sind verbindlich). Jeder Implementierer liest Spec, Übersicht und diese Datei.

Arbeitsort: WOMS-Worktree /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2, Branch auftraege/w2-auftraege, abgezweigt von feature/auftraege nach W0 (diese Basis steht auf dem zusammengeführten Redesign P4/P6/P7).

Global Constraints

Alle Punkte der Übersicht gelten. Für WOMS vor allem: Texte Deutsch, Beträge ganze Cent mit euro() aus src/lib/financeFormat.js, Berliner Kalendertag YYYY-MM-DD mit Anzeige TT.MM.JJJJ (dateDe), Grenzen aus V2 LIMITS, kein Push, nie --force, nie git add -A oder git add ., build/ nie anfassen, keine Live-Daten, schwere Läufe hinter dem flock der Übersicht. Zusätze für diesen Strang:

  • Befehle: Jeder Run-Block beginnt mit cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && (die Shell setzt das Verzeichnis zwischen Aufrufen zurück). Tests nur gezielt: timeout 900 nice -n 10 node --max-old-space-size=1024 --test <Dateien>. Nie npm test, nie npm ci, nie pkill -f. Schwere Läufe (Build, Browser) nur hinter flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock; läuft das Redesign noch schwere Läufe, zusätzlich dahinter flock /root/reports/ticketsystem-design-2026-10-04/sperren/schwer.lock (erst unsere, dann deren); vorher free -m (unter 1500 MB „available“: nicht starten, später wiederholen). rg ist ripgrep 13 ohne PCRE2 (kein -P).
  • Besitz: Dieser Strang ändert nur die Dateien der Tabelle „Dateien“. Nie anfassen: AuftraegeUndRechnungen.jsx, ProjektkontoListe.jsx, RechnungDialog.jsx, projektkonto.css, rechnung-dialog.css, src/lib/financeApi.js, teamApi.js, customerTabs.js, financeSettings.js, financeView.js, financeUi.js, InvoiceTable.jsx, FinancePage.jsx, WorksheetList.jsx, WorksheetTimesModal.jsx, src/components/ui/*, src/styles/global.css. Fehlt dort etwas, wird es unter „Vertragsfragen“ gemeldet, nicht ergänzt.
  • Redesign-Regeln: Bausteine Button, Badge, Card, EmptyState, Dialog aus src/components/ui; Meldungen als .msg (noticeProps aus src/lib/financeUi.js); keine Icon-Pakete (Zeichen „ד, „+“); kein style={{…}}; keine Farbliterale (nur Tokens wie var(--line)); kein !important; window.confirm für Rückfragen, nie window.alert. In einem <Dialog as="form"> trägt jeder Knopf type="button", außer „Weiter“ (type="submit"). Enter sendet nie die AB.
  • CSS: Nur src/components/auftraege/auftrag.css, alle Klassen beginnen mit auftrag-. main.jsx lädt App vor global.css: bei gleicher Spezifität gewinnt global.css, deshalb nie eine globale Klasse (.table, .field …) überschreiben, sondern eigene Klassen verwenden oder mit .auftrag-… .table höher gehen. container-type steht nur auf .auftrag-body (die Liste), nie auf einem Vorfahren eines Dialogs.
  • Texte: Die Texte der Tabellen und Tests in dieser Datei gelten wörtlich. WOMS-Texte sind sachlich (keine Anrede).
  • Tests: Datei je Lib unter tests/unit/<name>.test.mjs, loadModule('src/lib/<name>.js') aus ./load.mjs. Die Libs sind rein (kein React, kein Appwrite, kein import.meta.env).
  • Zeit der Backends: Nichts in dieser Datei darf einen echten Host erreichen. Das Prüfwerkzeug beantwortet alles aus Testdaten und bricht fremde Hosts ab.

Review Focus

  1. Kundenart fehlt beim ersten Senden: Schritt 1 lässt ohne Kundenart kein „Weiter“ zu, der Server antwortet bei einem Entwurf ohne Kundenart mit 400, die Liste erklärt das und führt zur Übersicht. Tests: Task 3 (pruefeSchritt 1, sendeGate), Task 4 (auftragFehler, 400 Kundenart), Task 8 (Dialog-Fehler „Bitte die Kundenart wählen.“).
  2. Kunde ohne Anschrift (Server 409): Entwurf speichern geht, Senden im Dialog ist gesperrt, und ein 409 des Servers in der Liste zeigt „Adresse des Kunden unvollständig …“ mit „Zur Übersicht“. Tests: Task 3 (sendeGate), Task 4 (auftragFehler, 409 Adresse), Task 8 (Zeile entw-adresse).
  3. Bearbeiten nach dem Senden, „Projekt zuordnen“: Gesendete ABs bieten nie „Bearbeiten“, „Senden“ oder „Löschen“, nur „Neue Fassung“ (und PDF, Erinnerung, Stornieren je Status); der Dialog öffnet keinen Nicht-Entwurf. „Projekt zuordnen“ ändert nur das Projekt (nie Inhalt oder Status) und erscheint nur bei einer AB ohne Projekt im Status Entwurf, Versendet, Bestätigt oder Abgerechnet und nur, wenn es mindestens ein Projekt zur Auswahl gibt. Tests: Task 4 (auftragAktionen, projektZuordnenMoeglich), Task 7 (Regel „nicht bearbeitbar“), Task 8 (Liste, Zuordnen).
  4. Doppelklick auf Senden und Zuordnen (Liste und Dialog): genau eine Anfrage, auch wenn der Server langsam antwortet. Tests: Task 4 (neueSperre), Task 8 (Doppelklick: ein POST …/senden, ein POST …/projekt).
  5. Handy-Breite 390 px: Tabelle, Auswahl „Projekt zuordnen“, Positionszeilen, Zeitquelle und Dialog laufen nicht waagerecht über, Knöpfe bleiben erreichbar. Test: Task 8 (overflow-x und Bildschirmfotos bei 390 px).
  6. Hinweise blockieren nie: Die fehlende Steuernummer (Schritt 4) und die Papierfassung bei „Vor Ort“ mit Privatperson (Schritt 2 und 4) sind reine Texte; „Weiter“ und „Senden“ hängen nur an pruefeSchritt und sendeGate. Sind die Einstellungen nicht ladbar, erscheint kein Hinweis (es wird nichts behauptet). Tests: Task 2 (steuernummerFehlt), Task 3 (vorOrtHinweis, auftragHinweise), Task 8 (Dialog mit und ohne Hinweise).

Dateien

Datei Art Task
src/lib/kundenFelder.js, tests/unit/kundenFelder.test.mjs neu 2
src/pages/CustomerDetailPage.jsx (nur OverviewTab) ändern 2
src/components/finance/SettingsCard.jsx (nur SettingsForm) ändern 2
src/lib/auftragView.js, tests/unit/auftragView.test.mjs neu 3, 4
src/lib/auftragPdf.js, tests/unit/auftragPdf.test.mjs neu 4
src/components/auftraege/AuftragListe.jsx Platzhalter von W0 ersetzen 5
src/components/auftraege/auftrag.css neu 5 (Task 6 und 7 hängen an)
src/components/auftraege/AuftragSchritte.jsx neu 6
src/components/auftraege/AuftragDialog.jsx Platzhalter von W0 ersetzen 7
src/components/auftraege/AuftragVorschau.jsx neu 7

Task 1: Worktree und Ausgangslage

Files: keine Änderung (nur anlegen und prüfen).

Interfaces:

  • Consumes: Branch feature/auftraege im WOMS-Worktree /home/webklar/apps/ticket.webklar.com/dev-auftraege nach W0 (Container, financeApi.js-Funktionen, vier Platzhalter, echtes node_modules).

  • Produces: Worktree /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 auf Branch auftraege/w2-auftraege.

  • Step 1: Worktree anlegen

Run: git -C /home/webklar/apps/ticket.webklar.com/dev-auftraege worktree add ../dev-auftraege-w2 -b auftraege/w2-auftraege feature/auftraege && cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && ln -s ../dev-auftraege/node_modules node_modules && git status --short && git log --oneline -3 Expected: git status zeigt nur ?? node_modules (Symlink, bleibt unversioniert, nie git add -A), darunter die jüngsten W0-Commits. Fehlt ../dev-auftraege/node_modules, stoppen und melden (W0 legt es an).

  • Step 2: W0-Ergebnis prüfen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && ls src/components/auftraege src/components/ui/Dialog.jsx src/lib/financeUi.js src/lib/auftraegeAnsicht.js src/components/finance/FormField.jsx src/hooks/useWorkorders.js src/hooks/useScrollIntoView.js src/lib/openInNewTab.js && for n in listAuftraege getAuftrag createAuftrag patchAuftrag deleteAuftrag sendAuftrag erinnereAuftrag neueFassungAuftrag storniereAuftrag ordneAuftragProjektZu getAuftragPdf getSettings; do rg -q "\b$n\b" src/lib/financeApi.js && echo "da $n" || echo "FEHLT $n"; done Expected: ls nennt AuftraegeUndRechnungen.jsx, AuftragListe.jsx und AuftragDialog.jsx (Platzhalter von W0, sie rendern null) und alle übrigen Pfade; zwölfmal da … (W0 exportiert die AB-Funktionen als export const { … } = createAuftraegeEndpoints(request), darunter ordneAuftragProjektZu(id, projektId) für „Projekt zuordnen“; getSettings besteht schon). Fehlt etwas, stoppen und melden: financeApi.js, auftraegeAnsicht.js und der Container gehören W0.

  • Step 3: Verdrahtung des Containers lesen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && rg -n -B1 -A8 "<AuftragListe|<AuftragDialog" src/components/auftraege/AuftraegeUndRechnungen.jsx; head -20 src/components/auftraege/AuftragListe.jsx src/components/auftraege/AuftragDialog.jsx; ls src/components/auftraege/auftrag.css Expected: Aufrufe mit genau den Props aus V8: <AuftragListe auftraege konten onChanged onBearbeiten onShowOverview /> und <AuftragDialog open onClose onSaved kunde projektId auftrag konten onShowOverview /> (die Übersicht, Nachträge, und Plan W0 Task 7, Zeilen e und g, legen fest, dass der Container konten={ansicht.konten} und onShowOverview an beide durchreicht). Weiter gilt laut W0: onSaved schließt den Dialog im Container und zeigt die Meldung auftragMeldung(auftrag), kunde trägt die frische Kundenart (kundeMitKundenart), onShowOverview wechselt auf den Reiter Übersicht (setTab('overview'), die Adresse wird /customers/<id> ohne tab). Weicht der Stand davon ab, wird das im Abschlussbericht gemeldet, nicht im Container behoben. Die Exportnamen der Platzhalter (export default function AuftragListe / AuftragDialog) bleiben. auftrag.css gibt es noch nicht (das ls meldet „No such file“; W0 legt keine CSS-Datei an), W2 legt sie in Task 5 an.

  • Step 4: Ausgangs-Tests

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/financeView.test.mjs tests/unit/financeSettings.test.mjs tests/unit/customerTabs.test.mjs tests/unit/ticketView.test.mjs Expected: Zeile fail 0 (prüft zugleich den esbuild-Lader über den Symlink). Kein Commit.


Task 2: Kundenart und Steuernummer

Files:

  • Create: src/lib/kundenFelder.js
  • Create: tests/unit/kundenFelder.test.mjs
  • Modify: src/pages/CustomerDetailPage.jsx (OverviewTab)
  • Modify: src/components/finance/SettingsCard.jsx (SettingsForm)

Interfaces:

  • Consumes: FormField und useRef in SettingsCard.jsx (vorhanden); databases.updateDocument und upgradeToCustomer(customer, fullData) in OverviewTab (vorhanden, fullData wird in das Dokument gespreizt).

  • Produces (src/lib/kundenFelder.js): KUNDENARTEN, KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartLabel(value) → string, kundenartAenderung(neu, gespeichert) → {} | { kundenart }, STEUERNUMMER_MAX, STEUERNUMMER_HINWEIS, steuernummerAenderung(eingabe, gespeichert) → { ok: true, payload: {} | { steuernummer } } | { ok: false, fehler: string }, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt(einstellungen) → boolean (für den Hinweis in Schritt 4 des Dialogs, Task 7).

  • Regel der Steuernummer (Übersicht V4, Plan L0 Task 12; der Client prüft wie der Server): getrimmt höchstens 40 Zeichen aus ASCII-Buchstaben, Ziffern, Leerzeichen, /, - und . (auch eine USt-IdNr. „DE…“); leer ist erlaubt und löscht den Wert.

  • Step 1: Failing Test schreiben (tests/unit/kundenFelder.test.mjs)

import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'

const {
  KUNDENARTEN, KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartLabel, kundenartAenderung,
  STEUERNUMMER_MAX, STEUERNUMMER_HINWEIS, steuernummerAenderung, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt,
} = await loadModule('src/lib/kundenFelder.js')

test('Kundenart: Werte (Vertrag V2), Auswahl und Beschriftung', () => {
  assert.deepEqual(KUNDENARTEN, ['unternehmen', 'privat'])
  assert.deepEqual(KUNDENART_OPTIONEN, [
    { value: '', label: 'Nicht festgelegt' },
    { value: 'unternehmen', label: 'Unternehmen' },
    { value: 'privat', label: 'Privatperson' },
  ])
  assert.equal(KUNDENART_HINWEIS, 'Für Auftragsbestätigungen: Privatpersonen bekommen die Widerrufsbelehrung.')
  assert.equal(kundenartLabel('unternehmen'), 'Unternehmen')
  assert.equal(kundenartLabel('privat'), 'Privatperson')
  for (const v of ['', undefined, null, 'firma']) assert.equal(kundenartLabel(v), 'Nicht festgelegt')
})

test('kundenartAenderung: nur eine geänderte, bekannte Kundenart wird geschrieben', () => {
  assert.deepEqual(kundenartAenderung('', ''), {})
  assert.deepEqual(kundenartAenderung('', undefined), {})
  assert.deepEqual(kundenartAenderung('privat', 'privat'), {})
  assert.deepEqual(kundenartAenderung('privat', ''), { kundenart: 'privat' })
  assert.deepEqual(kundenartAenderung('privat', undefined), { kundenart: 'privat' })
  assert.deepEqual(kundenartAenderung('unternehmen', 'privat'), { kundenart: 'unternehmen' })
  assert.deepEqual(kundenartAenderung('', 'privat'), { kundenart: '' })
  // unbekannte Werte werden nie geschrieben
  assert.deepEqual(kundenartAenderung('firma', ''), {})
  assert.deepEqual(kundenartAenderung('firma', 'privat'), {})
})

test('Steuernummer: Hinweis und Grenze', () => {
  assert.equal(STEUERNUMMER_HINWEIS, 'Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.')
  assert.equal(STEUERNUMMER_MAX, 40)
})

test('steuernummerAenderung: unverändert sendet nichts, geändert den getrimmten Wert, leer löscht', () => {
  assert.deepEqual(steuernummerAenderung('12/345/67890', '12/345/67890'), { ok: true, payload: {} })
  assert.deepEqual(steuernummerAenderung('', ''), { ok: true, payload: {} })
  assert.deepEqual(steuernummerAenderung('', null), { ok: true, payload: {} })
  assert.deepEqual(steuernummerAenderung('  12/345/67890  ', '12/345/67890'), { ok: true, payload: {} })
  assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', ''), { ok: true, payload: { steuernummer: '12/345/67890' } })
  assert.deepEqual(steuernummerAenderung('', '12/345/67890'), { ok: true, payload: { steuernummer: '' } })
  assert.equal(steuernummerAenderung('123 456 789-0', '').ok, true)
  assert.equal(steuernummerAenderung('1'.repeat(40), '').ok, true)
})

test('steuernummerAenderung: Buchstaben, Ziffern, Leerzeichen, „/“, „-“ und „.“ sind erlaubt wie auf dem Server (V4)', () => {
  for (const gut of ['12/345/67890', '12.345.67890', 'DE123456789', 'de 123 456 789', 'DE-123.456/789', 'a', 'USt-IdNr 12/345']) {
    assert.deepEqual(steuernummerAenderung(gut, ''), { ok: true, payload: { steuernummer: gut } }, gut)
  }
  assert.deepEqual(steuernummerAenderung('  DE123456789 ', ''), { ok: true, payload: { steuernummer: 'DE123456789' } })
  assert.deepEqual(steuernummerAenderung('A'.repeat(40), ''), { ok: true, payload: { steuernummer: 'A'.repeat(40) } })
})

test('steuernummerAenderung: andere Zeichen und zu lang', () => {
  const zeichen = 'Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.'
  for (const schlecht of ['12_345', '12,345', '12#34', 'Müller 12', 'ß12', '12\t34', '12\n34', '12+34', '123']) {
    assert.deepEqual(steuernummerAenderung(schlecht, ''), { ok: false, fehler: zeichen }, JSON.stringify(schlecht))
  }
  const LANG = 'Die Steuernummer darf höchstens 40 Zeichen lang sein.'
  assert.deepEqual(steuernummerAenderung('1'.repeat(41), ''), { ok: false, fehler: LANG })
  assert.deepEqual(steuernummerAenderung('A'.repeat(41), ''), { ok: false, fehler: LANG })
  assert.equal(steuernummerAenderung('_'.repeat(41), '').fehler, LANG) // die Länge wird zuerst geprüft
})

test('steuernummerFehlt: Hinweis nur, wenn die Einstellungen da sind und die Steuernummer leer ist', () => {
  assert.equal(STEUERNUMMER_FEHLT_HINWEIS, 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.')
  for (const leer of [{ steuernummer: '' }, { steuernummer: '   ' }, { steuernummer: null }, { steuernummer: 12 }, {}, { bankDetailsText: 'IBAN' }]) {
    assert.equal(steuernummerFehlt(leer), true, JSON.stringify(leer))
  }
  for (const da of [{ steuernummer: '12/345/67890' }, { steuernummer: ' DE123456789 ' }]) assert.equal(steuernummerFehlt(da), false)
  // noch nicht geladen oder nicht ladbar: es wird nichts behauptet
  for (const unbekannt of [undefined, null, 'x', 5, []]) assert.equal(steuernummerFehlt(unbekannt), false, String(unbekannt))
})
  • Step 2: Test laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/kundenFelder.test.mjs Expected: FAIL (esbuild: src/lib/kundenFelder.js lässt sich nicht auflösen).

  • Step 3: src/lib/kundenFelder.js implementieren

Reine Datei ohne Importe, deutscher Kopfkommentar im Stil von financeSettings.js. Regeln:

  • KUNDENARTEN = ['unternehmen', 'privat']; KUNDENART_OPTIONEN, KUNDENART_HINWEIS, STEUERNUMMER_HINWEIS, STEUERNUMMER_FEHLT_HINWEIS, STEUERNUMMER_MAX wörtlich wie im Test.

  • kundenartLabel(value): Beschriftung der Option mit diesem Wert, für alles Unbekannte (auch '', null) „Nicht festgelegt“.

  • kundenartAenderung(neu, gespeichert): beide als Text lesen (undefined und null gelten als ''). Ist neu weder '' noch in KUNDENARTEN, oder gleich gespeichert: {}. Sonst { kundenart: neu }.

  • steuernummerAenderung(eingabe, gespeichert): beide trimmen (kein Text = ''). Gleich: { ok: true, payload: {} }. Länger als STEUERNUMMER_MAX (zuerst geprüft): Fehler „Die Steuernummer darf höchstens 40 Zeichen lang sein.“. Passt /^[A-Za-z0-9\/ .-]*$/ nicht (genau die Regel des Servers: nur ASCII-Buchstaben, keine Umlaute, als Leerzeichen nur U+0020): Fehler „Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.“. Sonst { ok: true, payload: { steuernummer: <getrimmt> } } (leer löscht den Wert).

  • steuernummerFehlt(einstellungen): true nur, wenn einstellungen ein Objekt ist (kein Array, nicht null) und einstellungen.steuernummer kein Text oder nach trim() leer ist; für alles andere (undefined = noch nicht geladen, null = nicht ladbar) false, damit nie etwas behauptet wird, was nicht feststeht.

  • Step 4: Test laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/kundenFelder.test.mjs Expected: PASS, 7 Tests, fail 0.

  • Step 5: Kundenart in OverviewTab (src/pages/CustomerDetailPage.jsx)

Anker sind die Namen, Zeilennummern verschieben sich.

  • Import ergänzen: import { KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartAenderung } from '../lib/kundenFelder'.
  • In OverviewTab neben form: const [kundenart, setKundenart] = useState(customer.kundenart || '') und const felder = () => ({ ...form, ...kundenartAenderung(kundenart, customer.kundenart) }). save schreibt felder() statt form, upgrade übergibt felder() statt form an upgradeToCustomer. ready = isInvoiceReady(…) bleibt unverändert.
  • Markup: erste Zeile des <dl className="kv kunde-kv"> in <Card title="Rechnungsdaten"> (vor „Strasse + Nr.“):
Element Umsetzung
Beschriftung <dt><label htmlFor="kunde-kundenart">Kundenart</label></dt>
Feld <dd> mit <select id="kunde-kundenart" className="select" value={kundenart} onChange={(e) => setKundenart(e.target.value)}> und je Eintrag von KUNDENART_OPTIONEN ein <option key value>{label}</option>; darunter <div className="hint">{KUNDENART_HINWEIS}</div>

Gespeichert wird mit dem vorhandenen Knopf „Speichern“. kundenart geht nur ins Dokument, wenn es sich geändert hat (so scheitert das Speichern der übrigen Stammdaten nie an einem noch nicht angelegten Attribut).

  • Step 6: Steuernummer in SettingsForm (src/components/finance/SettingsCard.jsx)

  • Import ergänzen: import { STEUERNUMMER_HINWEIS, steuernummerAenderung } from '../../lib/kundenFelder'.

  • Zustand: const [steuernummer, setSteuernummer] = useState(() => (typeof initial?.steuernummer === 'string' ? initial.steuernummer : '')) und const gespeichertRef = useRef(typeof initial?.steuernummer === 'string' ? initial.steuernummer : '').

  • submit: const stn = steuernummerAenderung(steuernummer, gespeichertRef.current) zusätzlich zu validateSettingsForm(form). Ist eines von beiden nicht ok: setErrors({ ...(check.errors || {}), ...(stn.ok ? {} : { steuernummer: stn.fehler }) }), die bisherige Warnung „Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.“, Fokus auf das erste Feld aus [...SETTINGS_FIELD_ORDER, 'steuernummer']. Sonst patchSettings({ ...check.payload, ...stn.payload }). Nach Erfolg gilt als gespeichert der Text aus data.einstellungen.steuernummer, wenn der Server einen liefert, sonst der gesendete Wert: setSteuernummer(…) und gespeichertRef.current = ….

  • Markup: nach dem Zähler fin-counter (Bankverbindung), im selben <fieldset className="fin-fieldset">:

<FormField id={fieldId('steuernummer')} label="Steuernummer" hint={STEUERNUMMER_HINWEIS} error={errors.steuernummer}>
  <input className="input" autoComplete="off" value={steuernummer} onChange={…} placeholder="z. B. 12/345/67890 oder DE123456789" />
</FormField>

onChange setzt den Text, löscht errors.steuernummer und eine offene Meldung (wie change(name) für die anderen Felder).

  • Step 7: Syntax und Hygiene

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && node_modules/.bin/esbuild src/pages/CustomerDetailPage.jsx src/components/finance/SettingsCard.jsx --loader:.jsx=jsx --jsx=automatic --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/syntax --log-level=warning && git diff --stat && git diff -U0 src/pages/CustomerDetailPage.jsx src/components/finance/SettingsCard.jsx | rg "^\+.*(react-icons|style=\{\{|form-control|window\.alert)" Expected: esbuild ohne Ausgabe, git diff --stat nennt genau diese zwei Dateien mit je weniger als 40 geänderten Zeilen, der letzte rg findet nichts (Exit 1).

  • Step 8: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/lib/kundenFelder.js tests/unit/kundenFelder.test.mjs src/pages/CustomerDetailPage.jsx src/components/finance/SettingsCard.jsx && git commit -m "Kundenart in der Kunden-Uebersicht und Steuernummer in den Finanz-Einstellungen (Helfer mit Tests)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 3: Dialoglogik in auftragView.js

Files:

  • Create: src/lib/auftragView.js
  • Create: tests/unit/auftragView.test.mjs

Interfaces:

  • Consumes: canInvoice, dateDe, euro, ymdBerlin aus ./financeFormat; ADDRESS_INCOMPLETE_TEXT, addressComplete, invoiceTotalCents, parseQuantity, parseUnitPrice aus ./financeView; euroField aus ./financeSettings; ticketNumber aus ./ticketView; KUNDENARTEN, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt aus ./kundenFelder (Task 2).

  • Produces (benannte Exporte; Formular ist die Form aus neuesFormular, Schlüssel siehe Test, Konto ist { projektId, name, tickets: [{ id, woid }] } aus V3):

    • Konstanten: AUFTRAG_LIMITS, STUNDENSATZ_STANDARD_CENTS, AUFTRAG_WEGE, ZAHLUNGSPLAENE, SCHRITTE; zahlungsplanOptionen(art) → [{ value, label }].
    • Eingaben: parseStunden(text) → Minuten | null, stundenText(minuten) → string, parseProzent(text) → 1..99 | null, parseTage(text) → 0..60 | null, heuteBerlin(now?) → 'YYYY-MM-DD'.
    • Formular: neuePosition(), neuesFormular({ projektId, konten, heute }) → Formular, auftragZuFormular(auftrag) → Formular, waehleProjekt(form, projektId, konten) → Formular, wechselArt(form, art) → Formular, formularSummeCents(form) → number | null.
    • Prüfen: pruefeSchritt(schritt, form, { kunde }) → { ok, fehler: { feld: Meldung }, erstes }, formularZuNutzlast(form, { kunde }) → { ok: true, payload, summeCents } | { ok: false, schritt, erstes, fehler }, sendeGate(kunde, kundenart) → { erlaubt, grund, zurUebersicht }.
    • Texte und Auswahl: zahlungsplanVorschau({ art, prozent, text, summeCents, zielTage }) → string, zeitquelleOptionen(tickets, auswahl, projektTicketIds) → [{ id, label, gewaehlt, projekt }], schalteZeitquelle(auswahl, id, an) → string[].
    • Hinweise (nicht blockierend): kundenartWirksam(kunde, kundenart) → '' | 'unternehmen' | 'privat', VOR_ORT_HINWEIS, vorOrtHinweis(weg, kundenart) → string ('' = kein Hinweis), auftragHinweise({ kunde, form, einstellungen }) → [{ key: 'steuernummer' | 'vorOrt', text }].
  • Step 1: Failing Tests schreiben (tests/unit/auftragView.test.mjs)

import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'

const {
  AUFTRAG_LIMITS, STUNDENSATZ_STANDARD_CENTS, AUFTRAG_WEGE, SCHRITTE, zahlungsplanOptionen,
  parseStunden, stundenText, parseProzent, parseTage, heuteBerlin,
  neuePosition, neuesFormular, auftragZuFormular, waehleProjekt, wechselArt, formularSummeCents,
  pruefeSchritt, formularZuNutzlast, zahlungsplanVorschau, sendeGate, zeitquelleOptionen, schalteZeitquelle,
  kundenartWirksam, VOR_ORT_HINWEIS, vorOrtHinweis, auftragHinweise,
} = await loadModule('src/lib/auftragView.js')

const KONTEN = [
  { projektId: 'p1', name: 'Website Praxis Müller', tickets: [{ id: 't1', woid: '100131' }] },
  { projektId: 'p2', name: 'Shop Relaunch', tickets: [{ id: 't2', woid: '100140' }, { id: 't3', woid: '100141' }] },
]
const KUNDE = { $id: 'c1', name: 'Praxis Müller', email: 'info@praxis.test', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', kundenart: '', customerStatus: 'customer' }
const festpreisForm = (over = {}) => ({
  ...neuesFormular({ heute: '2026-10-06' }),
  titel: 'Website', kundenart: 'privat', auftragWeg: 'telefon',
  positionen: [{ uid: 1, description: 'Startseite', quantity: '1', price: '800,00' }],
  ...over,
})
const aufwandForm = (over = {}) => festpreisForm({ art: 'aufwand', positionen: [], ...over })
const OK = { ok: true, fehler: {}, erstes: '' }

test('Konstanten gleichen dem Vertrag V2 und der Spec (Limits, Wege, Zahlungspläne, Schritte)', () => {
  assert.deepEqual(AUFTRAG_LIMITS, {
    maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000,
    maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60,
    standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10,
    maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140,
  })
  assert.equal(STUNDENSATZ_STANDARD_CENTS, 10000)
  assert.deepEqual(AUFTRAG_WEGE, [
    { value: 'muendlich', label: 'Mündlich' }, { value: 'telefon', label: 'Telefonisch' },
    { value: 'email', label: 'Per E-Mail' }, { value: 'vor_ort', label: 'Vor Ort' }, { value: 'chat', label: 'Im Chat' },
  ])
  assert.deepEqual(zahlungsplanOptionen('festpreis'), [
    { value: 'nach_abnahme', label: 'Alles nach Abnahme' },
    { value: 'anzahlung', label: 'Anzahlung bei Auftrag, Rest nach Abnahme' },
    { value: 'frei', label: 'Frei (eigener Text)' },
  ])
  assert.deepEqual(zahlungsplanOptionen('aufwand'), [
    { value: 'nach_abnahme', label: 'Alles nach Abnahme' },
    { value: 'monatlich', label: 'Monatlich nach Aufwand' },
    { value: 'frei', label: 'Frei (eigener Text)' },
  ])
  assert.deepEqual(SCHRITTE, [
    { id: 1, label: 'Projekt' }, { id: 2, label: 'Auftrag' }, { id: 3, label: 'Zahlung und Termine' }, { id: 4, label: 'Vorschau' },
  ])
})

test('parseStunden: Stunden mit Komma in 15-Minuten-Schritten werden Minuten', () => {
  const gut = { '10': 600, '10,25': 615, '10,5': 630, '10,50': 630, '10,75': 645, '0,25': 15, '1': 60, '12,5 h': 750, '2 Std.': 120, ' 8 ': 480, '1666': 99960 }
  for (const [text, minuten] of Object.entries(gut)) assert.equal(parseStunden(text), minuten, text)
  for (const schlecht of ['', '0', '0,00', '10,3', '10,30', '10,1', '10.5', '-1', 'abc', '1666,25', '1667', '10,255', '1e3', ',5', '10 Minuten', null, undefined]) {
    assert.equal(parseStunden(schlecht), null, String(schlecht))
  }
})

test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => {
  assert.deepEqual([15, 30, 45, 60, 75, 120, 135, 150, 600, 99960].map(stundenText), ['0,25', '0,5', '0,75', '1', '1,25', '2', '2,25', '2,5', '10', '1666'])
  assert.equal(stundenText(null), '–')
  assert.equal(stundenText('x'), '–')
})

test('parseProzent und parseTage', () => {
  assert.deepEqual(['50', ' 30 % ', '1', '99'].map(parseProzent), [50, 30, 1, 99])
  for (const schlecht of ['', '0', '100', '-5', '33,3', '5a', 'x', null]) assert.equal(parseProzent(schlecht), null, String(schlecht))
  assert.deepEqual(['14', '0', '60', ' 7 '].map(parseTage), [14, 0, 60, 7])
  for (const schlecht of ['', '61', '-1', '1,5', 'x', '1000', null]) assert.equal(parseTage(schlecht), null, String(schlecht))
})

test('heuteBerlin: Berliner Kalendertag, nicht der UTC-Tag', () => {
  assert.equal(heuteBerlin(Date.parse('2026-10-05T22:30:00.000Z')), '2026-10-06')
  assert.equal(heuteBerlin(Date.parse('2026-10-05T21:59:00.000Z')), '2026-10-05')
  assert.match(heuteBerlin(), /^\d{4}-\d{2}-\d{2}$/)
})

test('neuesFormular: Vorgaben; ein Projekt füllt Titel und Zeitquelle', () => {
  const f = neuesFormular({ projektId: 'p2', konten: KONTEN, heute: '2026-10-06' })
  const { positionen, ...rest } = f
  assert.equal(positionen.length, 1)
  const { uid, ...zeile } = positionen[0]
  assert.equal(typeof uid, 'number')
  assert.deepEqual(zeile, { description: '', quantity: '1', price: '' })
  assert.deepEqual(rest, {
    projektId: 'p2', titel: 'Shop Relaunch', titelGeaendert: false, kundenart: '', art: 'festpreis',
    auftragVom: '2026-10-06', auftragWeg: '',
    stundensatz: '100,00', schaetzung: '',
    zahlungsart: 'nach_abnahme', prozent: '50', zahlungstext: '', zahlungsziel: '14',
    startAm: '', fertigBis: '', zeitquelle: ['t2', 't3'], zeitquelleGeaendert: false,
  })
  assert.deepEqual(neuesFormular({ konten: KONTEN, heute: '2026-10-06' }).zeitquelle, [])
  assert.equal(neuesFormular({ projektId: 'gibtsnicht', konten: KONTEN, heute: '2026-10-06' }).projektId, '')
  assert.equal(neuesFormular({ projektId: 'p1', heute: '2026-10-06' }).projektId, '')
  assert.notEqual(neuePosition().uid, neuePosition().uid)
})

test('auftragZuFormular: Entwurf vom Server wird zum Formular (Euro- und Stundentexte)', () => {
  const entwurf = {
    id: 'a1', status: 'entwurf', projektId: 'p1', titel: 'Website', art: 'festpreis',
    positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 }],
    stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
    zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
    auftragVom: '2026-10-05', auftragWeg: 'email', startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'],
  }
  const f = auftragZuFormular(entwurf)
  assert.deepEqual(f.positionen.map(({ uid, ...p }) => p), [{ description: 'Startseite', quantity: '2', price: '400,00' }])
  const { positionen, ...rest } = f
  assert.deepEqual(rest, {
    projektId: 'p1', titel: 'Website', titelGeaendert: true, kundenart: '', art: 'festpreis',
    auftragVom: '2026-10-05', auftragWeg: 'email',
    stundensatz: '100,00', schaetzung: '',
    zahlungsart: 'anzahlung', prozent: '30', zahlungstext: '', zahlungsziel: '21',
    startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'], zeitquelleGeaendert: true,
  })
  const aufwand = auftragZuFormular({
    ...entwurf, art: 'aufwand', positionen: [], stundensatzCents: 9550, schaetzungMinuten: 750, summeCents: null,
    zahlungsplan: { art: 'frei', prozent: null, text: 'Nach Absprache' },
  })
  assert.equal(aufwand.stundensatz, '95,50')
  assert.equal(aufwand.schaetzung, '12,5')
  assert.equal(aufwand.positionen.length, 1) // eine leere Zeile für den Wechsel auf Festpreis
  assert.deepEqual([aufwand.zahlungsart, aufwand.prozent, aufwand.zahlungstext], ['frei', '50', 'Nach Absprache'])
})

test('waehleProjekt: Titel und Zeitquelle folgen dem Projekt, bis sie von Hand geändert wurden', () => {
  const f0 = neuesFormular({ konten: KONTEN, heute: '2026-10-06' })
  const f1 = waehleProjekt(f0, 'p1', KONTEN)
  assert.deepEqual([f1.projektId, f1.titel, f1.zeitquelle], ['p1', 'Website Praxis Müller', ['t1']])
  const f2 = waehleProjekt(f1, 'p2', KONTEN)
  assert.deepEqual([f2.titel, f2.zeitquelle], ['Shop Relaunch', ['t2', 't3']])
  const f3 = waehleProjekt(f2, '', KONTEN)
  assert.deepEqual([f3.projektId, f3.titel, f3.zeitquelle], ['', '', []])
  assert.equal(waehleProjekt(f1, 'gibtsnicht', KONTEN).projektId, '')
  const hand = { ...f1, titel: 'Eigener Titel', titelGeaendert: true, zeitquelle: ['t7'], zeitquelleGeaendert: true }
  const f4 = waehleProjekt(hand, 'p2', KONTEN)
  assert.deepEqual([f4.projektId, f4.titel, f4.zeitquelle], ['p2', 'Eigener Titel', ['t7']])
  assert.equal(f0.projektId, '') // die Eingabe bleibt unverändert
})

test('wechselArt: ein unpassender Zahlungsplan wird zurückgesetzt', () => {
  const f = { ...neuesFormular({ heute: '2026-10-06' }), zahlungsart: 'anzahlung' }
  assert.equal(wechselArt(f, 'aufwand').art, 'aufwand')
  assert.equal(wechselArt(f, 'aufwand').zahlungsart, 'nach_abnahme')
  assert.equal(wechselArt(f, 'festpreis').zahlungsart, 'anzahlung')
  assert.equal(wechselArt({ ...f, art: 'aufwand', zahlungsart: 'monatlich' }, 'festpreis').zahlungsart, 'nach_abnahme')
  assert.equal(wechselArt({ ...f, zahlungsart: 'frei' }, 'aufwand').zahlungsart, 'frei')
  assert.equal(wechselArt(f, 'quatsch'), f)
})

test('formularSummeCents: Festpreis summiert gültige Positionen, Aufwand hat keine Summe', () => {
  const f = festpreisForm({
    positionen: [
      { uid: 1, description: 'a', quantity: '2', price: '100,00' },
      { uid: 2, description: 'b', quantity: '1', price: '50' },
      { uid: 3, description: 'c', quantity: 'x', price: '9,99' },
    ],
  })
  assert.equal(formularSummeCents(f), 25000)
  assert.equal(formularSummeCents(festpreisForm({ positionen: [] })), 0)
  assert.equal(formularSummeCents(aufwandForm()), null)
})

test('pruefeSchritt 1: Titel ist Pflicht, die Kundenart nur, wenn sie am Kunden fehlt', () => {
  const f = neuesFormular({ heute: '2026-10-06' })
  const r = pruefeSchritt(1, f, { kunde: KUNDE })
  assert.equal(r.ok, false)
  assert.deepEqual(r.fehler, { titel: 'Bitte einen Titel eingeben (höchstens 200 Zeichen).', kundenart: 'Bitte die Kundenart wählen.' })
  assert.equal(r.erstes, 'titel')
  assert.deepEqual(pruefeSchritt(1, { ...f, titel: ' Website ', kundenart: 'privat' }, { kunde: KUNDE }), OK)
  assert.deepEqual(pruefeSchritt(1, { ...f, titel: 'x' }, { kunde: { ...KUNDE, kundenart: 'privat' } }), OK)
  assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(201), kundenart: 'privat' }, { kunde: KUNDE }).ok, false)
  assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(200), kundenart: 'privat' }, { kunde: KUNDE }).ok, true)
  assert.equal(pruefeSchritt(1, { ...f, titel: 'x', kundenart: 'firma' }, { kunde: KUNDE }).fehler.kundenart, 'Bitte die Kundenart wählen.')
})

test('pruefeSchritt 2 (Festpreis): Datum, Weg, Positionen und Summe', () => {
  const pruefe = (over) => pruefeSchritt(2, festpreisForm(over), { kunde: KUNDE })
  assert.deepEqual(pruefe(), OK)
  const r = pruefe({ auftragVom: '', auftragWeg: '' })
  assert.deepEqual(r.fehler, { auftragVom: 'Bitte das Datum der Absprache angeben.', auftragWeg: 'Bitte den Weg der Absprache wählen.' })
  assert.equal(r.erstes, 'auftragVom')
  for (const schlecht of ['2026-02-30', '06.10.2026', '2026-1-1', 'morgen']) {
    assert.equal(pruefe({ auftragVom: schlecht }).fehler.auftragVom, 'Bitte das Datum der Absprache angeben.', schlecht)
  }
  assert.equal(pruefe({ auftragWeg: 'brieftaube' }).fehler.auftragWeg, 'Bitte den Weg der Absprache wählen.')
  assert.equal(pruefe({ art: 'xyz' }).fehler.art, 'Bitte die Art des Auftrags wählen.')

  const pos = (o = {}) => ({ uid: 1, description: 'x', quantity: '1', price: '10,00', ...o })
  const meldung = (positionen) => pruefe({ positionen }).fehler.positionen
  const PREIS = 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).'
  assert.equal(meldung([pos({ description: ' ' })]), 'Position 1: Bitte eine Beschreibung eingeben.')
  assert.equal(meldung([pos(), pos({ description: 'y'.repeat(201) })]), 'Position 2: Die Beschreibung darf höchstens 200 Zeichen lang sein.')
  assert.equal(meldung([pos({ quantity: '0' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
  assert.equal(meldung([pos({ quantity: '1000' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
  assert.equal(meldung([pos({ price: '' })]), `Position 1: ${PREIS}`)
  assert.equal(meldung([pos({ price: '100.000,01' })]), `Position 1: ${PREIS}`)
  assert.equal(meldung([]), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
  assert.equal(meldung(Array.from({ length: 21 }, () => pos())), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
  assert.equal(meldung(Array.from({ length: 20 }, () => pos())), undefined)
  // Summe: genau 999.999,99 € gehen, 1.000.000,00 € nicht
  const neun = Array.from({ length: 9 }, () => pos({ price: '100.000,00' }))
  assert.equal(meldung([...neun, pos({ price: '99.999,99' })]), undefined)
  assert.equal(meldung([...neun, pos({ price: '100.000,00' })]), 'Die Summe ist zu hoch (höchstens 999.999,99 €).')
})

test('pruefeSchritt 2 (Aufwand): Stundensatz und Schätzung, Positionen zählen nicht', () => {
  const pruefe = (over) => pruefeSchritt(2, aufwandForm(over), { kunde: KUNDE })
  const SATZ = 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).'
  assert.deepEqual(pruefe(), OK)
  assert.equal(pruefe({ schaetzung: '12,5' }).ok, true)
  assert.equal(pruefe({ stundensatz: '' }).fehler.stundensatz, SATZ)
  assert.equal(pruefe({ stundensatz: '12.50' }).fehler.stundensatz, SATZ)
  assert.equal(pruefe({ schaetzung: '10,3' }).fehler.schaetzung, 'Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens 1666).')
  assert.equal(pruefe({ positionen: [{ uid: 1, description: '', quantity: 'x', price: '' }] }).ok, true)
})

test('pruefeSchritt 3: Zahlungsplan, Zahlungsziel, Termine, Zeitquelle', () => {
  const pruefe = (f) => pruefeSchritt(3, f, { kunde: KUNDE })
  assert.deepEqual(pruefe(festpreisForm()), OK)
  assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: '50' })).ok, true)
  for (const schlecht of ['', '0', '100', '33,3']) {
    assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: schlecht })).fehler.prozent, 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.', schlecht)
  }
  assert.equal(pruefe(festpreisForm({ zahlungsart: 'nach_abnahme', prozent: 'x' })).ok, true) // Prozent zählt nur bei „Anzahlung“
  const TEXT = 'Bitte den Zahlungsplan als Text eingeben (höchstens 500 Zeichen).'
  assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: '  ' })).fehler.zahlungstext, TEXT)
  assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(501) })).fehler.zahlungstext, TEXT)
  assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(500) })).ok, true)
  for (const schlecht of ['', '61', '-1', '1,5']) {
    assert.equal(pruefe(festpreisForm({ zahlungsziel: schlecht })).fehler.zahlungsziel, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.', schlecht)
  }
  assert.equal(pruefe(festpreisForm({ zahlungsziel: '0' })).ok, true)
  assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-30' })).ok, true)
  assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-01' })).ok, true)
  assert.equal(pruefe(festpreisForm({ startAm: '2026-11-02', fertigBis: '2026-11-01' })).fehler.fertigBis, 'Die Fertigstellung liegt vor dem Start.')
  assert.equal(pruefe(festpreisForm({ startAm: '2026-13-01' })).fehler.startAm, 'Bitte ein gültiges Datum wählen.')
  assert.equal(pruefe(festpreisForm({ fertigBis: 'bald' })).fehler.fertigBis, 'Bitte ein gültiges Datum wählen.')
  const ids = (n) => Array.from({ length: n }, (_, i) => `t${i}`)
  assert.equal(pruefe(festpreisForm({ zeitquelle: ids(10) })).ok, true)
  assert.equal(pruefe(festpreisForm({ zeitquelle: ids(11) })).fehler.zeitquelle, 'Höchstens 10 Tickets als Zeitquelle.')
  const PASST = 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.'
  assert.equal(pruefe(festpreisForm({ zahlungsart: 'monatlich' })).fehler.zahlungsart, PASST)
  assert.equal(pruefe(aufwandForm({ zahlungsart: 'anzahlung' })).fehler.zahlungsart, PASST)
})

test('formularZuNutzlast: Festpreis in Cent, getrimmte Texte, Kundenart nur wenn sie am Kunden fehlt', () => {
  const f = festpreisForm({
    titel: '  Website Praxis  ', projektId: 'p1',
    positionen: [
      { uid: 1, description: ' Startseite ', quantity: '2', price: '400,00' },
      { uid: 2, description: 'Kontaktformular', quantity: '1', price: '1.234,50' },
    ],
    zahlungsart: 'anzahlung', prozent: '30', zahlungsziel: '21',
    startAm: '2026-11-01', fertigBis: '2026-11-30', zeitquelle: ['t1', 't2'],
  })
  const r = formularZuNutzlast(f, { kunde: KUNDE })
  assert.equal(r.ok, true)
  assert.equal(r.summeCents, 203450)
  assert.deepEqual(r.payload, {
    projektId: 'p1', titel: 'Website Praxis', kundenart: 'privat', art: 'festpreis',
    positionen: [
      { beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 },
      { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 123450 },
    ],
    stundensatzCents: null, schaetzungMinuten: null,
    zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
    auftragVom: '2026-10-06', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '2026-11-30',
    zeitquelle: ['t1', 't2'],
  })
  assert.equal('kundenart' in formularZuNutzlast(f, { kunde: { ...KUNDE, kundenart: 'unternehmen' } }).payload, false)
  const frei = formularZuNutzlast(festpreisForm({ zahlungsart: 'frei', zahlungstext: '  Zahlung in zwei Raten  ' }), { kunde: KUNDE })
  assert.deepEqual(frei.payload.zahlungsplan, { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten' })
  assert.equal(frei.payload.projektId, '')
  assert.deepEqual([frei.payload.startAm, frei.payload.fertigBis], ['', ''])
})

test('formularZuNutzlast: Aufwand mit Satz in Cent und Schätzung in Minuten, ohne Positionen und Summe', () => {
  const r = formularZuNutzlast(aufwandForm({ stundensatz: '95,50', schaetzung: '12,5', zahlungsart: 'monatlich' }), { kunde: { ...KUNDE, kundenart: 'privat' } })
  assert.equal(r.ok, true)
  assert.equal(r.summeCents, null)
  assert.deepEqual(r.payload.positionen, [])
  assert.equal(r.payload.art, 'aufwand')
  assert.equal(r.payload.stundensatzCents, 9550)
  assert.equal(r.payload.schaetzungMinuten, 750)
  assert.deepEqual(r.payload.zahlungsplan, { art: 'monatlich', prozent: null, text: '' })
  assert.equal(formularZuNutzlast(aufwandForm({ schaetzung: '' }), { kunde: KUNDE }).payload.schaetzungMinuten, null)
})

test('formularZuNutzlast: Fehler nennen den Schritt und das erste Feld', () => {
  const r1 = formularZuNutzlast(festpreisForm({ titel: '', zahlungsziel: '99' }), { kunde: KUNDE })
  assert.deepEqual([r1.ok, r1.schritt, r1.erstes], [false, 1, 'titel'])
  assert.equal(r1.fehler.titel, 'Bitte einen Titel eingeben (höchstens 200 Zeichen).')
  const r2 = formularZuNutzlast(festpreisForm({ auftragWeg: '' }), { kunde: KUNDE })
  assert.deepEqual([r2.ok, r2.schritt, r2.erstes], [false, 2, 'auftragWeg'])
  const r3 = formularZuNutzlast(festpreisForm({ zahlungsziel: '99' }), { kunde: KUNDE })
  assert.deepEqual([r3.ok, r3.schritt, r3.erstes], [false, 3, 'zahlungsziel'])
})

test('zahlungsplanVorschau: Wortlaut sinngemäß wie in der Auftragsbestätigung', () => {
  const v = (o) => zahlungsplanVorschau({ art: 'nach_abnahme', prozent: null, text: '', summeCents: 80000, zielTage: 14, ...o })
  assert.equal(v({}), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
  assert.equal(v({ summeCents: null }), 'Alles nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
  assert.equal(v({ art: 'anzahlung', prozent: 50 }), 'Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
  // Rundung wie im Vertrag V3: Anzahlung = Math.round(Summe × Prozent / 100), Rest = Summe − Anzahlung
  assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: 33333 }), 'Anzahlung 50 % (166,67 €) nach Auftragsbestätigung, Rest (166,66 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
  assert.equal(v({ art: 'anzahlung', prozent: 33, summeCents: 33333 }), 'Anzahlung 33 % (110,00 €) nach Auftragsbestätigung, Rest (223,33 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
  assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: null }), 'Anzahlung 50 % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
  assert.equal(v({ art: 'anzahlung', prozent: null }), '')
  assert.equal(v({ art: 'monatlich', summeCents: null }), 'Monatlich nach Aufwand; zahlbar innerhalb von 14 Tagen nach Rechnung.')
  assert.equal(v({ art: 'frei', text: '  Zwei Raten, je zur Hälfte  ' }), 'Zwei Raten, je zur Hälfte (zahlbar innerhalb von 14 Tagen nach Rechnung).')
  assert.equal(v({ art: 'frei', text: '' }), '')
  assert.equal(v({ zielTage: 0 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar sofort nach Rechnung.')
  assert.equal(v({ zielTage: 1 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 1 Tag nach Rechnung.')
  assert.equal(v({ zielTage: null }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
  assert.equal(v({ art: 'unbekannt' }), '')
})

test('sendeGate: Lead, E-Mail, Anschrift und Kundenart halten das Senden auf, der Entwurf bleibt möglich', () => {
  assert.deepEqual(sendeGate(KUNDE, 'privat'), { erlaubt: true, grund: '', zurUebersicht: false })
  assert.deepEqual(sendeGate({ ...KUNDE, kundenart: 'privat' }, ''), { erlaubt: true, grund: '', zurUebersicht: false })
  assert.deepEqual(sendeGate(KUNDE, ''), { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false })
  assert.deepEqual(sendeGate({ ...KUNDE, street: '' }, 'privat'), {
    erlaubt: false, grund: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
  })
  assert.deepEqual(sendeGate({ ...KUNDE, email: ' ' }, 'privat'), { erlaubt: false, grund: 'E-Mail fehlt', zurUebersicht: true })
  assert.deepEqual(sendeGate({ ...KUNDE, customerStatus: 'lead' }, 'privat'), { erlaubt: false, grund: 'Noch kein fester Kunde', zurUebersicht: true })
  assert.deepEqual(sendeGate(null, 'privat'), { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false })
})

const TICKETS = [
  { $id: 't9', woid: '100149', topic: 'Neueste Anfrage' },
  { $id: 't2', woid: '100140', topic: 'Shop Relaunch' },
  { $id: 't1', woid: '100131', topic: 'Website Praxis' },
  { $id: 'xyz12345', woid: '', title: 'Ohne Nummer' },
]

test('zeitquelleOptionen: Projekt-Tickets zuerst, dann gewählte, dann der Rest; unbekannte Auswahl bleibt sichtbar', () => {
  const o = zeitquelleOptionen(TICKETS, ['t9', 'alt77'], ['t1'])
  assert.deepEqual(o.map((x) => x.id), ['t1', 't9', 'alt77', 't2', 'xyz12345'])
  assert.deepEqual(o.map((x) => x.gewaehlt), [false, true, true, false, false])
  assert.deepEqual(o.map((x) => x.projekt), [true, false, false, false, false])
  assert.equal(o[0].label, '#100131 Website Praxis')
  assert.equal(o[1].label, '#100149 Neueste Anfrage')
  assert.equal(o[2].label, 'Ticket alt77')
  assert.equal(o[4].label, '#12345 Ohne Nummer')
  assert.deepEqual(zeitquelleOptionen(null, [], []), [])
})

test('schalteZeitquelle: höchstens 10, keine Doppelten, Reihenfolge der Auswahl', () => {
  assert.deepEqual(schalteZeitquelle(['a'], 'b', true), ['a', 'b'])
  assert.deepEqual(schalteZeitquelle(['a', 'b'], 'a', false), ['b'])
  assert.deepEqual(schalteZeitquelle(['a'], 'a', true), ['a'])
  assert.deepEqual(schalteZeitquelle(undefined, 'a', true), ['a'])
  const zehn = Array.from({ length: 10 }, (_, i) => `t${i}`)
  assert.deepEqual(schalteZeitquelle(zehn, 'neu', true), zehn)
  assert.deepEqual(schalteZeitquelle(zehn, 't3', false), zehn.filter((id) => id !== 't3'))
})

test('kundenartWirksam: die Kundenart am Kunden gilt, sonst die im Formular gewählte', () => {
  assert.equal(kundenartWirksam({ kundenart: 'unternehmen' }, 'privat'), 'unternehmen')
  assert.equal(kundenartWirksam({ kundenart: '' }, 'privat'), 'privat')
  assert.equal(kundenartWirksam({}, 'unternehmen'), 'unternehmen')
  assert.equal(kundenartWirksam(null, 'privat'), 'privat')
  assert.equal(kundenartWirksam({ kundenart: 'privat' }, ''), 'privat')
  assert.equal(kundenartWirksam({ kundenart: 'firma' }, ''), '')
  assert.equal(kundenartWirksam({ kundenart: '' }, 'firma'), '')
  assert.equal(kundenartWirksam(null, undefined), '')
})

test('vorOrtHinweis: nur bei „Vor Ort“ und Privatperson (§ 312f Abs. 1 BGB), nie als Sperre', () => {
  const TEXT = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
  assert.equal(VOR_ORT_HINWEIS, TEXT)
  assert.equal(vorOrtHinweis('vor_ort', 'privat'), TEXT)
  for (const [weg, art] of [['vor_ort', 'unternehmen'], ['vor_ort', ''], ['telefon', 'privat'], ['muendlich', 'privat'], ['email', 'privat'], ['chat', 'privat'], ['', 'privat'], [undefined, 'privat']]) {
    assert.equal(vorOrtHinweis(weg, art), '', `${weg}/${art}`)
  }
})

test('auftragHinweise: fehlende Steuernummer, dann „vor Ort“ bei Privatperson; sonst nichts', () => {
  const STN = 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.'
  const VOR = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
  const hinweise = (o) => auftragHinweise({ kunde: KUNDE, form: festpreisForm(), einstellungen: { steuernummer: '12/345/67890' }, ...o })
  assert.deepEqual(hinweise(), []) // festpreisForm: Privatperson, Weg „telefon“, Steuernummer vorhanden
  assert.deepEqual(hinweise({ einstellungen: { steuernummer: '' } }), [{ key: 'steuernummer', text: STN }])
  assert.deepEqual(hinweise({ einstellungen: {} }).map((h) => h.key), ['steuernummer'])
  // Einstellungen noch nicht geladen (undefined) oder nicht ladbar (null): keine Behauptung
  assert.deepEqual(hinweise({ einstellungen: undefined }), [])
  assert.deepEqual(hinweise({ einstellungen: null }), [])
  assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort' }) }), [{ key: 'vorOrt', text: VOR }])
  assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: 'unternehmen' }) }), [])
  assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }), [])
  // die Kundenart des Kunden hat Vorrang vor dem Formular
  const privatAmKunden = { kunde: { ...KUNDE, kundenart: 'privat' }, form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }
  assert.deepEqual(hinweise(privatAmKunden).map((h) => h.key), ['vorOrt'])
  assert.deepEqual(hinweise({ ...privatAmKunden, einstellungen: { steuernummer: '' } }).map((h) => h.key), ['steuernummer', 'vorOrt'])
})
  • Step 2: Tests laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs Expected: FAIL (esbuild: src/lib/auftragView.js lässt sich nicht auflösen).

  • Step 3: src/lib/auftragView.js implementieren (Teil 1)

Reine Datei, deutscher Kopfkommentar im Stil von financeView.js. Alle Texte und Zahlen wörtlich wie in den Tests. Regeln:

  • Konstanten: AUFTRAG_LIMITS (13 Schlüssel wie V2 LIMITS), STUNDENSATZ_STANDARD_CENTS = 10000, AUFTRAG_WEGE (Reihenfolge und Beschriftung wie im Test, value = Schlüssel aus V2), ZAHLUNGSPLAENE = { nach_abnahme, anzahlung, monatlich, frei } mit den Beschriftungen des Tests, SCHRITTE. zahlungsplanOptionen('aufwand') liefert nach_abnahme, monatlich, frei, jede andere Art nach_abnahme, anzahlung, frei.

  • parseStunden(eingabe): Text trimmen, ein Suffix /\s*(std\.?|h)$/i entfernen, dann muss /^(\d{1,4})(?:,(\d{1,2}))?$/ passen. Minuten = Stunden × 60 + (Bruchteil auf zwei Stellen mit 0 aufgefüllt × 60 / 100), also ,5 = 30. Gültig nur, wenn das Ergebnis eine ganze Zahl, größer 0, durch 15 teilbar und höchstens maxSchaetzungMinuten ist, sonst null. '', null, undefined ergeben null (leer heißt „keine Schätzung“, das entscheidet der Aufrufer).

  • stundenText(minuten): Number((minuten / 60).toFixed(2)) als Text mit Komma statt Punkt; keine endliche Zahl ergibt „–“.

  • parseProzent: trimmen, ein Suffix \s*% entfernen, /^\d{1,2}$/, Wert 1 bis 99. parseTage: trimmen, /^\d{1,3}$/, Wert höchstens maxZahlungszielTage. Beide: sonst null.

  • heuteBerlin(now = Date.now()): ymdBerlin(now).

  • neuePosition(): { uid, description: '', quantity: '1', price: '' } mit modulweit hochgezähltem uid (Zahl, nie zweimal derselbe).

  • neuesFormular({ projektId = '', konten = [], heute = heuteBerlin() }): Schlüssel und Vorgaben wie im Test (18 Schlüssel). projektId gilt nur, wenn es in konten vorkommt, sonst ''. titel ist der Name des Kontos, zeitquelle die IDs von konto.tickets, stundensatz ist euroField(STUNDENSATZ_STANDARD_CENTS).

  • auftragZuFormular(auftrag): gleiche 18 Schlüssel. titelGeaendert und zeitquelleGeaendert sind true; kundenart ist ''; projektId, titel aus dem Entwurf (fehlend = ''); Positionen als { uid, description, quantity: String(menge), price: euroField(einzelpreisCents) }, ohne Positionen eine leere Zeile; stundensatz = euroField(stundensatzCents), ohne Wert der Standard; schaetzung = stundenText(schaetzungMinuten) oder ''; zahlungsart, prozent (ohne Wert '50'), zahlungstext aus zahlungsplan; zahlungsziel = String(zahlungszielTage ?? 14); Datumsfelder und zeitquelle übernehmen (fehlend '' bzw. []).

  • waehleProjekt(form, projektId, konten): neues Objekt. Unbekannte projektId gilt als ''. Solange titelGeaendert falsch ist, wird titel zum Kontonamen (ohne Konto ''); solange zeitquelleGeaendert falsch ist, wird zeitquelle zu den Ticket-IDs des Kontos (ohne Konto []).

  • wechselArt(form, art): art nicht festpreis oder aufwand: dasselbe Objekt zurück. Sonst Kopie mit art; passt zahlungsart nicht in zahlungsplanOptionen(art), wird sie nach_abnahme.

  • formularSummeCents(form): null bei art !== 'festpreis', sonst invoiceTotalCents(form.positionen) (ungültige Zeilen zählen 0).

  • pruefeSchritt(schritt, form, { kunde }): liefert { ok, fehler, erstes } mit fehler als Objekt { feld: Meldung } in der Reihenfolge der Prüfungen und erstes als erstem Schlüssel ('' ohne Fehler). Datumsfelder prüft istTag(text): /^\d{4}-\d{2}-\d{2}$/ und dateDe(text) !== '–'.

    • Schritt 1: titel (getrimmt 1 bis maxTitel Zeichen), kundenart (nur wenn kunde.kundenart leer ist; muss in KUNDENARTEN sein).
    • Schritt 2: auftragVom (istTag), auftragWeg (Wert aus AUFTRAG_WEGE), dann je art: unbekannt: art („Bitte die Art des Auftrags wählen.“, mehr wird dann nicht geprüft). festpreis: positionen (nur die erste Meldung): Anzahl 1 bis maxPositionen, je Position der Reihe nach Beschreibung (leer, über 200), Menge (parseQuantity), Einzelpreis (parseUnitPrice) mit Präfix „Position n: “, zuletzt Summe über maxSummeCents. aufwand: stundensatz (parseUnitPrice), schaetzung (nur wenn nicht leer: parseStunden).
    • Schritt 3: zahlungsart (muss in zahlungsplanOptionen(art) stehen), prozent (nur bei anzahlung), zahlungstext (nur bei frei, getrimmt 1 bis 500), zahlungsziel, startAm und fertigBis (nur wenn nicht leer: istTag; stehen beide gültig da und liegt fertigBis vor startAm, Meldung an fertigBis), zeitquelle (höchstens maxZeitquellen).
  • formularZuNutzlast(form, { kunde }): prüft die Schritte 1 bis 3 der Reihe nach und liefert beim ersten Fehler { ok: false, schritt, erstes, fehler }. Sonst { ok: true, payload, summeCents } mit payload genau wie im Test: Texte getrimmt, kundenart nur wenn kunde.kundenart leer ist, positionen nur bei Festpreis (beschreibung, menge als Zahl, einzelpreisCents), stundensatzCents und schaetzungMinuten nur bei Aufwand (sonst null, die Schätzung null wenn leer), zahlungsplan = { art, prozent (nur anzahlung, sonst null), text (nur frei, sonst '') }, zahlungszielTage als Zahl, startAm/fertigBis '' wenn leer, zeitquelle als Kopie. Kein kundenId (das setzt der Aufrufer beim Anlegen).

  • zahlungsplanVorschau({ art, prozent, text, summeCents, zielTage }): tage = Number.isInteger(zielTage) ? zielTage : 14; ziel ist „sofort nach Rechnung“ bei 0, „innerhalb von 1 Tag nach Rechnung“ bei 1, sonst „innerhalb von n Tagen nach Rechnung“. Texte je Art wörtlich wie im Test; Anzahlung = Math.round(summeCents * prozent / 100), Rest = Summe − Anzahlung; ohne ganze summeCents entfallen die Beträge, ohne prozent ist der Text ''; unbekannte Art ''.

  • sendeGate(kunde, kundenart = ''): Reihenfolge der Prüfungen: kein Kunde (Grund „Kein Kunde ausgewählt“, zurUebersicht: false); canInvoice(kunde) nicht ok (Grund = dessen reason, zurUebersicht: true); !addressComplete(kunde) (Grund ADDRESS_INCOMPLETE_TEXT, zurUebersicht: true); weder kunde.kundenart noch kundenart gesetzt (Grund wie im Test, zurUebersicht: false). Sonst { erlaubt: true, grund: '', zurUebersicht: false }.

  • zeitquelleOptionen(tickets, auswahl, projektTicketIds): Reihenfolge: erst die IDs aus projektTicketIds, dann die übrigen aus auswahl (in deren Reihenfolge), dann alle weiteren Tickets in der Reihenfolge von tickets. Jeder Eintrag { id, label, gewaehlt, projekt }. label = #<ticketNumber(t)> plus Leerzeichen und topic || title (ohne Text nur die Nummer); ein Ticket, das nicht in tickets steht, heißt Ticket <letzte 5 Zeichen der ID>. Kein Array als tickets zählt als leer.

  • schalteZeitquelle(auswahl, id, an): neue Liste; an hängt id an, wenn sie fehlt und weniger als maxZeitquellen gewählt sind; sonst unverändert; !an entfernt id.

  • kundenartWirksam(kunde, kundenart): kunde?.kundenart, wenn der Wert in KUNDENARTEN steht; sonst kundenart, wenn der Wert in KUNDENARTEN steht; sonst '' (die Kundenart am Kunden hat Vorrang, weil der Server kundenart nur schreibt, wenn sie am Kunden leer ist).

  • VOR_ORT_HINWEIS wörtlich wie im Test; vorOrtHinweis(weg, kundenart): VOR_ORT_HINWEIS, wenn weg === 'vor_ort' und kundenart === 'privat', sonst ''. Rechtsgrund ist § 312f Abs. 1 BGB (bei außerhalb von Geschäftsräumen geschlossenen Verträgen mit Verbrauchern eine Papierfassung zur Verfügung stellen); der Hinweis ist reine Oberfläche und blockiert nichts.

  • auftragHinweise({ kunde, form, einstellungen }): Liste in dieser Reihenfolge: { key: 'steuernummer', text: STEUERNUMMER_FEHLT_HINWEIS }, wenn steuernummerFehlt(einstellungen); danach { key: 'vorOrt', text: VOR_ORT_HINWEIS }, wenn vorOrtHinweis(form.auftragWeg, kundenartWirksam(kunde, form.kundenart)) nicht leer ist. Sonst [].

  • Step 4: Tests laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs Expected: PASS, 24 Tests, fail 0.

  • Step 5: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/lib/auftragView.js tests/unit/auftragView.test.mjs && git commit -m "Auftragsbestaetigung: Dialoglogik (Parser, Validierung, Nutzlast, Zahlungsplan-Vorschau) mit Tests" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 4: Listenlogik in auftragView.js und PDF-Hilfe

Files:

  • Modify: src/lib/auftragView.js (Teil 2 anhängen)
  • Modify: tests/unit/auftragView.test.mjs (Tests anhängen, Importliste erweitern)
  • Create: src/lib/auftragPdf.js
  • Create: tests/unit/auftragPdf.test.mjs

Interfaces:

  • Consumes: dateDe, euro aus ./financeFormat; ADDRESS_INCOMPLETE_TEXT aus ./financeView; stundenText (Task 3); openInNewTab(getUrl, { win }) aus ./openInNewTab (vorhanden).

  • Produces: aus auftragView.js AUFTRAG_STATUS, PROJEKT_ZUORDNEN_STATUS, istEntwurf(auftrag), zuordnenOptionen(konten) → [{ value, label }], projektZuordnenMoeglich(auftrag, konten) → boolean, auftragAktionen(auftrag, { konten }?) → [{ key, label, danger? }], auftragNotizen(auftrag, alle?) → [{ key, tone, text }], auftragZeilen(auftraege, { konten }?) → Zeile[], auftragBestaetigung(key, auftrag, { email, projektName }?) → string, auftragErfolg(key, auftrag, { mailFehler, projektName }?) → { tone, text }, auftragFehler(err) → { art, text, zurUebersicht }, neueSperre() → { run(fn), belegt }. Aus auftragPdf.js PDF_FREIGABE_NACH_MS, alsPdfBlob(blob) → Blob, oeffnePdfImTab(holeBlob, { win, urlApi, timer }?) → Promise<void>.

  • Konto (aus V3 Projektkonto, nur diese Felder gelesen): { projektId, name }.

  • Zeile (aus auftragZeilen): { key, id, auftrag, nummerText, istEntwurf, titel, projektText, datumText, summeText, summeSub, statusLabel, statusTone, notizen, aktionen }. Aktionsschlüssel: bearbeiten, senden, pdf, loeschen, erinnern, neueFassung, stornieren, projekt.

  • Step 1: Failing Tests schreiben

Importliste am Dateianfang von tests/unit/auftragView.test.mjs um diese Namen erweitern: AUFTRAG_STATUS, PROJEKT_ZUORDNEN_STATUS, istEntwurf, zuordnenOptionen, projektZuordnenMoeglich, auftragAktionen, auftragNotizen, auftragZeilen, auftragBestaetigung, auftragErfolg, auftragFehler, neueSperre. Dann am Dateiende anhängen:

// --- Liste -------------------------------------------------------------------------------------------------------

const A = (o = {}) => ({
  id: 'a1', nummer: 'AB-2026-0002', status: 'versendet', kundenId: 'c1', projektId: 'p1', titel: 'Website Praxis Müller',
  art: 'festpreis', stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
  gesendetAm: '2026-10-06T08:15:00.000+00:00', gesendetAn: 'info@praxis.test', mailStatus: 'gesendet', mailFehler: '',
  bestaetigtAm: null, bestaetigtUeber: '', fruehbeginn: false, ersetztId: '', ersetztDurchId: '', widerrufenAm: null,
  erstelltAm: '2026-10-05T16:00:00.000+00:00', geaendertAm: '2026-10-06T08:15:00.000+00:00',
  pdfVorhanden: true, fruehesterBeginn: null, zahlungsplanText: '', aufwandText: null, ...o,
})

test('Status: Beschriftung und Ton für alle sieben Status (Vertrag V2)', () => {
  assert.deepEqual(Object.entries(AUFTRAG_STATUS).map(([k, v]) => [k, v.label]), [
    ['entwurf', 'Entwurf'], ['versendet', 'Versendet'], ['bestaetigt', 'Bestätigt'], ['abgerechnet', 'Abgerechnet'],
    ['storniert', 'Storniert'], ['ersetzt', 'Ersetzt'], ['widerrufen', 'Widerrufen'],
  ])
  assert.deepEqual(Object.values(AUFTRAG_STATUS).map((v) => v.tone), ['muted', 'info', 'ok', 'violet', 'plain', 'plain', 'danger'])
  // „Projekt zuordnen“ gilt für diese Status (Übersicht, Nachträge; V5 `POST …/projekt` lehnt die übrigen mit 409 ab)
  assert.deepEqual(PROJEKT_ZUORDNEN_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet'])
})

test('auftragZeilen: Nummer, Titel, Datum, Summe und Status', () => {
  const [z] = auftragZeilen([A()])
  assert.deepEqual([z.id, z.nummerText, z.titel, z.datumText], ['a1', 'AB-2026-0002', 'Website Praxis Müller', '06.10.2026'])
  assert.deepEqual([z.summeText, z.summeSub, z.istEntwurf], ['800,00 €', '', false])
  assert.deepEqual([z.statusLabel, z.statusTone], ['Versendet', 'info'])
  assert.equal(z.auftrag.id, 'a1')
})

test('auftragZeilen: Entwurf ohne Nummer, nach Aufwand, Projektname nur wenn er vom Titel abweicht', () => {
  const konten = [{ projektId: 'p1', name: 'Praxis Müller – Relaunch' }]
  const entwurf = A({
    id: 'a2', nummer: '', status: 'entwurf', gesendetAm: null, pdfVorhanden: false, art: 'aufwand', summeCents: null,
    stundensatzCents: 10000, schaetzungMinuten: 600, erstelltAm: '2026-10-05T22:30:00.000+00:00',
  })
  const [z] = auftragZeilen([entwurf], { konten })
  assert.deepEqual([z.nummerText, z.istEntwurf, z.datumText], ['Entwurf', true, '06.10.2026'])
  assert.deepEqual([z.summeText, z.summeSub], ['nach Aufwand', '100,00 € pro Std., Schätzung etwa 10 Std.'])
  assert.equal(z.projektText, 'Praxis Müller – Relaunch')
  assert.equal(auftragZeilen([A()], { konten: [{ projektId: 'p1', name: 'Website Praxis Müller' }] })[0].projektText, '')
  assert.equal(auftragZeilen([A({ projektId: '' })], { konten })[0].projektText, '')
  assert.equal(auftragZeilen([A({ projektId: 'p9' })], { konten })[0].projektText, '')
  assert.equal(auftragZeilen([A()])[0].projektText, '')
  assert.equal(auftragZeilen([A({ art: 'aufwand', summeCents: null, stundensatzCents: 9550, schaetzungMinuten: null })])[0].summeSub, '95,50 € pro Std.')
  assert.equal(auftragZeilen([A({ summeCents: null })])[0].summeText, '–')
})

test('auftragZeilen: Notizen und Aktionen hängen an der Zeile, die Reihenfolge des Servers bleibt', () => {
  const zeilen = auftragZeilen([A({ id: 'x2', mailStatus: 'fehler' }), A({ id: 'x1', status: 'entwurf', nummer: '' })])
  assert.deepEqual(zeilen.map((z) => z.id), ['x2', 'x1'])
  assert.deepEqual(zeilen[0].notizen.map((n) => n.text), ['Mail nicht zugestellt'])
  assert.equal(zeilen[0].aktionen[1].label, 'Erneut senden')
  assert.equal(zeilen[1].istEntwurf, true)
  assert.deepEqual(auftragZeilen(null), [])
  assert.deepEqual(auftragZeilen([null, 'x', A()]).map((z) => z.id), ['a1'])
})

test('Zusatzzeilen: Mail, bestätigt am, Frühbeginn, Arbeitsbeginn, Fassungen, Widerruf', () => {
  const texte = (o, alle) => auftragNotizen(A(o), alle).map((n) => n.text)
  const BESTAETIGT = '2026-10-07T09:00:00.000+00:00'
  assert.deepEqual(texte({}), [])
  assert.deepEqual(texte({ mailStatus: 'fehler', mailFehler: 'SMTP' }), ['Mail nicht zugestellt'])
  assert.deepEqual(auftragNotizen(A({ mailStatus: 'fehler' })).map((n) => n.tone), ['warn'])
  // nach der Bestätigung zählt ein früherer Mailfehler nicht mehr
  assert.deepEqual(texte({ status: 'bestaetigt', mailStatus: 'fehler', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026'])
  assert.deepEqual(texte({ fruehesterBeginn: '2026-10-21' }), ['Arbeitsbeginn frühestens am 21.10.2026'])
  assert.deepEqual(texte({ status: 'bestaetigt', fruehbeginn: true, bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Frühbeginn verlangt'])
  assert.deepEqual(texte({ status: 'bestaetigt', fruehesterBeginn: '2026-10-21', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Arbeitsbeginn frühestens am 21.10.2026'])
  // ohne Wirkung bei Entwurf und bei beendeten ABs
  assert.deepEqual(texte({ status: 'entwurf', fruehesterBeginn: '2026-10-21', fruehbeginn: true }), [])
  assert.deepEqual(texte({ status: 'storniert', fruehesterBeginn: '2026-10-21' }), [])
  assert.deepEqual(texte({ status: 'widerrufen', widerrufenAm: '2026-10-09T10:00:00.000+00:00' }), ['Widerrufen am 09.10.2026'])
  const alle = [
    A({ id: 'a1', nummer: 'AB-2026-0002', status: 'ersetzt', ersetztDurchId: 'a3' }),
    A({ id: 'a3', nummer: 'AB-2026-0004', ersetztId: 'a1' }),
    A({ id: 'a4', nummer: '', status: 'entwurf', ersetztId: 'a3' }),
  ]
  assert.deepEqual(auftragNotizen(alle[0], alle).map((n) => n.text), ['Ersetzt durch AB-2026-0004'])
  assert.deepEqual(auftragNotizen(alle[1], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0002'])
  assert.deepEqual(auftragNotizen(alle[2], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0004'])
  assert.deepEqual(texte({ ersetztId: 'fremd' }, []), ['Neue Fassung'])
  assert.deepEqual(texte({ status: 'ersetzt', ersetztDurchId: 'fremd' }, []), ['Ersetzt durch eine neue Fassung'])
})

test('auftragAktionen: je Status genau die erlaubten, nach dem Senden nur noch „Neue Fassung“ statt Bearbeiten', () => {
  const keys = (o) => auftragAktionen(A(o)).map((a) => a.key)
  assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'loeschen'])
  assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
  assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'stornieren'])
  assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung'])
  for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status)
  for (const status of ['versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) {
    assert.equal(keys({ status }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false, status)
  }
  // ohne gespeichertes PDF keine PDF-Aktion (beim Entwurf gibt es die Vorschau immer)
  assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'stornieren'])
  assert.deepEqual(keys({ status: 'storniert', pdfVorhanden: false }), [])
  assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).map((a) => a.label), ['Bearbeiten', 'Senden', 'Vorschau', 'Löschen'])
  assert.deepEqual(auftragAktionen(A()).map((a) => a.label), ['PDF', 'Erinnerung senden', 'Neue Fassung', 'Stornieren'])
  assert.equal(auftragAktionen(A({ mailStatus: 'fehler' }))[1].label, 'Erneut senden')
  assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).filter((a) => a.danger).map((a) => a.key), ['loeschen'])
  assert.deepEqual(auftragAktionen(A()).filter((a) => a.danger).map((a) => a.key), ['stornieren'])
  assert.deepEqual(auftragAktionen(A({ status: 'xyz', pdfVorhanden: false })), [])
  assert.deepEqual(auftragAktionen(null), [])
  assert.equal(istEntwurf(A({ status: 'entwurf' })), true)
  assert.equal(istEntwurf(A()), false)
  assert.equal(istEntwurf(null), false)
})

test('Projekt zuordnen: Auswahl aus den Konten, Verfügbarkeit nach Status und Projekt', () => {
  const konten = [{ projektId: 'p1', name: 'Website Praxis Müller' }, { projektId: 'p2', name: '  Shop Relaunch ' }, { projektId: 'p3', name: '' }, { name: 'ohne Kennung' }, null]
  assert.deepEqual(zuordnenOptionen(konten), [
    { value: 'p1', label: 'Website Praxis Müller' }, { value: 'p2', label: 'Shop Relaunch' }, { value: 'p3', label: 'p3' },
  ])
  assert.deepEqual(zuordnenOptionen(undefined), [])
  assert.deepEqual(zuordnenOptionen('x'), [])
  const ohne = (o) => A({ projektId: '', ...o })
  for (const status of ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet']) {
    assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), true, status)
  }
  for (const status of ['storniert', 'ersetzt', 'widerrufen', 'xyz']) {
    assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), false, status)
  }
  assert.equal(projektZuordnenMoeglich(ohne({ projektId: null }), konten), true)
  assert.equal(projektZuordnenMoeglich(ohne({ projektId: undefined }), konten), true)
  // hat die AB schon ein Projekt (auch ein unbekanntes), gibt es die Aktion nicht: der Server lehnt sie mit 409 ab
  assert.equal(projektZuordnenMoeglich(A({ projektId: 'p1' }), konten), false)
  assert.equal(projektZuordnenMoeglich(A({ projektId: 'p9' }), konten), false)
  // ohne Projekt zur Auswahl ist nichts zu tun
  assert.equal(projektZuordnenMoeglich(ohne({}), []), false)
  assert.equal(projektZuordnenMoeglich(ohne({}), undefined), false)
  assert.equal(projektZuordnenMoeglich(null, konten), false)
})

test('auftragAktionen mit Konten: „Projekt zuordnen“ nur ohne Projekt, vor den gefährlichen Aktionen', () => {
  const konten = [{ projektId: 'p1', name: 'Website' }]
  const keys = (o, k = konten) => auftragAktionen(A({ projektId: '', ...o }), { konten: k }).map((a) => a.key)
  assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'projekt', 'loeschen'])
  assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren'])
  assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'projekt', 'stornieren'])
  assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung', 'projekt'])
  assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'projekt', 'stornieren'])
  for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status)
  // mit Projekt, ohne Konten und ohne Angabe der Konten gibt es die Aktion nicht
  assert.deepEqual(auftragAktionen(A({ status: 'versendet' }), { konten }).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
  assert.deepEqual(keys({ status: 'versendet' }, []), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
  assert.deepEqual(auftragAktionen(A({ projektId: '' })).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
  // Beschriftung, nie gefährlich; Bearbeiten, Senden und Löschen bleiben dem Entwurf vorbehalten
  const aktion = auftragAktionen(A({ projektId: '' }), { konten }).find((a) => a.key === 'projekt')
  assert.equal(aktion.label, 'Projekt zuordnen')
  assert.notEqual(aktion.danger, true)
  assert.equal(keys({ status: 'versendet' }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false)
  // die Zeilen geben die Konten weiter
  assert.deepEqual(auftragZeilen([A({ projektId: '' })], { konten })[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren'])
  assert.deepEqual(auftragZeilen([A({ projektId: '' })])[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
})

test('Rückfragen: Senden, Löschen, Stornieren, Erinnerung, Projekt zuordnen', () => {
  assert.equal(
    auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' }), { email: 'info@praxis.test' }),
    'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an info@praxis.test senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.'
  )
  assert.equal(
    auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' })),
    'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an den Kunden senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.'
  )
  assert.equal(auftragBestaetigung('loeschen', A({ status: 'entwurf' })), 'Den Entwurf „Website Praxis Müller“ wirklich löschen? Das lässt sich nicht rückgängig machen.')
  assert.equal(
    auftragBestaetigung('stornieren', A()),
    'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ wirklich stornieren? Der Kunde bekommt keine Mail, der Bestätigungslink wird ungültig. Das lässt sich nicht rückgängig machen.'
  )
  assert.equal(
    auftragBestaetigung('erinnern', A()),
    'Die Auftragsbestätigung AB-2026-0002 jetzt noch einmal an den Kunden senden? Der bisherige Bestätigungslink gilt danach nicht mehr.'
  )
  assert.equal(
    auftragBestaetigung('projekt', A({ projektId: '' }), { projektName: 'Physio-Website' }),
    'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ dem Projekt „Physio-Website“ zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.'
  )
  assert.match(auftragBestaetigung('projekt', A({ projektId: '' })), /„Website Praxis Müller“ einem Projekt zuordnen\? /)
  // ein Entwurf bleibt über „Bearbeiten“ änderbar: keine Rückfrage
  assert.equal(auftragBestaetigung('projekt', A({ status: 'entwurf', nummer: '', projektId: '' }), { projektName: 'Physio-Website' }), '')
  for (const key of ['bearbeiten', 'pdf', 'neueFassung', 'gibtsnicht']) assert.equal(auftragBestaetigung(key, A()), '', key)
  assert.match(auftragBestaetigung('senden', A({ titel: '' })), /„ohne Titel“/)
})

test('Erfolgsmeldungen (Wortlaut des Containers von W0 für „senden“)', () => {
  const gesendet = A({ nummer: 'AB-2026-0003' }) // gesendetAn: info@praxis.test
  assert.deepEqual(auftragErfolg('senden', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde an info@praxis.test gesendet.' })
  assert.deepEqual(auftragErfolg('senden', A({ nummer: 'AB-2026-0003', gesendetAn: '' })), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde gesendet.' })
  assert.deepEqual(auftragErfolg('senden', gesendet, { mailFehler: true }), {
    tone: 'warn',
    text: 'Auftragsbestätigung AB-2026-0003 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.',
  })
  assert.deepEqual(auftragErfolg('erinnern', gesendet), { tone: 'ok', text: 'Die Erinnerung zu AB-2026-0003 wurde an info@praxis.test gesendet.' })
  assert.deepEqual(auftragErfolg('erinnern', gesendet, { mailFehler: true }), {
    tone: 'warn', text: 'Die Erinnerung zu AB-2026-0003 konnte nicht zugestellt werden. „Erneut senden“ versucht es noch einmal.',
  })
  assert.deepEqual(auftragErfolg('stornieren', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde storniert.' })
  assert.deepEqual(auftragErfolg('loeschen', A({ nummer: '' })), { tone: 'ok', text: 'Der Entwurf wurde gelöscht.' })
  assert.deepEqual(auftragErfolg('neueFassung', A({ nummer: '' })), { tone: 'ok', text: 'Neue Fassung als Entwurf angelegt.' })
  assert.deepEqual(auftragErfolg('projekt', gesendet, { projektName: 'Physio-Website' }), {
    tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde dem Projekt „Physio-Website“ zugeordnet.',
  })
  assert.deepEqual(auftragErfolg('projekt', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde einem Projekt zugeordnet.' })
  assert.deepEqual(auftragErfolg('projekt', A({ nummer: '' }), { projektName: 'Physio-Website' }), {
    tone: 'ok', text: 'Die Auftragsbestätigung wurde dem Projekt „Physio-Website“ zugeordnet.',
  })
  assert.deepEqual(auftragErfolg('senden', null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gesendet.' })
  assert.deepEqual(auftragErfolg('senden', null, { mailFehler: true }), {
    tone: 'warn', text: 'Die Auftragsbestätigung ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.',
  })
  assert.deepEqual(auftragErfolg('gibtsnicht', gesendet), { tone: 'ok', text: '' })
})

test('auftragFehler: Kundendaten-Fehler führen zur Übersicht, andere zeigen die Meldung des Servers (Texte aus Plan L3)', () => {
  const fehler = (status, message) => auftragFehler(Object.assign(new Error(message), { status }))
  assert.deepEqual(fehler(409, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.'), {
    art: 'adresse', text: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
  })
  assert.deepEqual(fehler(400, 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).'), {
    art: 'kundenart',
    text: 'Die Kundenart des Kunden fehlt. Bitte in der Übersicht des Kunden festlegen (Feld „Kundenart“) oder über „Bearbeiten“ und dann erneut senden.',
    zurUebersicht: true,
  })
  assert.deepEqual(fehler(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'), {
    art: 'email', text: 'Dem Kunden fehlt die E-Mail-Adresse. Bitte in der Übersicht eintragen und dann erneut senden.', zurUebersicht: true,
  })
  assert.deepEqual(fehler(400, 'Auftragsbestätigungen nur für feste Kunden.'), {
    art: 'kunde', text: 'Das ist noch kein fester Kunde. Bitte in der Übersicht zum festen Kunden machen und dann erneut senden.', zurUebersicht: true,
  })
  // Entwurf unvollständig (L4, wird von L3 beim Senden und bei der Vorschau durchgereicht): Meldung unverändert, kein „aktueller Stand“
  assert.deepEqual(fehler(409, 'Entwurf unvollständig: Titel fehlt.'), {
    art: 'entwurf', text: 'Entwurf unvollständig: Titel fehlt.', zurUebersicht: false,
  })
  assert.equal(fehler(409, 'Entwurf unvollständig: Kundendaten fehlen.').art, 'entwurf')
  assert.deepEqual(fehler(409, 'Nur ein Entwurf lässt sich senden.'), {
    art: 'stand', text: 'Nur ein Entwurf lässt sich senden. Die Liste zeigt den aktuellen Stand.', zurUebersicht: false,
  })
  assert.deepEqual(fehler(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.'), {
    art: 'eingabe', text: 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.', zurUebersicht: false,
  })
  assert.deepEqual(fehler(500, 'Das PDF konnte nicht erzeugt werden.'), {
    art: 'pdf', text: 'Das PDF konnte nicht erzeugt werden. Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.', zurUebersicht: false,
  })
  assert.deepEqual(fehler(500, 'Interner Fehler.'), {
    art: 'server',
    text: 'Interner Fehler. Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.',
    zurUebersicht: false,
  })
  assert.deepEqual(fehler(0, 'Das Kundenportal ist gerade nicht erreichbar.'), { art: 'netz', text: 'Das Kundenportal ist gerade nicht erreichbar.', zurUebersicht: false })
  assert.deepEqual(fehler(404, 'Auftragsbestätigung nicht gefunden.'), { art: 'sonst', text: 'Auftragsbestätigung nicht gefunden.', zurUebersicht: false })
  assert.deepEqual(auftragFehler(new Error('')), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false })
  assert.deepEqual(auftragFehler(null), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false })
})

test('neueSperre: ein zweiter Aufruf während des ersten wird ignoriert (Doppelklick)', async () => {
  const sperre = neueSperre()
  let aufrufe = 0
  let fertig
  const warten = new Promise((r) => { fertig = r })
  const erster = sperre.run(async () => { aufrufe += 1; await warten; return 'ok' })
  const zweiter = await sperre.run(async () => { aufrufe += 1; return 'doppelt' })
  assert.deepEqual(zweiter, { uebersprungen: true })
  assert.equal(sperre.belegt, true)
  fertig()
  assert.deepEqual(await erster, { uebersprungen: false, wert: 'ok' })
  assert.equal(aufrufe, 1)
  assert.equal(sperre.belegt, false)
  assert.deepEqual(await sperre.run(async () => 'wieder'), { uebersprungen: false, wert: 'wieder' })
})

test('neueSperre: nach einem Fehler wieder frei, der Fehler wird weitergereicht', async () => {
  const sperre = neueSperre()
  await assert.rejects(() => sperre.run(async () => { throw new Error('kaputt') }), /kaputt/)
  assert.equal(sperre.belegt, false)
})

Dann tests/unit/auftragPdf.test.mjs:

import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'

const { PDF_FREIGABE_NACH_MS, alsPdfBlob, oeffnePdfImTab } = await loadModule('src/lib/auftragPdf.js')

// Fester Ablauf: Tab zuerst (der Browser blockiert sonst das Pop-up), dann Blob holen, Objekt-URL erzeugen, später freigeben
function umgebung() {
  const ablauf = []
  const tab = { location: {}, opener: 'woms', close: () => ablauf.push('schliessen') }
  const win = { open: (...args) => { ablauf.push('oeffnen'); win.args = args; return tab } }
  const urlApi = {
    createObjectURL: () => { ablauf.push('url'); return 'blob:https://woms.test/abc' },
    revokeObjectURL: (url) => ablauf.push(`frei:${url}`),
  }
  const timer = (fn, ms) => { ablauf.push(`timer:${ms}`); timer.fn = fn }
  return { ablauf, tab, win, urlApi, timer }
}

test('alsPdfBlob: setzt den Typ application/pdf, ein passender Blob bleibt unverändert', async () => {
  const roh = new Blob(['%PDF-1.4'], { type: 'application/octet-stream' })
  const pdf = alsPdfBlob(roh)
  assert.equal(pdf.type, 'application/pdf')
  assert.equal(await pdf.text(), '%PDF-1.4')
  const schon = new Blob(['x'], { type: 'application/pdf' })
  assert.equal(alsPdfBlob(schon), schon)
})

test('oeffnePdfImTab: Tab zuerst, dann Blob, Objekt-URL im Tab, später freigeben', async () => {
  const u = umgebung()
  await oeffnePdfImTab(async () => { u.ablauf.push('holen'); return new Blob(['%PDF'], { type: 'application/pdf' }) }, u)
  assert.equal(PDF_FREIGABE_NACH_MS, 300000)
  assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'url', 'timer:300000'])
  assert.equal(u.tab.location.href, 'blob:https://woms.test/abc')
  assert.equal(u.tab.opener, null)
  u.timer.fn()
  assert.equal(u.ablauf.at(-1), 'frei:blob:https://woms.test/abc')
})

test('oeffnePdfImTab: Fehler beim Holen schließt den Tab, es entsteht keine URL', async () => {
  const u = umgebung()
  await assert.rejects(() => oeffnePdfImTab(async () => { u.ablauf.push('holen'); throw new Error('PDF nicht gefunden.') }, u), /PDF nicht gefunden/)
  assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'schliessen'])
})

test('oeffnePdfImTab: blockiertes Pop-up, der Blob wird gar nicht geholt', async () => {
  let geholt = false
  const win = { open: () => null }
  await assert.rejects(() => oeffnePdfImTab(async () => { geholt = true; return new Blob() }, { win }), { message: 'Neuer Tab wurde vom Browser blockiert.' })
  assert.equal(geholt, false)
})
  • Step 2: Tests laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs tests/unit/auftragPdf.test.mjs Expected: FAIL: die neuen Namen sind in auftragView.js nicht definiert, src/lib/auftragPdf.js fehlt.

  • Step 3: Teil 2 in src/lib/auftragView.js implementieren

Regeln (Texte wörtlich wie in den Tests):

  • AUFTRAG_STATUS: Objekt in der Reihenfolge der sieben Status, je { label, tone }; Töne muted, info, ok, violet, plain, plain, danger (Badge-Töne des Redesigns; widerrufen ist rot, weil die Erstattung von Hand folgt).

  • istEntwurf(auftrag): auftrag?.status === 'entwurf'.

  • PROJEKT_ZUORDNEN_STATUS: ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet'].

  • zuordnenOptionen(konten): kein Array: []. Sonst je Eintrag, der ein Objekt mit nichtleerer Text-projektId ist, { value: projektId, label } in der Reihenfolge der Eingabe; label ist der getrimmte name, ohne Namen die projektId.

  • projektZuordnenMoeglich(auftrag, konten): true nur, wenn auftrag ein Objekt ist, status in PROJEKT_ZUORDNEN_STATUS steht, projektId leer ist ('', null, fehlend; jede nichtleere Kennung zählt als „hat ein Projekt“, auch eine, die in konten fehlt, weil der Server sie mit 409 ablehnt) und zuordnenOptionen(konten) mindestens einen Eintrag hat.

  • auftragAktionen(auftrag, { konten } = {}): Liste von { key, label } (danger: true nur bei loeschen und stornieren, sonst ohne dieses Feld), je Status: entwurf: bearbeiten, senden, pdf (Beschriftung „Vorschau“, immer), loeschen. versendet: pdf, erinnern („Erinnerung senden“, bei mailStatus === 'fehler' „Erneut senden“), neueFassung, stornieren. bestaetigt: pdf, neueFassung, stornieren. abgerechnet: pdf, neueFassung. storniert, ersetzt, widerrufen und alles Unbekannte: nur pdf. Außer beim Entwurf erscheint pdf (Beschriftung „PDF“) nur bei pdfVorhanden === true. Gilt projektZuordnenMoeglich(auftrag, konten), kommt { key: 'projekt', label: 'Projekt zuordnen' } hinzu, und zwar unmittelbar vor der ersten gefährlichen Aktion (loeschen bzw. stornieren), gibt es keine, ans Ende. Kein Objekt: [].

  • auftragNotizen(auftrag, alle = []): Liste { key, tone, text } in genau dieser Reihenfolge: 1. „Mail nicht zugestellt“ (Ton warn; nur Status versendet und mailStatus === 'fehler'); 2. „Bestätigt am TT.MM.JJJJ“ (bestaetigtAm gesetzt, Status bestaetigt oder abgerechnet); 3. „Frühbeginn verlangt“ (fruehbeginn === true, Status nicht entwurf); 4. „Arbeitsbeginn frühestens am TT.MM.JJJJ“ (fruehesterBeginn gesetzt, Status versendet, bestaetigt oder abgerechnet); 5. bei ersetztId „Neue Fassung zu “ (Nummer der AB mit dieser id in alle, ohne Treffer oder Nummer nur „Neue Fassung“); 6. bei ersetztDurchId „Ersetzt durch “ (ohne Treffer „Ersetzt durch eine neue Fassung“); 7. „Widerrufen am TT.MM.JJJJ“ (Status widerrufen, widerrufenAm gesetzt). Andere Töne muted. Datumsangaben mit dateDe.

  • auftragZeilen(auftraege, { konten = [] } = {}): nur Objekte, Reihenfolge der Eingabe, alle für die Notizen ist die Eingabeliste. nummerText = nummer || 'Entwurf'; datumText = dateDe(gesendetAm || erstelltAm); projektText = Name des Kontos mit projektId der AB, aber nur wenn es existiert und (getrimmt) nicht dem titel gleicht, sonst ''; Summe: Festpreis euro(summeCents) (ohne ganze Zahl „–“), Aufwand summeText „nach Aufwand“ und summeSub „<euro(stundensatzCents)> pro Std.“, mit Schätzung zusätzlich „, Schätzung etwa Std.“; Status aus AUFTRAG_STATUS (unbekannt: Rohtext, Ton muted); notizen; aktionen = auftragAktionen(auftrag, { konten }); istEntwurf; auftrag (Original); key = id. Es gibt kein Feld kundenId (die Liste braucht es nicht mehr, „Zur Übersicht“ läuft über onShowOverview).

  • auftragBestaetigung(key, auftrag, { email, projektName } = {}): nur für senden, loeschen, stornieren, erinnern und projekt ein Text, sonst ''; Titel „<titel>“, ohne Titel „ohne Titel“; Nummer aus auftrag.nummer. projekt: bei einem Entwurf '' (er bleibt über „Bearbeiten“ änderbar), sonst Die Auftragsbestätigung <nummer> „<titel>“ <ziel> zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern. mit <ziel> = dem Projekt „<projektName>“ bzw. ohne projektName einem Projekt.

  • auftragErfolg(key, auftrag, { mailFehler = false, projektName = '' } = {}): { tone, text }. name ist „Auftragsbestätigung “, ohne Nummer oder ohne Objekt „Die Auftragsbestätigung“. projekt: <name> wurde dem Projekt „<projektName>“ zugeordnet., ohne projektName <name> wurde einem Projekt zugeordnet.. senden: derselbe Wortlaut wie auftragMeldung im Container von W0, also <name> wurde an <gesendetAn> gesendet. (ohne gesendetAn <name> wurde gesendet.); bei mailFehler Ton warn und <name> ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.. erinnern: Die Erinnerung zu <nummer> wurde an <gesendetAn oder „den Kunden“> gesendet. (ohne Nummer „“ = „der Auftragsbestätigung“), bei mailFehler Ton warn und der Text des Tests. stornieren: <name> wurde storniert.. loeschen, neueFassung und der unbekannte Schlüssel (Text '') wie im Test.

  • auftragFehler(err): status = err?.status, meldung = err?.message || ''. Der erste passende Fall gilt: 409 und /^Entwurf unvollständig/i → entwurf (Meldung unverändert, zurUebersicht: false; L4 meldet so einen unvollständigen Entwurf bei Vorschau und Senden, L3 reicht es durch; die Angabe „Die Liste zeigt den aktuellen Stand.“ wäre hier falsch); 409 und /adresse/i → adresse (Text ADDRESS_INCOMPLETE_TEXT, zurUebersicht: true); 400 und /kundenart/i → kundenart; 400 und /e-?mail/i → email; 400 und /feste[rn]? kunde|interessent|\blead\b/i → kunde (diese vier mit den festen Texten des Tests, zurUebersicht: true); 409 → stand (Meldung + „ Die Liste zeigt den aktuellen Stand.“); 400 → eingabe (Meldung); 500 und /pdf/i → pdf (Meldung + „ Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.“); status >= 500 → server (Meldung, ohne Meldung „Interner Fehler.“, + „ Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.“); status === 0 → netz (Meldung); sonst sonst (Meldung oder „Die Aktion ist fehlgeschlagen.“). zurUebersicht ist nur bei adresse, kundenart, email und kunde true, sonst false. Die Texte der Server stehen im Plan L3 (Abschnitt „Meldungstexte“) und im Plan L4 (Tabelle „Entwurf unvollständig“); die Erkennung hängt an den Wörtern „Entwurf unvollständig“ (Satzanfang), „Adresse“, „Kundenart“, „E-Mail“, „feste Kunden“. Die Fehler von POST …/projekt (V5: 400, 404, 409 „hat schon ein Projekt“ oder Status storniert, ersetzt, widerrufen) laufen über dieselben Fälle: 409 zeigt die Meldung des Servers mit „Die Liste zeigt den aktuellen Stand.“.

  • neueSperre(): { run(fn), get belegt() }. run setzt belegt sofort beim Aufruf (vor jedem await) und liefert { uebersprungen: true } (genau dieses Objekt), wenn schon eine Ausführung läuft; sonst führt es fn() aus und liefert { uebersprungen: false, wert }. Nach Ende oder Fehler ist die Sperre wieder frei, ein Fehler wird weitergereicht.

  • Step 4: src/lib/auftragPdf.js implementieren

Regeln: PDF_FREIGABE_NACH_MS = 300000. alsPdfBlob(blob): hat der Blob schon den Typ application/pdf, derselbe Blob, sonst new Blob([blob], { type: 'application/pdf' }). oeffnePdfImTab(holeBlob, { win = globalThis.window, urlApi = globalThis.URL, timer = globalThis.setTimeout } = {}) ruft openInNewTab(async () => { … }, { win }) auf; darin: Blob holen, alsPdfBlob, urlApi.createObjectURL, timer(() => urlApi.revokeObjectURL(url), PDF_FREIGABE_NACH_MS), die URL zurückgeben. Fehler und blockiertes Pop-up behandelt openInNewTab.

  • Step 5: Tests laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs tests/unit/auftragPdf.test.mjs tests/unit/kundenFelder.test.mjs Expected: PASS, auftragView 37 Tests (24 aus Task 3, 13 neue), auftragPdf 4, kundenFelder 7, fail 0.

  • Step 6: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/lib/auftragView.js tests/unit/auftragView.test.mjs src/lib/auftragPdf.js tests/unit/auftragPdf.test.mjs && git commit -m "Auftragsbestaetigung: Listenlogik (Status, Aktionen, Meldungen, Fehlerklassen, Sperre) und PDF-Hilfe mit Tests" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 5: AuftragListe mit Stil

Files:

  • Modify: src/components/auftraege/AuftragListe.jsx (Platzhalter von W0 ersetzen)
  • Create: src/components/auftraege/auftrag.css (hier die Listenregeln, Task 6 und 7 hängen an)

Interfaces:

  • Consumes: Badge, Button, Card, EmptyState aus ../ui; noticeProps aus ../../lib/financeUi; auftragZeilen, auftragBestaetigung, auftragErfolg, auftragFehler, zuordnenOptionen, neueSperre (Task 3 und 4); oeffnePdfImTab (Task 4); sendAuftrag, erinnereAuftrag, neueFassungAuftrag, storniereAuftrag, deleteAuftrag, ordneAuftragProjektZu, getAuftragPdf aus ../../lib/financeApi (W0, Rückgaben laut V5: { auftrag }, { auftrag, mailFehler }, { ok: true }, Fehler mit status und message).

  • Produces: export default function AuftragListe({ auftraege, konten, onChanged, onBearbeiten, onShowOverview }) mit genau den Props aus V8; der Container von W0 reicht konten (ansicht.konten) und onShowOverview (Wechsel auf den Reiter Übersicht) durch, es gibt keinen Rückfall und keine Vorgabewerte.

  • Step 1: AuftragListe.jsx bauen

Die Logik steckt in den Libs, die Komponente verdrahtet sie. Muster: src/components/finance/InvoiceTable.jsx (Sperre, Rückfrage, Meldung, Tabelle mit Rollen).

Element Umsetzung
Rahmen <Card title="Aufträge" className="auftrag-liste"> mit <div className="auftrag-body">; darin Meldung, dann Tabelle bzw. Leerzustand. Kein Dialog in dieser Datei
Daten const zeilen = useMemo(() => auftragZeilen(auftraege, { konten }), [auftraege, konten])
Leer EmptyState title="Noch keine Aufträge." hint="Eine Auftragsbestätigung entsteht über „Auftragsbestätigung“ in der Projektliste."
Meldung message ({ tone, text, zurUebersicht? }) als <p {...noticeProps(message.tone, 'auftrag-notice')}>; bei zurUebersicht im selben Absatz hinter dem Text (durch ein Leerzeichen getrennt) <button type="button" className="link" onClick={onShowOverview}>Zur Übersicht</button>
Tabelle <div className="auftrag-table-wrap"><table className="table auftrag-table" role="table"> mit <caption className="auftrag-sr">Auftragsbestätigungen, neueste zuerst</caption>; Spalten (th scope="col"): „Nummer“, „Projekt / Titel“, „Datum“, „Summe“ (auftrag-amount), „Status“, „Aktionen“; Zellen tragen data-label („Datum“, „Summe“) für die schmale Darstellung
Nummer <td className="auftrag-nummer">: nummerText; Entwürfe in <span className="muted">
Titel <td className="auftrag-titel">: <strong>{titel}</strong>, darunter <div className="cell-sub">{projektText}</div> wenn nicht leer
Datum <td className="auftrag-datum" data-label="Datum">: datumText
Summe <td className="auftrag-amount" data-label="Summe">: summeText, darunter <div className="cell-sub">{summeSub}</div> wenn nicht leer
Status <td className="auftrag-status"> mit <Badge tone={statusTone}>{statusLabel}</Badge>, darunter je Notiz <div className={note.tone === 'warn' ? 'msg warn auftrag-note' : 'muted auftrag-note'}>{text}</div>
Aktionen <td className="auftrag-aktionen"> mit <div className="auftrag-actions">; je Aktion: danger als <Button type="button" variant="danger">, sonst <button type="button" className="link">; aria-label = `${label}: ${nummerText}, ${titel}`; disabled={busy !== null}; bei der Aktion projekt zusätzlich aria-expanded (offen, wenn zuordnen?.id === zeile.id); während die eigene Aktion läuft die Laufbeschriftung: senden und erinnern „Sende …“, stornieren „Storniere …“, loeschen „Lösche …“, neueFassung „Lege an …“, pdf „Öffne …“
Projekt zuordnen Zustand const [zuordnen, setZuordnen] = useState(null) ({ id, projektId } der Zeile mit offener Auswahl oder null; es ist höchstens eine Auswahl offen). Ist zuordnen?.id === zeile.id, steht unter .auftrag-actions in derselben Zelle <div className="auftrag-zuordnen" role="group"> (mit aria-label „Projekt zuordnen: , “ wie bei den Aktionen) mit <select className="select" aria-label="Projekt wählen" value={wahl}> (erste Option value="" „Projekt wählen …“, dann je Eintrag von zuordnenOptionen(konten) <option value={value}>{label}</option>; onChange setzt projektId in zuordnen), <Button type="button" onClick={() => ordneZu(zeile)}> („Zuordnen“, während des Aufrufs „Ordne zu …“; gesperrt, solange wahl leer ist oder busy !== null) und <button type="button" className="link" disabled={busy !== null} onClick={() => setZuordnen(null)}>Abbrechen</button>. wahl ist zuordnen.projektId, aber nur, wenn der Wert in zuordnenOptionen(konten) vorkommt, sonst '' (so bleibt der Knopf gesperrt, wenn das Projekt nach einem Neuladen fehlt)

Ablauf run(zeile, aktion):

  1. bearbeiten: onBearbeiten(zeile.auftrag), fertig (kein Server, keine Sperre).
  2. projekt: setZuordnen({ id: zeile.id, projektId: '' }) (öffnet die Auswahl dieser Zeile und schließt eine andere), message zurücksetzen, fertig (kein Server, keine Sperre).
  3. Ist sperre.belegt (const sperre = useRef(neueSperre()).current), sofort zurück (so erscheint beim Doppelklick keine zweite Rückfrage). Dann die Rückfrage: const frage = auftragBestaetigung(aktion.key, zeile.auftrag); if (frage && !window.confirm(frage)) return.
  4. Alles Weitere läuft in sperre.run(…), busy = `${zeile.id}:${aktion.key}`, message zurücksetzen.
  5. pdf: oeffnePdfImTab(() => getAuftragPdf(zeile.id)), ohne ein await davor (der neue Tab muss im Klick aufgehen); ein Fehler wird { tone: 'warn', text: auftragFehler(err).text }.
  6. Die Server-Aktionen (senden → sendAuftrag, erinnern → erinnereAuftrag, stornieren → storniereAuftrag, loeschen → deleteAuftrag, neueFassung → neueFassungAuftrag) rufen mit zeile.id auf. Erfolg: message = auftragErfolg(aktion.key, data?.auftrag || zeile.auftrag, { mailFehler: data?.mailFehler }), dann onChanged(); bei neueFassung danach onBearbeiten(data.auftrag).
  7. Fehler: const f = auftragFehler(err); message = { tone: 'warn', text: f.text, zurUebersicht: f.zurUebersicht }; danach onChanged() (der Stand kann ein anderer sein). Nach Ende busy zurück auf null (nur wenn die Komponente noch gemountet ist: aliveRef wird im Effekt beim Einhängen true und beim Aushängen false, so übersteht es auch das doppelte Einhängen von React im Entwicklungsmodus).

Ablauf ordneZu(zeile) (Knopf „Zuordnen“; dieselbe sperre, dieselbe Fehlerbehandlung wie in run):

  1. const gewaehlt = zuordnenOptionen(konten).find((o) => o.value === zuordnen?.projektId); ohne gewaehlt oder bei sperre.belegt sofort zurück.
  2. Rückfrage vor dem Aufruf: const frage = auftragBestaetigung('projekt', zeile.auftrag, { projektName: gewaehlt.label }); if (frage && !window.confirm(frage)) return (bei einem Entwurf ist frage leer).
  3. In sperre.run(…): busy = `${zeile.id}:projekt`, message zurücksetzen, data = await ordneAuftragProjektZu(zeile.id, gewaehlt.value) (nur POST …/projekt mit { projektId }, sonst nichts: der Server setzt die Zeitquelle, das PDF bleibt unverändert). Erfolg: message = auftragErfolg('projekt', data?.auftrag || zeile.auftrag, { projektName: gewaehlt.label }), setZuordnen(null), onChanged(). Fehler wie in run Punkt 7 (Meldung, onChanged()), dazu setZuordnen(null).
  • Step 2: Listenregeln in auftrag.css

Datei neu anlegen, nur Tokens, kein !important, Muster: Regeln zu .fin-invoices und @container im Block FINANZEN von src/styles/global.css (rg -n "fin-invoices|@container" src/styles/global.css).

Klasse Regel
.auftrag-body container-type: inline-size (einzige Stelle)
.auftrag-notice margin: 0 0 8px
.auftrag-table-wrap overflow-x: auto
.auftrag-sr nur für Screenreader sichtbar (Standardmuster: 1 px, overflow: hidden, clip, absolut)
.auftrag-nummer white-space: nowrap
.auftrag-titel overflow-wrap: anywhere
.auftrag-amount rechtsbündig, font-variant-numeric: tabular-nums, white-space: nowrap
.auftrag-note margin-top: 2px; font-size: 13px; overflow-wrap: anywhere
.auftrag-actions display: flex; flex-wrap: wrap; gap: 4px 14px
.auftrag-zuordnen display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 8px; .select darin flex: 1 1 180px; min-width: 0; max-width: 100%
@container (max-width: 860px) thead nur für Screenreader sichtbar; jede Zeile ein Raster mit den Flächen nummer status / titel titel / datum summe / aktionen aktionen (Spalten minmax(0, 1fr) auto); die Zelle bekommt ihre Fläche über die Klasse (.auftrag-nummer, .auftrag-status, .auftrag-titel, .auftrag-datum, .auftrag-amount, .auftrag-aktionen); Zellen display: block; padding: 0; border: 0, Zeilentrenner 1px solid var(--line) mit padding: 10px 0; td[data-label]::before zeigt attr(data-label) in var(--text-2)
  • Step 3: Syntax und Hygiene

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && node_modules/.bin/esbuild src/components/auftraege/AuftragListe.jsx --loader:.jsx=jsx --jsx=automatic --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/syntax --log-level=warning; rg -n "react-icons|@tabler|style=\{\{|size=\"sm\"|form-control|form-group|ui-btn|window\.alert|#[0-9a-fA-F]{3,8}\b|rgba?\(|[!]important|console\.log|react-router" src/components/auftraege/AuftragListe.jsx src/components/auftraege/auftrag.css Expected: esbuild ohne Ausgabe, rg ohne Treffer (Exit 1; react-router steht im Muster, weil die Liste keinen Link mehr braucht: „Zur Übersicht“ läuft über onShowOverview).

  • Step 4: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/components/auftraege/AuftragListe.jsx src/components/auftraege/auftrag.css && git commit -m "Auftragsbestaetigung: Liste mit Status, Zusatzzeilen, Aktionen und Projekt zuordnen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 6: Dialog, Schritte 1 bis 3

Files:

  • Create: src/components/auftraege/AuftragSchritte.jsx
  • Modify: src/components/auftraege/auftrag.css (Regeln der Felder anhängen)

Interfaces:

  • Consumes: Badge aus ../ui; FormField aus ../finance/FormField (FormField({ id, label, hint, error, children }), das Kind bekommt id, aria-invalid, aria-describedby); useWorkorders({ customerId, limit }) aus ../../hooks/useWorkorders (liefert { workorders, loading, error }); customerName aus ../../lib/financeView; lineTotalCents aus ../../lib/financeView; euro aus ../../lib/financeFormat; aus ../../lib/auftragView AUFTRAG_LIMITS, AUFTRAG_WEGE, zahlungsplanOptionen, waehleProjekt, wechselArt, neuePosition, formularSummeCents, zahlungsplanVorschau, parseProzent, parseTage, zeitquelleOptionen, schalteZeitquelle, kundenartWirksam, vorOrtHinweis; KUNDENART_OPTIONEN, kundenartLabel aus ../../lib/kundenFelder.

  • Produces: benannte Exporte SchrittProjekt, SchrittAuftrag, SchrittZahlung, alle mit den Props { form, onChange, fehler, kunde, konten }; onChange(neuesFormular) bekommt immer das ganze neue Formular (aus den Funktionen der Lib oder { ...form, feld }). SchrittProjekt nimmt zusätzlich neueFassung (boolean). Jedes Feld hat die id ab-<Schlüssel> (Schlüssel wie in pruefeSchritt); Ausnahmen: positionen und zeitquelle sind <fieldset id="ab-positionen" tabIndex={-1}> bzw. id="ab-zeitquelle", art hat ab-art-festpreis und ab-art-aufwand, zahlungsart hat ab-zahlungsart-<value>.

  • Step 1: SchrittProjekt

Element Umsetzung
Kunde <p className="muted">Kunde: <strong> mit customerName(kunde) (ohne Namen das Wort „Kunde“) </strong> und, wenn vorhanden, · {kunde.email}</p>
Neue Fassung bei neueFassung: <p className="msg muted">Neue Fassung: Beim Senden ersetzt sie die bisherige Auftragsbestätigung.</p>
Projekt FormField id="ab-projekt" label="Projekt" mit <select className="select" value={form.projektId}>: erste Option value="" „Kein Projekt (freier Titel)“, dann je Konto value={projektId} mit name; onChange → onChange(waehleProjekt(form, e.target.value, konten))
Titel FormField id="ab-titel" label="Titel" hint="Steht als Titel auf der Auftragsbestätigung, z. B. „Website für Praxis Müller“." error={fehler.titel} mit <input className="input" maxLength={AUFTRAG_LIMITS.maxTitel}>; onChange → { ...form, titel, titelGeaendert: true }
Kundenart nur wenn !kunde.kundenart: FormField id="ab-kundenart" label="Kundenart" hint="Wird am Kunden gespeichert. Privatpersonen bekommen die Widerrufsbelehrung in der Auftragsbestätigung." error={fehler.kundenart} mit <select className="select">, Optionen „Bitte wählen“ (value=""), „Unternehmen“, „Privatperson“ (aus KUNDENART_OPTIONEN ohne den ersten Eintrag). Sonst <p className="muted">Kundenart: {kundenartLabel(kunde.kundenart)}. Änderbar in der Übersicht des Kunden.</p>
  • Step 2: SchrittAuftrag
Element Umsetzung
Datum und Weg <div className="field-row"> mit FormField id="ab-auftragVom" label="Datum der Absprache" error={fehler.auftragVom} (<input className="input" type="date">) und FormField id="ab-auftragWeg" label="Weg der Absprache" error={fehler.auftragWeg} (<select className="select">, erste Option value="" „Bitte wählen“, dann AUFTRAG_WEGE)
Hinweis „Vor Ort“ const vorOrt = vorOrtHinweis(form.auftragWeg, kundenartWirksam(kunde, form.kundenart)); ist er nicht leer, steht direkt unter der Zeile mit Datum und Weg <p className="msg warn auftrag-hinweis">{vorOrt}</p> (nicht blockierend: kein role, kein Fehler am Feld, „Weiter“ bleibt möglich; derselbe Text steht in Schritt 4, Task 7)
Art <fieldset className="auftrag-art"> mit <legend>Art des Auftrags</legend> und zwei <label className="check"> mit Radio (name="ab-art", ids ab-art-festpreis, ab-art-aufwand): „Festpreis“ mit <span className="hint">Feste Positionen mit Preisen.</span> und „Nach Aufwand“ mit <span className="hint">Stundensatz, abgerechnet nach erfasster Arbeitszeit.</span>; onChange → onChange(wechselArt(form, art)); ein Fehler fehler.art als <div className="msg err" role="alert">
Festpreis <fieldset id="ab-positionen" tabIndex={-1} className="auftrag-items"> mit <legend>Positionen</legend>; je Position eine .auftrag-item: Beschreibung (<input className="input" maxLength={200} aria-label="Position n: Beschreibung" placeholder="z. B. Startseite">), Menge (inputMode="numeric", aria-label="Position n: Menge"), Einzelpreis (inputMode="decimal", aria-label="Position n: Einzelpreis in Euro", placeholder="0,00"), Summe (lineTotalCents als euro, ungültig „–“), „Entfernen“ (<button type="button" className="link" aria-label="Position n entfernen">, gesperrt bei nur einer Position). Sichtbare Mini-Beschriftungen „Beschreibung“, „Menge“, „Einzelpreis in €“, „Summe“ (.auftrag-item-label). Darunter <button type="button" className="btn">+ Position</button> (gesperrt ab 20; hängt neuePosition() an) und <div className="auftrag-total" aria-live="polite">Summe und euro(formularSummeCents(form)), danach <p className="hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>; fehler.positionen als <div className="msg err" role="alert"> im Fieldset
Aufwand <div className="field-row"> mit FormField id="ab-stundensatz" label="Stundensatz in €" hint="Steht mit der Aufwandsregel in der Auftragsbestätigung." error={fehler.stundensatz} (<input className="input" inputMode="decimal">) und FormField id="ab-schaetzung" label="Geschätzter Umfang in Stunden (optional)" hint="In Schritten von 15 Minuten, z. B. 10 oder 12,5. Unverbindlich." error={fehler.schaetzung} (<input className="input" inputMode="decimal" placeholder="z. B. 10">)

Positionen ändern ersetzt nur die Zeile mit gleicher uid im Array. Die Felder beider Arten bleiben im Formular erhalten, wenn die Art wechselt.

  • Step 3: SchrittZahlung
Element Umsetzung
Zahlungsplan <fieldset className="auftrag-art"> mit <legend>Zahlungsplan</legend> und je Eintrag von zahlungsplanOptionen(form.art) ein <label className="check"> mit Radio (name="ab-zahlungsart", id ab-zahlungsart-<value>); onChange → { ...form, zahlungsart }; fehler.zahlungsart als msg err
Anzahlung bei zahlungsart === 'anzahlung': FormField id="ab-prozent" label="Anzahlung in %" error={fehler.prozent} (<input className="input auftrag-narrow" inputMode="numeric">)
Frei bei zahlungsart === 'frei': FormField id="ab-zahlungstext" label="Zahlungsplan als Text" error={fehler.zahlungstext} (<textarea className="textarea" rows={3} maxLength={500}>), darunter <div className="hint">{länge} / 500 Zeichen</div>
Vorschau const text = zahlungsplanVorschau({ art: form.zahlungsart, prozent: parseProzent(form.prozent), text: form.zahlungstext, summeCents: formularSummeCents(form), zielTage: parseTage(form.zahlungsziel) }); ist er nicht leer: <p className="msg muted auftrag-plan-vorschau">Sinngemäß: {text}</p>
Zahlungsziel FormField id="ab-zahlungsziel" label="Zahlungsziel in Tagen" hint="0 bis 60 Tage nach Rechnung." error={fehler.zahlungsziel} (<input className="input auftrag-narrow" inputMode="numeric">)
Termine <div className="field-row"> mit FormField id="ab-startAm" label="Start (optional)" error={fehler.startAm} und FormField id="ab-fertigBis" label="Voraussichtliche Fertigstellung (optional)" error={fehler.fertigBis}, beide <input className="input" type="date">
Zeitquelle Unterkomponente ZeitquelleAuswahl({ kundeId, form, onChange, projektTicketIds, fehler }): <fieldset id="ab-zeitquelle" tabIndex={-1} className="auftrag-zeitquelle">, <legend>Zeiterfassung aus diesen Tickets</legend>, <p className="hint">Arbeitsblätter dieser Tickets zählen zum Auftrag (Projektkonto, Aufwandsrechnung). Höchstens 10.</p>. Daten über useWorkorders({ customerId: kundeId, limit: 100 }), die Liste über zeitquelleOptionen(workorders, form.zeitquelle, projektTicketIds) mit projektTicketIds aus dem Konto des gewählten Projekts. Je Option <label className="check"><input type="checkbox" checked disabled={!gewaehlt && form.zeitquelle.length >= 10}> {label}</label>, bei projekt dahinter <Badge tone="muted">Ticket des Projekts</Badge>; Umschalten → onChange({ ...form, zeitquelle: schalteZeitquelle(form.zeitquelle, id, an), zeitquelleGeaendert: true }). Zustände: laden <div className="loading" role="status">Tickets werden geladen …</div>; Fehler <p className="msg warn" role="alert">Die Tickets konnten nicht geladen werden: {error}. Die bisherige Auswahl bleibt.</p>; ohne Tickets und ohne Auswahl <p className="muted">Dieser Kunde hat noch keine Tickets.</p>; fehler.zeitquelle als msg err. Gewählte Tickets stehen auch bei Ladefehler in der Liste
  • Step 4: Feldregeln in auftrag.css anhängen
Klasse Regel
.auftrag-art border: 0; display: grid; gap: 8px; legend mit font-weight: 500; .check { flex-wrap: wrap } und .check .hint { flex-basis: 100%; padding-left: 23px } (der Hinweis steht in eigener Zeile unter der Beschriftung)
.auftrag-items border: 0; display: grid; gap: 12px
.auftrag-item Raster minmax(0, 1fr) 80px 120px 110px auto, gap: 8px 12px, align-items: end; .auftrag-item .input width: 100%; Menge, Preis und Summe rechtsbündig mit tabular-nums
.auftrag-item-label font-size: 12px; color: var(--text-2); ab der zweiten Zeile nur für Screenreader sichtbar (Muster .auftrag-sr)
.auftrag-total display: flex; justify-content: space-between; gap: 16px; font-weight: 600
.auftrag-narrow max-width: 120px
.auftrag-plan-vorschau overflow-wrap: anywhere
.auftrag-hinweis margin: 0; overflow-wrap: anywhere (Hinweise in Schritt 2 und 4)
.auftrag-zeitquelle border: 0; display: grid; gap: 6px; Liste der Optionen in einem Block mit max-height: 220px; overflow-y: auto; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 6px 0
@media (max-width: 860px) .auftrag-item zweizeilig minmax(0, .7fr) minmax(0, 1fr) minmax(0, 1fr) auto mit den Flächen desc desc desc desc / qty price sum remove, alle .auftrag-item-label sichtbar; .auftrag-narrow max-width: none
  • Step 5: Syntax und Hygiene

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && node_modules/.bin/esbuild src/components/auftraege/AuftragSchritte.jsx --loader:.jsx=jsx --jsx=automatic --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/syntax --log-level=warning; rg -n "react-icons|@tabler|style=\{\{|size=\"sm\"|form-control|form-group|ui-btn|window\.alert|#[0-9a-fA-F]{3,8}\b|rgba?\(|[!]important|console\.log" src/components/auftraege/AuftragSchritte.jsx src/components/auftraege/auftrag.css; rg -n -A3 "<button" src/components/auftraege/AuftragSchritte.jsx Expected: esbuild ohne Ausgabe, das erste rg ohne Treffer. Beim zweiten rg von Hand prüfen: jedes <button trägt type="button" (in der Zeile oder den drei Folgezeilen).

  • Step 6: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/components/auftraege/AuftragSchritte.jsx src/components/auftraege/auftrag.css && git commit -m "Auftragsbestaetigung: Dialog-Schritte 1 bis 3 (Projekt, Auftrag, Zahlung und Termine)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 7: Dialog, Schritt 4 (Vorschau, Entwurf, Senden)

Files:

  • Modify: src/components/auftraege/AuftragDialog.jsx (Platzhalter von W0 ersetzen)
  • Create: src/components/auftraege/AuftragVorschau.jsx
  • Modify: src/components/auftraege/auftrag.css (Regeln anhängen)

Interfaces:

  • Consumes: Dialog, Button aus ../ui; useScrollIntoView aus ../../hooks/useScrollIntoView; createAuftrag, patchAuftrag, sendAuftrag, getAuftragPdf aus ../../lib/financeApi (W0) und getSettings (besteht dort, liefert { einstellungen }); alsPdfBlob (Task 4); aus ../../lib/auftragView SCHRITTE, neuesFormular, auftragZuFormular, heuteBerlin, pruefeSchritt, formularZuNutzlast, zahlungsplanOptionen, sendeGate, auftragHinweise, kundenartWirksam, auftragBestaetigung, auftragFehler, neueSperre; SchrittProjekt, SchrittAuftrag, SchrittZahlung (Task 6).

  • Produces: export default function AuftragDialog({ open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview }) mit genau den Props aus V8; der Container von W0 reicht konten (ansicht.konten) und onShowOverview (Wechsel auf den Reiter Übersicht) durch, es gibt keinen Rückfall und keine Vorgabewerte. AuftragVorschau({ phase, pdfUrl, meldung, gate, privat, hinweise, onRetry, onShowOverview }) (Schritt 4, ohne eigenen Zustand).

  • Vertrag mit dem Container (Plan W0, Task 7): Nach Speichern oder Senden ruft der Dialog onSaved(auftrag) und danach immer onClose(). Der Container schließt in onSaved selbst, lädt neu und zeigt auftragMeldung(auftrag) („Der Entwurf wurde gespeichert.“, „Auftragsbestätigung AB-… wurde an … gesendet.“ oder die Warnung zu mailStatus: 'fehler'); beide Aufrufe sind in jeder Reihenfolge und mehrfach unschädlich. Der Dialog hat deshalb keine eigene Ergebnisansicht.

  • Step 1: AuftragVorschau.jsx

Schritt 4 als reine Darstellung:

Element Umsetzung
Laden phase === 'speichern': <div className="loading" role="status">Entwurf wird gespeichert …</div>; phase === 'pdf': <div className="loading" role="status">Vorschau wird erstellt …</div>
Fehler meldung als <div className="msg err" role="alert" ref={errorRef}> (useScrollIntoView(errorRef, meldung)) mit meldung.text und bei meldung.zurUebersicht dahinter <button type="button" className="link" onClick={onShowOverview}>Zur Übersicht</button>; ohne pdfUrl und ohne laufende Phase zusätzlich <Button type="button" onClick={onRetry}>Erneut versuchen</Button>
Gespeichert mit pdfUrl: <p className="msg ok">Entwurf gespeichert. Er bleibt in der Liste, bis er gesendet oder gelöscht wird.</p>
Sperre gate && !gate.erlaubt: <div className="msg warn" role="alert"><span>{gate.grund}</span> und bei gate.zurUebersicht dahinter <button type="button" className="link" onClick={onShowOverview}>Zur Übersicht</button></div>; der Entwurf lässt sich trotzdem speichern
Hinweise je Eintrag von hinweise (aus auftragHinweise: fehlende Steuernummer, „Vor Ort“ bei Privatperson) <p className="msg warn auftrag-hinweis" key={key}>{text}</p>; nicht blockierend (kein role="alert", der Knopf „Senden“ hängt nur an gate und phase); ohne Einträge nichts
Privatperson bei privat: <p className="hint">Privatperson: Die Auftragsbestätigung enthält die Widerrufsbelehrung. Mit der Arbeit darf erst nach Ablauf der Widerrufsfrist begonnen werden, es sei denn, der Kunde verlangt es ausdrücklich (Häkchen auf der Bestätigungsseite).</p>
Vorschau <iframe className="auftrag-pdf" title="Vorschau der Auftragsbestätigung" src={pdfUrl} /> und darunter <a className="link" href={pdfUrl} target="_blank" rel="noopener noreferrer">PDF in neuem Tab öffnen</a>

Die Reihenfolge in der Datei ist: Laden, Fehler, Gespeichert, Sperre, Hinweise, Privatperson, Vorschau. Alles, was vor dem Senden zu lesen ist, steht über dem hohen PDF-Rahmen.

  • Step 2: AuftragDialog.jsx bauen

Aufbau und Regeln:

  • Hülle (AuftragDialog, ohne Hooks): !open gibt null. Ist auftrag gesetzt und auftrag.status !== 'entwurf', zeigt sie <Dialog title="Auftragsbestätigung" onClose={onClose} footer={<Button type="button" onClick={onClose}>Schließen</Button>}> mit <p className="msg warn" role="alert">Nur Entwürfe lassen sich bearbeiten. Eine gesendete Auftragsbestätigung lässt sich nur über „Neue Fassung“ in der Liste ändern.</p>. Sonst die nicht exportierte Komponente AuftragForm (alle Hooks und der Zustand) mit key={auftrag?.id || projektId || 'neu'}: frischer Zustand bei jedem Öffnen, Muster InvoiceCreateModal.

  • Zustand: schritt (1 bis 4), form (Start: auftrag ? auftragZuFormular(auftrag) : neuesFormular({ projektId: projektId || '', konten, heute: heuteBerlin() })), fehler ({}), meldung (null oder { text, zurUebersicht }), entwurf (Start auftrag || null), signatur (''), pdfUrl (''), phase ('', 'speichern', 'pdf', 'senden'), einstellungen (Start undefined = noch nicht geladen, null = nicht ladbar, sonst das Objekt einstellungen aus getSettings()). Refs: sperre = useRef(neueSperre()).current, aliveRef (wie in AuftragListe: im Effekt beim Einhängen true, beim Aushängen false), schmutzigRef (es wurde gespeichert und noch nicht gemeldet), urlRef (aktuelle Objekt-URL). Beim Unmount URL.revokeObjectURL(urlRef.current).

  • Einstellungen laden (einmal): ein useEffect beim Einhängen: getSettings().then((d) => { if (aliveRef.current) setEinstellungen(d?.einstellungen ?? null) }).catch(() => { if (aliveRef.current) setEinstellungen(null) }). Kein Fehlertext, kein zweiter Abruf (AuftragForm hängt je Öffnen neu ein): Der Hinweis auf die fehlende Steuernummer ist freiwillig, und ein Ladefehler zeigt keinen Hinweis. const hinweise = auftragHinweise({ kunde, form, einstellungen }) wird bei jedem Rendern berechnet (reine Funktion); die Einstellungen ändern weder weiter() noch senden().

  • Kopf: <Dialog as="form" wide title={auftrag ? 'Auftragsbestätigung bearbeiten' : 'Auftragsbestätigung'} busy={phase !== ''} onClose={schliessen} onSubmit={(e) => { e.preventDefault(); if (schritt < 4) weiter() }} footer={…}>. Im Körper zuerst die Schrittleiste <ol className="auftrag-schritte" aria-label="Schritte"> mit je SCHRITTE-Eintrag <li aria-current={id === schritt ? 'step' : undefined}><span className="auftrag-schritt-nr">{id}</span><span className="auftrag-schritt-text">{label}</span></li>, dann der Inhalt des Schritts.

  • weiter(): pruefeSchritt(schritt, form, { kunde }); bei Fehlern setFehler(r.fehler) und Fokus auf document.getElementById('ab-' + r.erstes) (Ausnahmen laut Task 6: art → ab-art-festpreis, zahlungsart → ab-zahlungsart- plus der erste Wert von zahlungsplanOptionen(form.art)); sonst setFehler({}) und schritt + 1. Aus Schritt 3 wird zurVorschau().

  • zurVorschau(): formularZuNutzlast(form, { kunde }); bei ok: false springt der Dialog zu r.schritt und zeigt r.fehler. Sonst setSchritt(4) und vorschauLaden(r.payload).

  • vorschauLaden(payload): const neu = JSON.stringify(payload); ist neu === signatur && pdfUrl, nichts tun. Sonst in sperre.run: phase 'speichern', meldung null; data = entwurf ? await patchAuftrag(entwurf.id, payload) : await createAuftrag({ kundenId: kunde.$id, ...payload }); setEntwurf(data.auftrag), schmutzigRef.current = true, setSignatur(neu); phase 'pdf'; blob = await getAuftragPdf(data.auftrag.id); url = URL.createObjectURL(alsPdfBlob(blob)); alte URL freigeben, setPdfUrl(url). Fehler: setMeldung(auftragFehler(err)) (der Entwurf bleibt, falls er schon gespeichert war; „Erneut versuchen“ ist onRetry={zurVorschau}). Zuletzt phase '', alles nur wenn noch gemountet.

  • zurueck(): schritt - 1, meldung null. Wer von Schritt 4 zurückgeht und etwas ändert, löst beim nächsten Weg nach Schritt 4 ein neues PATCH aus (andere signatur).

  • „Als Entwurf speichern“: der Entwurf ist in Schritt 4 schon gespeichert; der Knopf ruft onSaved(entwurf), setzt schmutzigRef zurück und onClose().

  • senden(): Ist sperre.belegt, sofort zurück (keine zweite Rückfrage beim Doppelklick). const gate = sendeGate(kunde, form.kundenart); ist es nicht erlaubt, meldung = { text: gate.grund, zurUebersicht: gate.zurUebersicht } und Ende. Dann window.confirm(auftragBestaetigung('senden', entwurf, { email: kunde.email })), bei Abbruch Ende. In sperre.run: phase 'senden', data = await sendAuftrag(entwurf.id), schmutzigRef.current = false, onSaved(data.auftrag), onClose(); Fehler: setMeldung(auftragFehler(err)); zuletzt phase '' (nur wenn noch gemountet: nach onSaved ist der Dialog meist schon weg). Alle Knöpfe sind während phase !== '' gesperrt. Eine fehlgeschlagene Mail ist kein Fehler dieses Aufrufs: data.auftrag.mailStatus ist dann 'fehler', und der Container zeigt die Warnung.

  • schliessen() (Dialog-Schließen, „Abbrechen“): ist schmutzigRef.current wahr, zuerst onSaved(entwurf); dann onClose(). Bei phase !== '' tut der Dialog nichts (busy).

  • Fuß je Zustand in <div className="auftrag-foot">, alle Knöpfe type="button" außer „Weiter“: Schritt 1: <span className="grow" />, „Abbrechen“, „Weiter“ (variant="primary", type="submit"). Schritt 2 und 3: „Zurück“, <span className="grow" />, „Abbrechen“, „Weiter“ (in Schritt 3 beschriftet „Zur Vorschau“). Schritt 4: „Zurück“, <span className="grow" />, „Als Entwurf speichern“, „Senden“ (variant="primary"; Beschriftung „Sende …“ bei phase === 'senden'; disabled bei phase !== '', ohne entwurf, oder wenn sendeGate nicht erlaubt, dann mit title={gate.grund}).

  • Schrittinhalt: 1 SchrittProjekt (mit neueFassung={Boolean(auftrag?.ersetztId)}), 2 SchrittAuftrag (zeigt den Hinweis „Vor Ort“ selbst, Task 6), 3 SchrittZahlung, 4 AuftragVorschau mit privat={kundenartWirksam(kunde, form.kundenart) === 'privat'}, gate={sendeGate(kunde, form.kundenart)} und hinweise={hinweise}. onShowOverview des Dialogs wird als () => { schliessen(); onShowOverview() } an AuftragVorschau gereicht: erst den Dialog schließen (ein gespeicherter Entwurf wird dabei gemeldet), dann auf den Reiter Übersicht wechseln.

  • Step 3: Regeln in auftrag.css anhängen

Klasse Regel
.auftrag-schritte list-style: none; display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; color: var(--text-2); li[aria-current="step"] color: var(--text); font-weight: 600; .auftrag-schritt-nr mit margin-right: 6px
.auftrag-pdf width: 100%; height: min(70vh, 640px); border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-subtle)
.auftrag-foot display: flex; flex: 1; flex-wrap: wrap; align-items: center; gap: 8px; .grow { flex: 1 }
@media (max-width: 860px) .auftrag-schritt-text ausgeblendet außer bei [aria-current="step"]; .auftrag-pdf height: 50vh; .auftrag-foot .btn { flex: 1 1 auto }
  • Step 4: Syntax und Hygiene

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && node_modules/.bin/esbuild src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragVorschau.jsx --loader:.jsx=jsx --jsx=automatic --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/syntax --log-level=warning; rg -n "react-icons|@tabler|style=\{\{|size=\"sm\"|form-control|form-group|ui-btn|window\.alert|#[0-9a-fA-F]{3,8}\b|rgba?\(|[!]important|console\.log|react-router|UebersichtLink" src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragVorschau.jsx src/components/auftraege/auftrag.css; rg -n "<button|<Button" src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragVorschau.jsx Expected: esbuild ohne Ausgabe, das erste rg ohne Treffer. Beim zweiten rg von Hand prüfen: jeder Knopf trägt type="button", nur „Weiter“ trägt type="submit".

  • Step 5: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragVorschau.jsx src/components/auftraege/auftrag.css && git commit -m "Auftragsbestaetigung: Dialog Schritt 4 (Vorschau, Entwurf speichern, Senden mit Rueckfrage und Sperre)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 8: Selbstprüfung

Files: keine Änderung außer Korrekturen an eigenen Dateien (je Korrektur ein eigener Commit mit deutscher Nachricht und der Schlusszeile).

Interfaces:

  • Consumes: alles aus Task 1 bis 7; das Prüfwerkzeug /root/reports/ticketsystem-design-2026-10-04/werkzeug (nur lesend: backend.mjs, browser.mjs, checks.mjs, fixtures/); Schlüssel der schweren Läufe /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock.

  • Produces: grüne Tests, sauberer Worktree, Bildschirmfotos unter /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/shots/<Breite>x<Höhe>/ und der Abschlussbericht.

  • Step 1: Eigene und verwandte Tests

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs tests/unit/auftragPdf.test.mjs tests/unit/kundenFelder.test.mjs tests/unit/financeView.test.mjs tests/unit/financeSettings.test.mjs tests/unit/customerTabs.test.mjs tests/unit/ticketView.test.mjs Expected: fail 0.

  • Step 2: Hygiene über alle eigenen Dateien

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && rg -n "react-icons|@tabler|style=\{\{|size=\"sm\"|form-control|form-group|ui-btn|window\.alert|#[0-9a-fA-F]{3,8}\b|rgba?\(|[!]important|console\.log|react-router|UebersichtLink" src/components/auftraege/AuftragListe.jsx src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragSchritte.jsx src/components/auftraege/AuftragVorschau.jsx src/components/auftraege/auftrag.css; git status --short; git diff --stat feature/auftraege...HEAD Expected: rg ohne Treffer; git status zeigt nur ?? node_modules; git diff --stat (drei Punkte: ab dem Abzweig) nennt ausschließlich die Dateien der Tabelle „Dateien“ (10 Quelldateien und 3 Testdateien). Eine andere Datei wird mit git checkout <Abzweig-Commit> -- <Datei> zurückgenommen (<Abzweig-Commit> aus git merge-base feature/auftraege HEAD) und gemeldet.

  • Step 3: Vite-Build hinter dem flock

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && free -m | head -2 && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock env VITE_APPWRITE_ENDPOINT=https://ticket.test/v1 VITE_APPWRITE_PROJECT_ID=test VITE_PROJECT_ADMIN_URL=https://portal.test NODE_OPTIONS=--max-old-space-size=1200 systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 node_modules/.bin/vite build --outDir /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/dist --emptyOutDir Expected: Vorher zeigt free -m mindestens 1500 MB bei „available“ (sonst warten). Der Build endet mit Exit 0 und listet index.html und assets/*. Bei Fehlern in eigenen Dateien beheben und neu bauen. Läuft das Redesign noch schwere Läufe, steht flock /root/reports/ticketsystem-design-2026-10-04/sperren/schwer.lock direkt hinter der ersten Sperre (also flock <unsere> flock <deren> env …).

  • Step 4: Harness schreiben (/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/shots-w2.mjs, nicht im Repo)

Das Skript läuft mit dem Test-Backend des Prüfwerkzeugs (alles aus Testdaten, kein Request erreicht einen echten Host) und ergänzt nur die Portal-Endpunkte, die das Werkzeug noch nicht kennt. Modell für Kontext (serviceWorkers: 'block', isMobile und deviceScaleFactor 2 bei 390 px), Schrift und Uhr ist runOne in /root/reports/ticketsystem-design-2026-10-04/werkzeug/shots.mjs; die Funktionen buildFontCss und installFonts dort in das Skript kopieren, die Schriftdateien liegen unter /root/reports/ticketsystem-design-2026-10-04/werkzeug/fonts. Gerüst:

import { mkdirSync } from 'node:fs'
import { createBackend, loadFixtures } from '/root/reports/ticketsystem-design-2026-10-04/werkzeug/backend.mjs'
import { launchChromium } from '/root/reports/ticketsystem-design-2026-10-04/werkzeug/browser.mjs'
import { runChecks } from '/root/reports/ticketsystem-design-2026-10-04/werkzeug/checks.mjs'
import { jetzt } from '/root/reports/ticketsystem-design-2026-10-04/werkzeug/fixtures/helfer.mjs'

const OUT = '/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2'
const PORTAL = 'https://portal.test'
const CORS = { 'access-control-allow-origin': 'https://ticket.test', 'access-control-allow-credentials': 'true' }
const PDF = '%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 100]>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n'

// Je Ansicht und Viewport ein frischer Zustand (Entwürfe, Einstellungen) und ein frisches Backend
function neuerZustand() { /* auftraege: 9 ABs wie unten (mit und ohne projektId), einstellungen mit steuernummer '' (eine Ansicht setzt sie) */ }

function mitEigenenRouten(backend, zustand, meine) {
  return async (route, request) => {
    const url = new URL(request.url())
    if (url.origin === PORTAL && url.pathname.startsWith('/api/admin/finanzen/') && request.method() !== 'OPTIONS') {
      const antwort = await beantworte(zustand, request.method(), url, request.method() === 'GET' ? null : request.postDataJSON())
      if (antwort) {
        meine.push({ method: request.method(), path: url.pathname, body: request.postData() })
        return route.fulfill({ status: antwort.status, headers: { ...CORS, 'content-type': antwort.pdf ? 'application/pdf' : 'application/json' }, body: antwort.pdf ? PDF : JSON.stringify(antwort.json) })
      }
    }
    return backend.handle(route, request) // Preflights und alles Übrige beantwortet das Werkzeug
  }
}
// beantworte(zustand, methode, url, body) -> { status, json | pdf: true, verzoegerungMs? } | null (null = Werkzeug antwortet)

context.route('**/*', mitEigenenRouten(…)), await context.clock.setFixedTime(jetzt()), Viewports [1440, 900] und [390, 844], reducedMotion: 'reduce', locale: 'de-DE', timezoneId: 'Europe/Berlin', Browser über launchChromium(['--lang=de-DE', '--host-resolver-rules=MAP * ~NOTFOUND']), createBackend({ fixtures: await loadFixtures('standard'), scenario: 'angemeldet', dist: ${OUT}/dist }) (ein Pfad mit / gilt als Build-Ordner). Kunde ist c4 („Physiotherapie am Markt“, hat im Werkzeug schon Reiter und Rechnungen). Nach jeder Ansicht runChecks(page) aufrufen und die Treffer overflow-x, bad-text und icon-svg ausgeben (overflow-x und bad-text müssen leer sein).

Antworten von beantworte (Formen laut V5; Pfad nach /api/admin/finanzen):

  • GET /projektkonten?kunde=c4: { auftraegeAktiv: true, kunde: { id: 'c4', name: 'Physiotherapie am Markt', kundenart: '' }, konten: [<ein Konto 'p-physio' mit Name „Physio-Website“ und tickets aus den Tickets des Kunden c4 in den Testdaten>], ohneProjekt: { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }, stripeOk: true }.
  • GET /auftraege?kunde=c4: { auftraege } mit neun ABs (Felder wie V3 AuftragTeam; wo unten nichts anderes steht projektId: 'p-physio', auftragWeg: 'telefon' und ein Titel, der vom Kontonamen „Physio-Website“ abweicht, damit die Zeile den Projektnamen zeigt): entw-1 Entwurf Festpreis; entw-adresse Entwurf ohne Projekt (projektId: ''), dessen POST …/senden mit 409 { error: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.' } antwortet; ab-1 versendet mit mailStatus: 'fehler'; ab-2 versendet ohne Projekt, fruehesterBeginn: '2026-10-21'; ab-3 bestätigt, fruehbeginn: true, Aufwand mit Schätzung; ab-4 abgerechnet; ab-5 storniert ohne Projekt (an dieser Zeile darf es kein „Projekt zuordnen“ geben); ab-6 ersetzt (ersetztDurchId: 'ab-1') und ab-7 widerrufen ohne Projekt. Nummern AB-2026-0001 bis -0007, Entwürfe ohne Nummer. Die AB-Liste ist Zustand des Läufers: eine Zuordnung (unten) verändert spätere GET-Antworten.
  • POST /auftraege/<id>/senden: { auftrag: <AB mit Status 'versendet', Nummer 'AB-2026-0008', gesendetAn 'praxis@physio.test', mailStatus 'gesendet'>, mailFehler: false } nach 800 ms Verzögerung (macht den Doppelklick wirksam), für entw-adresse der 409. POST …/erinnern, …/stornieren, …/neue-fassung (neuer Entwurf entw-neu mit ersetztId) liefern { auftrag }; PATCH /auftraege/<id> mischt den Körper in den Entwurf und liefert { auftrag }; POST /auftraege legt einen Entwurf an; DELETE liefert { ok: true }; GET …/pdf liefert PDF.
  • POST /auftraege/<id>/projekt mit { projektId }: nach 800 ms Verzögerung (macht den Doppelklick wirksam) { auftrag: <die AB mit gesetzter projektId> } und die AB bleibt im Zustand zugeordnet; hat die AB schon eine projektId, 409 { error: 'Die Auftragsbestätigung hat schon ein Projekt.' } (Text nur im Läufer erfunden).
  • GET und PATCH /einstellungen: { einstellungen: { minBufferCents: 50000, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: 'Bankverbindung: WEBklar GbR · IBAN DE00 0000 0000 0000 0000 00', steuernummer: '' } }; PATCH mischt die gesendeten Felder ein. Die Ansicht dialog-ohne-hinweise startet mit steuernummer: '12/345/67890'.

Ansichten, Schritte und Zusicherungen (jede je Viewport, ein werfender Schritt ist ein Fehler; window.confirm mit page.on('dialog', (d) => d.accept()) bestätigen):

Name Adresse Schritte Zusicherungen
kunde-uebersicht /customers/c4 in „Kundenart“ „Privatperson“ wählen, „Speichern“, danach noch einmal „Speichern“ das Feld hat die Optionen „Nicht festgelegt“, „Unternehmen“, „Privatperson“ und den Hinweis; der erste Schreibaufruf auf customers/documents/c4 enthält "kundenart":"privat", der zweite enthält kundenart nicht
einstellungen-steuernummer /finance/einstellungen „12_345“ eintragen, „Speichern“; „DE123456789“ eintragen, „Speichern“; „Speichern“ Hinweistext „Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.“ sichtbar; nach „12_345“ steht die Fehlermeldung „Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.“ am Feld und es gibt keinen PATCH; danach ein PATCH mit "steuernummer":"DE123456789" (Buchstaben sind erlaubt wie auf dem Server); der dritte PATCH ohne steuernummer
auftraege-liste /customers/c4?tab=invoices auf die Überschrift „Aufträge“ warten neun Zeilen mit Statusabzeichen (Text); „Mail nicht zugestellt“, „Arbeitsbeginn frühestens am 21.10.2026“, „Frühbeginn verlangt“ sichtbar; „Bearbeiten“, „Senden“ und „Löschen“ nur in den beiden Entwurfszeilen; „Erinnerung“ oder „Erneut senden“ nur bei ab-1 und ab-2; „Neue Fassung“ bei ab-1 bis ab-4; „Projekt zuordnen“ genau bei entw-adresse und ab-2 (nicht bei ab-5 und ab-7, deren Status es ausschließt, und nicht bei den Zeilen mit Projekt)
auftrag-projekt wie auftraege-liste in der Zeile ab-2 „Projekt zuordnen“, „Abbrechen“, wieder „Projekt zuordnen“; im Feld „Projekt wählen“ die Option „Physio-Website“ wählen; doppelt auf „Zuordnen“ klicken; danach in der Zeile entw-adresse „Projekt zuordnen“, „Physio-Website“ wählen, einmal „Zuordnen“ vor der Wahl ist „Zuordnen“ gesperrt, „Abbrechen“ schließt die Auswahl; genau ein POST …/auftraege/ab-2/projekt mit Körper {"projektId":"p-physio"} und nur eine Rückfrage (die AB ist kein Entwurf); Meldung „Auftragsbestätigung AB-2026-0002 wurde dem Projekt „Physio-Website“ zugeordnet.“; danach hat ab-2 kein „Projekt zuordnen“ mehr und zeigt „Physio-Website“ unter dem Titel; bei entw-adresse kommt dieselbe Aktion ohne Rückfrage durch (Entwurf); kein waagerechter Überlauf bei 390 px
auftrag-doppelklick wie auftraege-liste Doppelklick (dblclick) auf „Senden“ in der Zeile entw-1 genau ein POST …/auftraege/entw-1/senden, nur eine Rückfrage; Meldung „Auftragsbestätigung AB-2026-0008 wurde an praxis@physio.test gesendet.“ im Abschnitt „Aufträge“
auftrag-adresse wie auftraege-liste „Senden“ in der Zeile entw-adresse, danach „Zur Übersicht“ Meldung „Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.“ mit dem Knopf „Zur Übersicht“; der Klick wechselt auf den Reiter Übersicht (onShowOverview), die Adresse ist danach /customers/c4 ohne tab
dialog-schritte wie auftraege-liste „Bearbeiten“ in der Zeile entw-1; in Schritt 1 „Weiter“ ohne Kundenart; Kundenart „Privatperson“ wählen; „Weiter“; Schritt 2 Weg „Vor Ort“ wählen, „Nach Aufwand“ und wieder „Festpreis“; „Weiter“; Schritt 3 „Anzahlung bei Auftrag, Rest nach Abnahme“ wählen; „Zur Vorschau“ nach dem ersten „Weiter“ steht „Bitte die Kundenart wählen.“ am Feld und der Schritt bleibt; Aufnahmen von Schritt 1, 2, 3 und 4; in Schritt 2 erscheint erst nach der Wahl „Vor Ort“ der Hinweis „Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.“; in Schritt 3 steht „Sinngemäß: Anzahlung 50 % …“; in Schritt 4 hat iframe[title="Vorschau der Auftragsbestätigung"] ein src mit blob:, es gibt „Zurück“, „Als Entwurf speichern“, „Senden“ (nicht gesperrt), und über der Vorschau stehen beide Hinweise („Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.“ und der „Vor Ort“-Hinweis); es gab genau einen Abruf GET …/einstellungen, ein PATCH …/auftraege/entw-1 und ein GET …/pdf
dialog-ohne-hinweise wie auftraege-liste, Einstellungen mit Steuernummer „Bearbeiten“ in der Zeile entw-1; Kundenart „Unternehmen“ wählen; „Weiter“; Weg „Vor Ort“ wählen; „Weiter“, „Zur Vorschau“ in Schritt 2 und 4 kein Hinweis (ein Unternehmen braucht keine Papierfassung, die Steuernummer ist eingetragen); es gibt keinen Knoten mit der Klasse auftrag-hinweis; „Senden“ ist bedienbar
dialog-senden wie dialog-schritte in Schritt 4 doppelt auf „Senden“ klicken genau ein POST …/senden, nur eine Rückfrage; der Dialog ist danach geschlossen und der Container zeigt „Auftragsbestätigung AB-2026-0008 wurde an praxis@physio.test gesendet.“ (auftragMeldung von W0)
neue-fassung wie auftraege-liste „Neue Fassung“ in der Zeile ab-1 POST …/neue-fassung, dann öffnet der Container den Dialog mit dem neuen Entwurf (Hinweis „Neue Fassung: Beim Senden ersetzt sie die bisherige Auftragsbestätigung.“). Öffnet der Container ihn nicht, ist das ein Befund für „Vertragsfragen“ (W0), kein Grund, den Container zu ändern

Läuft ein Teil nicht, weil das Werkzeug oder W0 etwas anders macht als hier beschrieben, wird das Skript angepasst (nicht der Container) und der Unterschied im Bericht genannt.

  • Step 5: Harness ausführen und Bilder ansehen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && free -m | head -2 && FIXTURE_TODAY=2026-10-05 flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 node /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/shots-w2.mjs Expected: Jede Ansicht endet ohne geworfene Zusicherung, overflow-x und bad-text sind in allen Aufnahmen leer, es gibt keine Konsolenfehler außer den bewusst erzeugten 409. Die Bilder liegen unter …/auftraege-w2/shots/1440x900/ und …/390x844/. Mit dem Werkzeug „Read“ mindestens ansehen: auftraege-liste, auftrag-projekt (offene Auswahl), dialog-schritte (alle vier Schritte, in Schritt 2 und 4 mit Hinweisen), kunde-uebersicht, einstellungen-steuernummer, jeweils bei 1440 und 390 px. Prüfen: Zusatzzeilen lesbar, Knöpfe erreichbar, Auswahl „Projekt zuordnen“ bei 390 px in der Zelle „Aktionen“ ohne Überlauf, Tabelle bei 390 px als gestapelte Zeilen, Positionszeilen bei 390 px zweizeilig, Hinweise lesbar und über der Vorschau, Dialog als Bottom-Sheet ohne waagerechten Überlauf. Funde werden in der Datei des zuständigen Tasks behoben (eigener Commit), danach Step 1 bis 5 wiederholen. Die zweite Sperre des Redesigns gilt wie in Step 3.

  • Step 6: Vertragsliste abhaken

Vor dem Abschlussbericht jede Zeile mit Datei und Zeile belegen:

  • V8: AuftragListe mit { auftraege, konten, onChanged, onBearbeiten, onShowOverview } und AuftragDialog mit { open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview } (alle Props aus V8, keine Rückfallwerte, kein Link, kein UebersichtLink); Aktion „Projekt zuordnen“ (Auswahl aus konten, ordneAuftragProjektZu, danach onChanged()); Hinweise (Steuernummer fehlt in Schritt 4, „Vor Ort“ bei Privatperson in Schritt 2 und 4, beide nicht blockierend); src/lib/auftragView.js mit Tests; auftrag.css; Kundenart in CustomerDetailPage.jsx; Steuernummer in SettingsCard.jsx.
  • V2: AUFTRAG_LIMITS (13 Schlüssel), STUNDENSATZ_STANDARD_CENTS, sieben Status, fünf Wege, vier Zahlungsplan-Arten, zwei Kundenarten (durch Tests festgehalten).
  • V4 Finanzen steuernummer: Client-Prüfung wie der Server (höchstens 40 Zeichen aus ASCII-Buchstaben, Ziffern, Leerzeichen, /, -, .; leer löscht), kundenFelder.js und Test.
  • V5 über financeApi.js: createAuftrag, patchAuftrag, deleteAuftrag, sendAuftrag, erinnereAuftrag, neueFassungAuftrag, storniereAuftrag, ordneAuftragProjektZu (POST …/auftraege/:id/projekt mit { projektId }), getAuftragPdf, getSettings; PATCH /einstellungen mit steuernummer; customers.kundenart direkt über Appwrite.
  • V3 AuftragTeam: gelesen werden id, nummer, status, projektId, titel, art, stundensatzCents, schaetzungMinuten, summeCents, gesendetAm, erstelltAm, mailStatus, bestaetigtAm, fruehbeginn, ersetztId, ersetztDurchId, widerrufenAm, pdfVorhanden, fruehesterBeginn sowie beim Bearbeiten die Eingabefelder des Entwurfs.
  • Abschlussbericht: Pfad des Worktrees, letzter Commit, Testzahlen, Pfad der Bilder und des Harness-Skripts, Abweichungen von W0, V8 und den Nachträgen der Planrunde, Befunde für Z2 (etwa ein Container, der konten oder onShowOverview doch nicht durchreicht, oder die neuen Dateien aus „Vertragsfragen“ Nr. 6).

Vertragsfragen

Alle Fragen dieses Strangs sind in der Planrunde entschieden (Übersicht, Abschnitt „Nachträge nach der Planrunde“, dazu V4, V5 und V8); der Plan setzt die Entscheidungen um, offen ist nichts. Abgeglichen mit den Plänen W0 (09), L0 (01), L3 (04) und L4 (05); was dort schon festliegt, steht unten unter „Bereits abgestimmt“.

  1. Zusätzliche Props konten und onShowOverview (V8): Entschieden (siehe Übersicht, Nachträge): V8 nennt beide Props, und der Container von W0 reicht konten und onShowOverview an AuftragListe und AuftragDialog durch (Plan W0, Task 7, Zeilen e und g). Umsetzung: beide sind Pflicht-Props ohne Rückfallwerte; die Liste zeigt den Projektnamen aus konten, „Zur Übersicht“ ruft onShowOverview (Task 5 und 7). Der frühere Rückfall auf einen Link /customers/<id> ist entfallen (kein UebersichtLink, kein react-router-dom in diesem Strang).
  2. Vorschau ohne gespeicherten Entwurf: Entschieden (siehe Übersicht, Nachträge): V5 liefert die Vorschau eines Entwurfs über GET …/auftraege/:id/pdf (409 „Entwurf unvollständig“), das PDF gibt es also nur zu einem gespeicherten Entwurf. Umsetzung: W2 legt den Entwurf beim Wechsel nach Schritt 4 an bzw. aktualisiert ihn (POST/PATCH) und holt dann das PDF; „nicht gespeichert“ in der Spec heißt „das PDF wird nicht archiviert“. Ein so entstandener Entwurf bleibt in der Liste, auch wenn der Dialog danach ohne Senden geschlossen wird (der Container meldet „Der Entwurf wurde gespeichert.“). Antwortet der Server 409 „Entwurf unvollständig: …“, zeigt der Dialog den Satz unverändert (Task 4, auftragFehler, Klasse entwurf).
  3. Steuernummer: Entschieden (siehe Übersicht, Nachträge): V4 erlaubt Buchstaben, Ziffern, Leerzeichen, /, - und ., höchstens 40 Zeichen, leer löscht (auch eine USt-IdNr. „DE…“); L0 setzt in Task 12 /^[A-Za-z0-9\/ .-]*$/ (nur ASCII-Buchstaben, keine Umlaute). Umsetzung: Der Client prüft genauso (Task 2, steuernummerAenderung) und sendet steuernummer nur bei Änderung, damit Speichern nie an einem noch nicht aktualisierten Portal scheitert.
  4. customers.kundenart: Entschieden (siehe Übersicht, Nachträge): Laut Abschnitt „Livegang“ setzt der WOMS-Push das Attribut voraus, das Setup-Skript (L0) legt es vorher an. Umsetzung: W2 schreibt das Feld in der Übersicht nur bei Änderung direkt in Appwrite (Task 2).
  5. „Später zuordenbar“ (Spec Abschnitt 1, Schritt 1): Entschieden (siehe Übersicht, Nachträge): V5 hat POST …/auftraege/:id/projekt, V8 die Aktion „Projekt zuordnen“; eine AB ohne Projekt lässt sich in der Liste einem Projekt zuordnen. Umsetzung: Aktion „Projekt zuordnen“ für Entwurf, Versendet, Bestätigt und Abgerechnet, nur ohne Projekt und nur, wenn es in konten mindestens ein Projekt gibt (Task 4 und 5); sie ruft ordneAuftragProjektZu(id, projektId) und danach onChanged(). Die Statusliste schließt Entwürfe ein; ein Entwurf bleibt zusätzlich über „Bearbeiten“ änderbar. Die Rückfrage vor dem Zuordnen gibt es nur bei einer schon versendeten oder späteren AB, weil sich deren Projekt danach nur noch über eine neue Fassung ändern lässt.
  6. Dateien, Abhängigkeiten, Zusätze: Entschieden (siehe Übersicht, Nachträge): Der Abschnitt „Zusätzliche Dateien je Strang“ nennt für W2 AuftragListe.jsx, AuftragDialog.jsx, auftrag.css, auftragView.js, auftragPdf.js, kundenFelder.js, CustomerDetailPage.jsx (Feld Kundenart) und SettingsCard.jsx. Dieser Plan legt dazu AuftragSchritte.jsx, AuftragVorschau.jsx und die drei Testdateien an (neu, im Verzeichnis von W2; kein anderer Strang legt dort Dateien mit diesen Namen an); die Dateiprüfung in Plan Z (12) führt sie in der Liste von W2 schon (UebersichtLink.jsx steht dort noch, wird aber nicht mehr angelegt: unschädlich). CustomerDetailPage.jsx ändern W0 (Reiter invoices) und W2 (OverviewTab) an verschiedenen Stellen; Z2 prüft den Merge. W2 nutzt FormField, noticeProps, Dialog, useWorkorders, useScrollIntoView, openInNewTab und getSettings; fällt eine davon im Redesign (P10) weg, müssen die Importe nachgezogen werden. Der Zahlungsplan-Text im Dialog (zahlungsplanVorschau) ist sinngemäß, maßgeblich bleibt der Wortlaut im PDF (L7). Auch „Erinnerung senden“ fragt vorher nach, weil der bisherige Bestätigungslink dadurch ungültig wird. Der Wortlaut von „senden“ in auftragErfolg folgt auftragMeldung aus W0, damit Liste und Container dieselben Sätze zeigen.
  7. Prüfwerkzeug: Entschieden (siehe Übersicht, Nachträge): Laut Absatz „WOMS“ entstehen Bildschirmfotos nur mit eigenen Skripten im Scratchpad, das Werkzeug des Redesigns wird nicht geändert. Umsetzung: eigener Läufer unter …/scratchpad/auftraege-w2 (Skript und Daten dort), der die neuen Endpunkte ergänzt (Vertragsfrage 10 von W0); Z2 kann ihn wiederverwenden.
  8. Hinweise im Dialog: Entschieden (siehe Übersicht, Nachträge): V8 verlangt nicht blockierende Hinweise. Schritt 4 weist auf eine fehlende Steuernummer in den Finanz-Einstellungen hin (die Einstellungen werden einmal über getSettings geladen), und bei Weg „Vor Ort“ mit Kundenart Privatperson steht „Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.“ (§ 312f Abs. 1 BGB). Umsetzung: Task 2, 3, 6 und 7. V8 nennt für den zweiten Hinweis keinen Schritt; dieser Plan zeigt ihn in Schritt 2 unter dem Weg (sobald Weg und Kundenart feststehen) und noch einmal in Schritt 4 über der Vorschau.

Bereits abgestimmt (keine Aktion): kunde im AuftragDialog ist das Kundendokument mit frischer Kundenart (kundeMitKundenart, W0 Task 6 und 7), nie null; nach Speichern oder Senden folgt onSaved(auftrag) und dann onClose(), der Container schließt und meldet über auftragMeldung (W0 Task 7); W0 reicht konten und onShowOverview an Liste und Dialog durch (W0 Task 7, Zeilen e und g) und exportiert ordneAuftragProjektZu (W0 Task 3, Vertragsfrage 13); getSettings besteht in financeApi.js; die financeApi.js-Funktionen liefern die JSON-Körper aus V5 und getAuftragPdf einen Blob, Fehler tragen status und message (W0 Task 2 und 3); die Wertebereiche (Anzahlung 1 bis 99 Prozent und nur bei Festpreis, „Monatlich“ nur bei Aufwand, Stundensatz 0,01 bis 100.000,00 €, Schätzung Vielfaches von 15 bis 99.960, Zahlungsziel 0 bis 60, Weg Pflicht) und die Meldungstexte beim Senden stehen im Plan L3 und decken sich mit der Oberfläche; „Entwurf unvollständig: …“ kommt aus L4 und wird von L3 durchgereicht; aendereEntwurf schreibt kundenart wie legeEntwurfAn; der PDF-Tab folgt dem Vorschlag von W0 (Vertragsfrage 5).