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

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

1430 lines
154 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AB-UI in WOMS – Umsetzungsplan (Strang W2)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Admins sehen in WOMS beim Kunden die Auftragsbestätigungen (AB) als Liste mit Status und Aktionen und legen sie in einem Dialog mit vier Schritten an (Projekt, Auftrag, Zahlung und Termine, Vorschau mit Entwurf oder Senden). Eine AB ohne Projekt lässt sich in der Liste nachträglich einem Projekt zuordnen. Im Dialog erscheinen zwei nicht blockierende Hinweise (fehlende Steuernummer; Papierfassung bei Aufträgen vor Ort mit Privatpersonen). Dazu kommen das Feld „Kundenart“ in der Kunden-Übersicht und die „Steuernummer“ in den Finanz-Einstellungen.
**Architecture:** Alle Regeln (Eingaben prüfen, Cent und Minuten, Status und Aktionen, Texte, Fehlerklassen, Doppelklick-Sperre) liegen als reine Funktionen in `src/lib/auftragView.js` und zwei kleinen Helfern (`kundenFelder.js`, `auftragPdf.js`) und sind mit `node --test` getestet. `AuftragListe` und `AuftragDialog` (mit den Schritt-Dateien) sind dünne React-Schichten darüber; sie rufen nur die `financeApi.js`-Funktionen aus W0 auf. Gestaltung nach den Regeln des weißen Designs (Bausteine aus `src/components/ui`, `Dialog`, eigene Datei `auftrag.css`, nichts in `global.css`).
**Tech Stack:** React 18, Vite 5, reines CSS mit Container Queries, Tests `node --test` mit esbuild-Lader (`tests/unit/load.mjs`, Muster `tests/unit/financeView.test.mjs`), Prüfwerkzeug des Redesigns (`/root/reports/ticketsystem-design-2026-10-04/werkzeug`, playwright-core) nur lesend.
**Spec:** `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md` (Abschnitte 1 und 2; Abschnitt 5 und 6 für Felder und Endpunkte).
**Übersicht/Verträge:** `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md` (V2 Konstanten, V3 `AuftragTeam`, V4 Steuernummer, V5 Endpunkte, V8 Komponenten und der Abschnitt „Nachträge nach der Planrunde“ sind verbindlich). Jeder Implementierer liest Spec, Übersicht und diese Datei.
**Arbeitsort:** WOMS-Worktree `/home/webklar/apps/ticket.webklar.com/dev-auftraege-w2`, Branch `auftraege/w2-auftraege`, abgezweigt von `feature/auftraege` nach W0 (diese Basis steht auf dem zusammengeführten Redesign P4/P6/P7).
## Global Constraints
Alle Punkte der Übersicht gelten. Für WOMS vor allem: Texte Deutsch, Beträge ganze Cent mit `euro()` aus `src/lib/financeFormat.js`, Berliner Kalendertag `YYYY-MM-DD` mit Anzeige `TT.MM.JJJJ` (`dateDe`), Grenzen aus V2 `LIMITS`, kein Push, nie `--force`, nie `git add -A` oder `git add .`, `build/` nie anfassen, keine Live-Daten, schwere Läufe hinter dem `flock` der Übersicht. Zusätze für diesen Strang:
- **Befehle:** Jeder Run-Block beginnt mit `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 &&` (die Shell setzt das Verzeichnis zwischen Aufrufen zurück). Tests nur gezielt: `timeout 900 nice -n 10 node --max-old-space-size=1024 --test <Dateien>`. Nie `npm test`, nie `npm ci`, nie `pkill -f`. Schwere Läufe (Build, Browser) nur hinter `flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock`; läuft das Redesign noch schwere Läufe, zusätzlich dahinter `flock /root/reports/ticketsystem-design-2026-10-04/sperren/schwer.lock` (erst unsere, dann deren); vorher `free -m` (unter 1500 MB „available“: nicht starten, später wiederholen). `rg` ist ripgrep 13 ohne PCRE2 (kein `-P`).
- **Besitz:** Dieser Strang ändert nur die Dateien der Tabelle „Dateien“. Nie anfassen: `AuftraegeUndRechnungen.jsx`, `ProjektkontoListe.jsx`, `RechnungDialog.jsx`, `projektkonto.css`, `rechnung-dialog.css`, `src/lib/financeApi.js`, `teamApi.js`, `customerTabs.js`, `financeSettings.js`, `financeView.js`, `financeUi.js`, `InvoiceTable.jsx`, `FinancePage.jsx`, `WorksheetList.jsx`, `WorksheetTimesModal.jsx`, `src/components/ui/*`, `src/styles/global.css`. Fehlt dort etwas, wird es unter „Vertragsfragen“ gemeldet, nicht ergänzt.
- **Redesign-Regeln:** Bausteine `Button`, `Badge`, `Card`, `EmptyState`, `Dialog` aus `src/components/ui`; Meldungen als `.msg` (`noticeProps` aus `src/lib/financeUi.js`); keine Icon-Pakete (Zeichen „ד, „+“); kein `style={{…}}`; keine Farbliterale (nur Tokens wie `var(--line)`); kein `!important`; `window.confirm` für Rückfragen, nie `window.alert`. In einem `<Dialog as="form">` trägt jeder Knopf `type="button"`, außer „Weiter“ (`type="submit"`). Enter sendet nie die AB.
- **CSS:** Nur `src/components/auftraege/auftrag.css`, alle Klassen beginnen mit `auftrag-`. `main.jsx` lädt `App` vor `global.css`: bei gleicher Spezifität gewinnt `global.css`, deshalb nie eine globale Klasse (`.table`, `.field` …) überschreiben, sondern eigene Klassen verwenden oder mit `.auftrag-… .table` höher gehen. `container-type` steht nur auf `.auftrag-body` (die Liste), nie auf einem Vorfahren eines Dialogs.
- **Texte:** Die Texte der Tabellen und Tests in dieser Datei gelten wörtlich. WOMS-Texte sind sachlich (keine Anrede).
- **Tests:** Datei je Lib unter `tests/unit/<name>.test.mjs`, `loadModule('src/lib/<name>.js')` aus `./load.mjs`. Die Libs sind rein (kein React, kein Appwrite, kein `import.meta.env`).
- **Zeit der Backends:** Nichts in dieser Datei darf einen echten Host erreichen. Das Prüfwerkzeug beantwortet alles aus Testdaten und bricht fremde Hosts ab.
## Review Focus
1. **Kundenart fehlt beim ersten Senden:** Schritt 1 lässt ohne Kundenart kein „Weiter“ zu, der Server antwortet bei einem Entwurf ohne Kundenart mit 400, die Liste erklärt das und führt zur Übersicht. Tests: Task 3 (`pruefeSchritt 1`, `sendeGate`), Task 4 (`auftragFehler`, 400 Kundenart), Task 8 (Dialog-Fehler „Bitte die Kundenart wählen.“).
2. **Kunde ohne Anschrift (Server 409):** Entwurf speichern geht, Senden im Dialog ist gesperrt, und ein 409 des Servers in der Liste zeigt „Adresse des Kunden unvollständig …“ mit „Zur Übersicht“. Tests: Task 3 (`sendeGate`), Task 4 (`auftragFehler`, 409 Adresse), Task 8 (Zeile `entw-adresse`).
3. **Bearbeiten nach dem Senden, „Projekt zuordnen“:** Gesendete ABs bieten nie „Bearbeiten“, „Senden“ oder „Löschen“, nur „Neue Fassung“ (und PDF, Erinnerung, Stornieren je Status); der Dialog öffnet keinen Nicht-Entwurf. „Projekt zuordnen“ ändert nur das Projekt (nie Inhalt oder Status) und erscheint nur bei einer AB ohne Projekt im Status Entwurf, Versendet, Bestätigt oder Abgerechnet und nur, wenn es mindestens ein Projekt zur Auswahl gibt. Tests: Task 4 (`auftragAktionen`, `projektZuordnenMoeglich`), Task 7 (Regel „nicht bearbeitbar“), Task 8 (Liste, Zuordnen).
4. **Doppelklick auf Senden und Zuordnen (Liste und Dialog):** genau eine Anfrage, auch wenn der Server langsam antwortet. Tests: Task 4 (`neueSperre`), Task 8 (Doppelklick: ein POST `…/senden`, ein POST `…/projekt`).
5. **Handy-Breite 390 px:** Tabelle, Auswahl „Projekt zuordnen“, Positionszeilen, Zeitquelle und Dialog laufen nicht waagerecht über, Knöpfe bleiben erreichbar. Test: Task 8 (`overflow-x` und Bildschirmfotos bei 390 px).
6. **Hinweise blockieren nie:** Die fehlende Steuernummer (Schritt 4) und die Papierfassung bei „Vor Ort“ mit Privatperson (Schritt 2 und 4) sind reine Texte; „Weiter“ und „Senden“ hängen nur an `pruefeSchritt` und `sendeGate`. Sind die Einstellungen nicht ladbar, erscheint kein Hinweis (es wird nichts behauptet). Tests: Task 2 (`steuernummerFehlt`), Task 3 (`vorOrtHinweis`, `auftragHinweise`), Task 8 (Dialog mit und ohne Hinweise).
## Dateien
| Datei | Art | Task |
|---|---|---|
| `src/lib/kundenFelder.js`, `tests/unit/kundenFelder.test.mjs` | neu | 2 |
| `src/pages/CustomerDetailPage.jsx` (nur `OverviewTab`) | ändern | 2 |
| `src/components/finance/SettingsCard.jsx` (nur `SettingsForm`) | ändern | 2 |
| `src/lib/auftragView.js`, `tests/unit/auftragView.test.mjs` | neu | 3, 4 |
| `src/lib/auftragPdf.js`, `tests/unit/auftragPdf.test.mjs` | neu | 4 |
| `src/components/auftraege/AuftragListe.jsx` | Platzhalter von W0 ersetzen | 5 |
| `src/components/auftraege/auftrag.css` | neu | 5 (Task 6 und 7 hängen an) |
| `src/components/auftraege/AuftragSchritte.jsx` | neu | 6 |
| `src/components/auftraege/AuftragDialog.jsx` | Platzhalter von W0 ersetzen | 7 |
| `src/components/auftraege/AuftragVorschau.jsx` | neu | 7 |
---
### Task 1: Worktree und Ausgangslage
**Files:** keine Änderung (nur anlegen und prüfen).
**Interfaces:**
- Consumes: Branch `feature/auftraege` im WOMS-Worktree `/home/webklar/apps/ticket.webklar.com/dev-auftraege` nach W0 (Container, `financeApi.js`-Funktionen, vier Platzhalter, echtes `node_modules`).
- Produces: Worktree `/home/webklar/apps/ticket.webklar.com/dev-auftraege-w2` auf Branch `auftraege/w2-auftraege`.
- [ ] **Step 1: Worktree anlegen**
Run: `git -C /home/webklar/apps/ticket.webklar.com/dev-auftraege worktree add ../dev-auftraege-w2 -b auftraege/w2-auftraege feature/auftraege && cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && ln -s ../dev-auftraege/node_modules node_modules && git status --short && git log --oneline -3`
Expected: `git status` zeigt nur `?? node_modules` (Symlink, bleibt unversioniert, nie `git add -A`), darunter die jüngsten W0-Commits. Fehlt `../dev-auftraege/node_modules`, stoppen und melden (W0 legt es an).
- [ ] **Step 2: W0-Ergebnis prüfen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && ls src/components/auftraege src/components/ui/Dialog.jsx src/lib/financeUi.js src/lib/auftraegeAnsicht.js src/components/finance/FormField.jsx src/hooks/useWorkorders.js src/hooks/useScrollIntoView.js src/lib/openInNewTab.js && for n in listAuftraege getAuftrag createAuftrag patchAuftrag deleteAuftrag sendAuftrag erinnereAuftrag neueFassungAuftrag storniereAuftrag ordneAuftragProjektZu getAuftragPdf getSettings; do rg -q "\b$n\b" src/lib/financeApi.js && echo "da $n" || echo "FEHLT $n"; done`
Expected: `ls` nennt `AuftraegeUndRechnungen.jsx`, `AuftragListe.jsx` und `AuftragDialog.jsx` (Platzhalter von W0, sie rendern `null`) und alle übrigen Pfade; zwölfmal `da …` (W0 exportiert die AB-Funktionen als `export const { … } = createAuftraegeEndpoints(request)`, darunter `ordneAuftragProjektZu(id, projektId)` für „Projekt zuordnen“; `getSettings` besteht schon). Fehlt etwas, stoppen und melden: `financeApi.js`, `auftraegeAnsicht.js` und der Container gehören W0.
- [ ] **Step 3: Verdrahtung des Containers lesen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && rg -n -B1 -A8 "<AuftragListe|<AuftragDialog" src/components/auftraege/AuftraegeUndRechnungen.jsx; head -20 src/components/auftraege/AuftragListe.jsx src/components/auftraege/AuftragDialog.jsx; ls src/components/auftraege/auftrag.css`
Expected: Aufrufe mit genau den Props aus V8: `<AuftragListe auftraege konten onChanged onBearbeiten onShowOverview />` und `<AuftragDialog open onClose onSaved kunde projektId auftrag konten onShowOverview />` (die Übersicht, Nachträge, und Plan W0 Task 7, Zeilen e und g, legen fest, dass der Container `konten={ansicht.konten}` und `onShowOverview` an beide durchreicht). Weiter gilt laut W0: `onSaved` schließt den Dialog im Container und zeigt die Meldung `auftragMeldung(auftrag)`, `kunde` trägt die frische Kundenart (`kundeMitKundenart`), `onShowOverview` wechselt auf den Reiter Übersicht (`setTab('overview')`, die Adresse wird `/customers/<id>` ohne `tab`). Weicht der Stand davon ab, wird das im Abschlussbericht gemeldet, nicht im Container behoben. Die Exportnamen der Platzhalter (`export default function AuftragListe` / `AuftragDialog`) bleiben. `auftrag.css` gibt es noch nicht (das `ls` meldet „No such file“; W0 legt keine CSS-Datei an), W2 legt sie in Task 5 an.
- [ ] **Step 4: Ausgangs-Tests**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/financeView.test.mjs tests/unit/financeSettings.test.mjs tests/unit/customerTabs.test.mjs tests/unit/ticketView.test.mjs`
Expected: Zeile `fail 0` (prüft zugleich den esbuild-Lader über den Symlink). Kein Commit.
---
### Task 2: Kundenart und Steuernummer
**Files:**
- Create: `src/lib/kundenFelder.js`
- Create: `tests/unit/kundenFelder.test.mjs`
- Modify: `src/pages/CustomerDetailPage.jsx` (`OverviewTab`)
- Modify: `src/components/finance/SettingsCard.jsx` (`SettingsForm`)
**Interfaces:**
- Consumes: `FormField` und `useRef` in `SettingsCard.jsx` (vorhanden); `databases.updateDocument` und `upgradeToCustomer(customer, fullData)` in `OverviewTab` (vorhanden, `fullData` wird in das Dokument gespreizt).
- Produces (`src/lib/kundenFelder.js`): `KUNDENARTEN`, `KUNDENART_OPTIONEN`, `KUNDENART_HINWEIS`, `kundenartLabel(value) → string`, `kundenartAenderung(neu, gespeichert) → {} | { kundenart }`, `STEUERNUMMER_MAX`, `STEUERNUMMER_HINWEIS`, `steuernummerAenderung(eingabe, gespeichert) → { ok: true, payload: {} | { steuernummer } } | { ok: false, fehler: string }`, `STEUERNUMMER_FEHLT_HINWEIS`, `steuernummerFehlt(einstellungen) → boolean` (für den Hinweis in Schritt 4 des Dialogs, Task 7).
- Regel der Steuernummer (Übersicht V4, Plan L0 Task 12; der Client prüft wie der Server): getrimmt höchstens 40 Zeichen aus ASCII-Buchstaben, Ziffern, Leerzeichen, `/`, `-` und `.` (auch eine USt-IdNr. „DE…“); leer ist erlaubt und löscht den Wert.
- [ ] **Step 1: Failing Test schreiben** (`tests/unit/kundenFelder.test.mjs`)
```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: <getrimmt> } }` (leer löscht den Wert).
- `steuernummerFehlt(einstellungen)`: `true` nur, wenn `einstellungen` ein Objekt ist (kein Array, nicht `null`) und `einstellungen.steuernummer` kein Text oder nach `trim()` leer ist; für alles andere (`undefined` = noch nicht geladen, `null` = nicht ladbar) `false`, damit nie etwas behauptet wird, was nicht feststeht.
- [ ] **Step 4: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/kundenFelder.test.mjs`
Expected: PASS, 7 Tests, `fail 0`.
- [ ] **Step 5: Kundenart in `OverviewTab` (`src/pages/CustomerDetailPage.jsx`)**
Anker sind die Namen, Zeilennummern verschieben sich.
- Import ergänzen: `import { KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartAenderung } from '../lib/kundenFelder'`.
- In `OverviewTab` neben `form`: `const [kundenart, setKundenart] = useState(customer.kundenart || '')` und `const felder = () => ({ ...form, ...kundenartAenderung(kundenart, customer.kundenart) })`. `save` schreibt `felder()` statt `form`, `upgrade` übergibt `felder()` statt `form` an `upgradeToCustomer`. `ready = isInvoiceReady(…)` bleibt unverändert.
- Markup: erste Zeile des `<dl className="kv kunde-kv">` in `<Card title="Rechnungsdaten">` (vor „Strasse + Nr.“):
| Element | Umsetzung |
|---|---|
| Beschriftung | `<dt><label htmlFor="kunde-kundenart">Kundenart</label></dt>` |
| Feld | `<dd>` mit `<select id="kunde-kundenart" className="select" value={kundenart} onChange={(e) => setKundenart(e.target.value)}>` und je Eintrag von `KUNDENART_OPTIONEN` ein `<option key value>{label}</option>`; darunter `<div className="hint">{KUNDENART_HINWEIS}</div>` |
Gespeichert wird mit dem vorhandenen Knopf „Speichern“. `kundenart` geht nur ins Dokument, wenn es sich geändert hat (so scheitert das Speichern der übrigen Stammdaten nie an einem noch nicht angelegten Attribut).
- [ ] **Step 6: Steuernummer in `SettingsForm` (`src/components/finance/SettingsCard.jsx`)**
- Import ergänzen: `import { STEUERNUMMER_HINWEIS, steuernummerAenderung } from '../../lib/kundenFelder'`.
- Zustand: `const [steuernummer, setSteuernummer] = useState(() => (typeof initial?.steuernummer === 'string' ? initial.steuernummer : ''))` und `const gespeichertRef = useRef(typeof initial?.steuernummer === 'string' ? initial.steuernummer : '')`.
- `submit`: `const stn = steuernummerAenderung(steuernummer, gespeichertRef.current)` zusätzlich zu `validateSettingsForm(form)`. Ist eines von beiden nicht ok: `setErrors({ ...(check.errors || {}), ...(stn.ok ? {} : { steuernummer: stn.fehler }) })`, die bisherige Warnung „Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.“, Fokus auf das erste Feld aus `[...SETTINGS_FIELD_ORDER, 'steuernummer']`. Sonst `patchSettings({ ...check.payload, ...stn.payload })`. Nach Erfolg gilt als gespeichert der Text aus `data.einstellungen.steuernummer`, wenn der Server einen liefert, sonst der gesendete Wert: `setSteuernummer(…)` und `gespeichertRef.current = …`.
- Markup: nach dem Zähler `fin-counter` (Bankverbindung), im selben `<fieldset className="fin-fieldset">`:
```jsx
<FormField id={fieldId('steuernummer')} label="Steuernummer" hint={STEUERNUMMER_HINWEIS} error={errors.steuernummer}>
<input className="input" autoComplete="off" value={steuernummer} onChange={…} placeholder="z. B. 12/345/67890 oder DE123456789" />
</FormField>
```
`onChange` setzt den Text, löscht `errors.steuernummer` und eine offene Meldung (wie `change(name)` für die anderen Felder).
- [ ] **Step 7: Syntax und Hygiene**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && node_modules/.bin/esbuild src/pages/CustomerDetailPage.jsx src/components/finance/SettingsCard.jsx --loader:.jsx=jsx --jsx=automatic --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/syntax --log-level=warning && git diff --stat && git diff -U0 src/pages/CustomerDetailPage.jsx src/components/finance/SettingsCard.jsx | rg "^\+.*(react-icons|style=\{\{|form-control|window\.alert)"`
Expected: esbuild ohne Ausgabe, `git diff --stat` nennt genau diese zwei Dateien mit je weniger als 40 geänderten Zeilen, der letzte `rg` findet nichts (Exit 1).
- [ ] **Step 8: Commit**
```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 <noreply@anthropic.com>"
```
---
### Task 3: Dialoglogik in `auftragView.js`
**Files:**
- Create: `src/lib/auftragView.js`
- Create: `tests/unit/auftragView.test.mjs`
**Interfaces:**
- Consumes: `canInvoice`, `dateDe`, `euro`, `ymdBerlin` aus `./financeFormat`; `ADDRESS_INCOMPLETE_TEXT`, `addressComplete`, `invoiceTotalCents`, `parseQuantity`, `parseUnitPrice` aus `./financeView`; `euroField` aus `./financeSettings`; `ticketNumber` aus `./ticketView`; `KUNDENARTEN`, `STEUERNUMMER_FEHLT_HINWEIS`, `steuernummerFehlt` aus `./kundenFelder` (Task 2).
- Produces (benannte Exporte; `Formular` ist die Form aus `neuesFormular`, Schlüssel siehe Test, `Konto` ist `{ projektId, name, tickets: [{ id, woid }] }` aus V3):
- Konstanten: `AUFTRAG_LIMITS`, `STUNDENSATZ_STANDARD_CENTS`, `AUFTRAG_WEGE`, `ZAHLUNGSPLAENE`, `SCHRITTE`; `zahlungsplanOptionen(art) → [{ value, label }]`.
- Eingaben: `parseStunden(text) → Minuten | null`, `stundenText(minuten) → string`, `parseProzent(text) → 1..99 | null`, `parseTage(text) → 0..60 | null`, `heuteBerlin(now?) → 'YYYY-MM-DD'`.
- Formular: `neuePosition()`, `neuesFormular({ projektId, konten, heute }) → Formular`, `auftragZuFormular(auftrag) → Formular`, `waehleProjekt(form, projektId, konten) → Formular`, `wechselArt(form, art) → Formular`, `formularSummeCents(form) → number | null`.
- Prüfen: `pruefeSchritt(schritt, form, { kunde }) → { ok, fehler: { feld: Meldung }, erstes }`, `formularZuNutzlast(form, { kunde }) → { ok: true, payload, summeCents } | { ok: false, schritt, erstes, fehler }`, `sendeGate(kunde, kundenart) → { erlaubt, grund, zurUebersicht }`.
- Texte und Auswahl: `zahlungsplanVorschau({ art, prozent, text, summeCents, zielTage }) → string`, `zeitquelleOptionen(tickets, auswahl, projektTicketIds) → [{ id, label, gewaehlt, projekt }]`, `schalteZeitquelle(auswahl, id, an) → string[]`.
- Hinweise (nicht blockierend): `kundenartWirksam(kunde, kundenart) → '' | 'unternehmen' | 'privat'`, `VOR_ORT_HINWEIS`, `vorOrtHinweis(weg, kundenart) → string` (`''` = kein Hinweis), `auftragHinweise({ kunde, form, einstellungen }) → [{ key: 'steuernummer' | 'vorOrt', text }]`.
- [ ] **Step 1: Failing Tests schreiben** (`tests/unit/auftragView.test.mjs`)
```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` = `#<ticketNumber(t)>` plus Leerzeichen und `topic || title` (ohne Text nur die Nummer); ein Ticket, das nicht in `tickets` steht, heißt `Ticket <letzte 5 Zeichen der ID>`. Kein Array als `tickets` zählt als leer.
- **`schalteZeitquelle(auswahl, id, an)`:** neue Liste; `an` hängt `id` an, wenn sie fehlt und weniger als `maxZeitquellen` gewählt sind; sonst unverändert; `!an` entfernt `id`.
- **`kundenartWirksam(kunde, kundenart)`:** `kunde?.kundenart`, wenn der Wert in `KUNDENARTEN` steht; sonst `kundenart`, wenn der Wert in `KUNDENARTEN` steht; sonst `''` (die Kundenart am Kunden hat Vorrang, weil der Server `kundenart` nur schreibt, wenn sie am Kunden leer ist).
- **`VOR_ORT_HINWEIS`** wörtlich wie im Test; **`vorOrtHinweis(weg, kundenart)`:** `VOR_ORT_HINWEIS`, wenn `weg === 'vor_ort'` und `kundenart === 'privat'`, sonst `''`. Rechtsgrund ist § 312f Abs. 1 BGB (bei außerhalb von Geschäftsräumen geschlossenen Verträgen mit Verbrauchern eine Papierfassung zur Verfügung stellen); der Hinweis ist reine Oberfläche und blockiert nichts.
- **`auftragHinweise({ kunde, form, einstellungen })`:** Liste in dieser Reihenfolge: `{ key: 'steuernummer', text: STEUERNUMMER_FEHLT_HINWEIS }`, wenn `steuernummerFehlt(einstellungen)`; danach `{ key: 'vorOrt', text: VOR_ORT_HINWEIS }`, wenn `vorOrtHinweis(form.auftragWeg, kundenartWirksam(kunde, form.kundenart))` nicht leer ist. Sonst `[]`.
- [ ] **Step 4: Tests laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs`
Expected: PASS, 24 Tests, `fail 0`.
- [ ] **Step 5: Commit**
```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 <noreply@anthropic.com>"
```
---
### Task 4: Listenlogik in `auftragView.js` und PDF-Hilfe
**Files:**
- Modify: `src/lib/auftragView.js` (Teil 2 anhängen)
- Modify: `tests/unit/auftragView.test.mjs` (Tests anhängen, Importliste erweitern)
- Create: `src/lib/auftragPdf.js`
- Create: `tests/unit/auftragPdf.test.mjs`
**Interfaces:**
- Consumes: `dateDe`, `euro` aus `./financeFormat`; `ADDRESS_INCOMPLETE_TEXT` aus `./financeView`; `stundenText` (Task 3); `openInNewTab(getUrl, { win })` aus `./openInNewTab` (vorhanden).
- Produces: aus `auftragView.js` `AUFTRAG_STATUS`, `PROJEKT_ZUORDNEN_STATUS`, `istEntwurf(auftrag)`, `zuordnenOptionen(konten) → [{ value, label }]`, `projektZuordnenMoeglich(auftrag, konten) → boolean`, `auftragAktionen(auftrag, { konten }?) → [{ key, label, danger? }]`, `auftragNotizen(auftrag, alle?) → [{ key, tone, text }]`, `auftragZeilen(auftraege, { konten }?) → Zeile[]`, `auftragBestaetigung(key, auftrag, { email, projektName }?) → string`, `auftragErfolg(key, auftrag, { mailFehler, projektName }?) → { tone, text }`, `auftragFehler(err) → { art, text, zurUebersicht }`, `neueSperre() → { run(fn), belegt }`. Aus `auftragPdf.js` `PDF_FREIGABE_NACH_MS`, `alsPdfBlob(blob) → Blob`, `oeffnePdfImTab(holeBlob, { win, urlApi, timer }?) → Promise<void>`.
- `Konto` (aus V3 `Projektkonto`, nur diese Felder gelesen): `{ projektId, name }`.
- `Zeile` (aus `auftragZeilen`): `{ key, id, auftrag, nummerText, istEntwurf, titel, projektText, datumText, summeText, summeSub, statusLabel, statusTone, notizen, aktionen }`. Aktionsschlüssel: `bearbeiten`, `senden`, `pdf`, `loeschen`, `erinnern`, `neueFassung`, `stornieren`, `projekt`.
- [ ] **Step 1: Failing Tests schreiben**
Importliste am Dateianfang von `tests/unit/auftragView.test.mjs` um diese Namen erweitern: `AUFTRAG_STATUS, PROJEKT_ZUORDNEN_STATUS, istEntwurf, zuordnenOptionen, projektZuordnenMoeglich, auftragAktionen, auftragNotizen, auftragZeilen, auftragBestaetigung, auftragErfolg, auftragFehler, neueSperre`. Dann am Dateiende anhängen:
```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>“ (Nummer der AB mit dieser `id` in `alle`, ohne Treffer oder Nummer nur „Neue Fassung“); 6. bei `ersetztDurchId` „Ersetzt durch <Nummer>“ (ohne Treffer „Ersetzt durch eine neue Fassung“); 7. „Widerrufen am TT.MM.JJJJ“ (Status `widerrufen`, `widerrufenAm` gesetzt). Andere Töne `muted`. Datumsangaben mit `dateDe`.
- **`auftragZeilen(auftraege, { konten = [] } = {})`:** nur Objekte, Reihenfolge der Eingabe, `alle` für die Notizen ist die Eingabeliste. `nummerText` = `nummer || 'Entwurf'`; `datumText` = `dateDe(gesendetAm || erstelltAm)`; `projektText` = Name des Kontos mit `projektId` der AB, aber nur wenn es existiert und (getrimmt) nicht dem `titel` gleicht, sonst `''`; Summe: Festpreis `euro(summeCents)` (ohne ganze Zahl „–“), Aufwand `summeText` „nach Aufwand“ und `summeSub` „<euro(stundensatzCents)> pro Std.“, mit Schätzung zusätzlich „, Schätzung etwa <stundenText> Std.“; Status aus `AUFTRAG_STATUS` (unbekannt: Rohtext, Ton `muted`); `notizen`; `aktionen` = `auftragAktionen(auftrag, { konten })`; `istEntwurf`; `auftrag` (Original); `key` = `id`. Es gibt kein Feld `kundenId` (die Liste braucht es nicht mehr, „Zur Übersicht“ läuft über `onShowOverview`).
- **`auftragBestaetigung(key, auftrag, { email, projektName } = {})`:** nur für `senden`, `loeschen`, `stornieren`, `erinnern` und `projekt` ein Text, sonst `''`; Titel `„<titel>“`, ohne Titel `„ohne Titel“`; Nummer aus `auftrag.nummer`. `projekt`: bei einem Entwurf `''` (er bleibt über „Bearbeiten“ änderbar), sonst `Die Auftragsbestätigung <nummer> „<titel>“ <ziel> zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.` mit `<ziel>` = `dem Projekt „<projektName>“` bzw. ohne `projektName` `einem Projekt`.
- **`auftragErfolg(key, auftrag, { mailFehler = false, projektName = '' } = {})`:** `{ tone, text }`. `name` ist „Auftragsbestätigung <nummer>“, ohne Nummer oder ohne Objekt „Die Auftragsbestätigung“. `projekt`: `<name> wurde dem Projekt „<projektName>“ zugeordnet.`, ohne `projektName` `<name> wurde einem Projekt zugeordnet.`. `senden`: derselbe Wortlaut wie `auftragMeldung` im Container von W0, also `<name> wurde an <gesendetAn> gesendet.` (ohne `gesendetAn` `<name> wurde gesendet.`); bei `mailFehler` Ton `warn` und `<name> ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.`. `erinnern`: `Die Erinnerung zu <nummer> wurde an <gesendetAn oder „den Kunden“> gesendet.` (ohne Nummer „<nummer>“ = „der Auftragsbestätigung“), bei `mailFehler` Ton `warn` und der Text des Tests. `stornieren`: `<name> wurde storniert.`. `loeschen`, `neueFassung` und der unbekannte Schlüssel (Text `''`) wie im Test.
- **`auftragFehler(err)`:** `status = err?.status`, `meldung = err?.message || ''`. Der erste passende Fall gilt: `409` und /^Entwurf unvollständig/i → `entwurf` (Meldung unverändert, `zurUebersicht: false`; L4 meldet so einen unvollständigen Entwurf bei Vorschau und Senden, L3 reicht es durch; die Angabe „Die Liste zeigt den aktuellen Stand.“ wäre hier falsch); `409` und /adresse/i → `adresse` (Text `ADDRESS_INCOMPLETE_TEXT`, `zurUebersicht: true`); `400` und /kundenart/i → `kundenart`; `400` und /e-?mail/i → `email`; `400` und /feste[rn]? kunde|interessent|\blead\b/i → `kunde` (diese vier mit den festen Texten des Tests, `zurUebersicht: true`); `409` → `stand` (Meldung + „ Die Liste zeigt den aktuellen Stand.“); `400` → `eingabe` (Meldung); `500` und /pdf/i → `pdf` (Meldung + „ Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.“); `status >= 500` → `server` (Meldung, ohne Meldung „Interner Fehler.“, + „ Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.“); `status === 0` → `netz` (Meldung); sonst `sonst` (Meldung oder „Die Aktion ist fehlgeschlagen.“). `zurUebersicht` ist nur bei `adresse`, `kundenart`, `email` und `kunde` `true`, sonst `false`. Die Texte der Server stehen im Plan L3 (Abschnitt „Meldungstexte“) und im Plan L4 (Tabelle „Entwurf unvollständig“); die Erkennung hängt an den Wörtern „Entwurf unvollständig“ (Satzanfang), „Adresse“, „Kundenart“, „E-Mail“, „feste Kunden“. Die Fehler von `POST …/projekt` (V5: 400, 404, 409 „hat schon ein Projekt“ oder Status storniert, ersetzt, widerrufen) laufen über dieselben Fälle: 409 zeigt die Meldung des Servers mit „Die Liste zeigt den aktuellen Stand.“.
- **`neueSperre()`:** `{ run(fn), get belegt() }`. `run` setzt `belegt` sofort beim Aufruf (vor jedem `await`) und liefert `{ uebersprungen: true }` (genau dieses Objekt), wenn schon eine Ausführung läuft; sonst führt es `fn()` aus und liefert `{ uebersprungen: false, wert }`. Nach Ende oder Fehler ist die Sperre wieder frei, ein Fehler wird weitergereicht.
- [ ] **Step 4: `src/lib/auftragPdf.js` implementieren**
Regeln: `PDF_FREIGABE_NACH_MS = 300000`. `alsPdfBlob(blob)`: hat der Blob schon den Typ `application/pdf`, derselbe Blob, sonst `new Blob([blob], { type: 'application/pdf' })`. `oeffnePdfImTab(holeBlob, { win = globalThis.window, urlApi = globalThis.URL, timer = globalThis.setTimeout } = {})` ruft `openInNewTab(async () => { … }, { win })` auf; darin: Blob holen, `alsPdfBlob`, `urlApi.createObjectURL`, `timer(() => urlApi.revokeObjectURL(url), PDF_FREIGABE_NACH_MS)`, die URL zurückgeben. Fehler und blockiertes Pop-up behandelt `openInNewTab`.
- [ ] **Step 5: Tests laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs tests/unit/auftragPdf.test.mjs tests/unit/kundenFelder.test.mjs`
Expected: PASS, `auftragView` 37 Tests (24 aus Task 3, 13 neue), `auftragPdf` 4, `kundenFelder` 7, `fail 0`.
- [ ] **Step 6: Commit**
```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 <noreply@anthropic.com>"
```
---
### Task 5: `AuftragListe` mit Stil
**Files:**
- Modify: `src/components/auftraege/AuftragListe.jsx` (Platzhalter von W0 ersetzen)
- Create: `src/components/auftraege/auftrag.css` (hier die Listenregeln, Task 6 und 7 hängen an)
**Interfaces:**
- Consumes: `Badge`, `Button`, `Card`, `EmptyState` aus `../ui`; `noticeProps` aus `../../lib/financeUi`; `auftragZeilen`, `auftragBestaetigung`, `auftragErfolg`, `auftragFehler`, `zuordnenOptionen`, `neueSperre` (Task 3 und 4); `oeffnePdfImTab` (Task 4); `sendAuftrag`, `erinnereAuftrag`, `neueFassungAuftrag`, `storniereAuftrag`, `deleteAuftrag`, `ordneAuftragProjektZu`, `getAuftragPdf` aus `../../lib/financeApi` (W0, Rückgaben laut V5: `{ auftrag }`, `{ auftrag, mailFehler }`, `{ ok: true }`, Fehler mit `status` und `message`).
- Produces: `export default function AuftragListe({ auftraege, konten, onChanged, onBearbeiten, onShowOverview })` mit genau den Props aus V8; der Container von W0 reicht `konten` (`ansicht.konten`) und `onShowOverview` (Wechsel auf den Reiter Übersicht) durch, es gibt keinen Rückfall und keine Vorgabewerte.
- [ ] **Step 1: `AuftragListe.jsx` bauen**
Die Logik steckt in den Libs, die Komponente verdrahtet sie. Muster: `src/components/finance/InvoiceTable.jsx` (Sperre, Rückfrage, Meldung, Tabelle mit Rollen).
| Element | Umsetzung |
|---|---|
| Rahmen | `<Card title="Aufträge" className="auftrag-liste">` mit `<div className="auftrag-body">`; darin Meldung, dann Tabelle bzw. Leerzustand. Kein Dialog in dieser Datei |
| Daten | `const zeilen = useMemo(() => auftragZeilen(auftraege, { konten }), [auftraege, konten])` |
| Leer | `EmptyState title="Noch keine Aufträge." hint="Eine Auftragsbestätigung entsteht über „Auftragsbestätigung“ in der Projektliste."` |
| Meldung | `message` (`{ tone, text, zurUebersicht? }`) als `<p {...noticeProps(message.tone, 'auftrag-notice')}>`; bei `zurUebersicht` im selben Absatz hinter dem Text (durch ein Leerzeichen getrennt) `<button type="button" className="link" onClick={onShowOverview}>Zur Übersicht</button>` |
| Tabelle | `<div className="auftrag-table-wrap"><table className="table auftrag-table" role="table">` mit `<caption className="auftrag-sr">Auftragsbestätigungen, neueste zuerst</caption>`; Spalten (`th scope="col"`): „Nummer“, „Projekt / Titel“, „Datum“, „Summe“ (`auftrag-amount`), „Status“, „Aktionen“; Zellen tragen `data-label` („Datum“, „Summe“) für die schmale Darstellung |
| Nummer | `<td className="auftrag-nummer">`: `nummerText`; Entwürfe in `<span className="muted">` |
| Titel | `<td className="auftrag-titel">`: `<strong>{titel}</strong>`, darunter `<div className="cell-sub">{projektText}</div>` wenn nicht leer |
| Datum | `<td className="auftrag-datum" data-label="Datum">`: `datumText` |
| Summe | `<td className="auftrag-amount" data-label="Summe">`: `summeText`, darunter `<div className="cell-sub">{summeSub}</div>` wenn nicht leer |
| Status | `<td className="auftrag-status">` mit `<Badge tone={statusTone}>{statusLabel}</Badge>`, darunter je Notiz `<div className={note.tone === 'warn' ? 'msg warn auftrag-note' : 'muted auftrag-note'}>{text}</div>` |
| Aktionen | `<td className="auftrag-aktionen">` mit `<div className="auftrag-actions">`; je Aktion: `danger` als `<Button type="button" variant="danger">`, sonst `<button type="button" className="link">`; `aria-label` = `` `${label}: ${nummerText}, ${titel}` ``; `disabled={busy !== null}`; bei der Aktion `projekt` zusätzlich `aria-expanded` (offen, wenn `zuordnen?.id === zeile.id`); während die eigene Aktion läuft die Laufbeschriftung: `senden` und `erinnern` „Sende …“, `stornieren` „Storniere …“, `loeschen` „Lösche …“, `neueFassung` „Lege an …“, `pdf` „Öffne …“ |
| Projekt zuordnen | Zustand `const [zuordnen, setZuordnen] = useState(null)` (`{ id, projektId }` der Zeile mit offener Auswahl oder `null`; es ist höchstens eine Auswahl offen). Ist `zuordnen?.id === zeile.id`, steht unter `.auftrag-actions` in derselben Zelle `<div className="auftrag-zuordnen" role="group">` (mit `aria-label` „Projekt zuordnen: <nummerText>, <titel>“ wie bei den Aktionen) mit `<select className="select" aria-label="Projekt wählen" value={wahl}>` (erste Option `value=""` „Projekt wählen …“, dann je Eintrag von `zuordnenOptionen(konten)` `<option value={value}>{label}</option>`; `onChange` setzt `projektId` in `zuordnen`), `<Button type="button" onClick={() => ordneZu(zeile)}>` („Zuordnen“, während des Aufrufs „Ordne zu …“; gesperrt, solange `wahl` leer ist oder `busy !== null`) und `<button type="button" className="link" disabled={busy !== null} onClick={() => setZuordnen(null)}>Abbrechen</button>`. `wahl` ist `zuordnen.projektId`, aber nur, wenn der Wert in `zuordnenOptionen(konten)` vorkommt, sonst `''` (so bleibt der Knopf gesperrt, wenn das Projekt nach einem Neuladen fehlt) |
Ablauf `run(zeile, aktion)`:
1. `bearbeiten`: `onBearbeiten(zeile.auftrag)`, fertig (kein Server, keine Sperre).
2. `projekt`: `setZuordnen({ id: zeile.id, projektId: '' })` (öffnet die Auswahl dieser Zeile und schließt eine andere), `message` zurücksetzen, fertig (kein Server, keine Sperre).
3. Ist `sperre.belegt` (`const sperre = useRef(neueSperre()).current`), sofort zurück (so erscheint beim Doppelklick keine zweite Rückfrage). Dann die Rückfrage: `const frage = auftragBestaetigung(aktion.key, zeile.auftrag)`; `if (frage && !window.confirm(frage)) return`.
4. Alles Weitere läuft in `sperre.run(…)`, `busy` = `` `${zeile.id}:${aktion.key}` ``, `message` zurücksetzen.
5. `pdf`: `oeffnePdfImTab(() => getAuftragPdf(zeile.id))`, ohne ein `await` davor (der neue Tab muss im Klick aufgehen); ein Fehler wird `{ tone: 'warn', text: auftragFehler(err).text }`.
6. Die Server-Aktionen (`senden` → `sendAuftrag`, `erinnern` → `erinnereAuftrag`, `stornieren` → `storniereAuftrag`, `loeschen` → `deleteAuftrag`, `neueFassung` → `neueFassungAuftrag`) rufen mit `zeile.id` auf. Erfolg: `message = auftragErfolg(aktion.key, data?.auftrag || zeile.auftrag, { mailFehler: data?.mailFehler })`, dann `onChanged()`; bei `neueFassung` danach `onBearbeiten(data.auftrag)`.
7. Fehler: `const f = auftragFehler(err)`; `message = { tone: 'warn', text: f.text, zurUebersicht: f.zurUebersicht }`; danach `onChanged()` (der Stand kann ein anderer sein). Nach Ende `busy` zurück auf `null` (nur wenn die Komponente noch gemountet ist: `aliveRef` wird im Effekt beim Einhängen `true` und beim Aushängen `false`, so übersteht es auch das doppelte Einhängen von React im Entwicklungsmodus).
Ablauf `ordneZu(zeile)` (Knopf „Zuordnen“; dieselbe `sperre`, dieselbe Fehlerbehandlung wie in `run`):
1. `const gewaehlt = zuordnenOptionen(konten).find((o) => o.value === zuordnen?.projektId)`; ohne `gewaehlt` oder bei `sperre.belegt` sofort zurück.
2. Rückfrage vor dem Aufruf: `const frage = auftragBestaetigung('projekt', zeile.auftrag, { projektName: gewaehlt.label })`; `if (frage && !window.confirm(frage)) return` (bei einem Entwurf ist `frage` leer).
3. In `sperre.run(…)`: `busy` = `` `${zeile.id}:projekt` ``, `message` zurücksetzen, `data = await ordneAuftragProjektZu(zeile.id, gewaehlt.value)` (nur `POST …/projekt` mit `{ projektId }`, sonst nichts: der Server setzt die Zeitquelle, das PDF bleibt unverändert). Erfolg: `message = auftragErfolg('projekt', data?.auftrag || zeile.auftrag, { projektName: gewaehlt.label })`, `setZuordnen(null)`, `onChanged()`. Fehler wie in `run` Punkt 7 (Meldung, `onChanged()`), dazu `setZuordnen(null)`.
- [ ] **Step 2: Listenregeln in `auftrag.css`**
Datei neu anlegen, nur Tokens, kein `!important`, Muster: Regeln zu `.fin-invoices` und `@container` im Block FINANZEN von `src/styles/global.css` (`rg -n "fin-invoices|@container" src/styles/global.css`).
| Klasse | Regel |
|---|---|
| `.auftrag-body` | `container-type: inline-size` (einzige Stelle) |
| `.auftrag-notice` | `margin: 0 0 8px` |
| `.auftrag-table-wrap` | `overflow-x: auto` |
| `.auftrag-sr` | nur für Screenreader sichtbar (Standardmuster: 1 px, `overflow: hidden`, `clip`, absolut) |
| `.auftrag-nummer` | `white-space: nowrap` |
| `.auftrag-titel` | `overflow-wrap: anywhere` |
| `.auftrag-amount` | rechtsbündig, `font-variant-numeric: tabular-nums`, `white-space: nowrap` |
| `.auftrag-note` | `margin-top: 2px; font-size: 13px; overflow-wrap: anywhere` |
| `.auftrag-actions` | `display: flex; flex-wrap: wrap; gap: 4px 14px` |
| `.auftrag-zuordnen` | `display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 8px`; `.select` darin `flex: 1 1 180px; min-width: 0; max-width: 100%` |
| `@container (max-width: 860px)` | `thead` nur für Screenreader sichtbar; jede Zeile ein Raster mit den Flächen `nummer status / titel titel / datum summe / aktionen aktionen` (Spalten `minmax(0, 1fr) auto`); die Zelle bekommt ihre Fläche über die Klasse (`.auftrag-nummer`, `.auftrag-status`, `.auftrag-titel`, `.auftrag-datum`, `.auftrag-amount`, `.auftrag-aktionen`); Zellen `display: block; padding: 0; border: 0`, Zeilentrenner `1px solid var(--line)` mit `padding: 10px 0`; `td[data-label]::before` zeigt `attr(data-label)` in `var(--text-2)` |
- [ ] **Step 3: Syntax und Hygiene**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && node_modules/.bin/esbuild src/components/auftraege/AuftragListe.jsx --loader:.jsx=jsx --jsx=automatic --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/syntax --log-level=warning; rg -n "react-icons|@tabler|style=\{\{|size=\"sm\"|form-control|form-group|ui-btn|window\.alert|#[0-9a-fA-F]{3,8}\b|rgba?\(|[!]important|console\.log|react-router" src/components/auftraege/AuftragListe.jsx src/components/auftraege/auftrag.css`
Expected: esbuild ohne Ausgabe, `rg` ohne Treffer (Exit 1; `react-router` steht im Muster, weil die Liste keinen `Link` mehr braucht: „Zur Übersicht“ läuft über `onShowOverview`).
- [ ] **Step 4: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/components/auftraege/AuftragListe.jsx src/components/auftraege/auftrag.css && git commit -m "Auftragsbestaetigung: Liste mit Status, Zusatzzeilen, Aktionen und Projekt zuordnen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 6: Dialog, Schritte 1 bis 3
**Files:**
- Create: `src/components/auftraege/AuftragSchritte.jsx`
- Modify: `src/components/auftraege/auftrag.css` (Regeln der Felder anhängen)
**Interfaces:**
- Consumes: `Badge` aus `../ui`; `FormField` aus `../finance/FormField` (`FormField({ id, label, hint, error, children })`, das Kind bekommt `id`, `aria-invalid`, `aria-describedby`); `useWorkorders({ customerId, limit })` aus `../../hooks/useWorkorders` (liefert `{ workorders, loading, error }`); `customerName` aus `../../lib/financeView`; `lineTotalCents` aus `../../lib/financeView`; `euro` aus `../../lib/financeFormat`; aus `../../lib/auftragView` `AUFTRAG_LIMITS`, `AUFTRAG_WEGE`, `zahlungsplanOptionen`, `waehleProjekt`, `wechselArt`, `neuePosition`, `formularSummeCents`, `zahlungsplanVorschau`, `parseProzent`, `parseTage`, `zeitquelleOptionen`, `schalteZeitquelle`, `kundenartWirksam`, `vorOrtHinweis`; `KUNDENART_OPTIONEN`, `kundenartLabel` aus `../../lib/kundenFelder`.
- Produces: benannte Exporte `SchrittProjekt`, `SchrittAuftrag`, `SchrittZahlung`, alle mit den Props `{ form, onChange, fehler, kunde, konten }`; `onChange(neuesFormular)` bekommt immer das ganze neue Formular (aus den Funktionen der Lib oder `{ ...form, feld }`). `SchrittProjekt` nimmt zusätzlich `neueFassung` (boolean). Jedes Feld hat die id `ab-<Schlüssel>` (Schlüssel wie in `pruefeSchritt`); Ausnahmen: `positionen` und `zeitquelle` sind `<fieldset id="ab-positionen" tabIndex={-1}>` bzw. `id="ab-zeitquelle"`, `art` hat `ab-art-festpreis` und `ab-art-aufwand`, `zahlungsart` hat `ab-zahlungsart-<value>`.
- [ ] **Step 1: `SchrittProjekt`**
| Element | Umsetzung |
|---|---|
| Kunde | `<p className="muted">Kunde: <strong>` mit `customerName(kunde)` (ohne Namen das Wort „Kunde“) `</strong>` und, wenn vorhanden, ` · {kunde.email}</p>` |
| Neue Fassung | bei `neueFassung`: `<p className="msg muted">Neue Fassung: Beim Senden ersetzt sie die bisherige Auftragsbestätigung.</p>` |
| Projekt | `FormField id="ab-projekt" label="Projekt"` mit `<select className="select" value={form.projektId}>`: erste Option `value=""` „Kein Projekt (freier Titel)“, dann je Konto `value={projektId}` mit `name`; `onChange` → `onChange(waehleProjekt(form, e.target.value, konten))` |
| Titel | `FormField id="ab-titel" label="Titel" hint="Steht als Titel auf der Auftragsbestätigung, z. B. „Website für Praxis Müller“." error={fehler.titel}` mit `<input className="input" maxLength={AUFTRAG_LIMITS.maxTitel}>`; `onChange` → `{ ...form, titel, titelGeaendert: true }` |
| Kundenart | nur wenn `!kunde.kundenart`: `FormField id="ab-kundenart" label="Kundenart" hint="Wird am Kunden gespeichert. Privatpersonen bekommen die Widerrufsbelehrung in der Auftragsbestätigung." error={fehler.kundenart}` mit `<select className="select">`, Optionen „Bitte wählen“ (`value=""`), „Unternehmen“, „Privatperson“ (aus `KUNDENART_OPTIONEN` ohne den ersten Eintrag). Sonst `<p className="muted">Kundenart: {kundenartLabel(kunde.kundenart)}. Änderbar in der Übersicht des Kunden.</p>` |
- [ ] **Step 2: `SchrittAuftrag`**
| Element | Umsetzung |
|---|---|
| Datum und Weg | `<div className="field-row">` mit `FormField id="ab-auftragVom" label="Datum der Absprache" error={fehler.auftragVom}` (`<input className="input" type="date">`) und `FormField id="ab-auftragWeg" label="Weg der Absprache" error={fehler.auftragWeg}` (`<select className="select">`, erste Option `value=""` „Bitte wählen“, dann `AUFTRAG_WEGE`) |
| Hinweis „Vor Ort“ | `const vorOrt = vorOrtHinweis(form.auftragWeg, kundenartWirksam(kunde, form.kundenart))`; ist er nicht leer, steht direkt unter der Zeile mit Datum und Weg `<p className="msg warn auftrag-hinweis">{vorOrt}</p>` (nicht blockierend: kein `role`, kein Fehler am Feld, „Weiter“ bleibt möglich; derselbe Text steht in Schritt 4, Task 7) |
| Art | `<fieldset className="auftrag-art">` mit `<legend>Art des Auftrags</legend>` und zwei `<label className="check">` mit Radio (`name="ab-art"`, ids `ab-art-festpreis`, `ab-art-aufwand`): „Festpreis“ mit `<span className="hint">Feste Positionen mit Preisen.</span>` und „Nach Aufwand“ mit `<span className="hint">Stundensatz, abgerechnet nach erfasster Arbeitszeit.</span>`; `onChange` → `onChange(wechselArt(form, art))`; ein Fehler `fehler.art` als `<div className="msg err" role="alert">` |
| Festpreis | `<fieldset id="ab-positionen" tabIndex={-1} className="auftrag-items">` mit `<legend>Positionen</legend>`; je Position eine `.auftrag-item`: Beschreibung (`<input className="input" maxLength={200} aria-label="Position n: Beschreibung" placeholder="z. B. Startseite">`), Menge (`inputMode="numeric"`, `aria-label="Position n: Menge"`), Einzelpreis (`inputMode="decimal"`, `aria-label="Position n: Einzelpreis in Euro"`, `placeholder="0,00"`), Summe (`lineTotalCents` als `euro`, ungültig „–“), „Entfernen“ (`<button type="button" className="link" aria-label="Position n entfernen">`, gesperrt bei nur einer Position). Sichtbare Mini-Beschriftungen „Beschreibung“, „Menge“, „Einzelpreis in €“, „Summe“ (`.auftrag-item-label`). Darunter `<button type="button" className="btn">+ Position</button>` (gesperrt ab 20; hängt `neuePosition()` an) und `<div className="auftrag-total" aria-live="polite">Summe` und `euro(formularSummeCents(form))`, danach `<p className="hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>`; `fehler.positionen` als `<div className="msg err" role="alert">` im Fieldset |
| Aufwand | `<div className="field-row">` mit `FormField id="ab-stundensatz" label="Stundensatz in €" hint="Steht mit der Aufwandsregel in der Auftragsbestätigung." error={fehler.stundensatz}` (`<input className="input" inputMode="decimal">`) und `FormField id="ab-schaetzung" label="Geschätzter Umfang in Stunden (optional)" hint="In Schritten von 15 Minuten, z. B. 10 oder 12,5. Unverbindlich." error={fehler.schaetzung}` (`<input className="input" inputMode="decimal" placeholder="z. B. 10">`) |
Positionen ändern ersetzt nur die Zeile mit gleicher `uid` im Array. Die Felder beider Arten bleiben im Formular erhalten, wenn die Art wechselt.
- [ ] **Step 3: `SchrittZahlung`**
| Element | Umsetzung |
|---|---|
| Zahlungsplan | `<fieldset className="auftrag-art">` mit `<legend>Zahlungsplan</legend>` und je Eintrag von `zahlungsplanOptionen(form.art)` ein `<label className="check">` mit Radio (`name="ab-zahlungsart"`, id `ab-zahlungsart-<value>`); `onChange` → `{ ...form, zahlungsart }`; `fehler.zahlungsart` als `msg err` |
| Anzahlung | bei `zahlungsart === 'anzahlung'`: `FormField id="ab-prozent" label="Anzahlung in %" error={fehler.prozent}` (`<input className="input auftrag-narrow" inputMode="numeric">`) |
| Frei | bei `zahlungsart === 'frei'`: `FormField id="ab-zahlungstext" label="Zahlungsplan als Text" error={fehler.zahlungstext}` (`<textarea className="textarea" rows={3} maxLength={500}>`), darunter `<div className="hint">{länge} / 500 Zeichen</div>` |
| Vorschau | `const text = zahlungsplanVorschau({ art: form.zahlungsart, prozent: parseProzent(form.prozent), text: form.zahlungstext, summeCents: formularSummeCents(form), zielTage: parseTage(form.zahlungsziel) })`; ist er nicht leer: `<p className="msg muted auftrag-plan-vorschau">Sinngemäß: {text}</p>` |
| Zahlungsziel | `FormField id="ab-zahlungsziel" label="Zahlungsziel in Tagen" hint="0 bis 60 Tage nach Rechnung." error={fehler.zahlungsziel}` (`<input className="input auftrag-narrow" inputMode="numeric">`) |
| Termine | `<div className="field-row">` mit `FormField id="ab-startAm" label="Start (optional)" error={fehler.startAm}` und `FormField id="ab-fertigBis" label="Voraussichtliche Fertigstellung (optional)" error={fehler.fertigBis}`, beide `<input className="input" type="date">` |
| Zeitquelle | Unterkomponente `ZeitquelleAuswahl({ kundeId, form, onChange, projektTicketIds, fehler })`: `<fieldset id="ab-zeitquelle" tabIndex={-1} className="auftrag-zeitquelle">`, `<legend>Zeiterfassung aus diesen Tickets</legend>`, `<p className="hint">Arbeitsblätter dieser Tickets zählen zum Auftrag (Projektkonto, Aufwandsrechnung). Höchstens 10.</p>`. Daten über `useWorkorders({ customerId: kundeId, limit: 100 })`, die Liste über `zeitquelleOptionen(workorders, form.zeitquelle, projektTicketIds)` mit `projektTicketIds` aus dem Konto des gewählten Projekts. Je Option `<label className="check"><input type="checkbox" checked disabled={!gewaehlt && form.zeitquelle.length >= 10}> {label}</label>`, bei `projekt` dahinter `<Badge tone="muted">Ticket des Projekts</Badge>`; Umschalten → `onChange({ ...form, zeitquelle: schalteZeitquelle(form.zeitquelle, id, an), zeitquelleGeaendert: true })`. Zustände: laden `<div className="loading" role="status">Tickets werden geladen …</div>`; Fehler `<p className="msg warn" role="alert">Die Tickets konnten nicht geladen werden: {error}. Die bisherige Auswahl bleibt.</p>`; ohne Tickets und ohne Auswahl `<p className="muted">Dieser Kunde hat noch keine Tickets.</p>`; `fehler.zeitquelle` als `msg err`. Gewählte Tickets stehen auch bei Ladefehler in der Liste |
- [ ] **Step 4: Feldregeln in `auftrag.css` anhängen**
| Klasse | Regel |
|---|---|
| `.auftrag-art` | `border: 0; display: grid; gap: 8px`; `legend` mit `font-weight: 500`; `.check { flex-wrap: wrap }` und `.check .hint { flex-basis: 100%; padding-left: 23px }` (der Hinweis steht in eigener Zeile unter der Beschriftung) |
| `.auftrag-items` | `border: 0; display: grid; gap: 12px` |
| `.auftrag-item` | Raster `minmax(0, 1fr) 80px 120px 110px auto`, `gap: 8px 12px`, `align-items: end`; `.auftrag-item .input` `width: 100%`; Menge, Preis und Summe rechtsbündig mit `tabular-nums` |
| `.auftrag-item-label` | `font-size: 12px; color: var(--text-2)`; ab der zweiten Zeile nur für Screenreader sichtbar (Muster `.auftrag-sr`) |
| `.auftrag-total` | `display: flex; justify-content: space-between; gap: 16px; font-weight: 600` |
| `.auftrag-narrow` | `max-width: 120px` |
| `.auftrag-plan-vorschau` | `overflow-wrap: anywhere` |
| `.auftrag-hinweis` | `margin: 0; overflow-wrap: anywhere` (Hinweise in Schritt 2 und 4) |
| `.auftrag-zeitquelle` | `border: 0; display: grid; gap: 6px`; Liste der Optionen in einem Block mit `max-height: 220px; overflow-y: auto; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 6px 0` |
| `@media (max-width: 860px)` | `.auftrag-item` zweizeilig `minmax(0, .7fr) minmax(0, 1fr) minmax(0, 1fr) auto` mit den Flächen `desc desc desc desc / qty price sum remove`, alle `.auftrag-item-label` sichtbar; `.auftrag-narrow` `max-width: none` |
- [ ] **Step 5: Syntax und Hygiene**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && node_modules/.bin/esbuild src/components/auftraege/AuftragSchritte.jsx --loader:.jsx=jsx --jsx=automatic --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/syntax --log-level=warning; rg -n "react-icons|@tabler|style=\{\{|size=\"sm\"|form-control|form-group|ui-btn|window\.alert|#[0-9a-fA-F]{3,8}\b|rgba?\(|[!]important|console\.log" src/components/auftraege/AuftragSchritte.jsx src/components/auftraege/auftrag.css; rg -n -A3 "<button" src/components/auftraege/AuftragSchritte.jsx`
Expected: esbuild ohne Ausgabe, das erste `rg` ohne Treffer. Beim zweiten `rg` von Hand prüfen: jedes `<button` trägt `type="button"` (in der Zeile oder den drei Folgezeilen).
- [ ] **Step 6: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/components/auftraege/AuftragSchritte.jsx src/components/auftraege/auftrag.css && git commit -m "Auftragsbestaetigung: Dialog-Schritte 1 bis 3 (Projekt, Auftrag, Zahlung und Termine)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 7: Dialog, Schritt 4 (Vorschau, Entwurf, Senden)
**Files:**
- Modify: `src/components/auftraege/AuftragDialog.jsx` (Platzhalter von W0 ersetzen)
- Create: `src/components/auftraege/AuftragVorschau.jsx`
- Modify: `src/components/auftraege/auftrag.css` (Regeln anhängen)
**Interfaces:**
- Consumes: `Dialog`, `Button` aus `../ui`; `useScrollIntoView` aus `../../hooks/useScrollIntoView`; `createAuftrag`, `patchAuftrag`, `sendAuftrag`, `getAuftragPdf` aus `../../lib/financeApi` (W0) und `getSettings` (besteht dort, liefert `{ einstellungen }`); `alsPdfBlob` (Task 4); aus `../../lib/auftragView` `SCHRITTE`, `neuesFormular`, `auftragZuFormular`, `heuteBerlin`, `pruefeSchritt`, `formularZuNutzlast`, `zahlungsplanOptionen`, `sendeGate`, `auftragHinweise`, `kundenartWirksam`, `auftragBestaetigung`, `auftragFehler`, `neueSperre`; `SchrittProjekt`, `SchrittAuftrag`, `SchrittZahlung` (Task 6).
- Produces: `export default function AuftragDialog({ open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview })` mit genau den Props aus V8; der Container von W0 reicht `konten` (`ansicht.konten`) und `onShowOverview` (Wechsel auf den Reiter Übersicht) durch, es gibt keinen Rückfall und keine Vorgabewerte. `AuftragVorschau({ phase, pdfUrl, meldung, gate, privat, hinweise, onRetry, onShowOverview })` (Schritt 4, ohne eigenen Zustand).
- Vertrag mit dem Container (Plan W0, Task 7): Nach Speichern oder Senden ruft der Dialog `onSaved(auftrag)` und danach immer `onClose()`. Der Container schließt in `onSaved` selbst, lädt neu und zeigt `auftragMeldung(auftrag)` („Der Entwurf wurde gespeichert.“, „Auftragsbestätigung AB-… wurde an … gesendet.“ oder die Warnung zu `mailStatus: 'fehler'`); beide Aufrufe sind in jeder Reihenfolge und mehrfach unschädlich. Der Dialog hat deshalb keine eigene Ergebnisansicht.
- [ ] **Step 1: `AuftragVorschau.jsx`**
Schritt 4 als reine Darstellung:
| Element | Umsetzung |
|---|---|
| Laden | `phase === 'speichern'`: `<div className="loading" role="status">Entwurf wird gespeichert …</div>`; `phase === 'pdf'`: `<div className="loading" role="status">Vorschau wird erstellt …</div>` |
| Fehler | `meldung` als `<div className="msg err" role="alert" ref={errorRef}>` (`useScrollIntoView(errorRef, meldung)`) mit `meldung.text` und bei `meldung.zurUebersicht` dahinter `<button type="button" className="link" onClick={onShowOverview}>Zur Übersicht</button>`; ohne `pdfUrl` und ohne laufende Phase zusätzlich `<Button type="button" onClick={onRetry}>Erneut versuchen</Button>` |
| Gespeichert | mit `pdfUrl`: `<p className="msg ok">Entwurf gespeichert. Er bleibt in der Liste, bis er gesendet oder gelöscht wird.</p>` |
| Sperre | `gate && !gate.erlaubt`: `<div className="msg warn" role="alert"><span>{gate.grund}</span>` und bei `gate.zurUebersicht` dahinter `<button type="button" className="link" onClick={onShowOverview}>Zur Übersicht</button></div>`; der Entwurf lässt sich trotzdem speichern |
| Hinweise | je Eintrag von `hinweise` (aus `auftragHinweise`: fehlende Steuernummer, „Vor Ort“ bei Privatperson) `<p className="msg warn auftrag-hinweis" key={key}>{text}</p>`; nicht blockierend (kein `role="alert"`, der Knopf „Senden“ hängt nur an `gate` und `phase`); ohne Einträge nichts |
| Privatperson | bei `privat`: `<p className="hint">Privatperson: Die Auftragsbestätigung enthält die Widerrufsbelehrung. Mit der Arbeit darf erst nach Ablauf der Widerrufsfrist begonnen werden, es sei denn, der Kunde verlangt es ausdrücklich (Häkchen auf der Bestätigungsseite).</p>` |
| Vorschau | `<iframe className="auftrag-pdf" title="Vorschau der Auftragsbestätigung" src={pdfUrl} />` und darunter `<a className="link" href={pdfUrl} target="_blank" rel="noopener noreferrer">PDF in neuem Tab öffnen</a>` |
Die Reihenfolge in der Datei ist: Laden, Fehler, Gespeichert, Sperre, Hinweise, Privatperson, Vorschau. Alles, was vor dem Senden zu lesen ist, steht über dem hohen PDF-Rahmen.
- [ ] **Step 2: `AuftragDialog.jsx` bauen**
Aufbau und Regeln:
- **Hülle (`AuftragDialog`, ohne Hooks):** `!open` gibt `null`. Ist `auftrag` gesetzt und `auftrag.status !== 'entwurf'`, zeigt sie `<Dialog title="Auftragsbestätigung" onClose={onClose} footer={<Button type="button" onClick={onClose}>Schließen</Button>}>` mit `<p className="msg warn" role="alert">Nur Entwürfe lassen sich bearbeiten. Eine gesendete Auftragsbestätigung lässt sich nur über „Neue Fassung“ in der Liste ändern.</p>`. Sonst die nicht exportierte Komponente `AuftragForm` (alle Hooks und der Zustand) mit `key={auftrag?.id || projektId || 'neu'}`: frischer Zustand bei jedem Öffnen, Muster `InvoiceCreateModal`.
- **Zustand:** `schritt` (1 bis 4), `form` (Start: `auftrag ? auftragZuFormular(auftrag) : neuesFormular({ projektId: projektId || '', konten, heute: heuteBerlin() })`), `fehler` (`{}`), `meldung` (`null` oder `{ text, zurUebersicht }`), `entwurf` (Start `auftrag || null`), `signatur` (`''`), `pdfUrl` (`''`), `phase` (`''`, `'speichern'`, `'pdf'`, `'senden'`), `einstellungen` (Start `undefined` = noch nicht geladen, `null` = nicht ladbar, sonst das Objekt `einstellungen` aus `getSettings()`). Refs: `sperre = useRef(neueSperre()).current`, `aliveRef` (wie in `AuftragListe`: im Effekt beim Einhängen `true`, beim Aushängen `false`), `schmutzigRef` (es wurde gespeichert und noch nicht gemeldet), `urlRef` (aktuelle Objekt-URL). Beim Unmount `URL.revokeObjectURL(urlRef.current)`.
- **Einstellungen laden (einmal):** ein `useEffect` beim Einhängen: `getSettings().then((d) => { if (aliveRef.current) setEinstellungen(d?.einstellungen ?? null) }).catch(() => { if (aliveRef.current) setEinstellungen(null) })`. Kein Fehlertext, kein zweiter Abruf (`AuftragForm` hängt je Öffnen neu ein): Der Hinweis auf die fehlende Steuernummer ist freiwillig, und ein Ladefehler zeigt keinen Hinweis. `const hinweise = auftragHinweise({ kunde, form, einstellungen })` wird bei jedem Rendern berechnet (reine Funktion); die Einstellungen ändern weder `weiter()` noch `senden()`.
- **Kopf:** `<Dialog as="form" wide title={auftrag ? 'Auftragsbestätigung bearbeiten' : 'Auftragsbestätigung'} busy={phase !== ''} onClose={schliessen} onSubmit={(e) => { e.preventDefault(); if (schritt < 4) weiter() }} footer={…}>`. Im Körper zuerst die Schrittleiste `<ol className="auftrag-schritte" aria-label="Schritte">` mit je `SCHRITTE`-Eintrag `<li aria-current={id === schritt ? 'step' : undefined}><span className="auftrag-schritt-nr">{id}</span><span className="auftrag-schritt-text">{label}</span></li>`, dann der Inhalt des Schritts.
- **`weiter()`:** `pruefeSchritt(schritt, form, { kunde })`; bei Fehlern `setFehler(r.fehler)` und Fokus auf `document.getElementById('ab-' + r.erstes)` (Ausnahmen laut Task 6: `art` → `ab-art-festpreis`, `zahlungsart` → `ab-zahlungsart-` plus der erste Wert von `zahlungsplanOptionen(form.art)`); sonst `setFehler({})` und `schritt + 1`. Aus Schritt 3 wird `zurVorschau()`.
- **`zurVorschau()`:** `formularZuNutzlast(form, { kunde })`; bei `ok: false` springt der Dialog zu `r.schritt` und zeigt `r.fehler`. Sonst `setSchritt(4)` und `vorschauLaden(r.payload)`.
- **`vorschauLaden(payload)`:** `const neu = JSON.stringify(payload)`; ist `neu === signatur && pdfUrl`, nichts tun. Sonst in `sperre.run`: `phase 'speichern'`, `meldung null`; `data = entwurf ? await patchAuftrag(entwurf.id, payload) : await createAuftrag({ kundenId: kunde.$id, ...payload })`; `setEntwurf(data.auftrag)`, `schmutzigRef.current = true`, `setSignatur(neu)`; `phase 'pdf'`; `blob = await getAuftragPdf(data.auftrag.id)`; `url = URL.createObjectURL(alsPdfBlob(blob))`; alte URL freigeben, `setPdfUrl(url)`. Fehler: `setMeldung(auftragFehler(err))` (der Entwurf bleibt, falls er schon gespeichert war; „Erneut versuchen“ ist `onRetry={zurVorschau}`). Zuletzt `phase ''`, alles nur wenn noch gemountet.
- **`zurueck()`:** `schritt - 1`, `meldung null`. Wer von Schritt 4 zurückgeht und etwas ändert, löst beim nächsten Weg nach Schritt 4 ein neues `PATCH` aus (andere `signatur`).
- **„Als Entwurf speichern“:** der Entwurf ist in Schritt 4 schon gespeichert; der Knopf ruft `onSaved(entwurf)`, setzt `schmutzigRef` zurück und `onClose()`.
- **`senden()`:** Ist `sperre.belegt`, sofort zurück (keine zweite Rückfrage beim Doppelklick). `const gate = sendeGate(kunde, form.kundenart)`; ist es nicht erlaubt, `meldung = { text: gate.grund, zurUebersicht: gate.zurUebersicht }` und Ende. Dann `window.confirm(auftragBestaetigung('senden', entwurf, { email: kunde.email }))`, bei Abbruch Ende. In `sperre.run`: `phase 'senden'`, `data = await sendAuftrag(entwurf.id)`, `schmutzigRef.current = false`, `onSaved(data.auftrag)`, `onClose()`; Fehler: `setMeldung(auftragFehler(err))`; zuletzt `phase ''` (nur wenn noch gemountet: nach `onSaved` ist der Dialog meist schon weg). Alle Knöpfe sind während `phase !== ''` gesperrt. Eine fehlgeschlagene Mail ist kein Fehler dieses Aufrufs: `data.auftrag.mailStatus` ist dann `'fehler'`, und der Container zeigt die Warnung.
- **`schliessen()`** (Dialog-Schließen, „Abbrechen“): ist `schmutzigRef.current` wahr, zuerst `onSaved(entwurf)`; dann `onClose()`. Bei `phase !== ''` tut der Dialog nichts (`busy`).
- **Fuß je Zustand** in `<div className="auftrag-foot">`, alle Knöpfe `type="button"` außer „Weiter“: Schritt 1: `<span className="grow" />`, „Abbrechen“, „Weiter“ (`variant="primary"`, `type="submit"`). Schritt 2 und 3: „Zurück“, `<span className="grow" />`, „Abbrechen“, „Weiter“ (in Schritt 3 beschriftet „Zur Vorschau“). Schritt 4: „Zurück“, `<span className="grow" />`, „Als Entwurf speichern“, „Senden“ (`variant="primary"`; Beschriftung „Sende …“ bei `phase === 'senden'`; `disabled` bei `phase !== ''`, ohne `entwurf`, oder wenn `sendeGate` nicht erlaubt, dann mit `title={gate.grund}`).
- **Schrittinhalt:** 1 `SchrittProjekt` (mit `neueFassung={Boolean(auftrag?.ersetztId)}`), 2 `SchrittAuftrag` (zeigt den Hinweis „Vor Ort“ selbst, Task 6), 3 `SchrittZahlung`, 4 `AuftragVorschau` mit `privat={kundenartWirksam(kunde, form.kundenart) === 'privat'}`, `gate={sendeGate(kunde, form.kundenart)}` und `hinweise={hinweise}`. `onShowOverview` des Dialogs wird als `() => { schliessen(); onShowOverview() }` an `AuftragVorschau` gereicht: erst den Dialog schließen (ein gespeicherter Entwurf wird dabei gemeldet), dann auf den Reiter Übersicht wechseln.
- [ ] **Step 3: Regeln in `auftrag.css` anhängen**
| Klasse | Regel |
|---|---|
| `.auftrag-schritte` | `list-style: none; display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; color: var(--text-2)`; `li[aria-current="step"]` `color: var(--text); font-weight: 600`; `.auftrag-schritt-nr` mit `margin-right: 6px` |
| `.auftrag-pdf` | `width: 100%; height: min(70vh, 640px); border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-subtle)` |
| `.auftrag-foot` | `display: flex; flex: 1; flex-wrap: wrap; align-items: center; gap: 8px`; `.grow { flex: 1 }` |
| `@media (max-width: 860px)` | `.auftrag-schritt-text` ausgeblendet außer bei `[aria-current="step"]`; `.auftrag-pdf` `height: 50vh`; `.auftrag-foot .btn { flex: 1 1 auto }` |
- [ ] **Step 4: Syntax und Hygiene**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && node_modules/.bin/esbuild src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragVorschau.jsx --loader:.jsx=jsx --jsx=automatic --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/syntax --log-level=warning; rg -n "react-icons|@tabler|style=\{\{|size=\"sm\"|form-control|form-group|ui-btn|window\.alert|#[0-9a-fA-F]{3,8}\b|rgba?\(|[!]important|console\.log|react-router|UebersichtLink" src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragVorschau.jsx src/components/auftraege/auftrag.css; rg -n "<button|<Button" src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragVorschau.jsx`
Expected: esbuild ohne Ausgabe, das erste `rg` ohne Treffer. Beim zweiten `rg` von Hand prüfen: jeder Knopf trägt `type="button"`, nur „Weiter“ trägt `type="submit"`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragVorschau.jsx src/components/auftraege/auftrag.css && git commit -m "Auftragsbestaetigung: Dialog Schritt 4 (Vorschau, Entwurf speichern, Senden mit Rueckfrage und Sperre)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 8: Selbstprüfung
**Files:** keine Änderung außer Korrekturen an eigenen Dateien (je Korrektur ein eigener Commit mit deutscher Nachricht und der Schlusszeile).
**Interfaces:**
- Consumes: alles aus Task 1 bis 7; das Prüfwerkzeug `/root/reports/ticketsystem-design-2026-10-04/werkzeug` (nur lesend: `backend.mjs`, `browser.mjs`, `checks.mjs`, `fixtures/`); Schlüssel der schweren Läufe `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock`.
- Produces: grüne Tests, sauberer Worktree, Bildschirmfotos unter `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/shots/<Breite>x<Höhe>/` und der Abschlussbericht.
- [ ] **Step 1: Eigene und verwandte Tests**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs tests/unit/auftragPdf.test.mjs tests/unit/kundenFelder.test.mjs tests/unit/financeView.test.mjs tests/unit/financeSettings.test.mjs tests/unit/customerTabs.test.mjs tests/unit/ticketView.test.mjs`
Expected: `fail 0`.
- [ ] **Step 2: Hygiene über alle eigenen Dateien**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && rg -n "react-icons|@tabler|style=\{\{|size=\"sm\"|form-control|form-group|ui-btn|window\.alert|#[0-9a-fA-F]{3,8}\b|rgba?\(|[!]important|console\.log|react-router|UebersichtLink" src/components/auftraege/AuftragListe.jsx src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragSchritte.jsx src/components/auftraege/AuftragVorschau.jsx src/components/auftraege/auftrag.css; git status --short; git diff --stat feature/auftraege...HEAD`
Expected: `rg` ohne Treffer; `git status` zeigt nur `?? node_modules`; `git diff --stat` (drei Punkte: ab dem Abzweig) nennt ausschließlich die Dateien der Tabelle „Dateien“ (10 Quelldateien und 3 Testdateien). Eine andere Datei wird mit `git checkout <Abzweig-Commit> -- <Datei>` zurückgenommen (`<Abzweig-Commit>` aus `git merge-base feature/auftraege HEAD`) und gemeldet.
- [ ] **Step 3: Vite-Build hinter dem `flock`**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && free -m | head -2 && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock env VITE_APPWRITE_ENDPOINT=https://ticket.test/v1 VITE_APPWRITE_PROJECT_ID=test VITE_PROJECT_ADMIN_URL=https://portal.test NODE_OPTIONS=--max-old-space-size=1200 systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 node_modules/.bin/vite build --outDir /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/dist --emptyOutDir`
Expected: Vorher zeigt `free -m` mindestens 1500 MB bei „available“ (sonst warten). Der Build endet mit Exit 0 und listet `index.html` und `assets/*`. Bei Fehlern in eigenen Dateien beheben und neu bauen. Läuft das Redesign noch schwere Läufe, steht `flock /root/reports/ticketsystem-design-2026-10-04/sperren/schwer.lock` direkt hinter der ersten Sperre (also `flock <unsere> flock <deren> env …`).
- [ ] **Step 4: Harness schreiben** (`/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/shots-w2.mjs`, nicht im Repo)
Das Skript läuft mit dem Test-Backend des Prüfwerkzeugs (alles aus Testdaten, kein Request erreicht einen echten Host) und ergänzt nur die Portal-Endpunkte, die das Werkzeug noch nicht kennt. Modell für Kontext (`serviceWorkers: 'block'`, `isMobile` und `deviceScaleFactor 2` bei 390 px), Schrift und Uhr ist `runOne` in `/root/reports/ticketsystem-design-2026-10-04/werkzeug/shots.mjs`; die Funktionen `buildFontCss` und `installFonts` dort in das Skript kopieren, die Schriftdateien liegen unter `/root/reports/ticketsystem-design-2026-10-04/werkzeug/fonts`. Gerüst:
```js
import { mkdirSync } from 'node:fs'
import { createBackend, loadFixtures } from '/root/reports/ticketsystem-design-2026-10-04/werkzeug/backend.mjs'
import { launchChromium } from '/root/reports/ticketsystem-design-2026-10-04/werkzeug/browser.mjs'
import { runChecks } from '/root/reports/ticketsystem-design-2026-10-04/werkzeug/checks.mjs'
import { jetzt } from '/root/reports/ticketsystem-design-2026-10-04/werkzeug/fixtures/helfer.mjs'
const OUT = '/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2'
const PORTAL = 'https://portal.test'
const CORS = { 'access-control-allow-origin': 'https://ticket.test', 'access-control-allow-credentials': 'true' }
const PDF = '%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 100]>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n'
// Je Ansicht und Viewport ein frischer Zustand (Entwürfe, Einstellungen) und ein frisches Backend
function neuerZustand() { /* auftraege: 9 ABs wie unten (mit und ohne projektId), einstellungen mit steuernummer '' (eine Ansicht setzt sie) */ }
function mitEigenenRouten(backend, zustand, meine) {
return async (route, request) => {
const url = new URL(request.url())
if (url.origin === PORTAL && url.pathname.startsWith('/api/admin/finanzen/') && request.method() !== 'OPTIONS') {
const antwort = await beantworte(zustand, request.method(), url, request.method() === 'GET' ? null : request.postDataJSON())
if (antwort) {
meine.push({ method: request.method(), path: url.pathname, body: request.postData() })
return route.fulfill({ status: antwort.status, headers: { ...CORS, 'content-type': antwort.pdf ? 'application/pdf' : 'application/json' }, body: antwort.pdf ? PDF : JSON.stringify(antwort.json) })
}
}
return backend.handle(route, request) // Preflights und alles Übrige beantwortet das Werkzeug
}
}
// beantworte(zustand, methode, url, body) -> { status, json | pdf: true, verzoegerungMs? } | null (null = Werkzeug antwortet)
```
`context.route('**/*', mitEigenenRouten(…))`, `await context.clock.setFixedTime(jetzt())`, Viewports `[1440, 900]` und `[390, 844]`, `reducedMotion: 'reduce'`, `locale: 'de-DE'`, `timezoneId: 'Europe/Berlin'`, Browser über `launchChromium(['--lang=de-DE', '--host-resolver-rules=MAP * ~NOTFOUND'])`, `createBackend({ fixtures: await loadFixtures('standard'), scenario: 'angemeldet', dist: `${OUT}/dist` })` (ein Pfad mit `/` gilt als Build-Ordner). Kunde ist `c4` („Physiotherapie am Markt“, hat im Werkzeug schon Reiter und Rechnungen). Nach jeder Ansicht `runChecks(page)` aufrufen und die Treffer `overflow-x`, `bad-text` und `icon-svg` ausgeben (`overflow-x` und `bad-text` müssen leer sein).
Antworten von `beantworte` (Formen laut V5; Pfad nach `/api/admin/finanzen`):
- `GET /projektkonten?kunde=c4`: `{ auftraegeAktiv: true, kunde: { id: 'c4', name: 'Physiotherapie am Markt', kundenart: '' }, konten: [<ein Konto 'p-physio' mit Name „Physio-Website“ und `tickets` aus den Tickets des Kunden c4 in den Testdaten>], ohneProjekt: { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }, stripeOk: true }`.
- `GET /auftraege?kunde=c4`: `{ auftraege }` mit neun ABs (Felder wie V3 `AuftragTeam`; wo unten nichts anderes steht `projektId: 'p-physio'`, `auftragWeg: 'telefon'` und ein Titel, der vom Kontonamen „Physio-Website“ abweicht, damit die Zeile den Projektnamen zeigt): `entw-1` Entwurf Festpreis; `entw-adresse` Entwurf **ohne Projekt** (`projektId: ''`), dessen `POST …/senden` mit 409 `{ error: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.' }` antwortet; `ab-1` versendet mit `mailStatus: 'fehler'`; `ab-2` versendet **ohne Projekt**, `fruehesterBeginn: '2026-10-21'`; `ab-3` bestätigt, `fruehbeginn: true`, Aufwand mit Schätzung; `ab-4` abgerechnet; `ab-5` storniert **ohne Projekt** (an dieser Zeile darf es kein „Projekt zuordnen“ geben); `ab-6` ersetzt (`ersetztDurchId: 'ab-1'`) und `ab-7` widerrufen **ohne Projekt**. Nummern `AB-2026-0001` bis `-0007`, Entwürfe ohne Nummer. Die AB-Liste ist Zustand des Läufers: eine Zuordnung (unten) verändert spätere `GET`-Antworten.
- `POST /auftraege/<id>/senden`: `{ auftrag: <AB mit Status 'versendet', Nummer 'AB-2026-0008', gesendetAn 'praxis@physio.test', mailStatus 'gesendet'>, mailFehler: false }` nach 800 ms Verzögerung (macht den Doppelklick wirksam), für `entw-adresse` der 409. `POST …/erinnern`, `…/stornieren`, `…/neue-fassung` (neuer Entwurf `entw-neu` mit `ersetztId`) liefern `{ auftrag }`; `PATCH /auftraege/<id>` mischt den Körper in den Entwurf und liefert `{ auftrag }`; `POST /auftraege` legt einen Entwurf an; `DELETE` liefert `{ ok: true }`; `GET …/pdf` liefert `PDF`.
- `POST /auftraege/<id>/projekt` mit `{ projektId }`: nach 800 ms Verzögerung (macht den Doppelklick wirksam) `{ auftrag: <die AB mit gesetzter projektId> }` und die AB bleibt im Zustand zugeordnet; hat die AB schon eine `projektId`, 409 `{ error: 'Die Auftragsbestätigung hat schon ein Projekt.' }` (Text nur im Läufer erfunden).
- `GET` und `PATCH /einstellungen`: `{ einstellungen: { minBufferCents: 50000, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: 'Bankverbindung: WEBklar GbR · IBAN DE00 0000 0000 0000 0000 00', steuernummer: '' } }`; `PATCH` mischt die gesendeten Felder ein. Die Ansicht `dialog-ohne-hinweise` startet mit `steuernummer: '12/345/67890'`.
Ansichten, Schritte und Zusicherungen (jede je Viewport, ein werfender Schritt ist ein Fehler; `window.confirm` mit `page.on('dialog', (d) => d.accept())` bestätigen):
| Name | Adresse | Schritte | Zusicherungen |
|---|---|---|---|
| `kunde-uebersicht` | `/customers/c4` | in „Kundenart“ „Privatperson“ wählen, „Speichern“, danach noch einmal „Speichern“ | das Feld hat die Optionen „Nicht festgelegt“, „Unternehmen“, „Privatperson“ und den Hinweis; der erste Schreibaufruf auf `customers/documents/c4` enthält `"kundenart":"privat"`, der zweite enthält `kundenart` nicht |
| `einstellungen-steuernummer` | `/finance/einstellungen` | „12_345“ eintragen, „Speichern“; „DE123456789“ eintragen, „Speichern“; „Speichern“ | Hinweistext „Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.“ sichtbar; nach „12_345“ steht die Fehlermeldung „Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.“ am Feld und es gibt keinen PATCH; danach ein PATCH mit `"steuernummer":"DE123456789"` (Buchstaben sind erlaubt wie auf dem Server); der dritte PATCH ohne `steuernummer` |
| `auftraege-liste` | `/customers/c4?tab=invoices` | auf die Überschrift „Aufträge“ warten | neun Zeilen mit Statusabzeichen (Text); „Mail nicht zugestellt“, „Arbeitsbeginn frühestens am 21.10.2026“, „Frühbeginn verlangt“ sichtbar; „Bearbeiten“, „Senden“ und „Löschen“ nur in den beiden Entwurfszeilen; „Erinnerung“ oder „Erneut senden“ nur bei `ab-1` und `ab-2`; „Neue Fassung“ bei `ab-1` bis `ab-4`; „Projekt zuordnen“ genau bei `entw-adresse` und `ab-2` (nicht bei `ab-5` und `ab-7`, deren Status es ausschließt, und nicht bei den Zeilen mit Projekt) |
| `auftrag-projekt` | wie `auftraege-liste` | in der Zeile `ab-2` „Projekt zuordnen“, „Abbrechen“, wieder „Projekt zuordnen“; im Feld „Projekt wählen“ die Option „Physio-Website“ wählen; doppelt auf „Zuordnen“ klicken; danach in der Zeile `entw-adresse` „Projekt zuordnen“, „Physio-Website“ wählen, einmal „Zuordnen“ | vor der Wahl ist „Zuordnen“ gesperrt, „Abbrechen“ schließt die Auswahl; genau ein POST `…/auftraege/ab-2/projekt` mit Körper `{"projektId":"p-physio"}` und nur eine Rückfrage (die AB ist kein Entwurf); Meldung „Auftragsbestätigung AB-2026-0002 wurde dem Projekt „Physio-Website“ zugeordnet.“; danach hat `ab-2` kein „Projekt zuordnen“ mehr und zeigt „Physio-Website“ unter dem Titel; bei `entw-adresse` kommt dieselbe Aktion ohne Rückfrage durch (Entwurf); kein waagerechter Überlauf bei 390 px |
| `auftrag-doppelklick` | wie `auftraege-liste` | Doppelklick (`dblclick`) auf „Senden“ in der Zeile `entw-1` | genau ein POST `…/auftraege/entw-1/senden`, nur eine Rückfrage; Meldung „Auftragsbestätigung AB-2026-0008 wurde an praxis@physio.test gesendet.“ im Abschnitt „Aufträge“ |
| `auftrag-adresse` | wie `auftraege-liste` | „Senden“ in der Zeile `entw-adresse`, danach „Zur Übersicht“ | Meldung „Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.“ mit dem Knopf „Zur Übersicht“; der Klick wechselt auf den Reiter Übersicht (`onShowOverview`), die Adresse ist danach `/customers/c4` ohne `tab` |
| `dialog-schritte` | wie `auftraege-liste` | „Bearbeiten“ in der Zeile `entw-1`; in Schritt 1 „Weiter“ ohne Kundenart; Kundenart „Privatperson“ wählen; „Weiter“; Schritt 2 Weg „Vor Ort“ wählen, „Nach Aufwand“ und wieder „Festpreis“; „Weiter“; Schritt 3 „Anzahlung bei Auftrag, Rest nach Abnahme“ wählen; „Zur Vorschau“ | nach dem ersten „Weiter“ steht „Bitte die Kundenart wählen.“ am Feld und der Schritt bleibt; Aufnahmen von Schritt 1, 2, 3 und 4; in Schritt 2 erscheint erst nach der Wahl „Vor Ort“ der Hinweis „Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.“; in Schritt 3 steht „Sinngemäß: Anzahlung 50 % …“; in Schritt 4 hat `iframe[title="Vorschau der Auftragsbestätigung"]` ein `src` mit `blob:`, es gibt „Zurück“, „Als Entwurf speichern“, „Senden“ (nicht gesperrt), und über der Vorschau stehen beide Hinweise („Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.“ und der „Vor Ort“-Hinweis); es gab genau einen Abruf `GET …/einstellungen`, ein `PATCH …/auftraege/entw-1` und ein `GET …/pdf` |
| `dialog-ohne-hinweise` | wie `auftraege-liste`, Einstellungen mit Steuernummer | „Bearbeiten“ in der Zeile `entw-1`; Kundenart „Unternehmen“ wählen; „Weiter“; Weg „Vor Ort“ wählen; „Weiter“, „Zur Vorschau“ | in Schritt 2 und 4 kein Hinweis (ein Unternehmen braucht keine Papierfassung, die Steuernummer ist eingetragen); es gibt keinen Knoten mit der Klasse `auftrag-hinweis`; „Senden“ ist bedienbar |
| `dialog-senden` | wie `dialog-schritte` | in Schritt 4 doppelt auf „Senden“ klicken | genau ein POST `…/senden`, nur eine Rückfrage; der Dialog ist danach geschlossen und der Container zeigt „Auftragsbestätigung AB-2026-0008 wurde an praxis@physio.test gesendet.“ (`auftragMeldung` von W0) |
| `neue-fassung` | wie `auftraege-liste` | „Neue Fassung“ in der Zeile `ab-1` | POST `…/neue-fassung`, dann öffnet der Container den Dialog mit dem neuen Entwurf (Hinweis „Neue Fassung: Beim Senden ersetzt sie die bisherige Auftragsbestätigung.“). Öffnet der Container ihn nicht, ist das ein Befund für „Vertragsfragen“ (W0), kein Grund, den Container zu ändern |
Läuft ein Teil nicht, weil das Werkzeug oder W0 etwas anders macht als hier beschrieben, wird das Skript angepasst (nicht der Container) und der Unterschied im Bericht genannt.
- [ ] **Step 5: Harness ausführen und Bilder ansehen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && free -m | head -2 && FIXTURE_TODAY=2026-10-05 flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 node /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/shots-w2.mjs`
Expected: Jede Ansicht endet ohne geworfene Zusicherung, `overflow-x` und `bad-text` sind in allen Aufnahmen leer, es gibt keine Konsolenfehler außer den bewusst erzeugten 409. Die Bilder liegen unter `…/auftraege-w2/shots/1440x900/` und `…/390x844/`. Mit dem Werkzeug „Read“ mindestens ansehen: `auftraege-liste`, `auftrag-projekt` (offene Auswahl), `dialog-schritte` (alle vier Schritte, in Schritt 2 und 4 mit Hinweisen), `kunde-uebersicht`, `einstellungen-steuernummer`, jeweils bei 1440 und 390 px. Prüfen: Zusatzzeilen lesbar, Knöpfe erreichbar, Auswahl „Projekt zuordnen“ bei 390 px in der Zelle „Aktionen“ ohne Überlauf, Tabelle bei 390 px als gestapelte Zeilen, Positionszeilen bei 390 px zweizeilig, Hinweise lesbar und über der Vorschau, Dialog als Bottom-Sheet ohne waagerechten Überlauf. Funde werden in der Datei des zuständigen Tasks behoben (eigener Commit), danach Step 1 bis 5 wiederholen. Die zweite Sperre des Redesigns gilt wie in Step 3.
- [ ] **Step 6: Vertragsliste abhaken**
Vor dem Abschlussbericht jede Zeile mit Datei und Zeile belegen:
- V8: `AuftragListe` mit `{ auftraege, konten, onChanged, onBearbeiten, onShowOverview }` und `AuftragDialog` mit `{ open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview }` (alle Props aus V8, keine Rückfallwerte, kein `Link`, kein `UebersichtLink`); Aktion „Projekt zuordnen“ (Auswahl aus `konten`, `ordneAuftragProjektZu`, danach `onChanged()`); Hinweise (Steuernummer fehlt in Schritt 4, „Vor Ort“ bei Privatperson in Schritt 2 und 4, beide nicht blockierend); `src/lib/auftragView.js` mit Tests; `auftrag.css`; Kundenart in `CustomerDetailPage.jsx`; Steuernummer in `SettingsCard.jsx`.
- V2: `AUFTRAG_LIMITS` (13 Schlüssel), `STUNDENSATZ_STANDARD_CENTS`, sieben Status, fünf Wege, vier Zahlungsplan-Arten, zwei Kundenarten (durch Tests festgehalten).
- V4 Finanzen `steuernummer`: Client-Prüfung wie der Server (höchstens 40 Zeichen aus ASCII-Buchstaben, Ziffern, Leerzeichen, `/`, `-`, `.`; leer löscht), `kundenFelder.js` und Test.
- V5 über `financeApi.js`: `createAuftrag`, `patchAuftrag`, `deleteAuftrag`, `sendAuftrag`, `erinnereAuftrag`, `neueFassungAuftrag`, `storniereAuftrag`, `ordneAuftragProjektZu` (`POST …/auftraege/:id/projekt` mit `{ projektId }`), `getAuftragPdf`, `getSettings`; `PATCH /einstellungen` mit `steuernummer`; `customers.kundenart` direkt über Appwrite.
- V3 `AuftragTeam`: gelesen werden `id, nummer, status, projektId, titel, art, stundensatzCents, schaetzungMinuten, summeCents, gesendetAm, erstelltAm, mailStatus, bestaetigtAm, fruehbeginn, ersetztId, ersetztDurchId, widerrufenAm, pdfVorhanden, fruehesterBeginn` sowie beim Bearbeiten die Eingabefelder des Entwurfs.
- Abschlussbericht: Pfad des Worktrees, letzter Commit, Testzahlen, Pfad der Bilder und des Harness-Skripts, Abweichungen von W0, V8 und den Nachträgen der Planrunde, Befunde für Z2 (etwa ein Container, der `konten` oder `onShowOverview` doch nicht durchreicht, oder die neuen Dateien aus „Vertragsfragen“ Nr. 6).
---
## Vertragsfragen
Alle Fragen dieses Strangs sind in der Planrunde **entschieden** (Übersicht, Abschnitt „Nachträge nach der Planrunde“, dazu V4, V5 und V8); der Plan setzt die Entscheidungen um, offen ist nichts. Abgeglichen mit den Plänen W0 (09), L0 (01), L3 (04) und L4 (05); was dort schon festliegt, steht unten unter „Bereits abgestimmt“.
1. **Zusätzliche Props `konten` und `onShowOverview` (V8):** Entschieden (siehe Übersicht, Nachträge): V8 nennt beide Props, und der Container von W0 reicht `konten` und `onShowOverview` an `AuftragListe` und `AuftragDialog` durch (Plan W0, Task 7, Zeilen e und g). Umsetzung: beide sind Pflicht-Props ohne Rückfallwerte; die Liste zeigt den Projektnamen aus `konten`, „Zur Übersicht“ ruft `onShowOverview` (Task 5 und 7). Der frühere Rückfall auf einen Link `/customers/<id>` ist entfallen (kein `UebersichtLink`, kein `react-router-dom` in diesem Strang).
2. **Vorschau ohne gespeicherten Entwurf:** Entschieden (siehe Übersicht, Nachträge): V5 liefert die Vorschau eines Entwurfs über `GET …/auftraege/:id/pdf` (409 „Entwurf unvollständig“), das PDF gibt es also nur zu einem gespeicherten Entwurf. Umsetzung: W2 legt den Entwurf beim Wechsel nach Schritt 4 an bzw. aktualisiert ihn (POST/PATCH) und holt dann das PDF; „nicht gespeichert“ in der Spec heißt „das PDF wird nicht archiviert“. Ein so entstandener Entwurf bleibt in der Liste, auch wenn der Dialog danach ohne Senden geschlossen wird (der Container meldet „Der Entwurf wurde gespeichert.“). Antwortet der Server 409 „Entwurf unvollständig: …“, zeigt der Dialog den Satz unverändert (Task 4, `auftragFehler`, Klasse `entwurf`).
3. **Steuernummer:** Entschieden (siehe Übersicht, Nachträge): V4 erlaubt Buchstaben, Ziffern, Leerzeichen, `/`, `-` und `.`, höchstens 40 Zeichen, leer löscht (auch eine USt-IdNr. „DE…“); L0 setzt in Task 12 `/^[A-Za-z0-9\/ .-]*$/` (nur ASCII-Buchstaben, keine Umlaute). Umsetzung: Der Client prüft genauso (Task 2, `steuernummerAenderung`) und sendet `steuernummer` nur bei Änderung, damit Speichern nie an einem noch nicht aktualisierten Portal scheitert.
4. **`customers.kundenart`:** Entschieden (siehe Übersicht, Nachträge): Laut Abschnitt „Livegang“ setzt der WOMS-Push das Attribut voraus, das Setup-Skript (L0) legt es vorher an. Umsetzung: W2 schreibt das Feld in der Übersicht nur bei Änderung direkt in Appwrite (Task 2).
5. **„Später zuordenbar“ (Spec Abschnitt 1, Schritt 1):** Entschieden (siehe Übersicht, Nachträge): V5 hat `POST …/auftraege/:id/projekt`, V8 die Aktion „Projekt zuordnen“; eine AB ohne Projekt lässt sich in der Liste einem Projekt zuordnen. Umsetzung: Aktion „Projekt zuordnen“ für Entwurf, Versendet, Bestätigt und Abgerechnet, nur ohne Projekt und nur, wenn es in `konten` mindestens ein Projekt gibt (Task 4 und 5); sie ruft `ordneAuftragProjektZu(id, projektId)` und danach `onChanged()`. Die Statusliste schließt Entwürfe ein; ein Entwurf bleibt zusätzlich über „Bearbeiten“ änderbar. Die Rückfrage vor dem Zuordnen gibt es nur bei einer schon versendeten oder späteren AB, weil sich deren Projekt danach nur noch über eine neue Fassung ändern lässt.
6. **Dateien, Abhängigkeiten, Zusätze:** Entschieden (siehe Übersicht, Nachträge): Der Abschnitt „Zusätzliche Dateien je Strang“ nennt für W2 `AuftragListe.jsx`, `AuftragDialog.jsx`, `auftrag.css`, `auftragView.js`, `auftragPdf.js`, `kundenFelder.js`, `CustomerDetailPage.jsx` (Feld Kundenart) und `SettingsCard.jsx`. Dieser Plan legt dazu `AuftragSchritte.jsx`, `AuftragVorschau.jsx` und die drei Testdateien an (neu, im Verzeichnis von W2; kein anderer Strang legt dort Dateien mit diesen Namen an); die Dateiprüfung in Plan Z (12) führt sie in der Liste von W2 schon (`UebersichtLink.jsx` steht dort noch, wird aber nicht mehr angelegt: unschädlich). `CustomerDetailPage.jsx` ändern W0 (Reiter `invoices`) und W2 (`OverviewTab`) an verschiedenen Stellen; Z2 prüft den Merge. W2 nutzt `FormField`, `noticeProps`, `Dialog`, `useWorkorders`, `useScrollIntoView`, `openInNewTab` und `getSettings`; fällt eine davon im Redesign (P10) weg, müssen die Importe nachgezogen werden. Der Zahlungsplan-Text im Dialog (`zahlungsplanVorschau`) ist sinngemäß, maßgeblich bleibt der Wortlaut im PDF (L7). Auch „Erinnerung senden“ fragt vorher nach, weil der bisherige Bestätigungslink dadurch ungültig wird. Der Wortlaut von „senden“ in `auftragErfolg` folgt `auftragMeldung` aus W0, damit Liste und Container dieselben Sätze zeigen.
7. **Prüfwerkzeug:** Entschieden (siehe Übersicht, Nachträge): Laut Absatz „WOMS“ entstehen Bildschirmfotos nur mit eigenen Skripten im Scratchpad, das Werkzeug des Redesigns wird nicht geändert. Umsetzung: eigener Läufer unter `…/scratchpad/auftraege-w2` (Skript und Daten dort), der die neuen Endpunkte ergänzt (Vertragsfrage 10 von W0); Z2 kann ihn wiederverwenden.
8. **Hinweise im Dialog:** Entschieden (siehe Übersicht, Nachträge): V8 verlangt nicht blockierende Hinweise. Schritt 4 weist auf eine fehlende Steuernummer in den Finanz-Einstellungen hin (die Einstellungen werden einmal über `getSettings` geladen), und bei Weg „Vor Ort“ mit Kundenart Privatperson steht „Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.“ (§ 312f Abs. 1 BGB). Umsetzung: Task 2, 3, 6 und 7. V8 nennt für den zweiten Hinweis keinen Schritt; dieser Plan zeigt ihn in Schritt 2 unter dem Weg (sobald Weg und Kundenart feststehen) und noch einmal in Schritt 4 über der Vorschau.
**Bereits abgestimmt (keine Aktion):** `kunde` im `AuftragDialog` ist das Kundendokument mit frischer Kundenart (`kundeMitKundenart`, W0 Task 6 und 7), nie `null`; nach Speichern oder Senden folgt `onSaved(auftrag)` und dann `onClose()`, der Container schließt und meldet über `auftragMeldung` (W0 Task 7); W0 reicht `konten` und `onShowOverview` an Liste und Dialog durch (W0 Task 7, Zeilen e und g) und exportiert `ordneAuftragProjektZu` (W0 Task 3, Vertragsfrage 13); `getSettings` besteht in `financeApi.js`; die `financeApi.js`-Funktionen liefern die JSON-Körper aus V5 und `getAuftragPdf` einen Blob, Fehler tragen `status` und `message` (W0 Task 2 und 3); die Wertebereiche (Anzahlung 1 bis 99 Prozent und nur bei Festpreis, „Monatlich“ nur bei Aufwand, Stundensatz 0,01 bis 100.000,00 €, Schätzung Vielfaches von 15 bis 99.960, Zahlungsziel 0 bis 60, Weg Pflicht) und die Meldungstexte beim Senden stehen im Plan L3 und decken sich mit der Oberfläche; „Entwurf unvollständig: …“ kommt aus L4 und wird von L3 durchgereicht; `aendereEntwurf` schreibt `kundenart` wie `legeEntwurfAn`; der PDF-Tab folgt dem Vorschlag von W0 (Vertragsfrage 5).