# 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 `. 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 `` 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/.test.mjs`, `loadModule('src/lib/.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 "` und `` (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/` 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`) ```js 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: } }` (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 `
` in `` (vor „Strasse + Nr.“): | Element | Umsetzung | |---|---| | Beschriftung | `
` | | Feld | `
` mit ` ``` `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** ```bash 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 " ``` --- ### 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`) ```js 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` = `#` plus Leerzeichen und `topic || title` (ohne Text nur die Nummer); ein Ticket, das nicht in `tickets` steht, heißt `Ticket `. 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** ```bash 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 " ``` --- ### 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`. - `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: ```js // --- 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`: ```js 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` „ 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 `„“`, ohne Titel `„ohne Titel“`; Nummer aus `auftrag.nummer`. `projekt`: bei einem Entwurf `''` (er bleibt über „Bearbeiten“ änderbar), sonst `Die Auftragsbestätigung „“ zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.` mit `` = `dem Projekt „“` 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`: ` wurde dem Projekt „“ zugeordnet.`, ohne `projektName` ` wurde einem Projekt zugeordnet.`. `senden`: derselbe Wortlaut wie `auftragMeldung` im Container von W0, also ` wurde an gesendet.` (ohne `gesendetAn` ` wurde gesendet.`); bei `mailFehler` Ton `warn` und ` ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.`. `erinnern`: `Die Erinnerung zu wurde an gesendet.` (ohne Nummer „“ = „der Auftragsbestätigung“), bei `mailFehler` Ton `warn` und der Text des Tests. `stornieren`: ` 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** ```bash 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 " ``` --- ### 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 | `` mit `
`; 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 `

`; bei `zurUebersicht` im selben Absatz hinter dem Text (durch ein Leerzeichen getrennt) `` | | Tabelle | `

` mit ``; 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 | `
Auftragsbestätigungen, neueste zuerst
`: `nummerText`; Entwürfe in `` | | Titel | ``: `{titel}`, darunter `
{projektText}
` wenn nicht leer | | Datum | `
`: `datumText` | | Summe | ``: `summeText`, darunter `
{summeSub}
` wenn nicht leer | | Status | `
` mit `{statusLabel}`, darunter je Notiz `
{text}
` | | Aktionen | `
` mit `
`; je Aktion: `danger` als `` (gesperrt ab 20; hängt `neuePosition()` an) und `
Summe` und `euro(formularSummeCents(form))`, danach `

Gemäß § 19 UStG ohne Umsatzsteuer.

`; `fehler.positionen` als `
` im Fieldset | | Aufwand | `
` mit `FormField id="ab-stundensatz" label="Stundensatz in €" hint="Steht mit der Aufwandsregel in der Auftragsbestätigung." error={fehler.stundensatz}` (``) 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}` (``) | 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 | `
` mit `Zahlungsplan` und je Eintrag von `zahlungsplanOptionen(form.art)` ein `