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

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

1844 lines
175 KiB
Markdown
Raw Permalink 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.
# W1 Rechnungs-UI – Umsetzungsplan (Strang W1)
> **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:** Im Ticketsystem (WOMS) zeigt der Kundenreiter „Aufträge und Rechnungen“ ein Projektkonto je Projekt; der neue Dialog „Rechnung schreiben“ erzeugt Rechnungen als Anzahlung, Schlussrechnung, nach Aufwand, als Festbetrag oder ohne Projekt; die Rechnungstabelle zeigt Projekt und Art; abgerechnete Arbeitsblätter tragen ein Abzeichen und warnen beim Ändern ihrer Zeit.
**Architecture:** Alle Regeln stehen in reinen Lib-Modulen mit `node --test`-Tests (`projektkontoView`, `rechnungDialogView`, `rechnungAnfrage`, `rechnungDialogState`); die React-Komponenten in `src/components/auftraege/` sind dünn und bauen auf den Bausteinen des weißen Designs (`Dialog`, `Field`, `Card`, `.seg`, `.table`, `.facts`) und den API-Funktionen aus W0 auf. Der Dialog hält seinen Zustand in einem Reducer, holt Projektliste, Projektkonto und Vorschau über Hooks (nur die jüngste Antwort zählt) und sperrt „Als Entwurf speichern“ und „Senden“, solange die Vorschau nicht zur aktuellen Eingabe gehört. Verhalten der Oberfläche prüft ein Playwright-Lauf gegen das nachgebildete Backend des Redesign-Werkzeugs (nur importiert, nie verändert).
**Tech Stack:** React 18, react-router-dom 6, Vite 5, Appwrite Web SDK 13, reines CSS; Tests `node:test` + `assert/strict` mit dem esbuild-Lader `tests/unit/load.mjs`; playwright-core 1.62.1 aus dem Redesign-Werkzeug (`/root/reports/ticketsystem-design-2026-10-04/werkzeug`, nur lesend genutzt).
**Spec:** `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md` (Abschnitte 1, 3, 4).
**Übersicht/Verträge:** `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md` (V3, V5, V8 und der Abschnitt „Nachträge nach der Planrunde“ verbindlich; wo dieser Plan abweicht, gilt die Übersicht). Die Fragen am Ende unter „Vertragsfragen“ sind alle entschieden (siehe Übersicht, Nachträge); die Liste hält fest, was entschieden wurde und wo dieser Plan es umsetzt.
Jeder Implementierer liest die Spec (Abschnitte 1, 3, 4), die Übersicht (V3, V5, V8, „Nachträge nach der Planrunde“, Global Constraints) und diese Datei. Was beim Start im Worktree liegt (API-Funktionen, Container, vier Platzhalter), beschreibt `2026-10-06-auftraege-09-woms-basis.md` (W0).
Die Tasks laufen der Reihe nach. Task 3 baut den Prüfläufer, den die Tasks 8, 9 und 10 um Szenarien erweitern; Task 11 darf erst nach Task 8 und 9 laufen; Task 2, 3 und 10 hängen nicht von der Dialogkette (Task 4 bis 8) ab.
---
## Global Constraints
Alle Punkte der Übersicht gelten (Sprache, Beträge in Cent, Berliner Tage, Grenzen, Abo-Zeit, Git ohne Push und ohne `git add -A`, Ressourcen und `flock`, Sicherheit der Umgebung, `build/` nie anfassen, vorhandene Tests bleiben grün), auch die „Nachträge nach der Planrunde“. Für W1 kommt hinzu:
- **Startbedingung (hart):** W0 ist abgeschlossen (Container `AuftraegeUndRechnungen`, vier Platzhalter und die API-Funktionen liegen in `feature/auftraege` des WOMS) und das Redesign P4, P6, P7 ist darin enthalten. Task 1 prüft das. Fehlt etwas, gilt **STOPP**: nichts anlegen, dem Orchestrator melden.
- **Eigene Dateien** sind genau die der Tabelle „Dateien“. Nur lesend benutzt werden: `financeView.js`, `financeFormat.js`, `financeUi.js`, `financeApi.js`, `financeRequest.js`, `teamApi.js`, `auftraegeEndpoints.js`, `auftraegeAnsicht.js`, `AuftraegeUndRechnungen.jsx`, `FinancePage.jsx`, `TicketPage.jsx`, `AddressWarning.jsx`, `customerTabs.js`, `global.css`, alles unter `src/components/ui/`, `useFinanceResource.js`, `requestGuard.js`. Fehlt dort etwas, wird es als Vertragsfrage notiert, nie in der fremden Datei nachgetragen.
- **Regeln des weißen Designs** für alle neuen und geänderten Dateien: keine Icon-Importe (`react-icons`, `@tabler`), kein `style={{…}}`, keine Farbliterale (nur Tokens wie `var(--line)`), kein `!important`, keine KOMPAT-Klassen (`ui-btn`, `form-control`, `form-group`, `form-label`, `ui-card`, `badge-…`, `overlay-content`, `overlay-close`, `spinner`, `kpi`). Handelnde Zeichen sind Text („ד, „+ Position“, „Entfernen“). Jeder Knopf in einem `<Dialog as="form">` trägt `type="button"`. Pro Abschnitt höchstens ein schwarzer Knopf (`Button variant="primary"`).
- **CSS-Reihenfolge:** `src/main.jsx` importiert `App` vor `./styles/global.css`; von Komponenten importierte Stylesheets stehen im Bundle **vor** `global.css`. Bei gleicher Spezifität gewinnt `global.css`. Eigene Regeln tragen deshalb ein Präfix (`pk-` für die Projektliste, `rd-` für den Rechnungsdialog) und überstimmen globale Regeln nur über höhere Spezifität (zwei Klassen), nie über `!important`.
- **Container-Falle:** `container-type: inline-size` macht ein Element zum Bezugsrahmen für `position: fixed`. Es steht in W1 nur auf `.pk` (Projektliste). `RechnungDialog` rendert nie als Nachfahre eines Elements mit `container-type` (weder in `ProjektkontoListe` noch in `InvoiceTable`/`.fin-body`).
- **Verhalten des bisherigen Dialogs bleibt:** Enter sendet nie (Formular ohne Submit-Knopf, `onSubmit` verhindert), „Senden“ fragt vorher mit `window.confirm(invoiceSendConfirmText(…))`, Adress-Sperre über 250 € (`invoiceSendGate`), Doppelklick-Sperre, Esc schließt zuerst die Vorschlagsliste des Kundenfelds, `onCreated(rechnung, info)` mit `info = { senden }` bzw. bei festgeschriebener, nicht versendeter Rechnung (502 mit `invoiceId`) `{ senden: true, notice, tone: 'warn', finalizedFilter }`, danach `onClose()`.
- **Zeilengrenzen (Übersicht, Global Constraints):** Rechnungen haben 1 bis 20 Zeilen (Minuszeilen zählen mit); **eine Schlussrechnung darf serverseitig bis zu 40 Zeilen haben** (AB-Positionen höchstens 20, Zusatzpositionen höchstens 10, Abzüge höchstens 10). Die Vorschau zeigt deshalb immer **alle** Zeilen der Serverantwort (kein `slice`, keine Obergrenze von 20 in der Anzeige). Was der Client schickt, bleibt bei höchstens `INVOICE_LIMITS.maxItems` (20) `items` je Liste; die engere Grenze von 10 Zusatzpositionen je Schlussrechnung prüft allein der Server, und der Dialog zeigt seine Meldung als Vorschaufehler (Vertragsfrage 15).
- **Texte wörtlich wie im Plan.** Beträge nur über `euro()` (Minus ist U+2212), Stunden nur über `stundenDe` (Komma, „Std.“), Datum über `dateDe`.
- **Schreibweise im Code (Übersicht, Global Constraints):** nie ein Unicode-Escape mit genau vier Hexziffern (Backslash, `u`, vier Ziffern) schreiben, denn das Schreibwerkzeug macht daraus das Zeichen selbst (bei einem geschützten Leerzeichen unsichtbar); stattdessen das Zeichen wörtlich, `\u{…}` oder `String.fromCodePoint(…)`. Task 12 Step 3 sucht danach.
- **Tests:** `tests/unit/<name>.test.mjs` mit `loadModule` (Muster `tests/unit/financeView.test.mjs`). Lib-Module von W1 importieren nie Appwrite oder `financeApi`/`teamApi` (sie sind unter Node nicht ladbar). Einzeltest: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/<datei>.test.mjs`.
- **Schwere Läufe** (volle Suite, `vite build`, Browser) nur hinter `flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock`; vorher `free -m` (unter 1500 MB „available“: nicht starten, später wiederholen, kein Foreground-`sleep`). Läuft das Redesign noch schwere Läufe, zusätzlich dessen Sperre `/root/reports/ticketsystem-design-2026-10-04/sperren/schwer.lock` (erst unsere, dann deren). Browser und Build unter `systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0`. Nie `pkill -f`.
- **Keine Live-Daten:** Bildschirmfotos und Verhaltensprüfungen laufen nur gegen das Test-Backend (Hosts `ticket.test`, `portal.test`); nichts erreicht echte Hosts.
- **Suche:** In dieser Shell ist `grep` eine Funktion, die Dateien still auslässt. Die Steps benutzen `rg` (13.0.0, ohne Lookarounds); notfalls `command grep -a`.
- **Git:** Worktree `/home/webklar/apps/ticket.webklar.com/dev-auftraege-w1`, Branch `auftraege/w1-rechnungen`. Commits einzeln mit Dateinamen, deutsche Nachricht, Schlusszeile `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` (zweites `-m`). Kein Push, nie `--force`. Der Symlink `node_modules` ist unversioniert und erscheint als `?? node_modules`.
- Jede Shell-Zeile steht für sich (Umgebungsvariablen bleiben nicht erhalten), deshalb stehen die Pfade ausgeschrieben in den Steps. Kürzel in Prosa: `WT` = `/home/webklar/apps/ticket.webklar.com/dev-auftraege-w1`, `SP` = `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1`.
## Dateien
| Datei | Aktion | Aufgabe | Task |
|---|---|---|---|
| `src/lib/projektkontoView.js` | neu | rein: Zeilen der Projektliste, Spalten Projekt und Art der Rechnungstabelle, Abzeichen „abgerechnet“ | 2 |
| `tests/unit/projektkontoView.test.mjs` | neu | Tests dazu | 2 |
| `src/components/auftraege/ProjektkontoListe.jsx` | ersetzt Platzhalter | Abschnitt „Projekte“ | 3 |
| `src/components/auftraege/projektkonto.css` | neu | Stile der Liste (`pk-`) | 3 |
| `src/lib/rechnungDialogView.js` | neu | rein: Projektwahl, Arten, ABs, Arbeitsblätter | 4 |
| `tests/unit/rechnungDialogView.test.mjs` | neu | Tests dazu | 4 |
| `src/lib/rechnungAnfrage.js` | neu | rein: Anfragekörper, Vorschau-Status, Zeilentexte, Absenden | 5 |
| `tests/unit/rechnungAnfrage.test.mjs` | neu | Tests dazu | 5 |
| `src/lib/rechnungDialogState.js` | neu | rein: Zustand und Reducer des Dialogs | 6 |
| `tests/unit/rechnungDialogState.test.mjs` | neu | Tests dazu | 6 |
| `src/lib/rechnungDialogApi.js` | neu | Appwrite-Lesezugriffe (Tickets und Arbeitsblätter des Kunden) | 6 |
| `src/hooks/useRechnungDaten.js` | neu | Hooks: Projektliste, Projektkonto, Vorschau, weitere Tickets | 6 |
| `src/components/auftraege/RechnungPositionen.jsx`, `RechnungVorschau.jsx`, `AufwandAuswahl.jsx`, `KontoZusammenfassung.jsx` | neu | Bausteine des Dialogs | 7 |
| `src/components/auftraege/rechnung-dialog.css` | neu | Stile des Dialogs (`rd-`) und der neuen Tabellenspalten | 7, 9 |
| `src/components/auftraege/RechnungDialog.jsx` | ersetzt Platzhalter | der Dialog „Rechnung schreiben“ | 8 |
| `src/components/finance/InvoiceTable.jsx` | ändern | Spalten Projekt und Art, neuer Dialog | 9 |
| `src/pages/FinancePage.jsx` | **unverändert** | der Dialog hängt in `InvoiceTable` (Vertragsfrage 1) | 9 |
| `src/hooks/useAbgerechnet.js` | neu | lädt `getAbgerechnet` je Ticket | 10 |
| `src/components/WorksheetList.jsx`, `WorksheetTimesModal.jsx` | ändern | Abzeichen und Warnung | 10 |
| `src/components/finance/InvoiceCreateModal.jsx` | löschen | ersetzt durch `RechnungDialog` | 11 |
Die Übersicht („Zusätzliche Dateien je Strang“) nennt für W1 Projektliste, Rechnungsdialog, beide Stylesheets, die beiden Ansichts-Libs, `InvoiceTable.jsx`, `WorksheetList.jsx`, `WorksheetTimesModal.jsx` und die Löschung von `InvoiceCreateModal.jsx` „+ weitere eigene Libs“. Als solche Zusätze legt W1 an: `rechnungAnfrage.js`, `rechnungDialogState.js`, `rechnungDialogApi.js`, `useRechnungDaten.js`, `useAbgerechnet.js` und die vier Bausteine unter `src/components/auftraege/`; Plan 12 (Z2.1) nimmt sie als Zusätze von W1 an. Ihre Namen und Stilpräfixe (`pk-`, `rd-`) kommen in den Plänen von W0 und W2 nicht vor.
## Review Focus
Fälle, die die Spec nicht ausdrücklich nennt und die im Betrieb am ehesten auftreten, wahrscheinlichster zuerst:
1. **Doppelklick auf „Senden“** (zwei Klicks im selben Takt, Rückfrage wird bestätigt): genau eine Rechnung, der zweite Aufruf wird verworfen. Tests: Task 5 „createSubmitter: Doppelklick …“; Task 8 Szenario `dlg-doppelklick` (genau ein `POST …/rechnungen`).
2. **Vorschau veraltet nach Abwahl eines Arbeitsblatts:** „Als Entwurf speichern“ und „Senden“ bleiben gesperrt, bis die Vorschau zur neuen Auswahl da ist; das abgewählte Blatt steht nie im Körper. Tests: Task 5 „Abwahl eines Arbeitsblatts …“ und „vorschauStatus …“; Task 8 Szenario `dlg-aufwand`.
3. **Kunde ohne Projekte** (und Projektliste nicht ladbar): nur „Ohne Projekt“, freie Positionen, Hinweis; die Liste zeigt Hinweis und die Zeile „Ohne Projekt“. Tests: Task 2 „ohneProjektZeile …“ und „Hinweis …“; Task 4 „wirksamesProjekt …“, „projektOptionen …“, „artOptionen …“; Task 3 Szenario `konten-leer`; Task 8 Szenario `dlg-ohne-projekte`; Task 9 Szenario `dlg-projekte-fehler`.
4. **Stripe nicht erreichbar (`stripeOk: false`):** keine Nullen als Wahrheit, sondern „–“ bei Berechnet, bezahlt und (Festpreis) Noch offen plus Hinweis; die Rechnung bleibt schreibbar, der Server meldet seinen Fehler. Tests: Task 2 „kontoZeile: Stripe nicht erreichbar“; Task 3 Szenario `konten-stripe`; Task 8 Szenario `dlg-stripe`.
5. **Handy-Breite 390 px mit langen Inhalten** (Projektname aus 60 Zeichen ohne Leerzeichen, Zeilenbeschreibung aus 200 Zeichen ohne Leerzeichen): kein waagerechtes Scrollen in Projektliste, Dialog und Rechnungstabelle. Tests: der Läufer prüft bei jedem Szenario `scrollWidth <= clientWidth` (Seite und `.dialog-body`); Szenarien `konten-lang` (Task 3), `dlg-aufwand` (Task 8), `rechnungen-spalten` (Task 9).
---
### Task 1: Startbedingung prüfen, Worktree und Prüfordner anlegen
**Files:** keine Codeänderung. Legt `/home/webklar/apps/ticket.webklar.com/dev-auftraege-w1` (Branch `auftraege/w1-rechnungen`) und `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1` an.
**Interfaces:**
- Consumes: den fertigen Stand von W0 in `feature/auftraege` (WOMS).
- Produces: einen lauffähigen Worktree mit verlinktem `node_modules`, die Ausgangs-Testzahl `N0` und den Basis-Commit (beides gehört in die Abschlussmeldung, Task 12).
- [ ] **Step 1: Startbedingung prüfen**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git log --oneline -12
git status --short
for d in P4:src/lib/customerList.js P6:tests/unit/creditFormat.test.mjs P7:src/lib/financeUi.js; do f=${d#*:}; test -e "$f" && echo "${d%%:*} ok" || echo "FEHLT ${d%%:*} ($f)"; done
for n in getProjektkonten getProjektkonto previewInvoice createInvoice; do rg -q "\b$n\b" src/lib/financeApi.js && echo "da $n" || echo "FEHLT $n"; done
rg -q "\bgetAbgerechnet\b" src/lib/teamApi.js && echo "da getAbgerechnet" || echo "FEHLT getAbgerechnet"
ls src/components/auftraege
rg -n "Aufträge und Rechnungen" src/lib/customerTabs.js
rg -n "fin-body|InvoiceCreateModal" src/components/finance/InvoiceTable.jsx
ls -d node_modules/.bin/vite node_modules/.bin/esbuild
```
Expected: `git status` leer; `P4 ok`, `P6 ok`, `P7 ok`; fünfmal `da …`; `src/components/auftraege` enthält `AuftraegeUndRechnungen.jsx`, `ProjektkontoListe.jsx`, `RechnungDialog.jsx`, `AuftragListe.jsx`, `AuftragDialog.jsx`; die Reiterbeschriftung; `InvoiceTable.jsx` mit `fin-body` und `InvoiceCreateModal`; ein echtes `node_modules` mit `vite` und `esbuild`. Fehlt etwas: **STOPP** mit der Ausgabe dieser Befehle.
- [ ] **Step 2: Worktree anlegen und `node_modules` verlinken**
Run:
```bash
git -C /home/webklar/apps/ticket.webklar.com/dev-auftraege worktree add ../dev-auftraege-w1 -b auftraege/w1-rechnungen feature/auftraege
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1 && ln -s ../dev-auftraege/node_modules node_modules && ls node_modules/.bin/vite && git status --short && git log --oneline -1
```
Expected: der Worktree entsteht, `vite` ist über den Symlink erreichbar, `git status --short` zeigt genau `?? node_modules`, die letzte Zeile ist der Basis-Commit (notieren). Existieren Ordner oder Branch schon: **STOPP** (früherer Versuch, Orchestrator fragen).
- [ ] **Step 3: Prüfordner anlegen**
Run: `mkdir -p /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/shots /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/syntax && ls /root/reports/ticketsystem-design-2026-10-04/werkzeug/backend.mjs /root/reports/ticketsystem-design-2026-10-04/werkzeug/browser.mjs /root/reports/ticketsystem-design-2026-10-04/werkzeug/node_modules/playwright-core/package.json`
Expected: die drei Dateien existieren. Das Werkzeug wird nur importiert (`createBackend`, `loadFixtures`, `launchChromium`), nie verändert; Ausgaben liegen nur unter `SP`.
- [ ] **Step 4: Ausgangslage der Tests**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
free -m | head -2
flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock env NODE_OPTIONS=--max-old-space-size=1024 timeout 900 nice -n 10 npm test > /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/test-basis.log 2>&1; echo "exit $?"
rg "^(ℹ|#) (tests|pass|fail) " /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/test-basis.log
```
Expected: `exit 0` und `fail 0`. Die Zahl hinter `pass` ist `N0` (notieren; Soll am Ende: `N0 + 61`). Schlägt schon die Basis fehl: **STOPP** und melden.
Kein Commit.
### Task 2: Anzeigelogik des Projektkontos (`projektkontoView`)
**Files:**
- Create: `src/lib/projektkontoView.js`
- Test: `tests/unit/projektkontoView.test.mjs` (neu)
**Interfaces:**
- Consumes: `euro`, `euroWhole` aus `src/lib/financeFormat.js`. Eingaben haben die Form V3: `Projektkonto` (`projektTyp` ist laut L1 schon der Anzeigename der Projektart, z. B. „Website“, oder `''`), `ohneProjekt = { berechnetCents, bezahltCents, anzahl }`.
- Produces (benannte Exporte):
- Konstanten `STRIPE_HINWEIS`, `KEINE_PROJEKTE_HINWEIS` (Texte siehe Test)
- `stundenDe(minuten) → '12,5 Std.'`, `satzText(cents) → '100 €/Std.' | '–'`, `ticketsText(tickets) → '#100131, #100140'`
- `stripeHinweis(stripeOk) → STRIPE_HINWEIS | ''` (nur bei `stripeOk === false`)
- `vereinbartText(vereinbart) → string`
- `kontoZeile(konto, { stripeOk = true } = {}) → Zeile`, `ohneProjektZeile(ohne, { stripeOk = true } = {}) → Zeile` mit `Zeile = { key, projektId, name, meta, vereinbart, berechnet, bezahlt, offen, zeit: { stunden, wert, abo } }` (alles Zeichenketten)
- `artLabel(art) → string` (Spalte „Art“ der Rechnungstabelle), `projektSpalte(invoice) → string` (Spalte „Projekt“)
- `abgerechnetText(abgerechnet, worksheetId) → string | null`, `abgerechnetWarnung(abgerechnet, worksheetId) → string | null`, `ticketIdsOf(worksheets) → string[]`
- [ ] **Step 1: Failing Test schreiben**
`tests/unit/projektkontoView.test.mjs`:
```js
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const {
STRIPE_HINWEIS, KEINE_PROJEKTE_HINWEIS,
stundenDe, satzText, ticketsText, stripeHinweis, vereinbartText, kontoZeile, ohneProjektZeile,
artLabel, projektSpalte, abgerechnetText, abgerechnetWarnung, ticketIdsOf,
} = await loadModule('src/lib/projektkontoView.js')
const konto = (over = {}) => ({
projektId: 'p1',
name: 'Website Relaunch',
projektTyp: 'Website',
tickets: [{ id: 't1', woid: '100131' }],
zeitquelle: ['t1'],
vereinbart: { festpreisCents: 120000, aufwand: null },
berechnetCents: 50000,
bezahltCents: 20000,
offenCents: 70000,
zeit: { minuten: 750, aboMinuten: 0, offeneMinuten: 750, satzCents: 10000, wertCents: 125000, offenerWertCents: 125000 },
...over,
})
const AUFWAND = { festpreisCents: null, aufwand: { stundensatzCents: 10000, schaetzungMinuten: 600 } }
test('stundenDe: Dezimalstunden mit Komma, ohne überflüssige Nullen', () => {
assert.equal(stundenDe(750), '12,5 Std.')
assert.equal(stundenDe(135), '2,25 Std.')
assert.equal(stundenDe(60), '1 Std.')
assert.equal(stundenDe(20), '0,33 Std.')
assert.equal(stundenDe(0), '0 Std.')
assert.equal(stundenDe(null), '0 Std.')
assert.equal(stundenDe('abc'), '0 Std.')
})
test('satzText: ganze Euro ohne Cent, sonst mit Cent', () => {
assert.equal(satzText(10000), '100 €/Std.')
assert.equal(satzText(8550), '85,50 €/Std.')
assert.equal(satzText(null), '–')
})
test('ticketsText: Ticketnummern mit Raute, ohne Nummer fällt der Eintrag weg', () => {
assert.equal(ticketsText([{ id: 'a', woid: '100131' }]), '#100131')
assert.equal(ticketsText([{ id: 'a', woid: '100131' }, { id: 'b', woid: '100140' }]), '#100131, #100140')
assert.equal(ticketsText([{ id: 'a' }, null]), '')
assert.equal(ticketsText(undefined), '')
})
test('vereinbartText: Festpreis, Aufwand mit Schätzung, beides, keine AB', () => {
assert.equal(vereinbartText({ festpreisCents: 120000, aufwand: null }), '1.200,00 €')
assert.equal(vereinbartText(AUFWAND), 'nach Aufwand, 100 €/Std., Schätzung 10 Std.')
assert.equal(vereinbartText({ festpreisCents: null, aufwand: { stundensatzCents: 8550, schaetzungMinuten: null } }), 'nach Aufwand, 85,50 €/Std.')
assert.equal(vereinbartText({ festpreisCents: 120000, aufwand: { stundensatzCents: 10000, schaetzungMinuten: null } }), '1.200,00 € und nach Aufwand, 100 €/Std.')
assert.equal(vereinbartText({ festpreisCents: null, aufwand: null }), 'keine AB')
assert.equal(vereinbartText(undefined), 'keine AB')
})
test('kontoZeile: Festpreis-AB mit Berechnet, bezahlt, Noch offen und Zeit', () => {
assert.deepEqual(kontoZeile(konto()), {
key: 'p1',
projektId: 'p1',
name: 'Website Relaunch',
meta: 'Website · #100131',
vereinbart: '1.200,00 €',
berechnet: '500,00 €',
bezahlt: 'bezahlt 200,00 €',
offen: '700,00 €',
zeit: { stunden: '12,5 Std.', wert: 'Wert 1.250,00 €', abo: '' },
})
// ohne Art und ohne Ticket bleibt nur der Name
assert.equal(kontoZeile(konto({ projektTyp: '', tickets: [] })).meta, '')
})
test('kontoZeile: Aufwand-AB (Noch offen = Wert der offenen Zeit) und Abo-Zeit getrennt', () => {
const z = kontoZeile(konto({
vereinbart: AUFWAND,
offenCents: null,
zeit: { minuten: 300, aboMinuten: 90, offeneMinuten: 180, satzCents: 10000, wertCents: 50000, offenerWertCents: 30000 },
}))
assert.equal(z.vereinbart, 'nach Aufwand, 100 €/Std., Schätzung 10 Std.')
assert.equal(z.offen, '300,00 €')
assert.deepEqual(z.zeit, { stunden: '5 Std.', wert: 'Wert 500,00 €', abo: 'über Abo: 1,5 Std.' })
})
test('kontoZeile: ohne AB steht „keine AB“ und Noch offen ist „–“', () => {
const z = kontoZeile(konto({ vereinbart: { festpreisCents: null, aufwand: null }, offenCents: null }))
assert.equal(z.vereinbart, 'keine AB')
assert.equal(z.offen, '–')
})
test('kontoZeile: Stripe nicht erreichbar zeigt „–“ statt Nullen, Zeit und Vereinbartes bleiben', () => {
const z = kontoZeile(konto({ berechnetCents: 0, bezahltCents: 0, offenCents: null }), { stripeOk: false })
assert.deepEqual([z.vereinbart, z.berechnet, z.bezahlt, z.offen], ['1.200,00 €', '–', '', '–'])
assert.equal(z.zeit.wert, 'Wert 1.250,00 €')
// Aufwand: der Wert der offenen Zeit braucht kein Stripe
const a = kontoZeile(konto({ vereinbart: AUFWAND, offenCents: null }), { stripeOk: false })
assert.equal(a.offen, '1.250,00 €')
assert.equal(stripeHinweis(false), STRIPE_HINWEIS)
assert.equal(stripeHinweis(true), '')
assert.equal(stripeHinweis(undefined), '')
assert.equal(STRIPE_HINWEIS, 'Stripe ist gerade nicht erreichbar. Berechnete und bezahlte Beträge sind deshalb nicht verfügbar.')
})
test('ohneProjektZeile: Rechnungen ohne Projekt, ohne Vereinbart und Zeit', () => {
assert.deepEqual(ohneProjektZeile({ berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }), {
key: 'ohne-projekt',
projektId: '',
name: 'Ohne Projekt',
meta: '3 Rechnungen',
vereinbart: '–',
berechnet: '300,00 €',
bezahlt: 'bezahlt 100,00 €',
offen: '–',
zeit: { stunden: '–', wert: '', abo: '' },
})
assert.equal(ohneProjektZeile({ berechnetCents: 0, bezahltCents: 0, anzahl: 1 }).meta, '1 Rechnung')
assert.equal(ohneProjektZeile({ berechnetCents: 0, bezahltCents: 0, anzahl: 0 }).meta, 'keine Rechnungen')
assert.equal(ohneProjektZeile(undefined).meta, 'keine Rechnungen')
const s = ohneProjektZeile({ berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }, { stripeOk: false })
assert.deepEqual([s.berechnet, s.bezahlt], ['–', ''])
})
test('Hinweis für Kunden ohne Projekte', () => {
assert.equal(KEINE_PROJEKTE_HINWEIS, 'Dieser Kunde hat noch keine Projekte. Rechnungen entstehen ohne Projektbezug.')
})
test('artLabel: Spalte Art der Rechnungstabelle', () => {
assert.deepEqual(
['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss', 'abo'].map((a) => artLabel(a)),
['Ohne Projekt', 'Festbetrag', 'Aufwand', 'Anzahlung', 'Schlussrechnung', 'Abo']
)
assert.equal(artLabel(undefined), '–')
assert.equal(artLabel('unbekannt'), '–')
assert.equal(artLabel('constructor'), '–')
})
test('projektSpalte: Projektname der Rechnung oder „–“', () => {
assert.equal(projektSpalte({ projektName: ' Website Relaunch ' }), 'Website Relaunch')
assert.equal(projektSpalte({ projektName: '' }), '–')
assert.equal(projektSpalte({}), '–')
assert.equal(projektSpalte(null), '–')
})
test('abgerechnetText und abgerechnetWarnung: Rechnungsnummer oder Entwurf', () => {
const map = { w1: 'WEBKLAR-0009', w2: 'Entwurf' }
assert.equal(abgerechnetText(map, 'w1'), 'abgerechnet (WEBKLAR-0009)')
assert.equal(abgerechnetText(map, 'w2'), 'abgerechnet (Entwurf)')
assert.equal(abgerechnetText(map, 'w3'), null)
assert.equal(abgerechnetText(map, 'constructor'), null)
assert.equal(abgerechnetText(null, 'w1'), null)
assert.equal(abgerechnetWarnung(map, 'w1'), 'Dieses Arbeitsblatt ist abgerechnet (WEBKLAR-0009). Eine Änderung ändert die Rechnung nicht.')
assert.equal(abgerechnetWarnung(map, 'w2'), 'Dieses Arbeitsblatt ist abgerechnet (Entwurf). Eine Änderung ändert die Rechnung nicht.')
assert.equal(abgerechnetWarnung(map, 'w3'), null)
})
test('ticketIdsOf: eindeutige Ticket-Kennungen der Arbeitsblätter, höchstens fünf', () => {
assert.deepEqual(ticketIdsOf([{ workorderId: 't1' }, { workorderId: 't1' }, { workorderId: 't2' }, {}, null]), ['t1', 't2'])
assert.deepEqual(ticketIdsOf(undefined), [])
assert.equal(ticketIdsOf(Array.from({ length: 7 }, (_, i) => ({ workorderId: `t${i}` }))).length, 5)
})
```
- [ ] **Step 2: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/projektkontoView.test.mjs`
Expected: FAIL (esbuild findet `src/lib/projektkontoView.js` nicht).
- [ ] **Step 3: `src/lib/projektkontoView.js` implementieren**
Reine Datei, einziger Import `euro`, `euroWhole` aus `./financeFormat`; Kommentarkopf deutsch im Stil der Nachbardateien. Regeln:
- `stundenDe(minuten)`: Stunden = `minuten / 60`, auf höchstens 2 Nachkommastellen gerundet (`Math.round(h * 100) / 100`), Dezimalkomma, keine überflüssigen Nullen, Suffix ` Std.`; keine Zahl → 0.
- `satzText(cents)`: keine Zahl → `'–'`; sonst ganze Euro über `euroWhole` (`'100 €'`), mit Cent über `euro` (`'85,50 €'`), Suffix `/Std.`.
- `ticketsText(tickets)`: je Ticket mit nichtleerem `woid` `#<woid>`, mit `', '` verbunden; sonst `''`.
- `vereinbartText(v)`: Teil 1 `euro(festpreisCents)` bei einer Zahl; Teil 2 `nach Aufwand, <satzText>` plus `, Schätzung <stundenDe>` nur bei `schaetzungMinuten > 0`; Teile mit `' und '` verbunden; kein Teil → `'keine AB'`.
- `kontoZeile(konto, { stripeOk = true })`: `stripe = stripeOk !== false`. `key` und `projektId` = `konto.projektId`; `name` getrimmt (leer → `'Projekt'`); `meta` = `[projektTyp getrimmt, ticketsText(tickets)]` ohne Leere mit `' · '`; `berechnet` = `euro(berechnetCents)`, `bezahlt` = `bezahlt <euro(bezahltCents)>` (bei Stripe-Ausfall `'–'` bzw. `''`); `offen`: bei Festpreis (`festpreisCents` ist eine Zahl) `euro(offenCents)` (bei Stripe-Ausfall `'–'`), sonst bei Aufwand `euro(zeit.offenerWertCents)`, sonst `'–'`; `zeit.stunden` = `stundenDe(zeit.minuten)`, `zeit.wert` = `Wert <euro(zeit.wertCents)>`, `zeit.abo` = `über Abo: <stundenDe(zeit.aboMinuten)>` nur bei `aboMinuten > 0`, sonst `''`.
- `ohneProjektZeile(ohne, { stripeOk = true })`: feste Werte `key 'ohne-projekt'`, `projektId ''`, `name 'Ohne Projekt'`, `vereinbart '–'`, `offen '–'`, `zeit { stunden: '–', wert: '', abo: '' }`; `meta` aus `anzahl` (0 oder fehlend `keine Rechnungen`, 1 `1 Rechnung`, sonst `<n> Rechnungen`); `berechnet`/`bezahlt` wie oben.
- `artLabel(art)`: Tabelle `{ frei: 'Ohne Projekt', festbetrag: 'Festbetrag', aufwand: 'Aufwand', anzahlung: 'Anzahlung', schluss: 'Schlussrechnung', abo: 'Abo' }`, nur eigene Schlüssel (`Object.prototype.hasOwnProperty`), sonst `'–'`.
- `projektSpalte(invoice)`: `projektName` getrimmt oder `'–'`.
- `abgerechnetText(map, id)`: nur bei eigenem Schlüssel `id` in einem Objekt und nichtleerem Wert `abgerechnet (<Wert>)`, sonst `null`; `abgerechnetWarnung` wie der Test, gleiche Bedingung.
- `ticketIdsOf(worksheets)`: eindeutige, getrimmte, nichtleere `workorderId` in Reihenfolge des ersten Auftretens, höchstens 5.
- [ ] **Step 4: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/projektkontoView.test.mjs`
Expected: PASS, `# pass 14`, `# fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
git add src/lib/projektkontoView.js tests/unit/projektkontoView.test.mjs
git commit -m "Projektkonto: Anzeigelogik für Zeilen, Rechnungsspalten und Abzeichen „abgerechnet“ mit Tests" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 3: Projektliste `ProjektkontoListe` und Prüfläufer
**Files:**
- Modify (ersetzt den Platzhalter von W0 vollständig): `src/components/auftraege/ProjektkontoListe.jsx`
- Create: `src/components/auftraege/projektkonto.css`
- Create (nur unter `SP`, nicht im Repo): `lauf.sh`, `shots.mjs`, `fixtures-w1.mjs`, `szenarien.mjs`
**Interfaces:**
- Consumes: `kontoZeile`, `ohneProjektZeile`, `stripeHinweis`, `KEINE_PROJEKTE_HINWEIS` (Task 2); `Card` aus `../ui`. Der Container von W0 ruft `<ProjektkontoListe konten ohneProjekt auftraegeAktiv stripeOk onRechnung onAuftrag />`; die Listenüberschrift „Projekte“ rendert die Komponente selbst (W0 Vertragsfrage 4).
- Produces: `export default function ProjektkontoListe({ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung, onAuftrag })`. `onRechnung(projektId)` und `onAuftrag(projektId)` bekommen die Projektkennung, die Zeile „Ohne Projekt“ ruft sie mit `null`. Der Prüfläufer (`lauf.sh [--ohne-build] [--only id,id]`) liefert Bilder unter `SP/shots/<w>x<h>/<id>.png` und die Zeilen `ok …`/`FAIL …`; spätere Tasks ergänzen nur Szenarien.
- [ ] **Step 1: Platzhalter und Einbindung lesen**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
cat src/components/auftraege/ProjektkontoListe.jsx
rg -n "ProjektkontoListe|onRechnung|onAuftrag" src/components/auftraege/AuftraegeUndRechnungen.jsx
```
Expected: ein Platzhalter mit der Prop-Signatur, der `null` liefert; im Container wird die Liste mit allen sechs Props eingebunden. Weicht die Einbindung ab: **STOPP**, melden.
- [ ] **Step 2: `projektkonto.css` schreiben**
Dateiinhalt (Tokens aus `global.css`, keine Farbliterale; jede Regel trägt `.pk`, weil dieses Stylesheet im Bundle vor `global.css` steht):
```css
/* Projektliste im Kundenreiter „Aufträge und Rechnungen“. .pk ist der Container der Abfrage unten (in W1 nur hier
container-type; der Rechnungsdialog liegt nie darin). Jede Regel trägt .pk und überstimmt so .table td aus global.css. */
.pk { container-type: inline-size; }
.pk .pk-hinweis { margin: 0 0 8px; }
.pk .pk-table th, .pk .pk-table td { vertical-align: top; }
.pk .pk-name, .pk .pk-table td .cell-sub { overflow-wrap: anywhere; }
.pk .pk-table .num { font-variant-numeric: tabular-nums; }
.pk .pk-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 14px; }
.pk .pk-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@container (max-width: 860px) {
.pk .pk-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.pk .pk-table, .pk .pk-table tbody { display: block; }
.pk .pk-table tr {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; align-items: start;
grid-template-areas: "name name" "vereinbart berechnet" "offen zeit" "actions actions";
padding: 12px 0; border-bottom: 1px solid var(--line);
}
.pk .pk-table td, .pk .pk-table td:first-child, .pk .pk-table td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
.pk .pk-name { grid-area: name; }
.pk .pk-vereinbart { grid-area: vereinbart; }
.pk .pk-berechnet { grid-area: berechnet; }
.pk .pk-offen { grid-area: offen; }
.pk .pk-zeit { grid-area: zeit; }
.pk .pk-actions-cell { grid-area: actions; }
.pk .pk-actions { justify-content: flex-start; }
.pk .pk-table td[data-label]::before { content: attr(data-label); display: block; font-size: 12px; font-weight: 500; color: var(--text-2); }
}
```
- [ ] **Step 3: `ProjektkontoListe.jsx` schreiben**
Kommentarkopf deutsch (Aufgabe, Props, „Ohne Projekt“ = `null`); `import './projektkonto.css'`. Aufbau:
- `zeilen = [...konten.map((k) => kontoZeile(k, { stripeOk })), ohneProjektZeile(ohneProjekt, { stripeOk })]`. `konten` darf fehlen (dann `[]`).
- Wurzel `<Card title="Projekte"><div className="pk">…</div></Card>`.
- In `.pk`, in dieser Reihenfolge: ist `stripeHinweis(stripeOk)` nichtleer, `<p className="msg warn pk-hinweis" role="status">{hinweis}</p>`; ist `konten` leer, `<p className="msg muted pk-hinweis">{KEINE_PROJEKTE_HINWEIS}</p>`; dann `<table className="table pk-table" role="table">` mit `<caption className="pk-sr">Projektkonten</caption>`.
- Kopf (`role="rowgroup"`, eine Zeile): `Projekt`, `Vereinbart`, `Berechnet`, `Noch offen`, `Zeit erfasst` und eine letzte Zelle `<span className="pk-sr">Aktionen</span>`; alle `<th role="columnheader" scope="col">`.
- Je Zeile (`role="row"`, `key={z.key}`) sechs `<td role="cell">`:
1. `pk-name`: `<div className="cell-title">{z.name}</div>` und, wenn `z.meta`, `<div className="cell-sub">{z.meta}</div>`.
2. `pk-vereinbart`, `data-label="Vereinbart"`: `{z.vereinbart}`.
3. `pk-berechnet`, `data-label="Berechnet"`: `<div className="num">{z.berechnet}</div>` und, wenn `z.bezahlt`, `<div className="cell-sub">{z.bezahlt}</div>`.
4. `pk-offen`, `data-label="Noch offen"`: `<span className="num">{z.offen}</span>`.
5. `pk-zeit`, `data-label="Zeit erfasst"`: `<div className="num">{z.zeit.stunden}</div>` und je nichtleerem `z.zeit.wert` und `z.zeit.abo` ein `<div className="cell-sub">`.
6. `pk-actions-cell`: `<div className="pk-actions">` mit `<button type="button" className="link" aria-label=… onClick={() => onAuftrag(id)}>Auftragsbestätigung</button>` (nur wenn `auftraegeAktiv`) und `<button type="button" className="link" aria-label=… onClick={() => onRechnung(id)}>Rechnung</button>`. `id` ist `z.projektId` bzw. `null` bei der Zeile „Ohne Projekt“. `aria-label`: `Auftragsbestätigung für <Name>` und `Rechnung für <Name>`, bei „Ohne Projekt“ `Auftragsbestätigung ohne Projekt` und `Rechnung ohne Projekt`.
- [ ] **Step 4: Syntax und Hygiene**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
node_modules/.bin/esbuild src/components/auftraege/ProjektkontoListe.jsx src/components/auftraege/projektkonto.css --loader:.jsx=jsx --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/syntax --log-level=warning
rg -n "react-icons|@tabler|style=\{\{|ui-btn|form-control|form-group|form-label|ui-card|badge-|overlay-content|overlay-close|spinner|kpi|#[0-9a-fA-F]{3,8}\b|rgba?\(|!important|console\.log" src/components/auftraege/ProjektkontoListe.jsx src/components/auftraege/projektkonto.css
```
Expected: esbuild ohne Ausgabe; `rg` ohne Treffer.
- [ ] **Step 5: Prüfläufer anlegen (nur unter `SP`)**
`SP/lauf.sh` (ausführbar machen, `chmod +x`):
```bash
#!/usr/bin/env bash
# lauf.sh [--ohne-build] [--only id,id]: Build mit Test-Endpunkten, dann der Läufer (alles hinter dem gemeinsamen flock)
WT=/home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
SP=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1
LOCK=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock
REDESIGN_LOCK=/root/reports/ticketsystem-design-2026-10-04/sperren/schwer.lock
build=1; args=()
for a in "$@"; do if [ "$a" = "--ohne-build" ]; then build=0; else args+=("$a"); fi; done
avail=$(free -m | awk '/^Mem:/ {print $7}')
if [ "$avail" -lt 1500 ]; then echo "lauf.sh: nur $avail MB frei (unter 1500), später erneut starten" >&2; exit 3; fi
exec 9>"$LOCK"; flock 9
if [ -d "$(dirname "$REDESIGN_LOCK")" ]; then exec 8>"$REDESIGN_LOCK"; flock 8; fi
if [ "$build" = 1 ]; then
(cd "$WT" && 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 nice -n 10 systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 \
node node_modules/vite/bin/vite.js build --outDir "$SP/dist" --emptyOutDir) || exit 1
fi
FIXTURE_TODAY=2026-10-05 nice -n 10 systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 node "$SP/shots.mjs" "${args[@]}"
```
`SP/shots.mjs`:
```js
// Aufruf nur über lauf.sh. Rendert die Szenarien aus szenarien.mjs gegen das Test-Backend des Redesign-Werkzeugs
// (nur importiert, nie verändert) und prüft sie. Exit 0 = alles ok, 1 = ein Szenario ist durchgefallen, 2 = Aufruf.
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import assert from 'node:assert/strict'
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 { SZENARIEN } from './szenarien.mjs'
import { portalAntworten } from './fixtures-w1.mjs'
const SP = '/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1'
const ORIGIN = 'https://ticket.test'
const CORS = {
'access-control-allow-origin': ORIGIN,
'access-control-allow-credentials': 'true',
'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
'access-control-allow-headers': 'authorization,content-type,x-appwrite-jwt',
}
// Die neuen Portal-Endpunkte beantwortet fixtures-w1.mjs; alles andere fällt an das Backend des Werkzeugs durch
const NEU = /^https:\/\/portal\.test\/api\/(admin\/finanzen\/(projektkonten|rechnungen)|team\/arbeitsblaetter\/abgerechnet)/
const i = process.argv.indexOf('--only')
const nur = i > 0 ? process.argv[i + 1].split(',') : null
const auswahl = SZENARIEN.filter((s) => !nur || nur.includes(s.id))
if (nur && auswahl.length !== nur.length) {
console.error(`unbekanntes Szenario in --only: ${nur.filter((id) => !SZENARIEN.some((s) => s.id === id)).join(', ')}`)
process.exit(2)
}
const browser = await launchChromium()
let durchgefallen = 0
for (const s of auswahl) {
for (const [w, h] of s.viewports ?? [[1440, 900], [390, 844]]) {
const mobil = w < 1000
const backend = createBackend({ fixtures: await loadFixtures('standard'), scenario: 'angemeldet', dist: join(SP, 'dist') })
const portal = portalAntworten(backend, s.portal ?? {})
await s.daten?.(backend, portal)
const context = await browser.newContext({
viewport: { width: w, height: h }, deviceScaleFactor: mobil ? 2 : 1, isMobile: mobil, hasTouch: mobil,
locale: 'de-DE', timezoneId: 'Europe/Berlin', reducedMotion: 'reduce', colorScheme: 'light', serviceWorkers: 'block',
})
await context.route('**/*', (route, request) => Promise.resolve(backend.handle(route, request)).catch(() => {}))
await context.route(NEU, (route, request) => portal.handle(route, request, CORS)) // zuletzt registriert = zuerst gefragt
const page = await context.newPage()
page.setDefaultTimeout(10_000)
const seitenfehler = []
page.on('pageerror', (e) => seitenfehler.push(String(e?.message ?? e)))
page.on('dialog', (d) => d.accept()) // window.confirm der Rückfragen
const name = `${s.id} ${w}x${h}`
try {
await page.goto(`${ORIGIN}${s.path}`, { waitUntil: 'load' })
await page.waitForLoadState('networkidle').catch(() => {})
await s.steps(page, { backend, portal, viewport: [w, h], mobil, assert })
// Review Focus 5: nie waagerecht scrollen (Seite und Dialog)
const breite = await page.evaluate(() => {
const body = document.querySelector('.dialog-body')
return { seite: document.documentElement.scrollWidth - document.documentElement.clientWidth, dialog: body ? body.scrollWidth - body.clientWidth : 0 }
})
assert.ok(breite.seite <= 1 && breite.dialog <= 1, `waagerechtes Scrollen: ${JSON.stringify(breite)}`)
mkdirSync(join(SP, 'shots', `${w}x${h}`), { recursive: true })
await page.screenshot({ path: join(SP, 'shots', `${w}x${h}`, `${s.id}.png`), fullPage: !s.nurViewport })
const hart = backend.violations.filter((v) => ['external-request', 'backend-error', 'missing-asset', 'unhandled-request'].includes(v.kind))
assert.deepEqual(hart, [], 'Verstöße des Test-Backends')
assert.deepEqual(seitenfehler, [], 'JavaScript-Fehler auf der Seite')
console.log(`ok ${name}`)
} catch (err) {
durchgefallen += 1
console.log(`FAIL ${name}: ${String(err?.message ?? err).split('\n').slice(0, 3).join(' | ')}`)
mkdirSync(join(SP, 'shots'), { recursive: true })
await page.screenshot({ path: join(SP, 'shots', `FAIL-${s.id}-${w}x${h}.png`) }).catch(() => {})
} finally {
await context.close()
}
}
}
await browser.close()
process.exit(durchgefallen ? 1 : 0)
```
Taucht ein `unhandled-request` für eine Adresse auf, die nichts mit W1 zu tun hat (Chat, Zähler …), wird sie in `fixtures-w1.mjs` mit einer leeren Antwort ergänzt, nicht im Läufer ausgenommen.
`SP/fixtures-w1.mjs` exportiert `portalAntworten(backend, optionen) → { calls, handle(route, request, cors), state }`. Optionen (alle optional): `ab` (Standard `false`), `anzahlungBerechnet` (`false`), `stripeOk` (`true`), `konten` (Liste ersetzt `konten`, z. B. `[]`), `langerName` (`false`), `kunde` (`'c2'`), `vorschauDelayMs`, `postDelayMs`, `postFehler` (`{ status, error }`), `vorschauFehler` (`{ status, error }`), `kontenFehler` (`{ status, error }`: der GET `…/projektkonten?kunde=` antwortet damit), `abgerechnet` (`{}`), `schluss40` (`false`; die Vorschau einer Schlussrechnung hat dann 40 Zeilen, siehe Task 8). `state` hält die aufgelösten Werte und darf von Szenarien verändert werden; `calls` sammelt `{ method, path, query, body }` jeder beantworteten Anfrage. Antworten (Portal-Formen nach V5; nicht behandelte Methoden/Pfade und alle `GET …/rechnungen` rufen `route.fallback()`, `OPTIONS` antwortet 204 mit CORS; jede eigene Antwort trägt die CORS-Köpfe aus `cors` und `content-type: application/json`, auch die Fehlerantworten, sonst kann die Seite den Körper nicht lesen):
- **Kunde c2 „Kanzlei Weber & Partner“** (Testdaten des Werkzeugs; Anschrift vollständig, Tickets `wo100133` mit Blatt `ws100148` und `wo100128` mit Blatt `ws100149`). `GET …/projektkonten?kunde=<id>`: `{ auftraegeAktiv: ab, kunde: { id: 'c2', name: 'Kanzlei Weber & Partner', kundenart: '' }, konten, ohneProjekt: { berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }, stripeOk }`. Für jeden anderen Kunden: `konten: []`, `ohneProjekt` mit Nullen, `auftraegeAktiv: ab`.
- **`konten`** (neueste zuerst; `projektTyp` ist der Anzeigename):
| `projektId` | Name | `projektTyp` | Ticket | `vereinbart` (mit `ab`) | berechnet / bezahlt / offen | `zeit` (min, abo, offene min, Satz, Wert, offener Wert) |
|---|---|---|---|---|---|---|
| `p-fest` | Fachanwaltsseiten | Website | `{ id: 'wo-demo-fest', woid: '100200' }` | `festpreisCents: 120000` (ohne `ab`: `null`) | 40000 / 0 / `offenCents`: mit `ab` 120000, mit `ab` und `anzahlungBerechnet` 80000, ohne `ab` `null` | 750, 90, 750, 10000, 125000, 125000 |
| `p2` | Kanzlei Weber | Website | `{ id: 'wo100133', woid: '100133' }` | `aufwand: { stundensatzCents: 10000, schaetzungMinuten: 600 }` (ohne `ab`: `null`) | 0 / 0 / `null` | 195, 0, 195, 10000, 32500, 32500 |
| `p-lang` | `MandantenportalRelaunchKanzleiWeberPartnerMannheimEntwurf2026` (nur mit `langerName`, sonst „Logo-Entwurf“) | `''` | keins | `null` | 0 / 0 / `null` | 0, 0, 0, 10000, 0, 0 |
Bei `stripeOk: false` sind `berechnetCents`/`bezahltCents` 0 und `offenCents` `null` (Verhalten von L1).
- **`GET …/projektkonten/<projektId>`**: `{ konto: <Eintrag der Liste>, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk }`. `auftraege` nur mit `ab`: bei `p-fest` die Festpreis-AB `{ id: 'a1', nummer: 'AB-2026-0002', status: 'bestaetigt', art: 'festpreis', summeCents: 120000, stundensatzCents: null, schaetzungMinuten: null, gesendetAm: '2026-10-06T08:15:00.000Z', zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, positionen: [{ beschreibung: 'Fachanwaltsseiten: Konzept, Umsetzung, Einrichtung', menge: 1, einzelpreisCents: 120000 }] }`, bei `p2` die Aufwands-AB `{ id: 'x1', nummer: 'AB-2026-0003', status: 'bestaetigt', art: 'aufwand', summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, gesendetAm: '2026-10-07T08:00:00.000Z', zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }`. `rechnungen`: bei `p-fest` mit `anzahlungBerechnet` `[{ id: 'in_a', number: 'WEBKLAR-0007', status: 'offen', createdDate: '2026-10-08', totalCents: 40000, paidCents: 0, art: 'anzahlung', auftragId: 'a1' }]`, sonst `[]` (bei `stripeOk: false` immer `[]`). `offeneArbeitsblaetter` nur bei `p2`: `ws100148` (ticket `wo100133`, `2026-10-04`, 90 min, „Seitenaufbau übernommen, Platzhaltertexte eingesetzt.“), `b-demo-2` (`2026-10-04`, 60 min, „Kontaktblock gebaut“), `b-demo-3` (`2026-10-05`, 45 min, „Footer und Impressum“), jeweils `{ id, ticketId, woid: '100133', datum, minuten, text, aboZeit: false, kommentar: false }`. Unbekannte Kennung: 404 `{ error: 'Projekt nicht gefunden.' }`.
- **`GET /api/team/arbeitsblaetter/abgerechnet?ticketId=…`**: `{ abgerechnet: state.abgerechnet }`.
- **`POST …/rechnungen/vorschau` und `POST …/rechnungen`** füllen Task 8; bis dahin antworten beide `route.fallback()`.
`SP/szenarien.mjs` exportiert `SZENARIEN = [{ id, path, portal?, daten?(backend, portal), viewports?, nurViewport?, steps(page, { backend, portal, viewport, mobil, assert }) }]`. Hilfen im Kopf der Datei: `zeilen(page)` (die `tr` im `tbody` von `getByRole('table', { name: 'Projektkonten' })`) und `text(locator)`. Szenarien dieses Tasks (`steps` prüfen mit `assert`; sichtbare Texte wörtlich wie in Task 2):
| ID | Pfad | `portal` | Prüfungen |
|---|---|---|---|
| `konten` | `/customers/c2?tab=invoices` | `{}` | Überschrift „Projekte“ sichtbar; 4 Zeilen (3 Projekte plus „Ohne Projekt“); Zeile „Fachanwaltsseiten“ enthält `Website · #100200`, `keine AB`, `400,00 €`, `bezahlt 0,00 €`, `12,5 Std.`, `Wert 1.250,00 €`, `über Abo: 1,5 Std.`; Zeile „Kanzlei Weber“ enthält `Website · #100133`, `3,25 Std.`, `Wert 325,00 €`; letzte Zeile enthält `Ohne Projekt`, `3 Rechnungen`, `300,00 €`, `bezahlt 100,00 €`; vier Knöpfe `Rechnung für …`/`Rechnung ohne Projekt`, kein Knopf `Auftragsbestätigung …`; weder Stripe- noch Keine-Projekte-Hinweis |
| `konten-ab` | `/customers/c2?tab=invoices` | `{ ab: true, anzahlungBerechnet: true }` | „Fachanwaltsseiten“: Vereinbart `1.200,00 €`, Noch offen `800,00 €`; „Kanzlei Weber“: `nach Aufwand, 100 €/Std., Schätzung 10 Std.`, Noch offen `325,00 €`; vier Knöpfe `Auftragsbestätigung …`; ein Klick auf `Rechnung für Kanzlei Weber` löst keinen JavaScript-Fehler aus |
| `konten-stripe` | `/customers/c2?tab=invoices` | `{ ab: true, stripeOk: false }` | Hinweis „Stripe ist gerade nicht erreichbar. Berechnete und bezahlte Beträge sind deshalb nicht verfügbar.“ sichtbar; „Fachanwaltsseiten“: Vereinbart `1.200,00 €`, Berechnet `–`, kein `bezahlt`, Noch offen `–`; „Kanzlei Weber“: Noch offen `325,00 €`; „Ohne Projekt“: Berechnet `–` |
| `konten-leer` | `/customers/c5?tab=invoices` | `{ ab: false }` | Hinweis „Dieser Kunde hat noch keine Projekte. Rechnungen entstehen ohne Projektbezug.“; genau eine Zeile „Ohne Projekt“; Knopf `Rechnung ohne Projekt` |
| `konten-lang` | `/customers/c2?tab=invoices` | `{ langerName: true }` | die Zeile mit dem 61 Zeichen langen Namen ist sichtbar (der Läufer prüft bei 390 px zusätzlich das waagerechte Scrollen) |
- [ ] **Step 6: Läufe und Bilder**
Run: `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/lauf.sh --only konten,konten-ab,konten-stripe,konten-leer,konten-lang`
Expected: Exit 0 und zehnmal `ok …` (fünf Szenarien in zwei Breiten). Exit 3 heißt zu wenig freier Speicher: später wiederholen. Danach `SP/shots/1440x900/konten.png`, `SP/shots/390x844/konten.png` und `…/konten-stripe.png` mit dem Read-Werkzeug ansehen: Tabelle mit Haarlinien und Textknöpfen bei 1440 px; bei 390 px je Projekt ein Block mit Namen oben, vier beschrifteten Werten in zwei Spalten und den Knöpfen darunter, nichts schneidet ab.
- [ ] **Step 7: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
git add src/components/auftraege/ProjektkontoListe.jsx src/components/auftraege/projektkonto.css
git commit -m "Projektkonto: Projektliste mit Vereinbart, Berechnet, Noch offen und Zeit, Knöpfe Rechnung und Auftragsbestätigung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 4: Dialoglogik – Projekte, Arten, ABs, Arbeitsblätter (`rechnungDialogView`)
**Files:**
- Create: `src/lib/rechnungDialogView.js`
- Test: `tests/unit/rechnungDialogView.test.mjs` (neu)
**Interfaces:**
- Consumes: `euro`, `dateDe`, `ymdBerlin` (`financeFormat`), `worksheetMinutes` (`support`), `germanDateToIso` (`ticketForm`), `ticketsText` (Task 2). Eingaben nach V3/V4: `Projektkonto`, `AuftragTeam` (`id, nummer, status, art, summeCents, gesendetAm, zahlungsplan`), `RechnungKurz` (`id, status, auftragId, totalCents`), `Arbeitsblatt` (`id, ticketId, woid, datum, minuten, text, aboZeit, kommentar`).
- Produces (benannte Exporte):
- Konstanten `ART_OPTION_LABEL = { frei: 'Freie Positionen', anzahlung: 'Anzahlung', schluss: 'Schlussrechnung', aufwand: 'Nach Aufwand', festbetrag: 'Festbetrag' }`, `AB_HINWEIS`, `ENTWURF_HINWEIS`, `OHNE_PROJEKT = 'Ohne Projekt'`
- Projektwahl: `startProjekt(propProjektId) → null | string`, `projektOptionen(konten) → [{ value, label }]`, `wirksamesProjekt(wahl, konten) → null | string` (`null` = noch zu wählen, `''` = Ohne Projekt)
- ABs: `gueltigeFestpreisAbs(auftraege) → AuftragTeam[]`, `aufwandAuftrag(auftraege) → AuftragTeam | null`, `abLabel(auftrag) → string`, `berechnetFuerAuftrag(rechnungen, auftragId) → number`, `hatEntwurfZurAuftrag(rechnungen, auftragId) → boolean`
- Arten: `artOptionen({ projektId, auftraege, rechnungen, auftraegeAktiv }) → [{ key, label, enabled, hint }]`, `standardArt(optionen, { auftraege, rechnungen, offeneMinuten }) → string`, `standardAuftrag(art, auftraege, rechnungen = []) → string`, `anzahlungStandard(auftrag) → { modus, wert, bezeichnung }`
- Arbeitsblätter: `ausgewaehlteIds(blaetter, abgewaehlt) → string[]`, `blaetterSumme(blaetter, ids) → { anzahl, minuten }`, `blattAusAppwrite(ws, abgerechnet) → Arbeitsblatt | null`, `ticketOptionen(tickets, { ausgeschlossen = [] }) → [{ value, label, ticketId, woid }]`
- [ ] **Step 1: Failing Test schreiben**
`tests/unit/rechnungDialogView.test.mjs`:
```js
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const {
AB_HINWEIS, ENTWURF_HINWEIS,
startProjekt, projektOptionen, wirksamesProjekt,
gueltigeFestpreisAbs, aufwandAuftrag, abLabel, berechnetFuerAuftrag, hatEntwurfZurAuftrag,
artOptionen, standardArt, standardAuftrag, anzahlungStandard,
ausgewaehlteIds, blaetterSumme, blattAusAppwrite, ticketOptionen,
} = await loadModule('src/lib/rechnungDialogView.js')
const ab = (over = {}) => ({
id: 'a1', nummer: 'AB-2026-0002', status: 'bestaetigt', art: 'festpreis', summeCents: 80000,
gesendetAm: '2026-10-06T08:15:00.000Z', zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, stundensatzCents: null, ...over,
})
const rk = (over = {}) => ({
id: 'in_1', number: 'WEBKLAR-0007', status: 'offen', createdDate: '2026-10-08', totalCents: 40000, paidCents: 0, art: 'anzahlung', auftragId: 'a1', ...over,
})
const bl = (id, minuten, over = {}) => ({
id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false, ...over,
})
const KONTEN = [
{ projektId: 'p1', name: 'Website Relaunch', tickets: [{ id: 't1', woid: '100131' }] },
{ projektId: 'p2', name: 'Shop', tickets: [] },
]
const optionen = (args) => artOptionen({ projektId: 'p1', auftraege: [], rechnungen: [], auftraegeAktiv: true, ...args })
const zustand = (o) => o.map((x) => [x.key, x.enabled])
test('startProjekt: Vorauswahl der Aufrufer (undefined = keine, null = Ohne Projekt, Text = Projekt)', () => {
assert.equal(startProjekt(undefined), null)
assert.equal(startProjekt(null), '')
assert.equal(startProjekt('p1'), 'p1')
})
test('projektOptionen: Projekte mit Ticketnummer, am Ende „Ohne Projekt“', () => {
assert.deepEqual(projektOptionen(KONTEN), [
{ value: 'p1', label: 'Website Relaunch · #100131' },
{ value: 'p2', label: 'Shop' },
{ value: '', label: 'Ohne Projekt' },
])
assert.deepEqual(projektOptionen([]), [{ value: '', label: 'Ohne Projekt' }])
assert.deepEqual(projektOptionen(undefined), [{ value: '', label: 'Ohne Projekt' }])
})
test('wirksamesProjekt: null = noch zu wählen, „“ = Ohne Projekt, Kunde ohne Projekte = Ohne Projekt', () => {
assert.equal(wirksamesProjekt('', KONTEN), '')
assert.equal(wirksamesProjekt('p2', KONTEN), 'p2')
assert.equal(wirksamesProjekt('gibtsnicht', KONTEN), null)
assert.equal(wirksamesProjekt('p1', undefined), 'p1') // Liste noch nicht geladen: die Vorauswahl bleibt
assert.equal(wirksamesProjekt(null, KONTEN), null)
assert.equal(wirksamesProjekt(null, []), '') // Kunde ohne Projekte: gleich „Ohne Projekt“
assert.equal(wirksamesProjekt(null, undefined), null)
})
test('gueltigeFestpreisAbs: nur Festpreis mit Status versendet oder bestätigt, neueste zuerst', () => {
const list = [
ab({ id: 'a1' }),
ab({ id: 'a2', status: 'entwurf' }),
ab({ id: 'a3', art: 'aufwand' }),
ab({ id: 'a4', status: 'abgerechnet' }),
ab({ id: 'a5', status: 'versendet', gesendetAm: '2026-10-07T08:00:00.000Z' }),
ab({ id: 'a6', status: 'storniert' }),
]
assert.deepEqual(gueltigeFestpreisAbs(list).map((a) => a.id), ['a5', 'a1'])
assert.deepEqual(gueltigeFestpreisAbs(undefined), [])
})
test('aufwandAuftrag: jüngste gültige Aufwands-AB', () => {
const list = [
ab({ id: 'x1', art: 'aufwand', status: 'bestaetigt', gesendetAm: '2026-10-01T08:00:00.000Z' }),
ab({ id: 'x2', art: 'aufwand', status: 'versendet', gesendetAm: '2026-10-05T08:00:00.000Z' }),
ab({ id: 'x3', art: 'aufwand', status: 'storniert', gesendetAm: '2026-10-09T08:00:00.000Z' }),
ab({ id: 'a1' }),
]
assert.equal(aufwandAuftrag(list).id, 'x2')
assert.equal(aufwandAuftrag([ab()]), null)
assert.equal(aufwandAuftrag(undefined), null)
})
test('abLabel: Nummer, Datum des Sendens und Summe bzw. „nach Aufwand“', () => {
assert.equal(abLabel(ab()), 'AB-2026-0002 vom 06.10.2026 · 800,00 €')
assert.equal(abLabel(ab({ art: 'aufwand', summeCents: null })), 'AB-2026-0002 vom 06.10.2026 · nach Aufwand')
})
test('berechnetFuerAuftrag und hatEntwurfZurAuftrag: nur finalisierte zählen, ein Entwurf blockiert', () => {
const list = [
rk(),
rk({ id: 'in_2', status: 'bezahlt', totalCents: 10000 }),
rk({ id: 'in_3', status: 'entwurf', totalCents: 5000 }),
rk({ id: 'in_4', status: 'storniert', totalCents: 7000 }),
rk({ id: 'in_5', auftragId: 'a2', totalCents: 9000 }),
]
assert.equal(berechnetFuerAuftrag(list, 'a1'), 50000)
assert.equal(berechnetFuerAuftrag(list, 'a2'), 9000)
assert.equal(berechnetFuerAuftrag(undefined, 'a1'), 0)
assert.equal(hatEntwurfZurAuftrag(list, 'a1'), true)
assert.equal(hatEntwurfZurAuftrag(list, 'a2'), false)
})
test('artOptionen: mit gültiger Festpreis-AB sind alle vier Arten wählbar', () => {
const o = optionen({ auftraege: [ab()] })
assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', true], ['aufwand', true], ['festbetrag', true]])
assert.deepEqual(o.map((x) => x.label), ['Anzahlung', 'Schlussrechnung', 'Nach Aufwand', 'Festbetrag'])
})
test('artOptionen: ohne AB oder bei ausgeschaltetem Schalter erklärt ein Hinweis die Sperre', () => {
for (const args of [{ auftraege: [] }, { auftraege: [ab()], auftraegeAktiv: false }, { auftraege: [ab({ status: 'entwurf' })] }]) {
const o = optionen(args)
assert.deepEqual(zustand(o), [['anzahlung', false], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
assert.equal(o[0].hint, AB_HINWEIS)
assert.equal(o[1].hint, AB_HINWEIS)
}
assert.equal(AB_HINWEIS, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
})
test('artOptionen: ein offener Entwurf zur AB sperrt die Schlussrechnung, nicht die Anzahlung', () => {
const o = optionen({ auftraege: [ab()], rechnungen: [rk({ status: 'entwurf' })] })
assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
assert.equal(o[1].hint, ENTWURF_HINWEIS)
assert.equal(ENTWURF_HINWEIS, 'Zu dieser Auftragsbestätigung gibt es noch einen Entwurf. Erst senden oder löschen.')
// zwei ABs: die Schlussrechnung bleibt wählbar, solange eine AB ohne Entwurf da ist
const zwei = optionen({ auftraege: [ab(), ab({ id: 'a2', nummer: 'AB-2026-0003' })], rechnungen: [rk({ status: 'entwurf' })] })
assert.equal(zwei[1].enabled, true)
})
test('artOptionen: ohne Wahl keine Arten, ohne Projekt nur die freie Rechnung', () => {
assert.deepEqual(artOptionen({ projektId: null, auftraege: [ab()], rechnungen: [], auftraegeAktiv: true }), [])
const frei = artOptionen({ projektId: '', auftraege: [ab()], rechnungen: [], auftraegeAktiv: true })
assert.deepEqual(zustand(frei), [['frei', true]])
})
test('standardArt: Anzahlung, Schlussrechnung, Aufwand oder Festbetrag je nach Lage', () => {
const lage = (args, extra = {}) => {
const eingabe = { auftraege: [], rechnungen: [], offeneMinuten: 0, ...args, ...extra }
return standardArt(optionen({ auftraege: eingabe.auftraege, rechnungen: eingabe.rechnungen }), eingabe)
}
assert.equal(lage({ auftraege: [ab()] }), 'anzahlung') // Zahlungsplan Anzahlung, noch nichts berechnet
assert.equal(lage({ auftraege: [ab()], rechnungen: [rk()] }), 'schluss') // Anzahlung schon berechnet
const nachAbnahme = ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })
assert.equal(lage({ auftraege: [nachAbnahme] }), 'schluss')
assert.equal(lage({}, { offeneMinuten: 90 }), 'aufwand')
assert.equal(lage({}), 'festbetrag')
const ohneProjekt = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true })
assert.equal(standardArt(ohneProjekt, { auftraege: [], rechnungen: [], offeneMinuten: 0 }), 'frei')
assert.equal(standardArt([], { auftraege: [], rechnungen: [], offeneMinuten: 0 }), '')
})
test('standardAuftrag: die jüngste passende AB, bei Schlussrechnung ohne offenen Entwurf', () => {
const neu = ab({ id: 'a5', gesendetAm: '2026-10-07T08:00:00.000Z' })
assert.equal(standardAuftrag('anzahlung', [ab(), neu]), 'a5')
assert.equal(standardAuftrag('schluss', [ab(), neu]), 'a5')
assert.equal(standardAuftrag('schluss', [ab(), neu], [rk({ auftragId: 'a5', status: 'entwurf' })]), 'a1')
assert.equal(standardAuftrag('anzahlung', []), '')
assert.equal(standardAuftrag('aufwand', [ab({ id: 'x2', art: 'aufwand' })]), '') // die Aufwands-AB ordnet der Server zu
assert.equal(standardAuftrag('festbetrag', [ab()]), '')
})
test('anzahlungStandard: Prozent aus dem Zahlungsplan, sonst 50', () => {
const vorgabe = { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }
assert.deepEqual(anzahlungStandard(ab()), vorgabe)
assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'anzahlung', prozent: 33.5, text: '' } })), { ...vorgabe, wert: '33,5' })
assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), vorgabe)
assert.deepEqual(anzahlungStandard(null), vorgabe)
})
test('ausgewaehlteIds und blaetterSumme: alle sind vorausgewählt, abgewählte fallen heraus', () => {
const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)]
assert.deepEqual(ausgewaehlteIds(B, []), ['b1', 'b2', 'b3'])
assert.deepEqual(ausgewaehlteIds(B, ['b2']), ['b1', 'b3'])
assert.deepEqual(ausgewaehlteIds(undefined, []), [])
assert.deepEqual(blaetterSumme(B, ['b1', 'b3']), { anzahl: 2, minuten: 105 })
assert.deepEqual(blaetterSumme(B, []), { anzahl: 0, minuten: 0 })
})
const ws = (over = {}) => ({
$id: 'w1', woid: '100140', workorderId: 't2', totalTime: 60, supporters: '', isComment: false, countsToPlan: false,
startDate: '06.10.2026', endDate: '06.10.2026', details: 'Kontaktformular gebaut', $createdAt: '2026-10-06T07:00:00.000Z', ...over,
})
test('blattAusAppwrite: abrechenbares Blatt mit Minuten (inkl. Unterstützung), Datum und kurzem Text', () => {
assert.deepEqual(blattAusAppwrite(ws(), {}), {
id: 'w1', ticketId: 't2', woid: '100140', datum: '2026-10-06', minuten: 60, text: 'Kontaktformular gebaut', aboZeit: false, kommentar: false,
})
// Unterstützung zählt mit: 60 + 1 × 30
const mit = ws({ supporters: JSON.stringify([{ employeeId: 'e1', name: 'A', task: 'Review' }]) })
assert.equal(blattAusAppwrite(mit, {}).minuten, 90)
// ein Altwert außerhalb des 15-Minuten-Rasters wird aufgerundet
assert.equal(blattAusAppwrite(ws({ totalTime: 70 }), {}).minuten, 75)
// ohne gültiges Datum zählt der Erstellungstag, der Text ist die erste Zeile, höchstens 120 Zeichen
assert.equal(blattAusAppwrite(ws({ startDate: '', endDate: '' }), {}).datum, '2026-10-06')
assert.equal(blattAusAppwrite(ws({ details: 'Zeile 1\nZeile 2' }), {}).text, 'Zeile 1')
assert.equal(blattAusAppwrite(ws({ details: 'a'.repeat(300) }), {}).text.length, 120)
})
test('blattAusAppwrite: Kommentar, Abo-Zeit, 0 Minuten und schon Abgerechnetes sind null', () => {
assert.equal(blattAusAppwrite(ws({ isComment: true }), {}), null)
assert.equal(blattAusAppwrite(ws({ countsToPlan: true }), {}), null)
assert.equal(blattAusAppwrite(ws({ totalTime: 0 }), {}), null)
assert.equal(blattAusAppwrite(ws(), { w1: 'WEBKLAR-0009' }), null)
assert.equal(blattAusAppwrite(ws(), { w1: 'Entwurf' }), null)
assert.equal(blattAusAppwrite({}, {}), null)
})
test('ticketOptionen: Tickets des Kunden mit Nummer, ohne die schon gewählten', () => {
const T = [
{ $id: 't1', woid: '100131', topic: 'Relaunch' },
{ $id: 't2', woid: '100140', topic: 'Kontaktformular' },
{ $id: 't3', woid: '', topic: '', title: 'Alt' },
{ $id: 't4', woid: '100150', topic: '', title: '' },
]
assert.deepEqual(ticketOptionen(T, { ausgeschlossen: ['t1'] }), [
{ value: 't2', ticketId: 't2', woid: '100140', label: '#100140 · Kontaktformular' },
{ value: 't4', ticketId: 't4', woid: '100150', label: '#100150 · Kein Betreff' },
])
assert.deepEqual(ticketOptionen(undefined), [])
})
```
- [ ] **Step 2: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/rechnungDialogView.test.mjs`
Expected: FAIL (esbuild findet `src/lib/rechnungDialogView.js` nicht).
- [ ] **Step 3: `src/lib/rechnungDialogView.js` implementieren**
Reine Datei mit den Importen aus „Consumes“, deutscher Kommentarkopf. Regeln:
- `ART_OPTION_LABEL`, `AB_HINWEIS`, `ENTWURF_HINWEIS`, `OHNE_PROJEKT`: Texte wörtlich wie in den Tests.
- `startProjekt(p)`: `undefined` → `null`, `null` → `''`, sonst `String(p)`.
- `projektOptionen(konten)`: je Konto mit nichtleerer `projektId` `{ value: projektId, label: '<Name> · <ticketsText>' }` (ohne ` · `, wenn kein Ticket; leerer Name → „Projekt“), am Ende `{ value: '', label: OHNE_PROJEKT }`; kein Array → nur die letzte Option.
- `wirksamesProjekt(wahl, konten)`: `''` → `''`; nichtleerer Text: ist `konten` kein Array (noch nicht geladen) bleibt er, sonst bleibt er nur, wenn ein Konto diese `projektId` hat, sonst `null`; `null`/`undefined`: ist `konten` ein leeres Array → `''`, sonst `null`.
- `gueltigeFestpreisAbs`: `art === 'festpreis'` und Status `versendet` oder `bestaetigt`, sortiert nach `Date.parse(gesendetAm)` absteigend (fehlend = 0), bei Gleichstand nach `nummer` absteigend; `aufwandAuftrag`: dasselbe mit `art === 'aufwand'`, das erste oder `null`.
- `abLabel`: `<nummer>` plus ` vom <dateDe(gesendetAm)>` (nur wenn lesbar) plus ` · <euro(summeCents)>`, bei `art === 'aufwand'` ` · nach Aufwand`.
- `berechnetFuerAuftrag`: Summe `totalCents` der Rechnungen mit dieser `auftragId` und Status `offen`, `ueberfaellig`, `bezahlt` oder `uneinbringlich`; `hatEntwurfZurAuftrag`: irgendeine Rechnung mit dieser `auftragId` und Status `entwurf`.
- `artOptionen`: `projektId` `null`/`undefined` → `[]`; `''` → eine Option `frei` (`enabled: true`, `hint: ''`); sonst die vier Optionen in der Reihenfolge `anzahlung`, `schluss`, `aufwand`, `festbetrag`. `abs = auftraegeAktiv ? gueltigeFestpreisAbs(auftraege) : []`. `anzahlung`: `enabled = abs.length > 0`, sonst `hint = AB_HINWEIS`. `schluss`: `enabled` nur, wenn mindestens eine AB aus `abs` keinen Entwurf hat (`hatEntwurfZurAuftrag`); `hint` ist `AB_HINWEIS` ohne `abs`, `ENTWURF_HINWEIS`, wenn `abs` da ist, aber jede einen Entwurf hat, sonst `''`. `aufwand` und `festbetrag` immer wählbar.
- `standardArt(optionen, { auftraege, rechnungen, offeneMinuten })`: nur `frei` → `'frei'`; leere Liste → `''`; ist `anzahlung` wählbar und die jüngste gültige Festpreis-AB hat `zahlungsplan.art === 'anzahlung'`, nichts Berechnetes (`berechnetFuerAuftrag === 0`) und keinen Entwurf → `'anzahlung'`; sonst ist `schluss` wählbar → `'schluss'`; sonst `offeneMinuten > 0` → `'aufwand'`; sonst `'festbetrag'`.
- `standardAuftrag(art, auftraege, rechnungen = [])`: `anzahlung` → jüngste gültige Festpreis-AB; `schluss` → jüngste gültige Festpreis-AB ohne Entwurf; sonst `''` (auch bei `aufwand`: die Aufwands-AB ordnet der Server zu); ohne Treffer `''`.
- `anzahlungStandard(auftrag)`: `prozent` aus `zahlungsplan`, wenn `zahlungsplan.art === 'anzahlung'` und `prozent > 0`, sonst 50; `wert` als Text mit Dezimalkomma; `modus 'prozent'`, `bezeichnung 'Anzahlung'`.
- `ausgewaehlteIds(blaetter, abgewaehlt)`: alle `id` in Reihenfolge der Liste ohne die abgewählten; `blaetterSumme`: Anzahl und Minutensumme der Blätter, deren `id` in `ids` steht.
- `blattAusAppwrite(ws, abgerechnet)`: `null` ohne `$id`, bei `isComment`, bei `countsToPlan`, wenn `abgerechnet` die `$id` als eigenen Schlüssel kennt (Rechnung oder `'Entwurf'`) oder wenn `worksheetMinutes(ws)` nicht über 0 liegt. Sonst `{ id: $id, ticketId: workorderId, woid, datum, minuten, text, aboZeit: false, kommentar: false }` mit `minuten = Math.ceil(worksheetMinutes(ws) / 15) * 15` (nicht `roundUpToStep`, das 0 auf 15 hebt), `datum = germanDateToIso(startDate || endDate)`, sonst `ymdBerlin($createdAt)`, sonst `''`, und `text` = erste nichtleere Zeile von `details`, getrimmt, auf 120 Zeichen gekürzt.
- `ticketOptionen(tickets, { ausgeschlossen = [] })`: nur Tickets mit `$id` und `woid`, die nicht ausgeschlossen sind; `label` `#<woid> · <topic, sonst title, sonst „Kein Betreff“>`.
- [ ] **Step 4: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/rechnungDialogView.test.mjs tests/unit/projektkontoView.test.mjs`
Expected: PASS, `# pass 32` (18 und 14), `# fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
git add src/lib/rechnungDialogView.js tests/unit/rechnungDialogView.test.mjs
git commit -m "Rechnungsdialog: Logik für Projektwahl, Rechnungsarten, Auftragsbestätigungen und Arbeitsblätter mit Tests" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 5: Anfragen, Vorschau-Status, Zeilentexte, Absenden (`rechnungAnfrage`)
**Files:**
- Create: `src/lib/rechnungAnfrage.js`
- Test: `tests/unit/rechnungAnfrage.test.mjs` (neu)
**Interfaces:**
- Consumes: aus `financeView.js` `INVOICE_LIMITS`, `ADDRESS_INCOMPLETE_TEXT`, `customerName`, `finalizedFilter`, `invoiceFailureMessage`, `invoiceSendConfirmText`, `invoiceSendGate`, `isFinalizedNotSent`, `parseQuantity`, `parseUnitPrice`, `validateInvoiceForm`; `ausgewaehlteIds` (Task 4). Die Eingabe `e` des Dialogs ist ein flaches Objekt: `{ kunde /* Kundendokument oder null */, art, projektId /* '' = Ohne Projekt */, auftragId, items, zusatz /* Formularzeilen { uid, description, quantity, price } (Texte) */, blaetter, abgewaehlt, anzahlung /* { modus: 'prozent'|'betrag', wert, bezeichnung } */, zeilentexte, zeitraum, zeitraumGeaendert, days, memo }`.
- Produces (benannte Exporte):
- `LEISTUNGSZEITRAUM_MAX = 140`, `BEZEICHNUNG_MAX = 60`, `VORSCHAU_ARTEN = ['aufwand', 'anzahlung', 'schluss']`
- `vorschauAnfrage(e) → { ok: true, body } | { ok: false, error }` (Körper für `previewInvoice`: wie `rechnungAnfrage` ohne `daysUntilDue` und `memo`; `error: ''` heißt „keine Vorschau für diese Art“)
- `rechnungAnfrage(e) → { ok: true, body, totalCents: number | null } | { ok: false, error }` (Körper für `createInvoice` ohne `senden`; `totalCents` nur bei freien Positionen, sonst `null`)
- `vorschauSchluessel(body) → string`, `vorschauStatus({ art, anfrage, vorschau, schluessel }) → { state, message }` mit `state` aus `nicht-noetig | ungueltig | laedt | fehler | bereit`, `darfSpeichern(status) → boolean`
- `zeileTeilen(beschreibung) → { vorn, hinten }`, `zeileBearbeitbar(position) → boolean`, `maxVorn(hinten) → number`
- `createSubmitter({ create, confirm }) → async submit({ anfrage, senden, kunde, totalCents }) → { status: 'ok', rechnung, senden } | { status: 'abgebrochen' } | { status: 'laeuft' } | { status: 'fehler', message, httpStatus? } | { status: 'festgeschrieben', notice, finalizedFilter }`
- [ ] **Step 1: Failing Test schreiben**
`tests/unit/rechnungAnfrage.test.mjs`:
```js
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const {
LEISTUNGSZEITRAUM_MAX, BEZEICHNUNG_MAX,
vorschauAnfrage, rechnungAnfrage, vorschauSchluessel, vorschauStatus, darfSpeichern,
zeileTeilen, zeileBearbeitbar, maxVorn, createSubmitter,
} = await loadModule('src/lib/rechnungAnfrage.js')
const { ADDRESS_INCOMPLETE_TEXT } = await loadModule('src/lib/financeView.js')
const kunde = { $id: 'c1', name: 'Anelia Wolf', email: 'anelia@example.de', street: 'Hauptstr. 1', postalCode: '67655', city: 'Kaiserslautern', customerStatus: 'customer' }
const zeile = (uid, description, quantity, price) => ({ uid, description, quantity, price })
const bl = (id, minuten) => ({ id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false })
const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)]
const basis = (over = {}) => ({
kunde, art: 'festbetrag', projektId: 'p1', auftragId: '',
items: [zeile(1, 'Erstellung der Website, Pauschalpreis laut Absprache', '1', '500,00')], zusatz: [],
blaetter: [], abgewaehlt: [], anzahlung: { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' },
zeilentexte: {}, zeitraum: '', zeitraumGeaendert: false, days: '14', memo: '', ...over,
})
test('rechnungAnfrage: Festbetrag ohne AB (500 € laut Absprache)', () => {
const r = rechnungAnfrage(basis())
assert.equal(r.ok, true)
assert.deepEqual(r.body, {
customerId: 'c1', art: 'festbetrag', projektId: 'p1', daysUntilDue: 14,
items: [{ description: 'Erstellung der Website, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }],
})
assert.equal(r.totalCents, 50000)
})
test('rechnungAnfrage: ohne Projekt ist die Art frei und der Körper trägt kein projektId', () => {
const r = rechnungAnfrage(basis({ art: 'frei', projektId: '' }))
assert.equal(r.ok, true)
assert.equal(r.body.art, 'frei')
assert.equal('projektId' in r.body, false)
})
test('rechnungAnfrage: Notiz und selbst geänderter Leistungszeitraum gehen mit, ein Vorschlag nicht', () => {
assert.equal(LEISTUNGSZEITRAUM_MAX, 140)
const r = rechnungAnfrage(basis({ memo: ' Danke ', zeitraum: ' Oktober 2026 ', zeitraumGeaendert: true }))
assert.equal(r.body.memo, 'Danke')
assert.equal(r.body.leistungszeitraum, 'Oktober 2026')
assert.equal('leistungszeitraum' in rechnungAnfrage(basis({ zeitraum: '01.10.–20.10.2026', zeitraumGeaendert: false })).body, false)
assert.equal('leistungszeitraum' in rechnungAnfrage(basis({ zeitraum: ' ', zeitraumGeaendert: true })).body, false)
})
test('rechnungAnfrage: Festbetrag prüft Kunde, Projekt, Positionen, Zahlungsziel und Zeitraum', () => {
assert.deepEqual(rechnungAnfrage(basis({ projektId: '' })), { ok: false, error: 'Bitte ein Projekt wählen.' })
assert.equal(rechnungAnfrage(basis({ kunde: null })).error, 'Bitte einen Kunden wählen.')
assert.equal(rechnungAnfrage(basis({ items: [zeile(1, '', '1', '10,00')] })).error, 'Position 1: Bitte eine Beschreibung eingeben.')
assert.equal(rechnungAnfrage(basis({ days: 'abc' })).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.')
assert.equal(
rechnungAnfrage(basis({ zeitraum: 'x'.repeat(141), zeitraumGeaendert: true })).error,
'Der Leistungszeitraum darf höchstens 140 Zeichen lang sein.'
)
})
test('rechnungAnfrage: Aufwand sendet nur die gewählten Arbeitsblätter, Zusatzpositionen und Zeilentexte', () => {
const r = rechnungAnfrage(basis({
art: 'aufwand', blaetter: B, abgewaehlt: ['b2'],
zeilentexte: { '2026-10-06': '06.10.2026: Startseite', leer: ' ' },
zusatz: [zeile(2, 'Domain-Registrierung', '1', '12,00'), zeile(3, '', '1', '')],
}))
assert.equal(r.ok, true)
assert.deepEqual(r.body, {
customerId: 'c1', art: 'aufwand', projektId: 'p1', daysUntilDue: 14,
arbeitsblattIds: ['b1', 'b3'],
items: [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1200 }],
zeilentexte: { '2026-10-06': '06.10.2026: Startseite' },
})
assert.equal(r.totalCents, null)
})
test('rechnungAnfrage: Aufwand ohne Arbeitsblatt und unvollständige Zusatzpositionen', () => {
assert.deepEqual(
rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, abgewaehlt: ['b1', 'b2', 'b3'] })),
{ ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
)
assert.equal(
rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, 'Domain', '1', '')] })).error,
'Zusatzposition 1: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).'
)
assert.equal(
rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, '', '1', '5,00')] })).error,
'Zusatzposition 1: Bitte eine Beschreibung eingeben.'
)
// eine ganz leere Zeile zählt nicht
const r = rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, '', '1', '')] }))
assert.equal(r.ok, true)
assert.equal('items' in r.body, false)
})
test('rechnungAnfrage: Anzahlung in Prozent oder in Euro mit Bezeichnung', () => {
const prozent = rechnungAnfrage(basis({ art: 'anzahlung', auftragId: 'a1', anzahlung: { modus: 'prozent', wert: '33,5', bezeichnung: ' Teilrechnung ' } }))
assert.deepEqual(prozent.body, {
customerId: 'c1', art: 'anzahlung', projektId: 'p1', auftragId: 'a1', daysUntilDue: 14,
anzahlung: { prozent: 33.5, bezeichnung: 'Teilrechnung' },
})
const betrag = rechnungAnfrage(basis({ art: 'anzahlung', auftragId: 'a1', anzahlung: { modus: 'betrag', wert: '400,00', bezeichnung: 'Anzahlung' } }))
assert.deepEqual(betrag.body.anzahlung, { betragCents: 40000, bezeichnung: 'Anzahlung' })
assert.equal(BEZEICHNUNG_MAX, 60)
})
test('rechnungAnfrage: Anzahlung prüft AB, Prozent, Betrag und Bezeichnung', () => {
const an = (anzahlung, auftragId = 'a1') => rechnungAnfrage(basis({ art: 'anzahlung', auftragId, anzahlung }))
assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }, '').error, 'Bitte eine Auftragsbestätigung wählen.')
for (const wert of ['0', '0,00', '101', 'abc', '']) {
assert.equal(an({ modus: 'prozent', wert, bezeichnung: 'Anzahlung' }).error, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.', wert)
}
for (const wert of ['0,5', '100']) assert.equal(an({ modus: 'prozent', wert, bezeichnung: 'Anzahlung' }).ok, true, wert)
assert.equal(an({ modus: 'betrag', wert: 'abc', bezeichnung: 'Anzahlung' }).error, 'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.')
assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: ' ' }).error, 'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).')
assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: 'x'.repeat(61) }).error, 'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).')
})
test('rechnungAnfrage: Schlussrechnung mit Nachträgen; die Vorschau trägt weder Zahlungsziel noch Notiz', () => {
const e = basis({ art: 'schluss', auftragId: 'a1', zusatz: [zeile(2, 'Nachtrag Logo', '1', '150,00')], memo: 'Danke' })
const items = [{ description: 'Nachtrag Logo', quantity: 1, unitAmountCents: 15000 }]
assert.deepEqual(rechnungAnfrage(e).body, {
customerId: 'c1', art: 'schluss', projektId: 'p1', auftragId: 'a1', items, daysUntilDue: 14, memo: 'Danke',
})
assert.deepEqual(vorschauAnfrage(e), { ok: true, body: { customerId: 'c1', art: 'schluss', projektId: 'p1', auftragId: 'a1', items } })
assert.deepEqual(rechnungAnfrage(basis({ art: 'schluss', auftragId: '' })), { ok: false, error: 'Bitte eine Auftragsbestätigung wählen.' })
})
test('vorschauAnfrage: Festbetrag hat keine Vorschau; Zahlungsziel und Notiz blockieren sie nie', () => {
assert.deepEqual(vorschauAnfrage(basis()), { ok: false, error: '' })
assert.deepEqual(vorschauAnfrage(basis({ art: 'frei', projektId: '' })), { ok: false, error: '' })
const e = basis({ art: 'schluss', auftragId: 'a1', days: 'abc', memo: 'x'.repeat(1001) })
assert.equal(vorschauAnfrage(e).ok, true)
assert.equal(rechnungAnfrage(e).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.')
assert.deepEqual(vorschauAnfrage(basis({ art: 'aufwand', kunde: null })), { ok: false, error: 'Bitte einen Kunden wählen.' })
})
test('vorschauSchluessel: gleiche Auswahl in anderer Reihenfolge ist derselbe Schlüssel, Texte zählen nicht', () => {
const key = (over) => vorschauSchluessel(vorschauAnfrage(basis({ art: 'aufwand', blaetter: B, ...over })).body)
const k1 = key({})
assert.equal(key({ blaetter: [B[2], B[0], B[1]] }), k1)
assert.notEqual(key({ abgewaehlt: ['b2'] }), k1)
assert.equal(key({ zeilentexte: { '2026-10-06': 'x' }, zeitraum: 'Oktober', zeitraumGeaendert: true }), k1)
assert.notEqual(key({ zusatz: [zeile(2, 'Domain', '1', '12,00')] }), k1)
})
test('vorschauStatus: nicht nötig, ungültig, lädt, veraltet, Fehler, bereit', () => {
const ok = { ok: true, body: {} }
const fertig = { key: 'k1', status: 'ok', daten: { summeCents: 30000, positionen: [] } }
const status = (args) => vorschauStatus({ art: 'aufwand', anfrage: ok, vorschau: fertig, schluessel: 'k1', ...args })
assert.deepEqual(
vorschauStatus({ art: 'festbetrag', anfrage: { ok: false, error: '' }, vorschau: null, schluessel: '' }),
{ state: 'nicht-noetig', message: '' }
)
assert.deepEqual(
status({ anfrage: { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }, schluessel: '' }),
{ state: 'ungueltig', message: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
)
assert.deepEqual(status({ vorschau: null }), { state: 'laedt', message: '' })
assert.equal(status({ vorschau: { key: 'k1', status: 'laedt' } }).state, 'laedt')
assert.equal(status({ schluessel: 'k2' }).state, 'laedt') // veraltet: die Eingabe hat sich geändert
assert.deepEqual(
status({ vorschau: { key: 'k1', status: 'fehler', message: 'Arbeitsblatt bereits abgerechnet' } }),
{ state: 'fehler', message: 'Arbeitsblatt bereits abgerechnet' }
)
assert.deepEqual(
status({ vorschau: { key: 'k1', status: 'ok', daten: { summeCents: 0, positionen: [] } } }),
{ state: 'fehler', message: 'Nichts mehr offen.' }
)
assert.deepEqual(status({}), { state: 'bereit', message: '' })
assert.equal(darfSpeichern({ state: 'bereit' }), true)
assert.equal(darfSpeichern({ state: 'nicht-noetig' }), true)
for (const state of ['laedt', 'ungueltig', 'fehler']) assert.equal(darfSpeichern({ state }), false, state)
})
test('Abwahl eines Arbeitsblatts macht die Vorschau veraltet und sperrt Entwurf und Senden', () => {
const e1 = basis({ art: 'aufwand', blaetter: B })
const a1 = vorschauAnfrage(e1)
const k1 = vorschauSchluessel(a1.body)
const vorschau = { key: k1, status: 'ok', daten: { summeCents: 32500, positionen: [] } }
assert.equal(darfSpeichern(vorschauStatus({ art: 'aufwand', anfrage: a1, vorschau, schluessel: k1 })), true)
const a2 = vorschauAnfrage({ ...e1, abgewaehlt: ['b2'] })
const k2 = vorschauSchluessel(a2.body)
assert.deepEqual(a2.body.arbeitsblattIds, ['b1', 'b3'])
assert.notEqual(k2, k1)
assert.equal(darfSpeichern(vorschauStatus({ art: 'aufwand', anfrage: a2, vorschau, schluessel: k2 })), false)
})
test('zeileTeilen, zeileBearbeitbar und maxVorn: nur Aufwandszeilen sind bearbeitbar, geteilt wird am letzten Gedankenstrich', () => {
const tag = '06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00 €'
assert.deepEqual(zeileTeilen(tag), { vorn: '06.10.2026: Startseite, Kontaktformular', hinten: ' – 2,25 Std. à 100,00 €' })
assert.deepEqual(zeileTeilen('KW 41/2026: A – B – 6,5 Std. à 100,00 €'), { vorn: 'KW 41/2026: A – B', hinten: ' – 6,5 Std. à 100,00 €' })
assert.deepEqual(zeileTeilen('Domain-Registrierung'), { vorn: 'Domain-Registrierung', hinten: '' })
const pos = (schluessel, beschreibung = tag) => ({ schluessel, beschreibung })
assert.equal(zeileBearbeitbar(pos('2026-10-06')), true)
assert.equal(zeileBearbeitbar(pos('KW-2026-41')), true)
for (const schluessel of ['ab-1', 'zusatz-1', 'abzug-in_123', 'anzahlung']) assert.equal(zeileBearbeitbar(pos(schluessel)), false, schluessel)
assert.equal(zeileBearbeitbar(pos('2026-10-06', 'ohne Strich')), false)
const hinten = ' – 2,25 Std. à 100,00 €'
assert.equal(maxVorn(hinten), 200 - hinten.length)
assert.equal(maxVorn('x'.repeat(250)), 1)
})
const okAntwort = { rechnung: { id: 'in_1', number: 'WEBKLAR-0009', status: 'offen', customerEmail: 'anelia@example.de' } }
const anfrage = rechnungAnfrage(basis())
test('createSubmitter: Entwurf fragt nicht nach und sendet senden=false', async () => {
const gesendet = []
const submit = createSubmitter({
create: async (body) => (gesendet.push(body), okAntwort),
confirm: () => assert.fail('beim Entwurf gibt es keine Rückfrage'),
})
const r = await submit({ anfrage, senden: false, kunde, totalCents: 50000 })
assert.deepEqual(r, { status: 'ok', rechnung: okAntwort.rechnung, senden: false })
assert.equal(gesendet[0].senden, false)
assert.equal(gesendet[0].art, 'festbetrag')
})
test('createSubmitter: Senden fragt vorher nach, bei „Abbrechen“ geht nichts raus', async () => {
let frage = ''
let create = 0
const submit = createSubmitter({ create: async () => { create += 1; return okAntwort }, confirm: (text) => { frage = text; return false } })
assert.deepEqual(await submit({ anfrage, senden: true, kunde, totalCents: 50000 }), { status: 'abgebrochen' })
assert.equal(create, 0)
assert.equal(frage, 'Rechnung für Anelia Wolf über 500,00 € jetzt festschreiben und an anelia@example.de senden? Danach lässt sie sich nur noch stornieren.')
})
test('createSubmitter: Adress-Sperre über 250 € ohne Anschrift gilt auch hier', async () => {
let create = 0
const submit = createSubmitter({ create: async () => { create += 1; return okAntwort }, confirm: () => true })
const ohneAdresse = { ...kunde, street: '' }
assert.deepEqual(await submit({ anfrage, senden: true, kunde: ohneAdresse, totalCents: 25001 }), { status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT })
assert.equal(create, 0)
assert.equal((await submit({ anfrage, senden: true, kunde: ohneAdresse, totalCents: 25000 })).status, 'ok') // Kleinbetrag
assert.equal((await submit({ anfrage, senden: false, kunde: ohneAdresse, totalCents: 25001 })).status, 'ok') // Entwurf bleibt möglich
})
test('createSubmitter: Doppelklick auf Senden legt nur eine Rechnung an', async () => {
let create = 0
let fertig
const submit = createSubmitter({
create: () => { create += 1; return new Promise((resolve) => { fertig = () => resolve(okAntwort) }) },
confirm: () => true,
})
const erster = submit({ anfrage, senden: true, kunde, totalCents: 50000 })
const zweiter = await submit({ anfrage, senden: true, kunde, totalCents: 50000 })
assert.deepEqual(zweiter, { status: 'laeuft' })
fertig()
assert.equal((await erster).status, 'ok')
assert.equal(create, 1)
// danach geht wieder ein Aufruf (nach einem Fehler muss der Nutzer es erneut versuchen können)
const dritter = submit({ anfrage, senden: false, kunde, totalCents: 50000 })
fertig()
assert.equal((await dritter).status, 'ok')
assert.equal(create, 2)
})
test('createSubmitter: Fehler des Servers kommen als Meldung zurück, auch 409 und „festgeschrieben“', async () => {
const wirft = (err) => createSubmitter({ create: async () => { throw err }, confirm: () => true })
const konflikt = Object.assign(new Error('Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).'), { status: 409 })
assert.deepEqual(await wirft(konflikt)({ anfrage, senden: false, kunde, totalCents: 1 }), {
status: 'fehler', message: 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).', httpStatus: 409,
})
const ohneStatus = new Error('Irgendwas ist schiefgegangen.')
assert.deepEqual(await wirft(ohneStatus)({ anfrage, senden: false, kunde, totalCents: 1 }), { status: 'fehler', message: 'Irgendwas ist schiefgegangen.' })
const festgeschrieben = Object.assign(new Error('Rechnung WEBKLAR-0009 wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.'), {
status: 502, invoiceId: 'in_9', invoiceNumber: 'WEBKLAR-0009',
})
assert.deepEqual(await wirft(festgeschrieben)({ anfrage, senden: true, kunde, totalCents: 50000 }), {
status: 'festgeschrieben',
notice: 'Rechnung WEBKLAR-0009 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.',
finalizedFilter: 'offen',
})
})
test('createSubmitter: eine ungültige Anfrage geht nie an den Server', async () => {
let create = 0
const submit = createSubmitter({ create: async () => { create += 1 }, confirm: () => true })
const r = await submit({ anfrage: { ok: false, error: 'Bitte einen Kunden wählen.' }, senden: false, kunde, totalCents: 0 })
assert.deepEqual(r, { status: 'fehler', message: 'Bitte einen Kunden wählen.' })
assert.equal(create, 0)
})
```
- [ ] **Step 2: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/rechnungAnfrage.test.mjs`
Expected: FAIL (esbuild findet `src/lib/rechnungAnfrage.js` nicht).
- [ ] **Step 3: `src/lib/rechnungAnfrage.js` implementieren**
Reine Datei (Importe laut „Consumes“), deutscher Kommentarkopf. Regeln:
- **Zeilen prüfen** (`zusatzPruefen(rows, label = 'Zusatzposition')`): Eine Zeile ohne Beschreibung und ohne Preis gilt als leer und zählt nicht. Jede andere Zeile (Nummer = Index in der vollständigen Liste + 1) wird mit denselben Sätzen wie `validateInvoiceForm` geprüft, nur mit dem Etikett davor: `<Etikett> <n>: Bitte eine Beschreibung eingeben.`, `…: Die Beschreibung darf höchstens 200 Zeichen lang sein.`, `…: Die Menge muss eine ganze Zahl von 1 bis 999 sein.`, `…: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).` (Grenzen aus `INVOICE_LIMITS`, Zahlen über `parseQuantity` und `parseUnitPrice`); gültige Zeilen werden zu `{ description, quantity, unitAmountCents }`.
- **Prozent:** `parseProzent(text)`: Komma wird Punkt, erlaubt ist `^[0-9]{1,3}(\.[0-9]{1,2})?$` und ein Wert größer als 0 und höchstens 100, sonst `null`.
- **`vorschauAnfrage(e)`**, Reihenfolge der Prüfungen: Art nicht in `VORSCHAU_ARTEN` → `{ ok: false, error: '' }`; ohne `kunde` → `'Bitte einen Kunden wählen.'`. Grundkörper `{ customerId: kunde.$id, art, projektId }`. Aufwand: `ids = ausgewaehlteIds(blaetter, abgewaehlt)`, leer → `'Bitte mindestens ein Arbeitsblatt auswählen.'`, sonst `arbeitsblattIds: ids` (Reihenfolge der Liste); eine `auftragId` schickt W1 bei Aufwand nicht mit. Anzahlung und Schluss: ohne `auftragId` → `'Bitte eine Auftragsbestätigung wählen.'`, sonst `auftragId`. Anzahlung: Betrag prüfen (Modus `prozent` über `parseProzent` → `'Der Prozentsatz muss größer als 0 und höchstens 100 sein.'`, Modus `betrag` über `parseUnitPrice` → `'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.'`), danach die Bezeichnung (getrimmt, 1 bis `BEZEICHNUNG_MAX` Zeichen, sonst `'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).'`); Körperfeld `anzahlung: { prozent | betragCents, bezeichnung }` mit genau einem der beiden Beträge. Aufwand und Schluss: `zusatz` über `zusatzPruefen`, bei mindestens einer gültigen Zeile `items`. Nur bei Aufwand: `zeilentexte` mit den nichtleeren, getrimmten Einträgen (fehlt der Schlüssel im Körper, wenn nichts übrig bleibt). Leistungszeitraum: nur wenn `zeitraumGeaendert` und getrimmt nichtleer; länger als `LEISTUNGSZEITRAUM_MAX` → `'Der Leistungszeitraum darf höchstens 140 Zeichen lang sein.'`, sonst `leistungszeitraum`. Die Zahl der Zeilen begrenzt diese Funktion nicht: `items` und `zusatz` halten höchstens `INVOICE_LIMITS.maxItems` Zeilen je Liste (Reducer, Task 6); engere Grenzen des Servers (Schlussrechnung: höchstens 10 Zusatzpositionen) zeigt die Vorschau als Fehler.
- **`rechnungAnfrage(e)`**: `frei` und `festbetrag`: zuerst `validateInvoiceForm({ customer: kunde, items, days, memo })` (deren Fehlertext gilt unverändert), dann bei `festbetrag` ohne Projekt `'Bitte ein Projekt wählen.'`, dann der Leistungszeitraum wie oben; Körper `{ ...payload, art }` plus `projektId` (nur bei `festbetrag`) und `leistungszeitraum`; `totalCents` aus `validateInvoiceForm`. Alle anderen Arten: `vorschauAnfrage(e)` (Fehler gelten unverändert), dann `validateInvoiceForm` mit der Platzhalterposition `{ description: 'x', quantity: '1', price: '1' }` nur für Kunde, Zahlungsziel und Notiz (so bleiben Meldungen und Grenzen an einer Stelle); Körper = Vorschaukörper plus `daysUntilDue` und, wenn nichtleer, `memo`; `totalCents: null`.
- **`vorschauSchluessel(body)`**: `JSON.stringify` des Körpers ohne `zeilentexte` und `leistungszeitraum`, mit sortierten `arbeitsblattIds`. Sinn: Alles, was Zahlen der Vorschau ändert, ändert den Schlüssel; Texte nicht.
- **`vorschauStatus`**: Reihenfolge wie im Test (Art ohne Vorschau → `nicht-noetig`; `!anfrage.ok` → `ungueltig` mit `anfrage.error`; `!vorschau`, fremder `key` oder `status 'laedt'` → `laedt`; `status 'fehler'` → `fehler` mit `message` (Ersatz `'Die Vorschau konnte nicht geladen werden.'`); `daten.summeCents` nicht über 0 → `fehler` mit `'Nichts mehr offen.'`; sonst `bereit`). `message` ist sonst `''`. `darfSpeichern(status)`: `nicht-noetig` oder `bereit`.
- **`zeileTeilen(beschreibung)`**: teilt am **letzten** `' – '` (Leerzeichen, Gedankenstrich U+2013, Leerzeichen): `vorn` davor, `hinten` ab diesem Zeichen inklusive; ohne Treffer `{ vorn: beschreibung, hinten: '' }`. `zeileBearbeitbar(position)`: `schluessel` passt auf `^(\d{4}-\d{2}-\d{2}|KW-\d{4}-\d{2})$` und `zeileTeilen(beschreibung).hinten` ist nichtleer. `maxVorn(hinten)`: `Math.max(1, 200 - hinten.length)`.
- **`createSubmitter({ create, confirm })`**: Innerhalb der Closure steht ein Merker `running`. `submit({ anfrage, senden, kunde, totalCents })`, in dieser Reihenfolge und ohne `await` vor dem Setzen von `running`: `running` → `{ status: 'laeuft' }`; `!anfrage.ok` → `{ status: 'fehler', message: anfrage.error }`; bei `senden`: `invoiceSendGate(kunde, totalCents).sendAllowed` falsch → `{ status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT }`, `confirm(invoiceSendConfirmText({ name: customerName(kunde), email: kunde.email, totalCents }))` falsch → `{ status: 'abgebrochen' }`; dann `running = true`, `data = await create({ ...anfrage.body, senden: Boolean(senden) })` → `{ status: 'ok', rechnung: data?.rechnung || null, senden: Boolean(senden) }`. Fehler: `isFinalizedNotSent(err)` → `{ status: 'festgeschrieben', notice: invoiceFailureMessage(err), finalizedFilter: finalizedFilter(err) }`, sonst `{ status: 'fehler', message: invoiceFailureMessage(err) }` und `httpStatus: err.status` **nur**, wenn `err.status` eine ganze Zahl ist. `running` wird in `finally` zurückgesetzt.
- [ ] **Step 4: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/rechnungAnfrage.test.mjs tests/unit/rechnungDialogView.test.mjs tests/unit/projektkontoView.test.mjs`
Expected: PASS, `# pass 52` (20, 18 und 14), `# fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
git add src/lib/rechnungAnfrage.js tests/unit/rechnungAnfrage.test.mjs
git commit -m "Rechnungsdialog: Anfragekörper, Vorschau-Status, Zeilentexte und Absenden mit Doppelklick-Sperre samt Tests" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 6: Dialogzustand, Datenzugriff und Hooks
**Files:**
- Create: `src/lib/rechnungDialogState.js`, `src/lib/rechnungDialogApi.js`, `src/hooks/useRechnungDaten.js`
- Test: `tests/unit/rechnungDialogState.test.mjs` (neu)
**Interfaces:**
- Consumes: `INVOICE_LIMITS` (`financeView`); `getProjektkonten`, `getProjektkonto`, `previewInvoice` (`financeApi`, W0); `getAbgerechnet` (`teamApi`, W0); `blattAusAppwrite` (Task 4); `createRequestGuard` (`src/lib/requestGuard.js`); Appwrite über `src/lib/appwrite.js`.
- Produces:
- `anfangszustand({ projektId = null } = {}) → Zustand` mit den Feldern `projektId` (`null` = noch zu wählen, `''` = Ohne Projekt), `art`, `auftragId`, `items`, `zusatz`, `abgewaehlt`, `weitereTickets`, `anzahlung` (Teilwerte über `anzahlungStandard`), `zeilentexte`, `zeitraum`, `zeitraumGeaendert`, `days`, `memo`; `reduce(zustand, aktion) → Zustand` mit den Aktionen `projekt`, `art`, `auftrag`, `position`, `position+`, `position-`, `blatt`, `ticket+`, `ticket-`, `anzahlung`, `zeilentext`, `zeitraum`, `zeitraum-vorschlag`, `tage`, `notiz` (Form siehe Test)
- `listKundenTickets(customerId) → Promise<Dokument[]>`, `listTicketBlaetter(woid) → Promise<Dokument[]>`
- Hooks: `useProjektkonten(kundeId) → { data, loading, error, reload }`, `useProjektkontoDetail(projektId) → { data, loading, error, reload }`, `useRechnungVorschau({ anfrage, schluessel, aktiv }) → vorschau | null` mit `vorschau = { key, status: 'laedt' | 'ok' | 'fehler', daten?, message?, httpStatus? }`, `useKundenTickets(kundeId, aktiv) → { tickets, loading, error }`, `useWeitereBlaetter(weitereTickets) → { [ticketId]: { status: 'laedt' | 'ok' | 'fehler', blaetter, message } }`
- [ ] **Step 1: Failing Test schreiben**
`tests/unit/rechnungDialogState.test.mjs`:
```js
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { anfangszustand, reduce } = await loadModule('src/lib/rechnungDialogState.js')
test('anfangszustand: nichts gewählt, eine leere Position, Zahlungsziel 14', () => {
assert.deepEqual(anfangszustand({ projektId: 'p1' }), {
projektId: 'p1', art: '', auftragId: '',
items: [{ uid: 1, description: '', quantity: '1', price: '' }], zusatz: [],
abgewaehlt: [], weitereTickets: [], anzahlung: {}, zeilentexte: {},
zeitraum: '', zeitraumGeaendert: false, days: '14', memo: '',
})
assert.equal(anfangszustand().projektId, null)
assert.equal(anfangszustand({ projektId: '' }).projektId, '')
})
test('projekt: setzt alles zurück, behält aber Zahlungsziel und Notiz', () => {
let s = anfangszustand({ projektId: 'p1' })
s = reduce(s, { type: 'tage', wert: '30' })
s = reduce(s, { type: 'notiz', wert: 'Danke' })
s = reduce(s, { type: 'art', art: 'aufwand' })
s = reduce(s, { type: 'blatt', id: 'b1' })
s = reduce(s, { type: 'zeilentext', schluessel: '2026-10-06', text: 'Neu', original: 'Alt' })
s = reduce(s, { type: 'projekt', projektId: 'p2' })
assert.equal(s.projektId, 'p2')
assert.equal(s.art, '')
assert.deepEqual(s.abgewaehlt, [])
assert.deepEqual(s.zeilentexte, {})
assert.equal(s.days, '30')
assert.equal(s.memo, 'Danke')
assert.equal(reduce(s, { type: 'projekt', projektId: null }).projektId, null)
})
test('art: Positionen für Festbetrag und Zusatzpositionen bleiben getrennt und erhalten', () => {
let s = anfangszustand({ projektId: 'p1' })
s = reduce(s, { type: 'position', liste: 'items', uid: 1, feld: 'description', wert: 'Pauschale' })
s = reduce(s, { type: 'position+', liste: 'zusatz' })
s = reduce(s, { type: 'position', liste: 'zusatz', uid: 2, feld: 'price', wert: '12,00' })
s = reduce(s, { type: 'art', art: 'aufwand' })
assert.equal(s.art, 'aufwand')
assert.equal(s.items[0].description, 'Pauschale')
assert.deepEqual(s.zusatz, [{ uid: 2, description: '', quantity: '1', price: '12,00' }])
// eine unbekannte Liste oder ein unbekanntes Feld ändert nichts
assert.equal(reduce(s, { type: 'position', liste: 'x', uid: 1, feld: 'description', wert: 'y' }), s)
assert.equal(reduce(s, { type: 'position', liste: 'items', uid: 1, feld: 'uid', wert: 9 }), s)
})
test('position+ und position-: Positionen mindestens 1, Zusatzpositionen mindestens 0, höchstens 20', () => {
let s = anfangszustand({ projektId: 'p1' })
assert.equal(reduce(s, { type: 'position-', liste: 'items', uid: 1 }), s)
s = reduce(s, { type: 'position+', liste: 'zusatz' })
assert.equal(s.zusatz[0].uid, 2)
s = reduce(s, { type: 'position-', liste: 'zusatz', uid: 2 })
assert.deepEqual(s.zusatz, [])
for (let i = 0; i < 25; i += 1) s = reduce(s, { type: 'position+', liste: 'zusatz' })
assert.equal(s.zusatz.length, 20)
})
test('blatt: Abwählen und Wiederanwählen', () => {
let s = anfangszustand({ projektId: 'p1' })
s = reduce(s, { type: 'blatt', id: 'b2' })
assert.deepEqual(s.abgewaehlt, ['b2'])
s = reduce(s, { type: 'blatt', id: 'b2' })
assert.deepEqual(s.abgewaehlt, [])
})
test('ticket+ und ticket-: ein Ticket nur einmal, beim Entfernen verschwindet die Abwahl seiner Blätter', () => {
const ticket = { ticketId: 't2', woid: '100140', titel: 'Kontaktformular' }
let s = anfangszustand({ projektId: 'p1' })
s = reduce(s, { type: 'ticket+', ticket })
s = reduce(s, { type: 'ticket+', ticket })
assert.deepEqual(s.weitereTickets, [ticket])
s = reduce(s, { type: 'blatt', id: 'w1' })
s = reduce(s, { type: 'blatt', id: 'b9' })
s = reduce(s, { type: 'ticket-', ticketId: 't2', blattIds: ['w1'] })
assert.deepEqual(s.weitereTickets, [])
assert.deepEqual(s.abgewaehlt, ['b9'])
})
test('zeitraum-vorschlag gilt nur, solange der Nutzer das Feld nicht selbst geändert hat', () => {
let s = anfangszustand({ projektId: 'p1' })
s = reduce(s, { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' })
assert.deepEqual([s.zeitraum, s.zeitraumGeaendert], ['01.10.–20.10.2026', false])
assert.equal(reduce(s, { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' }), s) // gleicher Text: kein neuer Zustand
s = reduce(s, { type: 'zeitraum', text: 'Oktober 2026' })
assert.deepEqual([s.zeitraum, s.zeitraumGeaendert], ['Oktober 2026', true])
assert.equal(reduce(s, { type: 'zeitraum-vorschlag', text: '02.10.–21.10.2026' }).zeitraum, 'Oktober 2026')
// ein Wechsel der Art verwirft einen Vorschlag, nicht aber die Eingabe des Nutzers
const vorschlag = reduce(anfangszustand({ projektId: 'p1' }), { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' })
assert.equal(reduce(vorschlag, { type: 'art', art: 'festbetrag' }).zeitraum, '')
assert.equal(reduce(s, { type: 'art', art: 'festbetrag' }).zeitraum, 'Oktober 2026')
})
test('zeilentext: ein unveränderter oder leerer Text entfernt den Eintrag', () => {
let s = anfangszustand({ projektId: 'p1' })
const setze = (text) => reduce(s, { type: 'zeilentext', schluessel: '2026-10-06', text, original: 'Alt' })
s = setze('Neu')
assert.deepEqual(s.zeilentexte, { '2026-10-06': 'Neu' })
s = setze('Alt')
assert.deepEqual(s.zeilentexte, {})
s = setze('Neu')
s = setze(' ')
assert.deepEqual(s.zeilentexte, {})
})
test('anzahlung und auftrag: Eingaben ergänzen sich, eine andere AB setzt sie zurück', () => {
let s = anfangszustand({ projektId: 'p1' })
s = reduce(s, { type: 'anzahlung', patch: { modus: 'betrag' } })
s = reduce(s, { type: 'anzahlung', patch: { wert: '400,00' } })
assert.deepEqual(s.anzahlung, { modus: 'betrag', wert: '400,00' })
s = reduce(s, { type: 'auftrag', auftragId: 'a2' })
assert.deepEqual([s.auftragId, s.anzahlung], ['a2', {}])
assert.equal(reduce(s, { type: 'gibtsnicht' }), s)
})
```
- [ ] **Step 2: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/rechnungDialogState.test.mjs`
Expected: FAIL (Modul fehlt).
- [ ] **Step 3: `src/lib/rechnungDialogState.js` implementieren**
Rein, einziger Import `INVOICE_LIMITS` aus `./financeView`. Regeln:
- Leere Zeile: `{ uid, description: '', quantity: '1', price: '' }`. `anfangszustand` wie im Test; `days` ist `String(INVOICE_LIMITS.defaultDays)`.
- Neue `uid` einer Zeile: `1 +` größte `uid` beider Listen. `position+` ändert nichts, wenn die Liste schon `INVOICE_LIMITS.maxItems` Zeilen hat; `position-` ändert nichts, wenn die Liste ihr Minimum erreicht hat (`items` 1, `zusatz` 0). `position` akzeptiert nur die Listen `items` und `zusatz` und nur die Felder `description`, `quantity`, `price`.
- `projekt`: `{ ...anfangszustand({ projektId: a.projektId }), days: s.days, memo: s.memo }`. `art`: setzt `art`, leert `auftragId` und `zeilentexte` und, solange `zeitraumGeaendert` falsch ist, `zeitraum`. `auftrag`: setzt `auftragId`, leert `anzahlung`. `blatt`: schaltet die Kennung in `abgewaehlt` um. `ticket+`: hängt an, wenn `ticketId` noch fehlt. `ticket-`: entfernt das Ticket und alle `blattIds` aus `abgewaehlt`. `anzahlung`: mischt `patch` in `anzahlung`. `zeilentext`: bei leerem (getrimmtem) Text oder Text gleich `original` wird der Schlüssel entfernt, sonst der **unveränderte** Text gesetzt. `zeitraum`: setzt Text und `zeitraumGeaendert: true`. `zeitraum-vorschlag`: nur wenn `!zeitraumGeaendert`; bei gleichem Text derselbe Zustand. `tage`, `notiz`: setzen `days`, `memo`. Unbekannte Aktion: derselbe Zustand (Referenz).
- [ ] **Step 4: Test laufen lassen**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/rechnungDialogState.test.mjs`
Expected: PASS, `# pass 9`, `# fail 0`.
- [ ] **Step 5: Appwrite-Zugriffe und Hooks schreiben**
`src/lib/rechnungDialogApi.js` (Lesezugriffe wie `ProjectContextPanel` und `useWorksheets`, Appwrite liefert sonst nur 25 Dokumente):
```js
import { databases, DATABASE_ID, COLLECTIONS, Query } from './appwrite'
/** Tickets eines Kunden, neueste zuerst (für „Weiteres Ticket hinzunehmen“). */
export async function listKundenTickets(customerId) {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
Query.equal('customerId', customerId),
Query.orderDesc('$createdAt'),
Query.limit(100),
])
return res.documents
}
/** Alle Arbeitsblätter eines Tickets (nach Ticketnummer `woid`). */
export async function listTicketBlaetter(woid) {
const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKSHEETS, [
Query.equal('woid', woid),
Query.orderAsc('$createdAt'),
Query.limit(5000),
])
return res.documents
}
```
`src/hooks/useRechnungDaten.js` (deutscher Kommentarkopf; Importe: `useCallback`, `useEffect`, `useRef`, `useState`; `getProjektkonten`, `getProjektkonto`, `previewInvoice` aus `../lib/financeApi`; `getAbgerechnet` aus `../lib/teamApi`; die beiden Zugriffe oben; `blattAusAppwrite` aus `../lib/rechnungDialogView`; `createRequestGuard` aus `../lib/requestGuard`). Regeln:
- **Interner Helfer `useGeladen(laden, schluessel, fallbackError)`**: ohne `schluessel` (leer oder falsch) wird nichts geladen (`data: null`, `loading: false`, `error: ''`). Die Daten gehören zu ihrem Schlüssel: nach einem Wechsel des Schlüssels sofort `data: null` und `loading: true` (nie Daten eines anderen Kunden oder Projekts zeigen). Nur die jüngste Antwort zählt (`createRequestGuard`), nach dem Ausbau des Hooks gar keine. `data` bleibt beim Neuladen mit `reload()` stehen; `error` ist `err.message`, sonst `fallbackError`. `reload()` liefert ein Promise, das nie wirft.
- `useProjektkonten(kundeId)` = `useGeladen(() => getProjektkonten(kundeId), kundeId, 'Die Projekte konnten nicht geladen werden.')`; `useProjektkontoDetail(projektId)` = `useGeladen(() => getProjektkonto(projektId), projektId, 'Das Projektkonto konnte nicht geladen werden.')`.
- **`useRechnungVorschau({ anfrage, schluessel, aktiv })`**: hält `vorschau` (Start `null`). Der Körper wird über einen Ref gelesen (`bodyRef.current = anfrage.body` in jedem Render), der Effekt hängt nur an `[aktiv, schluessel]`. Ist `aktiv` und `anfrage.ok` und `schluessel` nichtleer: sofort `{ key: schluessel, status: 'laedt', daten: <vorherige Daten> }`, nach 350 ms Wartezeit `previewInvoice(bodyRef.current)`; Erfolg `{ key, status: 'ok', daten }`, Fehler `{ key, status: 'fehler', message: err.message || 'Die Vorschau konnte nicht geladen werden.', httpStatus: err.status, daten: <vorherige Daten> }`. Aufräumen löscht den Timer und verwirft die Antwort (jüngste Anfrage zählt).
- **`useKundenTickets(kundeId, aktiv)`**: lädt einmal je `kundeId`, sobald `aktiv` wahr ist; Rückgabe `{ tickets, loading, error }` (`error: 'Die Tickets konnten nicht geladen werden.'`).
- **`useWeitereBlaetter(weitereTickets)`**: je Ticket (nach `ticketId`) einmal `Promise.all([listTicketBlaetter(woid), getAbgerechnet(ticketId)])`; `blaetter` = Dokumente über `blattAusAppwrite(ws, antwort.abgerechnet)` ohne `null`, sortiert nach `datum`, dann `id`; Zustand `laedt`, dann `ok` oder `fehler` mit `message: 'Die Arbeitsblätter von #<woid> konnten nicht geladen werden.'`. Entfernte Tickets verschwinden aus dem Ergebnis, schon geladene werden nicht neu geladen.
- [ ] **Step 6: Syntax und Hygiene**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
node_modules/.bin/esbuild src/hooks/useRechnungDaten.js src/lib/rechnungDialogApi.js src/lib/rechnungDialogState.js --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/syntax --log-level=warning
rg -n "react-icons|@tabler|style=\{\{|#[0-9a-fA-F]{3,8}\b|console\.log" src/hooks/useRechnungDaten.js src/lib/rechnungDialogApi.js src/lib/rechnungDialogState.js
```
Expected: esbuild ohne Ausgabe (es bündelt nicht, es prüft nur die Syntax), `rg` ohne Treffer. Das Verhalten der Hooks prüft Task 8 im Browser.
- [ ] **Step 7: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
git add src/lib/rechnungDialogState.js src/lib/rechnungDialogApi.js src/hooks/useRechnungDaten.js tests/unit/rechnungDialogState.test.mjs
git commit -m "Rechnungsdialog: Zustand mit Reducer, Appwrite-Lesezugriffe und Hooks für Projektkonto, Vorschau und weitere Tickets" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 7: Bausteine des Dialogs und Stile
**Files:**
- Create: `src/components/auftraege/KontoZusammenfassung.jsx`, `RechnungPositionen.jsx`, `RechnungVorschau.jsx`, `AufwandAuswahl.jsx`, `rechnung-dialog.css`
**Interfaces:**
- Consumes: `Button`, `Field` (`../ui`); `euro`, `dateDe` (`financeFormat`); `INVOICE_LIMITS`, `lineTotalCents`, `invoiceTotalCents` (`financeView`); `isNegText` (`financeUi`); `stundenDe` (Task 2); `zeileTeilen`, `zeileBearbeitbar`, `maxVorn` (Task 5); `blaetterSumme`, `ausgewaehlteIds` (Task 4).
- Produces (Default-Exporte, rein darstellend, nie Daten laden):
- `KontoZusammenfassung({ zeile, hinweis })` mit `zeile` aus `kontoZeile` und `hinweis` aus `stripeHinweis`
- `RechnungPositionen({ legende, labelPrefix, idPrefix, zeilen, minZeilen = 0, onChange(uid, feld, wert), onAdd(), onRemove(uid), addLabel, zeigeSumme = false })`
- `RechnungVorschau({ positionen, summeCents, bearbeitbar = false, zeilentexte = {}, onZeilentext(schluessel, text, original), abgeblendet = false })`
- `AufwandAuswahl({ blaetter, abgewaehlt, onToggle(id), satzInfo, ticketOptionen, ticketsLaden, ticketsFehler, weitereTickets, weitereZustand, onAddTicket(option), onRemoveTicket(ticketId), disabled = false })` mit `weitereTickets = [{ ticketId, woid, titel }]` und `weitereZustand = { [ticketId]: { status, message } }`
- [ ] **Step 1: `rechnung-dialog.css` schreiben**
```css
/* Rechnung schreiben (Dialog). Dieses Stylesheet steht im Bundle vor global.css: Regeln, die globale Klassen überstimmen
müssen, tragen zwei Klassen. Task 9 hängt am Ende die Spalten der Rechnungstabelle an. */
.rd-group { border: 0; margin: 0; padding: 0; min-width: 0; }
.rd-group > legend { padding: 0; margin: 0 0 6px; font-weight: 500; }
.rd-stack { display: grid; gap: 8px; }
.rd-facts.facts { margin: 0; }
.seg.rd-seg { flex-wrap: wrap; max-width: 100%; justify-self: start; }
.rd-hint { margin: 0; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
/* Positionen */
.rd-item { display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end; }
.rd-item .input { width: 100%; }
.rd-item-field { min-width: 0; display: grid; gap: 4px; }
.rd-item-label { font-size: 12px; color: var(--text-2); }
.rd-item-sum { text-align: right; white-space: nowrap; padding-bottom: 6px; }
.rd-items-foot { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.rd-total { display: flex; gap: 16px; font-weight: 600; }
@media (max-width: 860px) {
.rd-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) auto; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
.rd-item-desc { grid-area: desc; }
.rd-item-qty { grid-area: qty; }
.rd-item-price { grid-area: price; }
.rd-item-sumfeld { grid-area: sum; }
.rd-item-remove { grid-area: remove; }
}
/* Arbeitsblätter */
.rd-blaetter { border-top: 1px solid var(--line); }
.rd-blatt { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 4px 12px; align-items: start; padding: 8px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
.rd-blatt input { accent-color: var(--accent); width: 15px; height: 15px; margin-top: 3px; }
.rd-blatt-text { min-width: 0; overflow-wrap: anywhere; }
.rd-blatt-zeit { white-space: nowrap; font-variant-numeric: tabular-nums; }
.rd-weiter { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
/* Vorschau der Rechnungszeilen */
.rd-lines { table-layout: fixed; }
.rd-lines .rd-line-text { overflow-wrap: anywhere; }
.rd-lines .fin-amount { width: 7.5rem; }
.rd-line-input { width: 100%; min-width: 0; }
.rd-line-tail { display: block; font-size: 13px; color: var(--text-2); overflow-wrap: anywhere; }
.rd-dim { opacity: 0.5; }
/* nur für Screenreader sichtbar */
.rd-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* Fuß des Dialogs */
.rd-foot { display: flex; flex: 1; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
@media (max-width: 860px) { .rd-foot .btn { flex: 1 1 auto; } }
```
- [ ] **Step 2: Bausteine schreiben**
Jede Datei beginnt mit einem deutschen Kommentar (Aufgabe, Props) und `import './rechnung-dialog.css'` steht nur in `RechnungDialog.jsx` (Task 8), nicht hier. Aufbau und Texte:
- **`KontoZusammenfassung`**: ein Fragment. `<div className="facts rd-facts">` mit vier `<div className="fact">`: „Vereinbart“ (`zeile.vereinbart`), „Berechnet“ (`zeile.berechnet`, darunter `<div className="xs muted">{zeile.bezahlt}</div>`, wenn nichtleer), „Noch offen“ (`zeile.offen`), „Zeit erfasst“ (`zeile.zeit.stunden`, darunter je ein `<div className="xs muted">` für `zeile.zeit.wert` und, wenn nichtleer, `zeile.zeit.abo`). Beschriftung `<div className="k">`, Wert `<div className="v">`. Ist `hinweis` nichtleer, folgt `<p className="msg warn" role="status">{hinweis}</p>`.
- **`RechnungPositionen`**: `<fieldset className="rd-group"><legend>{legende}</legend><div className="rd-stack">`; je Zeile `<div className="rd-item">` (Schlüssel `uid`) mit fünf Feldern in dieser Reihenfolge: Beschreibung (`rd-item-field rd-item-desc`; `<label className="rd-item-label" htmlFor={`${idPrefix}-desc-${uid}`}>Beschreibung</label>` (die Eingabe trägt dieselbe `id`; Menge und Preis entsprechend `-qty-` und `-price-`), `<input className="input" maxLength={INVOICE_LIMITS.maxDescription} placeholder="z. B. Webdesign Startseite" aria-label={`${labelPrefix} ${n}: Beschreibung`}>`), Menge (`rd-item-qty`, Beschriftung „Menge“, `inputMode="numeric"`, `className="input fin-amount"`, `aria-label` `… Menge`), Einzelpreis (`rd-item-price`, „Einzelpreis in €“, `inputMode="decimal"`, Platzhalter `0,00`, `aria-label` `… Einzelpreis in Euro`), Summe (`rd-item-field rd-item-sumfeld`, Beschriftung „Summe“, `<span className="fin-amount rd-item-sum">` mit `euro(lineTotalCents(zeile))` oder `–`) und Entfernen (`rd-item-remove`, `<button type="button" className="link" disabled={zeilen.length <= minZeilen} aria-label={`${labelPrefix} ${n} entfernen`}>Entfernen</button>`). Felder rufen `onChange(uid, 'description' | 'quantity' | 'price', wert)`. Darunter `<div className="rd-items-foot">` mit `<Button type="button" onClick={onAdd} disabled={zeilen.length >= INVOICE_LIMITS.maxItems}>{addLabel}</Button>` und, bei `zeigeSumme`, `<div className="rd-total" aria-live="polite"><span>Summe</span><span className="fin-amount">{euro(invoiceTotalCents(zeilen))}</span></div>`; bei `zeigeSumme` danach `<p className="rd-hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>`.
- **`RechnungVorschau`**: `<div className={abgeblendet ? 'rd-dim' : undefined} aria-busy={abgeblendet}>` mit `<table className="table rd-lines" role="table">`, `<caption className="rd-sr">Rechnungspositionen</caption>`, Kopf `Position` und `Betrag` (`className="fin-amount"`). Die Tabelle zeigt **alle** Positionen der Antwort, bei einer Schlussrechnung bis zu 40 (kein `slice`, keine Grenze von 20, keine feste Höhe). Je Position eine Zeile: Textzelle `rd-line-text` mit `{ vorn, hinten } = zeileTeilen(position.beschreibung)`: ist `bearbeitbar && zeileBearbeitbar(position)`, ein `<input className="input rd-line-input" aria-label={`Text zu Zeile ${n}`} value={zeilentexte[schluessel] ?? vorn} maxLength={maxVorn(hinten)} disabled={abgeblendet} onChange={(e) => onZeilentext(schluessel, e.target.value, vorn)}>` und darunter `<span className="rd-line-tail">{hinten}</span>`, sonst `{position.beschreibung}` und bei `menge > 1` `<div className="cell-sub">{menge} × {euro(einzelpreisCents)}</div>`; Betragszelle `className={'fin-amount' + (isNegText(text) ? ' fin-neg' : '')}` mit `euro(betragCents)`. Unter der Tabelle `<div className="rd-total"><span>Summe</span><span className="fin-amount">{euro(summeCents)}</span></div>` und `<p className="rd-hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>`; ist `bearbeitbar` und mindestens eine Zeile bearbeitbar, zusätzlich `<p className="rd-hint">Die Beschreibung der Zeilen lässt sich ändern, Stunden und Beträge stehen fest.</p>`.
- **`AufwandAuswahl`**: `<fieldset className="rd-group"><legend>Arbeitsblätter</legend><div className="rd-stack">`; `satzInfo` (wenn nichtleer) als `<p className="rd-hint">`; ohne Blätter `<p className="msg muted">Keine offenen Arbeitsblätter.</p>`, sonst `<div className="rd-blaetter">` mit je Blatt `<label className="rd-blatt">` (`<input type="checkbox" checked={!abgewaehlt.includes(id)} onChange={() => onToggle(id)} disabled={disabled}>`, `<span className="rd-blatt-text"><span className="num">{dateDe(datum)}</span> {text || 'Arbeitszeit'}</span>` plus `<span className="faint xs"> #{woid}</span>`, wenn die Blätter zu mehr als einem Ticket gehören, und `<span className="rd-blatt-zeit">{stundenDe(minuten)}</span>`). Darunter `<p className="rd-hint" aria-live="polite">{anzahl} von {blaetter.length} ausgewählt · {stundenDe(minuten)}</p>` (Zahlen aus `blaetterSumme(blaetter, ausgewaehlteIds(blaetter, abgewaehlt))`). Dann `<Field label="Weiteres Ticket des Kunden hinzunehmen" htmlFor="rd-ticket">` mit `<select id="rd-ticket" className="select" value="" disabled={ticketsLaden || disabled}>`: Option `<option value="">{ticketsLaden ? 'Tickets werden geladen …' : 'Ticket wählen …'}</option>` und je `ticketOptionen` eine Option (`value` = `value`, Text = `label`); Wahl ruft `onAddTicket(option)`; `ticketsFehler` als `<p className="msg warn" role="alert">{ticketsFehler}</p>`. Je `weitereTickets` eine Zeile `<div className="rd-weiter">` mit `#{woid} · {titel}`, dem Zustand (`weitereZustand[ticketId]`: `laedt` → „Arbeitsblätter werden geladen …“, `fehler` → `<span className="msg warn" role="alert">{message}</span>`) und `<button type="button" className="link" onClick={() => onRemoveTicket(ticketId)}>Entfernen</button>` (`aria-label` `Ticket #<woid> entfernen`).
- [ ] **Step 3: Syntax und Hygiene**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
node_modules/.bin/esbuild src/components/auftraege/KontoZusammenfassung.jsx src/components/auftraege/RechnungPositionen.jsx src/components/auftraege/RechnungVorschau.jsx src/components/auftraege/AufwandAuswahl.jsx src/components/auftraege/rechnung-dialog.css --loader:.jsx=jsx --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/syntax --log-level=warning
rg -n "react-icons|@tabler|style=\{\{|ui-btn|form-control|form-group|form-label|ui-card|badge-|overlay-content|overlay-close|spinner|kpi|#[0-9a-fA-F]{3,8}\b|rgba?\(|!important|console\.log" src/components/auftraege/KontoZusammenfassung.jsx src/components/auftraege/RechnungPositionen.jsx src/components/auftraege/RechnungVorschau.jsx src/components/auftraege/AufwandAuswahl.jsx src/components/auftraege/rechnung-dialog.css
rg -n "<button" -A2 src/components/auftraege/RechnungPositionen.jsx src/components/auftraege/AufwandAuswahl.jsx
rg -n "slice\(|\.length > 20" src/components/auftraege/RechnungVorschau.jsx
```
Expected: esbuild ohne Ausgabe; die erste `rg` ohne Treffer; bei der zweiten trägt jedes `<button` ein `type="button"` (bei mehrzeiligen Knöpfen stehen die Props in den Folgezeilen); die dritte ohne Treffer (die Vorschau kürzt nie, eine Schlussrechnung hat bis zu 40 Zeilen).
- [ ] **Step 4: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
git add src/components/auftraege/KontoZusammenfassung.jsx src/components/auftraege/RechnungPositionen.jsx src/components/auftraege/RechnungVorschau.jsx src/components/auftraege/AufwandAuswahl.jsx src/components/auftraege/rechnung-dialog.css
git commit -m "Rechnungsdialog: Bausteine für Projektzusammenfassung, Positionen, Vorschauzeilen und Arbeitsblattauswahl" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 8: Dialog `RechnungDialog` und Verhaltensprüfung
**Files:**
- Modify (ersetzt den Platzhalter von W0 vollständig): `src/components/auftraege/RechnungDialog.jsx`
- Ändern (nur unter `SP`): `fixtures-w1.mjs`, `szenarien.mjs`
**Interfaces:**
- Consumes: alles aus Task 2 bis 7; `Dialog`, `Field`, `Button`, `AutocompleteInput` (`../ui`); `AddressWarning` (`../finance/AddressWarning`, Props `{ customer, onShowOverview, variant }`); `useCustomers` (`../../hooks/useCustomers`); `useScrollIntoView`; `createInvoice` (`financeApi`); aus `financeView` `ADDRESS_INCOMPLETE_TEXT`, `INVOICE_LIMITS`, `customerPickerOptions`, `customerSuggestions`, `invoiceSendGate`, `invoiceTotalCents`, `resolveCustomerText`, `customerName`; aus `financeFormat` `canInvoice`, `euro`.
- Produces: `export default function RechnungDialog({ open, onClose, onCreated, kunde = null, projektId, auftraegeAktiv, onShowOverview = null })`. `projektId`: `undefined` (Prop weggelassen) = keine Vorauswahl, `null` = „Ohne Projekt“ vorausgewählt, Text = dieses Projekt vorausgewählt (V8). `auftraegeAktiv` ist nur der Startwert, bis `getProjektkonten` antwortet; danach gilt dessen Wert. `onCreated(rechnung, info)` und danach `onClose()` wie im bisherigen Dialog.
- [ ] **Step 1: `RechnungDialog.jsx` schreiben**
Kommentarkopf deutsch (Aufgabe, Props, Vorauswahl, Rückgabe an `onCreated`); `import './rechnung-dialog.css'`. Aufbau:
- **Äußere Komponente**: ohne `open` `null`. Mit `kunde`: `<RechnungFormular festerKunde={kunde} quelle={null} … />`. Sonst `<KundenWahl …/>`, die `useCustomers()` aufruft und `<RechnungFormular festerKunde={null} quelle={{ customers, loading, error }} … />` rendert (die Kundenliste wird nur im Fall ohne festen Kunden geladen, wie bisher).
- **`RechnungFormular`**: `useReducer(reduce, startProjekt(projektId), (p) => anfangszustand({ projektId: p }))`; `useState` für `customerText` (`''`), `error` (`''`), `busy` (`null | 'draft' | 'send'`); `submitter = useMemo(() => createSubmitter({ create: createInvoice, confirm: (t) => window.confirm(t) }), [])`; `errorRef` mit `useScrollIntoView(errorRef, error)`; `aliveRef`; `kundeIdRef`. `act(aktion)` leert `error`, dann `dispatch(aktion)`.
- **Abgeleitete Werte** (nichts davon steht im Zustand), in dieser Reihenfolge:
1. `options = customerPickerOptions(quelle?.customers)`, `resolved = resolveCustomerText(options, customerText)`, `kundeDoc = festerKunde || resolved.option?.customer || null`, `kunde = kundeDoc && canInvoice(kundeDoc).ok ? kundeDoc : null`; `blockiert` heißt: Kunde gefunden, aber `canInvoice` nicht ok.
2. `konten = useProjektkonten(kunde?.$id)`; `projekt = wirksamesProjekt(state.projektId, konten.data?.konten)`; `aktiv = konten.data ? Boolean(konten.data.auftraegeAktiv) : Boolean(auftraegeAktiv)`.
3. `detail = useProjektkontoDetail(projekt || '')`; `auftraege`, `rechnungen` aus `detail.data` (sonst `[]`).
4. `optionen`: bei `projekt === null` `[]`; bei `''` `artOptionen({ projektId: '', … })`; bei einem Projekt `[]`, solange `detail.data` fehlt, sonst `artOptionen({ projektId: projekt, auftraege, rechnungen, auftraegeAktiv: aktiv })`.
5. `art = optionen.some((o) => o.key === state.art && o.enabled) ? state.art : standardArt(optionen, { auftraege, rechnungen, offeneMinuten: detail.data?.konto?.zeit?.offeneMinuten || 0 })`.
6. `auftragId`: `state.auftragId`, wenn sie unter `gueltigeFestpreisAbs(auftraege)` steht (nur bei `anzahlung` und `schluss`), sonst `standardAuftrag(art, auftraege, rechnungen)`; `ab = auftraege.find((a) => a.id === auftragId)`.
7. `weitereTickets = state.weitereTickets`; `weitere = useWeitereBlaetter(weitereTickets)`; `blaetter = [...(detail.data?.offeneArbeitsblaetter || []), ...weitereTickets.flatMap((t) => weitere[t.ticketId]?.blaetter || [])]`; `kundenTickets = useKundenTickets(kunde?.$id, art === 'aufwand')`.
8. `e = { kunde, art, projektId: projekt ?? '', auftragId, items: state.items, zusatz: state.zusatz, blaetter, abgewaehlt: state.abgewaehlt, anzahlung: { ...anzahlungStandard(ab), ...state.anzahlung }, zeilentexte: state.zeilentexte, zeitraum: state.zeitraum, zeitraumGeaendert: state.zeitraumGeaendert, days: state.days, memo: state.memo }`.
9. `vorschauReq = vorschauAnfrage(e)`; `schluessel = vorschauReq.ok ? vorschauSchluessel(vorschauReq.body) : ''`; `vorschau = useRechnungVorschau({ anfrage: vorschauReq, schluessel, aktiv: VORSCHAU_ARTEN.includes(art) })`; `status = vorschauStatus({ art, anfrage: vorschauReq, vorschau, schluessel })`; `anfrage = rechnungAnfrage(e)`.
10. `totalCents`: bei `frei` und `festbetrag` `invoiceTotalCents(state.items)`, bei den übrigen Arten `vorschau.daten.summeCents`, wenn `status.state === 'bereit'`, sonst `null`; `gate = invoiceSendGate(kunde, totalCents)`; `darf = darfSpeichern(status) && busy === null && Boolean(kunde) && projekt !== null && art !== ''`.
- **Aussehen**: `<Dialog as="form" wide title="Rechnung schreiben" onClose={onClose} busy={busy !== null} onSubmit={(ev) => ev.preventDefault()} footer={…}>` mit den Abschnitten a bis h in dieser Reihenfolge:
- a. **Fehler**: `error` als `<div className="msg err" role="alert" ref={errorRef}>`.
- b. **Kunde** (`Field label="Kunde" htmlFor="rd-kunde"`): fester Kunde als `<p><strong>{customerName}</strong> · {email}</p>` (ohne `htmlFor`); sonst Ladetext „Kunden werden geladen …“ (`role="status"`), bei Fehler „Die Kunden konnten nicht geladen werden: <Meldung>“ (`msg warn`, `role="alert"`), sonst `AutocompleteInput id="rd-kunde" placeholder="Kunde suchen …"` mit `getSuggestions={(t) => customerSuggestions(options, t)}`. Eine Änderung setzt `customerText` und, wenn sich die aufgelöste Kunden-`$id` gegenüber `kundeIdRef` ändert, `act({ type: 'projekt', projektId: null })`. Hinweise: `blockiert` → „<Name>: <Grund>. Für diesen Kunden kann keine Rechnung geschrieben werden.“ (`msg warn`, `role="alert"`); `resolved.state === 'unknown'` → „Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.“ (`<p className="rd-hint">`); gewählter Kunde im Pickerfall → „Die Rechnung geht an <E-Mail>.“ (ebenso `rd-hint`); `gate.warn` → `<AddressWarning customer={kunde} variant="dialog" onShowOverview={…} />`, der Handler tut bei `busy` nichts, sonst `onClose()` und dann `onShowOverview()`; ohne `onShowOverview` bleibt die Prop weg. Ohne `kunde` endet die Anzeige hier.
- c. **Projekt** (`Field label="Projekt" htmlFor="rd-projekt"`): `<select id="rd-projekt" className="select">`. Solange `konten.loading`: Select gesperrt und darunter „Projekte werden geladen …“ (`role="status"`). Optionen `projektOptionen(konten.data?.konten)`; ist `projekt === null`, steht davor `<option value="?" disabled>Projekt wählen …</option>` und der Select hat `value="?"`. Eine Wahl ruft `act({ type: 'projekt', projektId: value })`. Bei `konten.error`: `<p className="msg warn" role="alert">Die Projekte konnten nicht geladen werden: <Meldung> <button type="button" className="link" onClick={konten.reload}>Erneut versuchen</button></p>` (der Dialog bleibt mit „Ohne Projekt“ bedienbar). Geladen und ohne Projekte: `KEINE_PROJEKTE_HINWEIS` als `<p className="rd-hint">`. Solange `projekt === null`, endet die Anzeige hier.
- d. **Projektkonto** (nur bei nichtleerem `projekt`): lädt: „Projektkonto wird geladen …“ (`role="status"`); Fehler: „Das Projektkonto konnte nicht geladen werden: <Meldung>“ mit „Erneut versuchen“ (`detail.reload`) und die Anzeige endet; sonst `<KontoZusammenfassung zeile={kontoZeile(detail.data.konto, { stripeOk: detail.data.stripeOk })} hinweis={stripeHinweis(detail.data.stripeOk)} />`.
- e. **Art** (nur wenn `optionen.length > 1`): `<fieldset className="rd-group"><legend>Art der Rechnung</legend><div className="rd-stack">` mit `<div className="seg rd-seg" role="group" aria-label="Art der Rechnung">` und je Option `<button type="button" aria-pressed={o.key === art} disabled={!o.enabled} title={o.hint || undefined} onClick={() => act({ type: 'art', art: o.key })}>{o.label}</button>`; darunter je verschiedenem nichtleerem `hint` der gesperrten Optionen ein `<p className="rd-hint">`.
- f. **Inhalt je Art** (nur wenn `art` nichtleer):
- `frei`, `festbetrag`: `RechnungPositionen legende="Positionen" labelPrefix="Position" idPrefix="rd-pos" minZeilen={1} addLabel="+ Position" zeigeSumme` über `state.items`; `onChange`/`onAdd`/`onRemove` setzen `act({ type: 'position' | 'position+' | 'position-', liste: 'items', … })`.
- `aufwand`: `AufwandAuswahl` mit `konto = detail.data.konto` (`satzInfo` = `Stundensatz <satzText(konto.zeit.satzCents)>`, ergänzt um `, laut <Nummer>`, wenn `aufwandAuftrag(auftraege)` existiert; `ticketOptionen={ticketOptionen(kundenTickets.tickets, { ausgeschlossen: [...konto.zeitquelle, ...konto.tickets.map((t) => t.id), ...weitereTickets.map((t) => t.ticketId)] })}`, `ticketsLaden={kundenTickets.loading}`, `ticketsFehler={kundenTickets.error}`, `weitereZustand={weitere}`; `onToggle` → `act({ type: 'blatt', id })`, `onAddTicket(o)` → `act({ type: 'ticket+', ticket: { ticketId: o.ticketId, woid: o.woid, titel: <Titel ohne „#… · “> } })`, `onRemoveTicket(id)` → `act({ type: 'ticket-', ticketId: id, blattIds: (weitere[id]?.blaetter || []).map((b) => b.id) })`); der Statusbereich; `RechnungVorschau bearbeitbar zeilentexte={state.zeilentexte} onZeilentext={(schluessel, text, original) => act({ type: 'zeilentext', schluessel, text, original })}`; danach `RechnungPositionen legende="Zusätzliche Positionen" labelPrefix="Zusatzposition" idPrefix="rd-zus" minZeilen={0} addLabel="+ Zusatzposition"` über `state.zusatz` (`liste: 'zusatz'`, ohne Summe).
- `anzahlung`: AB-Block, Anzahlungsfelder, Statusbereich, `RechnungVorschau`.
- `schluss`: AB-Block, `RechnungPositionen legende="Zusätzliche Positionen (Nachträge)" labelPrefix="Zusatzposition" idPrefix="rd-zus" minZeilen={0} addLabel="+ Zusatzposition"`, Statusbereich, `RechnungVorschau`.
- **AB-Block** (`anzahlung`, `schluss`): bei mehr als einer gültigen AB `<Field label="Auftragsbestätigung" htmlFor="rd-ab"><select id="rd-ab" className="select">` mit Optionen `abLabel` (Wahl → `act({ type: 'auftrag', auftragId })`), sonst eine Zeile `abLabel(ab)`; darunter `<p className="rd-hint">Auftragssumme <euro(ab.summeCents)> · bereits berechnet <euro(berechnet)> · noch nicht berechnet <euro(max(0, ab.summeCents − berechnet))></p>` mit `berechnet = berechnetFuerAuftrag(rechnungen, ab.id)`.
- **Anzahlungsfelder**: `<div className="seg rd-seg" role="group" aria-label="Anzahlung in">` mit den Knöpfen „Prozent“ und „Betrag“ (`aria-pressed`, setzen `modus` über `act({ type: 'anzahlung', patch })`, der Wert bleibt), `Field` „Anzahlung in %“ bzw. „Betrag in €“ (`input.input`, `inputMode="decimal"`, `id="rd-anzahlung-wert"`) und `Field` „Bezeichnung“ (`input.input`, `maxLength={BEZEICHNUNG_MAX}`, `id="rd-anzahlung-bez"`).
- **Statusbereich** (Aufwand, Anzahlung, Schluss): `status.state` `laedt` → `<p className="msg muted" role="status" aria-live="polite">Vorschau wird aktualisiert …</p>`; `ungueltig` mit nichtleerem `message` → `<p className="msg muted">{message}</p>`; `fehler` → `<p className="msg err" role="alert">{message}</p>`. Die Vorschauzeilen (`vorschau?.daten`) stehen bei `bereit` normal, bei `laedt` abgeblendet (`abgeblendet`), bei `ungueltig` und `fehler` gar nicht.
- g. **Gemeinsame Felder** (nur wenn `art` nichtleer): `Field label="Leistungszeitraum (optional)" htmlFor="rd-zeitraum"` (`input.input`, Platzhalter `z. B. 01.10.–20.10.2026`, Änderung `act({ type: 'zeitraum', text })`); `<div className="field-row">` mit `Field label="Zahlungsziel in Tagen" htmlFor="rd-tage"` (`input.input`, `inputMode="numeric"`, `act({ type: 'tage', wert })`) und `Field label="Notiz (erscheint auf der Rechnung)" htmlFor="rd-notiz"` (`textarea.textarea`, `rows={2}`, `maxLength={INVOICE_LIMITS.maxMemo}`, `act({ type: 'notiz', wert })`).
- h. **Fuß** (`footer`): `<div className="rd-foot">` mit `<Button type="button" onClick={onClose} disabled={busy !== null}>Abbrechen</Button>`, `<Button type="button" disabled={!darf} onClick={() => abschicken(false)}>{busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}</Button>` und `<Button type="button" variant="primary" disabled={!darf || !gate.sendAllowed} title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT} onClick={() => abschicken(true)}>{busy === 'send' ? 'Sende …' : 'Senden'}</Button>`.
- **Verhalten**:
- `abschicken(senden)`: `setBusy(senden ? 'send' : 'draft')`, `setError('')`, `r = await submitter({ anfrage, senden, kunde, totalCents })`, danach (wenn der Dialog noch lebt) `setBusy(null)`. Je `r.status`: `laeuft`, `abgebrochen` → nichts; `fehler` → `setError(r.message)`, bei `r.httpStatus === 409` zusätzlich `detail.reload()` (das Blatt ist inzwischen abgerechnet); `festgeschrieben` → `onCreated(null, { senden: true, notice: r.notice, tone: 'warn', finalizedFilter: r.finalizedFilter })` und `onClose()`; `ok` → `onCreated(r.rechnung, { senden: r.senden })` und `onClose()`.
- Effekt auf `vorschau`: Ist `art === 'aufwand'` und `status.state === 'bereit'`, `dispatch({ type: 'zeitraum-vorschlag', text: vorschau.daten.leistungszeitraum || '' })` (der Vorschlag überschreibt nie eine Eingabe des Nutzers).
- Effekt auf `vorschau`: Ein Fehler mit `httpStatus === 409` ruft einmal je Fehlerobjekt `detail.reload()`.
- Alle Knöpfe tragen `type="button"`; keine Icons, keine `style`-Attribute; der Dialog steht nie in einem Element mit `container-type`.
- [ ] **Step 2: Syntax und Hygiene**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
node_modules/.bin/esbuild src/components/auftraege/RechnungDialog.jsx --loader:.jsx=jsx --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/syntax --log-level=warning
rg -n "react-icons|@tabler|style=\{\{|ui-btn|form-control|form-group|form-label|ui-card|badge-|overlay-content|overlay-close|spinner|kpi|#[0-9a-fA-F]{3,8}\b|rgba?\(|!important|console\.log|useDialogFocus|keydown" src/components/auftraege/RechnungDialog.jsx
rg -n "<(Button|button)" -A3 src/components/auftraege/RechnungDialog.jsx | rg -c "type="
```
Expected: esbuild ohne Ausgabe; die erste `rg` ohne Treffer; die Zählung nennt mindestens so viele `type=`-Treffer wie Knöpfe im Dialog (jeder `<Button`/`<button` trägt `type="button"`; beim Lesen gegenprüfen).
- [ ] **Step 3: Nachbau des Portals für Vorschau und Anlegen (nur unter `SP`)**
In `fixtures-w1.mjs` ergänzen (vorher fielen beide Endpunkte an das Werkzeug durch). Beide protokollieren in `calls`.
- **`POST …/rechnungen/vorschau`** (wartet `state.vorschauDelayMs`; bei `state.vorschauFehler` Status und `{ error }`): Antwort 200 `{ positionen, summeCents, leistungszeitraum, customFields: [] }` je Art:
- `aufwand`: Tabelle der Blätter = die drei Blätter von `p2` plus `ws100149 = { datum: '2026-10-04', minuten: 30, text: 'SPF-Eintrag ergänzt, DKIM-Schlüssel fehlt noch beim Mailanbieter.' }`; eine unbekannte Kennung in `arbeitsblattIds` → 409 `{ error: 'Arbeitsblatt nicht gefunden.' }`. Je Tag (`datum`) eine Position `{ schluessel: datum, beschreibung: '<TT.MM.JJJJ>: <Texte mit "; " verbunden> – <Stunden mit Komma, ohne überflüssige Nullen> Std. à 100,00 €', menge: 1, einzelpreisCents: b, betragCents: b }` mit `b = Math.round(Minuten × 10000 / 60)`; `zeilentexte[datum]` ersetzt den Teil vor dem letzten ` – `; `items` folgen als `zusatz-<n>` (`menge`, `einzelpreisCents = unitAmountCents`); `leistungszeitraum` ist der Wert des Körpers, sonst `04.10.–05.10.2026` (erster bis letzter Tag, bei einem Tag `04.10.2026`).
- `anzahlung`: AB `a1` (Summe 120000): `betrag = anzahlung.betragCents`, sonst `Math.round(120000 × anzahlung.prozent / 100)`; eine Position `{ schluessel: 'anzahlung', beschreibung: '<bezeichnung> <prozent> % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (Fachanwaltsseiten)' (bei Euro-Betrag ohne „<prozent> %“), menge: 1, einzelpreisCents: betrag, betragCents: betrag }`; ein Betrag über der Auftragssumme → 400 `{ error: 'Die Anzahlung darf die Auftragssumme nicht übersteigen.' }`.
- `schluss`: `{ schluessel: 'ab-1', beschreibung: 'Fachanwaltsseiten: Konzept, Umsetzung, Einrichtung', menge: 1, einzelpreisCents: 120000, betragCents: 120000 }`, dann die `items` als `zusatz-<n>`, dann bei `anzahlungBerechnet` die Minuszeile `{ schluessel: 'abzug-in_a', beschreibung: 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026', menge: 1, einzelpreisCents: -40000, betragCents: -40000 }`. Ist `state.schluss40` gesetzt (Option `schluss40` oder vom Szenario gesetzt), liefert die Antwort stattdessen **40 Zeilen** wie eine ausgebaute Schlussrechnung des Servers: `ab-1` bis `ab-20` (`Fachanwaltsseiten: Baustein <n>`, je 50,00 €), die `items` als `zusatz-<n>`, mit erzeugten Nachträgen `Nachtrag <n>` zu je 10,00 € auf zehn Zeilen aufgefüllt, dann zehn Abzugszeilen `abzug-in_<n>` (`abzüglich Teilrechnung, Rechnung WEBKLAR-00<20 + n> vom <nn>.10.2026`, je 20,00 € abgezogen: `betragCents: -2000`).
- `summeCents` ist die Summe der `betragCents`; `leistungszeitraum` `''`, wo nichts gilt.
- **`POST …/rechnungen`** (wartet `state.postDelayMs`; bei `state.postFehler` dessen Status mit `{ error }`): Antwort 201 `{ rechnung: { id: 'in_neu<n>', number: senden ? 'WEBKLAR-0010' : '', status: senden ? 'offen' : 'entwurf', customerId, customerName: 'Kanzlei Weber & Partner', customerEmail: 'kanzlei@weber-partner.de', createdDate: '2026-10-05', dueDate: '2026-10-19', totalCents, remainingCents: totalCents, source: 'projekt', hostedUrl: '', pdfUrl: '' } }` mit `totalCents` aus denselben Regeln (bei `frei` und `festbetrag` Σ `quantity × unitAmountCents`).
- [ ] **Step 4: Szenarien ergänzen**
In `szenarien.mjs` ergänzen. Die Schritte benutzen `getByRole`/`getByLabel` mit den wörtlichen Texten dieses Plans; `portal.calls` ist die Liste der beantworteten Anfragen. `nurViewport: true` bei allen `dlg-*`. „POST rechnungen“ meint `method === 'POST'` und `path` endet auf `/rechnungen` (nicht `/vorschau`).
| ID | Pfad | `portal` | Ablauf und Prüfungen |
|---|---|---|---|
| `dlg-ohne-projekte` (Fokus 3) | `/customers/c5?tab=invoices` | `{}` | Klick `Rechnung ohne Projekt` → Dialog „Rechnung schreiben“; Projektauswahl enthält nur „Ohne Projekt“ und steht darauf; Hinweis „Dieser Kunde hat noch keine Projekte.“; keine Gruppe „Art der Rechnung“; Gruppe „Positionen“ da; Beschreibung `Beratung` und Preis `120,00` eintragen, `Als Entwurf speichern` → genau ein POST rechnungen mit `art: 'frei'`, ohne `projektId`, `senden: false`, `items[0].unitAmountCents: 12000`; der Dialog schließt |
| `dlg-festbetrag` (Fokus 4 teilweise) | `/customers/c2?tab=invoices` | `{ langerName: true }` | Klick auf den Knopf `Rechnung für Mandantenportal…` (Name per Regex) → Projekt vorausgewählt, Zusammenfassung zeigt `keine AB`; `Anzahlung` und `Schlussrechnung` sind gesperrt, Hinweis „Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.“; `Festbetrag` hat `aria-pressed="true"`; Enter in der Beschreibung löst keinen Request aus; Beschreibung und Preis füllen, `Als Entwurf speichern` → POST rechnungen mit `art: 'festbetrag'`, `projektId: 'p-lang'`, `daysUntilDue: 14` |
| `dlg-aufwand` (Fokus 2, 5) | `/customers/c2?tab=invoices` | `{}` | Klick `Rechnung für Kanzlei Weber` → `Nach Aufwand` ist gedrückt, drei Haken gesetzt, Zähler `3 von 3 ausgewählt · 3,25 Std.`; Vorschau mit zwei Zeilen (zweite: `05.10.2026: Footer und Impressum – 0,75 Std. à 100,00 €`) und Summe `325,00 €`; Leistungszeitraum `04.10.–05.10.2026`; beide Speicherknöpfe aktiv. Dann `portal.state.vorschauDelayMs = 800` und den Haken „Kontaktblock gebaut“ entfernen: **sofort** sind beide Knöpfe `disabled` und „Vorschau wird aktualisiert …“ steht da; nach der Antwort sind sie aktiv, die Summe ist `225,00 €`, und der letzte POST vorschau trägt `arbeitsblattIds: ['ws100148', 'b-demo-3']`. Den Text von Zeile 1 (`Text zu Zeile 1`) ändern, eine Zusatzposition mit 200 Zeichen ohne Leerzeichen als Beschreibung anlegen (keine waagerechte Scrollleiste), `Als Entwurf speichern` → POST rechnungen mit denselben `arbeitsblattIds`, `zeilentexte['2026-10-04']` mit dem geänderten Text und `items[0].description` von 200 Zeichen |
| `dlg-weitere-tickets` | `/customers/c2?tab=invoices` | `{}` | Klick `Rechnung für Kanzlei Weber`; im Feld „Weiteres Ticket des Kunden hinzunehmen“ `#100128 · Kontaktformular landet im Spam` wählen → Zeile „SPF-Eintrag ergänzt …“ mit `0,5 Std.`, Zähler `4 von 4 ausgewählt · 3,75 Std.`, das Ticket `#100133` steht nicht in der Auswahl; der nächste POST vorschau enthält `ws100149`; `Ticket #100128 entfernen` → Zeile weg, Zähler `3 von 3 …` |
| `dlg-doppelklick` (Fokus 1) | `/customers/c2?tab=invoices` | `{ langerName: true, postDelayMs: 1500 }` | Projekt `p-lang`, Festbetrag mit gültiger Position; `Senden` doppelt klicken (der Läufer bestätigt die Rückfrage); nach dem Schließen des Dialogs steht in `portal.calls` genau **ein** POST rechnungen mit `senden: true` |
| `dlg-anzahlung` | `/customers/c2?tab=invoices` | `{ ab: true }` | Klick `Rechnung für Fachanwaltsseiten` → `Anzahlung` gedrückt; AB-Zeile `AB-2026-0002 vom 06.10.2026 · 1.200,00 €` und `Auftragssumme 1.200,00 € · bereits berechnet 0,00 € · noch nicht berechnet 1.200,00 €`; Vorschau eine Zeile `Anzahlung 50 % gemäß …`, Summe `600,00 €`; `Betrag` wählen und `400,00` eintragen → Summe `400,00 €`; `abc` eintragen → „Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.“ und beide Speicherknöpfe `disabled` |
| `dlg-schluss` | `/customers/c2?tab=invoices` | `{ ab: true, anzahlungBerechnet: true }` | `Schlussrechnung` gedrückt; Vorschau: AB-Zeile `1.200,00 €`, Minuszeile `abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026` mit `−400,00 €` (Betragszelle trägt `fin-neg`), Summe `800,00 €`; Zusatzposition `Nachtrag Logo` mit `150,00` → Summe `950,00 €`. Danach `portal.state.schluss40 = true` setzen und die Beschreibung der Zusatzposition auf `Nachtrag Logo 2` ändern (anderer Vorschauschlüssel, neue Anfrage): nach der Antwort hat die Tabelle „Rechnungspositionen“ genau **40** Zeilen (die erste enthält `Baustein 1`, die einundzwanzigste `Nachtrag Logo 2`, die letzte `WEBKLAR-0030`), die Summe ist `1.040,00 €` und beide Speicherknöpfe sind aktiv: die Vorschau kürzt nie auf 20 Zeilen |
| `dlg-409` | `/customers/c2?tab=invoices` | `{ postFehler: { status: 409, error: 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).' } }` | Klick `Rechnung für Kanzlei Weber`, `Als Entwurf speichern` → die Meldung steht im Dialog (`role="alert"`), der Dialog bleibt offen, und `portal.calls` enthält danach einen zweiten GET `…/projektkonten/p2` |
| `dlg-stripe` (Fokus 4) | `/customers/c2?tab=invoices` | `{ ab: true, stripeOk: false, langerName: true }` | Klick `Rechnung für Fachanwaltsseiten`: Zusammenfassung zeigt den Stripe-Hinweis und Berechnet `–`, die Projektauswahl lässt sich auf „Ohne Projekt“ umstellen; Beschreibung und Preis füllen, `Als Entwurf speichern` → der POST rechnungen geht raus (der Dialog bleibt trotz des Hinweises bedienbar) |
- [ ] **Step 5: Läufe**
Run: `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/lauf.sh --only konten,konten-ab,konten-stripe,konten-leer,konten-lang,dlg-ohne-projekte,dlg-festbetrag,dlg-aufwand,dlg-weitere-tickets,dlg-doppelklick,dlg-anzahlung,dlg-schluss,dlg-409,dlg-stripe`
Expected: Exit 0 und `ok …` für jedes Szenario in beiden Breiten (28 Zeilen). Schlägt eines fehl: erst die Meldung lesen (`FAIL <id> <Breite>: …`) und das Bild `SP/shots/FAIL-….png` ansehen; die Ursache liegt in der Komponente, außer die Abweichung steht im Nachbau (dann dort korrigieren, nie die Prüfung abschwächen). Danach `SP/shots/1440x900/dlg-aufwand.png`, `SP/shots/390x844/dlg-aufwand.png`, `…/dlg-schluss.png` ansehen: breiter weißer Dialog mit Haarlinien, bei 390 px Bottom-Sheet mit umbrechendem Fuß, Art-Knöpfe in zwei Zeilen, nichts schneidet ab.
- [ ] **Step 6: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
git add src/components/auftraege/RechnungDialog.jsx
git commit -m "Rechnung schreiben: neuer Dialog mit Projektwahl, Zusammenfassung, Art, Aufwand mit Vorschau, Anzahlung und Schlussrechnung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 9: Rechnungstabelle mit Projekt und Art, neuer Dialog in `InvoiceTable`
**Files:**
- Modify: `src/components/finance/InvoiceTable.jsx`, `src/components/auftraege/rechnung-dialog.css`
- Unverändert (geprüft): `src/pages/FinancePage.jsx`
- Ändern (nur unter `SP`): `szenarien.mjs`
**Interfaces:**
- Consumes: `artLabel`, `projektSpalte` (Task 2); `RechnungDialog` (Task 8). `mapInvoice` des Portals liefert laut V4 je Rechnung zusätzlich `projektName`, `art` (`frei | festbetrag | aufwand | anzahlung | schluss | abo`), `projektId`, `auftragId`, `auftragNummer`; ein älteres Portal liefert sie nicht, dann zeigt die Tabelle „–“.
- Produces: `InvoiceTable` behält seine Props und ihren Vertrag (`onChanged`, `refreshToken`, `customer`, `onShowOverview`, Filter, Seiten). Der Knopf „Rechnung schreiben“ öffnet jetzt `RechnungDialog` (`kunde={customer}`, im Kundenreiter fest, auf der Finanzen-Seite `null` mit Kundenwahl; `projektId` und `auftraegeAktiv` bleiben weg: keine Vorauswahl, der Dialog fragt `getProjektkonten` selbst). Die Tabelle bekommt die Spalten **Projekt** und **Art**.
- [ ] **Step 1: Stile der neuen Spalten anhängen**
Am Ende von `rechnung-dialog.css`:
```css
/* Rechnungstabelle (InvoiceTable): Spalten Projekt und Art. Zwei Klassen vor dem Elementnamen, damit diese Rasterflächen
die Regel .fin-invoices tr aus global.css überstimmen (dieses Stylesheet steht davor im Bundle). */
.fin-block .fin-inv-project { overflow-wrap: anywhere; }
.fin-block .fin-inv-art { white-space: nowrap; }
@container (max-width: 860px) {
.fin-block .fin-invoices tr { grid-template-areas: "number status" "customer customer" "projekt art" "created due" "total remaining" "actions actions"; }
.fin-block .fin-inv-project { grid-area: projekt; }
.fin-block .fin-inv-art { grid-area: art; }
}
```
- [ ] **Step 2: `InvoiceTable.jsx` ändern**
Genau diese Änderungen, sonst bleibt die Datei unberührt:
- Importe: `import InvoiceCreateModal from './InvoiceCreateModal'` ersetzen durch `import RechnungDialog from '../auftraege/RechnungDialog'`; neu `import { artLabel, projektSpalte } from '../../lib/projektkontoView'`.
- Tabellenkopf: direkt nach `<th role="columnheader" scope="col">Kunde</th>` zwei Spalten `<th role="columnheader" scope="col">Projekt</th>` und `<th role="columnheader" scope="col">Art</th>`.
- Zeilen: direkt nach der Kundenzelle `<td role="cell" className="fin-inv-project" data-label="Projekt">{projektSpalte(byId[row.id])}</td>` und `<td role="cell" className="fin-inv-art" data-label="Art">{artLabel(byId[row.id]?.art)}</td>`. Die Zusatzzeile „· Abo“ in der Nummernzelle bleibt.
- Dialog: `<InvoiceCreateModal … />` am Ende des `<section>` ersetzen durch `<RechnungDialog open={createOpen} onClose={() => setCreateOpen(false)} onCreated={handleCreated} kunde={customer} onShowOverview={onShowOverview} />`. `handleCreated` bleibt unverändert (der Dialog ruft es mit `(rechnung, info)` wie bisher).
- Den Kommentar über der Komponente um einen Satz ergänzen: Spalten Projekt und Art, Dialog `RechnungDialog` (Projektwahl, Arten, Vorschau).
- [ ] **Step 3: Syntax, Hygiene und Beleg, dass `FinancePage.jsx` unverändert bleibt**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
node_modules/.bin/esbuild src/components/finance/InvoiceTable.jsx src/components/auftraege/rechnung-dialog.css --loader:.jsx=jsx --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/syntax --log-level=warning
rg -n "InvoiceCreateModal" src
git diff --stat -- src/pages/FinancePage.jsx
```
Expected: esbuild ohne Ausgabe; `rg` nennt nur noch `src/components/finance/InvoiceCreateModal.jsx` selbst (Task 11 löscht sie); `git diff --stat` für `FinancePage.jsx` ist leer.
- [ ] **Step 4: Szenarien ergänzen und laufen lassen**
In `szenarien.mjs` ergänzen. `daten(backend)` legt vor dem Start Rechnungen des Kunden `c2` an: `backend.data.finanzen.rechnungen.unshift(…)` mit je `{ id, number, status: 'offen', customerId: 'c2', customerName: 'Kanzlei Weber & Partner', customerEmail: 'kanzlei@weber-partner.de', createdDate: '2026-10-01', dueDate: '2026-10-15', totalCents, remainingCents: totalCents, source, hostedUrl: '', pdfUrl: '' }` und den Zusatzfeldern: `WEBKLAR-0011` (`projektName: 'Kanzlei Weber'`, `art: 'festbetrag'`), `WEBKLAR-0012` (`projektName` mit dem 61 Zeichen langen Namen, `art: 'aufwand'`), `WEBKLAR-0013` (`projektName: 'Fachanwaltsseiten'`, `art: 'schluss'`), `WEBKLAR-0014` (`source: 'abo'`, `art: 'abo'`, `projektName: ''`), `WEBKLAR-0015` (`art: 'frei'`, `projektName: ''`), `WEBKLAR-0016` (ohne `art` und `projektName`, wie von einem älteren Portal). Sie stehen in dieser Reihenfolge von oben nach unten in der Tabelle: alle tragen dasselbe Datum, die Tabelle behält die Reihenfolge der Antwort, also in einem einzigen `unshift(...liste)` anlegen.
| ID | Pfad | `portal` | Prüfungen |
|---|---|---|---|
| `rechnungen-spalten` (Fokus 5) | `/customers/c2?tab=invoices` | `{}` | In der Tabelle „Rechnungen, neueste zuerst“ gibt es die Spaltenköpfe `Projekt` und `Art`; die Zeilen zeigen der Reihe nach (Projekt, Art): `Kanzlei Weber`/`Festbetrag`, der lange Name/`Aufwand`, `Fachanwaltsseiten`/`Schlussrechnung`, `–`/`Abo`, `–`/`Ohne Projekt`, `–`/`–`; bei 390 px tragen die Zellen `data-label="Projekt"` und `data-label="Art"` und sind sichtbar |
| `rechnungen-spalten-finanzen` | `/finance` | `{}` | dieselben beiden Spaltenköpfe in der Rechnungstabelle der Finanzen-Seite |
| `dlg-kundenwahl` | `/finance` | `{}` | Klick `Rechnung schreiben` (Karte „Rechnungen“) → nur das Feld „Kunde“; `Kanz` eintippen, `Kanzlei Weber & Partner` wählen → die Projektauswahl steht auf „Projekt wählen …“ (Wert `?`), darunter nichts; `Kanzlei Weber` wählen → Zusammenfassung und Art erscheinen; den Kundentext leeren → Projektauswahl und Rest verschwinden; wieder `Kanzlei Weber & Partner` wählen → die Projektauswahl steht wieder auf „Projekt wählen …“ |
| `dlg-projekte-fehler` (Fokus 3) | `/customers/c2?tab=invoices` | `{ kontenFehler: { status: 500, error: 'Test: Projekte nicht ladbar' } }` | Im Reiter zeigt der Container seinen Fehlerblock „Projekte“ mit „Erneut versuchen“ (W0), die Rechnungsliste bleibt bedienbar; Klick auf `Rechnung schreiben` in der Karte „Rechnungen“ → der Dialog zeigt „Die Projekte konnten nicht geladen werden: Test: Projekte nicht ladbar“ mit „Erneut versuchen“; `Ohne Projekt` wählen, Position füllen, `Als Entwurf speichern` → der POST rechnungen geht raus |
| `dlg-aus-tabelle` | `/customers/c2?tab=invoices` | `{}` | Klick auf `Rechnung schreiben` in der Karte „Rechnungen“ → der Dialog zeigt den Kunden fest (Text „Kanzlei Weber & Partner“ und die E-Mail-Adresse), die Projektauswahl steht auf „Projekt wählen …“; `Ohne Projekt` wählen, Position füllen, `Als Entwurf speichern` → Meldung „Der Entwurf wurde gespeichert.“ über der Tabelle (der Vertrag `onCreated` von `InvoiceTable` gilt unverändert) |
Run: `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/lauf.sh --only rechnungen-spalten,rechnungen-spalten-finanzen,dlg-kundenwahl,dlg-projekte-fehler,dlg-aus-tabelle,konten,dlg-aufwand`
Expected: Exit 0 und vierzehnmal `ok …` (die letzten beiden Szenarien sind der Gegenlauf zu Task 3 und 8). Bilder `SP/shots/1440x900/rechnungen-spalten.png` und `SP/shots/390x844/rechnungen-spalten.png` ansehen: bei 1440 px eine zusätzliche Spalte Projekt und Art in der Tabelle, bei 390 px je Zeile eine Reihe „Projekt … Art …“ zwischen Kunde und Datum, nichts läuft über.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
git add src/components/finance/InvoiceTable.jsx src/components/auftraege/rechnung-dialog.css
git commit -m "Rechnungstabelle: Spalten Projekt und Art, Knopf Rechnung schreiben öffnet den neuen Dialog" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 10: Abzeichen „abgerechnet“ und Warnung in den Arbeitsblättern
**Files:**
- Create: `src/hooks/useAbgerechnet.js`
- Modify: `src/components/WorksheetList.jsx`, `src/components/WorksheetTimesModal.jsx`
- Ändern (nur unter `SP`): `szenarien.mjs`
**Interfaces:**
- Consumes: `getAbgerechnet(ticketId) → { abgerechnet: { <Arbeitsblatt-$id>: '<Rechnungsnummer>' | 'Entwurf' } }` (`teamApi`, W0; V5: alle Mitarbeiter, ohne Beträge; `ticketId` ist die Ticket-`$id`, im Arbeitsblatt `workorderId`); `abgerechnetText`, `abgerechnetWarnung`, `ticketIdsOf` (Task 2).
- Produces: `useAbgerechnet(ticketIds: string[]) → { [worksheetId]: string }` (ein Fehler liefert `{}`, der Hook wirft nie). `WorksheetList` und `WorksheetTimesModal` behalten Props und Verhalten; sie laden den Stand selbst, `TicketPage.jsx` bleibt unverändert.
- [ ] **Step 1: `src/hooks/useAbgerechnet.js` schreiben**
```js
import { useEffect, useState } from 'react'
import { getAbgerechnet } from '../lib/teamApi'
const NICHTS = Object.freeze({})
/**
* Arbeitsblatt-Kennung -> Rechnungsnummer oder „Entwurf“ für die Tickets in `ticketIds` (Team-Route, alle Mitarbeiter,
* ohne Beträge). Ein Fehler verbirgt nur die Abzeichen, das Arbeitsblatt bleibt bedienbar; der Hook wirft nie.
*/
export function useAbgerechnet(ticketIds) {
const key = (ticketIds || []).join(',')
const [state, setState] = useState({ key: '', map: NICHTS })
useEffect(() => {
if (!key) return undefined
let cancelled = false
Promise.all(key.split(',').map((id) => getAbgerechnet(id).then((r) => r?.abgerechnet || NICHTS).catch(() => NICHTS)))
.then((maps) => { if (!cancelled) setState({ key, map: Object.assign({}, ...maps) }) })
return () => { cancelled = true }
}, [key])
return state.key === key ? state.map : NICHTS
}
```
- [ ] **Step 2: `WorksheetList.jsx` und `WorksheetTimesModal.jsx` ändern**
- **`WorksheetList.jsx`**: Importe `useAbgerechnet` und `{ abgerechnetText, ticketIdsOf }`. Als **erste** Zeile der Komponente (vor den frühen Rückgaben für `loading` und leere Liste, Hooks müssen immer laufen) `const abgerechnet = useAbgerechnet(ticketIdsOf(worksheets))`. In der Zeilenschleife `const billed = abgerechnetText(abgerechnet, ws.$id)`; in `.ws-head` direkt hinter dem Abzeichen aus `worksheetBadge` und vor dem Abo-Umschalter `{billed && <span className="badge blue" title={…}>{billed}</span>}` mit `title` „Dieses Arbeitsblatt steht in einem Rechnungsentwurf.“ bei `abgerechnet (Entwurf)`, sonst „Dieses Arbeitsblatt ist auf einer Rechnung abgerechnet.“ Das Abzeichen sehen alle Mitarbeiter, es zeigt nie Beträge.
- **`WorksheetTimesModal.jsx`**: Importe wie oben. Am Anfang der Komponente `const abgerechnet = useAbgerechnet(ticketIdsOf([worksheet]))` und `const warnung = abgerechnetWarnung(abgerechnet, worksheet.$id)`. Direkt hinter dem Absatz `<p className="muted">WSID …</p>` und vor dem ersten `Field`: `{warnung && <p className="msg warn" role="alert">{warnung}</p>}`. Eine Sperre gibt es nicht: „Speichern“ bleibt unverändert möglich (WOMS schreibt Arbeitsblätter direkt in Appwrite).
- [ ] **Step 3: Syntax und Hygiene**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
node_modules/.bin/esbuild src/hooks/useAbgerechnet.js src/components/WorksheetList.jsx src/components/WorksheetTimesModal.jsx --loader:.jsx=jsx --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/syntax --log-level=warning
rg -n "react-icons|@tabler|style=\{\{|form-control|overlay-content|spinner|#[0-9a-fA-F]{3,8}\b|console\.log" src/hooks/useAbgerechnet.js src/components/WorksheetList.jsx src/components/WorksheetTimesModal.jsx
git diff --stat -- src/pages/TicketPage.jsx
```
Expected: esbuild ohne Ausgabe, `rg` ohne Treffer, `git diff --stat` für `TicketPage.jsx` leer.
- [ ] **Step 4: Szenarien ergänzen und laufen lassen**
Die Testdaten des Werkzeugs liefern Ticket 100131 (Kunde `c4`) mit den Arbeitsblättern `ws100141` (von KNSO, 90 min), `ws100143` (Git von knso, ohne Zeit), `ws100144` (von ANDJ, 60 min), `ws100146` (Git von ANDJ, 45 min); angemeldet ist `kenso-admin` (KNSO).
| ID | Pfad | `portal` | Prüfungen |
|---|---|---|---|
| `ticket-abgerechnet` | `/tickets/100131/arbeitsblaetter` | `{ abgerechnet: { ws100141: 'WEBKLAR-0009', ws100144: 'Entwurf' } }` | Die Zeile mit „WSID 100141“ trägt das Abzeichen `abgerechnet (WEBKLAR-0009)`, die mit „WSID 100144“ `abgerechnet (Entwurf)`, alle anderen Zeilen kein Abzeichen mit „abgerechnet“ (genau zwei insgesamt); in `portal.calls` steht genau ein GET `/api/team/arbeitsblaetter/abgerechnet` mit `ticketId=wo100131` |
| `ticket-abgerechnet-fehler` | `/tickets/100131/arbeitsblaetter` | `{ abgerechnetFehler: { status: 500, error: 'Test: Fehler' } }` | kein Abzeichen, die Zeilen und ihre Aktionen sind sichtbar, die Seite zeigt keine Fehlermeldung |
| `dlg-zeiten-abgerechnet` | `/tickets/100131/arbeitsblaetter` | `{ abgerechnet: { ws100141: 'WEBKLAR-0009' } }` | In der Zeile „WSID 100141“ auf `Zeiten bearbeiten` klicken → Dialog „Zeiten bearbeiten“ zeigt „Dieses Arbeitsblatt ist abgerechnet (WEBKLAR-0009). Eine Änderung ändert die Rechnung nicht.“ (`role="alert"`) und `Speichern` ist **nicht** gesperrt; in der Zeile „WSID 100143“ auf `Zeit nachtragen` klicken → Dialog ohne diese Warnung |
Dazu in `fixtures-w1.mjs` die Option `abgerechnetFehler` (der GET antwortet mit diesem Status und `{ error }`).
Run: `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/lauf.sh --only ticket-abgerechnet,ticket-abgerechnet-fehler,dlg-zeiten-abgerechnet`
Expected: Exit 0 und sechsmal `ok …`. Bilder `SP/shots/1440x900/ticket-abgerechnet.png` und `SP/shots/390x844/dlg-zeiten-abgerechnet.png` ansehen: blaues Abzeichen neben Person und Art, die Warnung steht orange über den Feldern.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
git add src/hooks/useAbgerechnet.js src/components/WorksheetList.jsx src/components/WorksheetTimesModal.jsx
git commit -m "Arbeitsblätter: Abzeichen „abgerechnet (Rechnungsnummer)“ für alle, Warnung beim Ändern der Zeit" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 11: `InvoiceCreateModal` entfernen
**Files:**
- Delete: `src/components/finance/InvoiceCreateModal.jsx`
**Interfaces:**
- Consumes: Task 8 und 9 (nichts verweist mehr auf die Datei).
- Produces: der Bestand enthält nur noch einen Rechnungsdialog.
- [ ] **Step 1: Verweise prüfen**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
rg -n "InvoiceCreateModal" src tests
```
Expected: Treffer nur in `src/components/finance/InvoiceCreateModal.jsx` selbst (Definition und Kommentar). Gibt es einen Import oder eine Verwendung woanders: **STOPP**, erst dort auf `RechnungDialog` umstellen (gehört die Datei einem anderen Strang, als Vertragsfrage melden).
- [ ] **Step 2: Datei löschen und Gegenprobe**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
git rm src/components/finance/InvoiceCreateModal.jsx
rg -n "InvoiceCreateModal" src tests
node_modules/.bin/esbuild src/components/finance/InvoiceTable.jsx src/pages/FinancePage.jsx src/pages/CustomerDetailPage.jsx --loader:.jsx=jsx --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/syntax --log-level=warning
```
Expected: `rg` ohne Treffer (auch keine Kommentare); esbuild ohne Ausgabe. Den Import-Graphen prüft der Build in Task 12.
- [ ] **Step 3: Commit**
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
git commit -m "Rechnungsdialog: alter InvoiceCreateModal entfällt, RechnungDialog ersetzt ihn überall" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
(Die mit `git rm` vorgemerkte Löschung gehört in diesen Commit.)
### Task 12: Selbstprüfung
**Files:** keine Änderung, außer Korrekturen aus den Prüfungen (je Korrektur ein eigener Commit mit den betroffenen Dateien).
- [ ] **Step 1: Arbeitsstand**
Run: `cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1 && git status --short && git log --oneline feature/auftraege..HEAD`
Expected: `git status --short` zeigt nur `?? node_modules`; das Log nennt die Commits der Tasks 2 bis 11 (zehn Commits, plus Korrekturen).
- [ ] **Step 2: Eigene Tests und volle Suite**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/projektkontoView.test.mjs tests/unit/rechnungDialogView.test.mjs tests/unit/rechnungAnfrage.test.mjs tests/unit/rechnungDialogState.test.mjs
free -m | head -2
flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock env NODE_OPTIONS=--max-old-space-size=1024 timeout 900 nice -n 10 npm test > /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/test.log 2>&1; echo "exit $?"
rg "^(ℹ|#) (tests|pass|fail) " /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/test.log
```
Expected: die vier eigenen Dateien zusammen `# pass 61` (14, 18, 20, 9) und `# fail 0`; die volle Suite `exit 0`, `fail 0`, und `pass` ist `N0 + 61` (`N0` aus Task 1 Step 4).
- [ ] **Step 3: Hygiene über alle eigenen Dateien**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
F="src/components/auftraege/ProjektkontoListe.jsx src/components/auftraege/RechnungDialog.jsx src/components/auftraege/RechnungPositionen.jsx src/components/auftraege/RechnungVorschau.jsx src/components/auftraege/AufwandAuswahl.jsx src/components/auftraege/KontoZusammenfassung.jsx src/components/auftraege/projektkonto.css src/components/auftraege/rechnung-dialog.css src/hooks/useRechnungDaten.js src/hooks/useAbgerechnet.js src/lib/projektkontoView.js src/lib/rechnungDialogView.js src/lib/rechnungAnfrage.js src/lib/rechnungDialogState.js src/lib/rechnungDialogApi.js"
rg -n "react-icons|@tabler|style=\{\{|ui-btn|form-control|form-group|form-label|ui-card|badge-|overlay-content|overlay-close|spinner|kpi|#[0-9a-fA-F]{3,8}\b|rgba?\(|!important|console\.log|InvoiceCreateModal" $F
rg -n '\\u[0-9a-fA-F]{4}' $F tests/unit/projektkontoView.test.mjs tests/unit/rechnungDialogView.test.mjs tests/unit/rechnungAnfrage.test.mjs tests/unit/rechnungDialogState.test.mjs
rg -n "slice\(|\.length > 20" src/components/auftraege/RechnungVorschau.jsx
git diff --name-only feature/auftraege..HEAD
```
Expected: die drei `rg` ohne Treffer (kein Unicode-Escape mit vier Hexziffern, keine Kürzung der Vorschau). Die Dateiliste nennt nur Dateien aus der Tabelle „Dateien“ und die Tests (`tests/unit/projektkontoView.test.mjs`, `rechnungDialogView.test.mjs`, `rechnungAnfrage.test.mjs`, `rechnungDialogState.test.mjs`), dazu `src/components/finance/InvoiceCreateModal.jsx` (gelöscht), `InvoiceTable.jsx`, `WorksheetList.jsx`, `WorksheetTimesModal.jsx`. Nicht darin: `FinancePage.jsx`, `TicketPage.jsx`, `financeView.js`, `global.css`, `financeApi.js`, `teamApi.js`.
- [ ] **Step 4: Vertragsliste (V3, V5, V8) abhaken**
Run:
```bash
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1
rg -n "export default function (ProjektkontoListe|RechnungDialog)\(" src/components/auftraege
rg -n "onRechnung|onAuftrag|auftraegeAktiv|stripeOk" src/components/auftraege/ProjektkontoListe.jsx | head -12
rg -n "onCreated\(|onClose\(\)|kunde = null|projektId|auftraegeAktiv|onShowOverview" src/components/auftraege/RechnungDialog.jsx | head -20
rg -n "^export (function|const) " src/lib/projektkontoView.js src/lib/rechnungDialogView.js src/lib/rechnungAnfrage.js src/lib/rechnungDialogState.js
```
Beim Lesen gegenprüfen und im Abschlussbericht abhaken:
- [ ] V8 `ProjektkontoListe({ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung(projektId|null), onAuftrag(projektId|null) })`.
- [ ] V8 `RechnungDialog({ open, onClose, onCreated(rechnung, info), kunde, projektId, auftraegeAktiv, onShowOverview })` (`onShowOverview` optional, Vertragsfrage 3); `projektId`: `undefined` keine Vorauswahl, `null` „Ohne Projekt“.
- [ ] V8 Lib-Dateien `projektkontoView.js`, `rechnungDialogView.js` mit Tests; Stile `projektkonto.css`, `rechnung-dialog.css`.
- [ ] V8 `InvoiceTable` mit Spalten Projekt und Art (Art-Beschriftungen: frei „Ohne Projekt“, festbetrag „Festbetrag“, aufwand „Aufwand“, anzahlung „Anzahlung“, schluss „Schlussrechnung“, abo „Abo“).
- [ ] V8 `WorksheetList` (Abzeichen `abgerechnet (<Nr>)`/`abgerechnet (Entwurf)`, für alle Mitarbeiter) und `WorksheetTimesModal` (Warnung wörtlich nach Spec).
- [ ] V8 `InvoiceCreateModal` gelöscht; `FinancePage.jsx` bewusst unverändert (Vertragsfrage 1).
- [ ] V5 benutzt: `getProjektkonten`, `getProjektkonto`, `previewInvoice`, `createInvoice` (Körper nach V5: `customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung { prozent | betragCents, bezeichnung }, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden`; bei Aufwand ohne `auftragId`), `getAbgerechnet`.
- [ ] Nachträge: `zeilentexte` ersetzen den ganzen Text vor dem letzten ` – ` (Vertragsfrage 2); weitere Tickets direkt aus Appwrite, `arbeitsblattIds` (4); Aufwand ohne `auftragId` (5); die Vorschau zeigt alle Zeilen der Antwort, bei einer Schlussrechnung bis zu 40, `items` bleiben bei höchstens 20 je Liste (15); kein Unicode-Escape mit vier Hexziffern (Step 3).
- [ ] **Step 5: Gesamtlauf mit Bildern**
Run: `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w1/lauf.sh`
Expected: Exit 0 und alle 22 Szenarien in beiden Breiten `ok` (44 Zeilen). Der Build läuft dabei einmal (Vite, hinter dem `flock`); Warnungen des Builds zu den eigenen Dateien werden behoben. Die Bilder liegen unter `SP/shots/1440x900/` und `SP/shots/390x844/`; mindestens `konten`, `konten-stripe`, `dlg-aufwand`, `dlg-schluss`, `dlg-kundenwahl`, `rechnungen-spalten`, `ticket-abgerechnet` und `dlg-zeiten-abgerechnet` mit dem Read-Werkzeug ansehen.
- [ ] **Step 6: Tote Klassen für P10 melden (nur lesen)**
Run: `node /root/reports/ticketsystem-design-2026-10-04/werkzeug/css-klassen.mjs /home/webklar/apps/ticket.webklar.com/dev-auftraege-w1 | node -e "const j=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log((j.dead||[]).filter((c)=>/^fin-(item|items|total|dialog-row|days|memo|fixed-customer)/.test(c)).join(' '))"`
Expected: eine Liste wie `fin-items fin-item … fin-total … fin-dialog-row`. Sie gehört in den Abschlussbericht (Aufräumen in `global.css` ist Sache von P10, die Datei gehört W1 nicht). Ist das Werkzeug nicht lauffähig (Exit 2), entfällt der Schritt mit einem Satz im Bericht.
- [ ] **Step 7: Abschlussbericht an den Orchestrator**
Nicht pushen. Der Bericht nennt: Branch `auftraege/w1-rechnungen` mit dem Basis-Commit (Task 1) und der Commit-Liste, die Testzahlen (eigene 61, Suite `N0 → neu`), den Pfad der Bilder (`SP/shots`) samt Szenarioliste, `SP/fixtures-w1.mjs` und `SP/szenarien.mjs` als Vorlage für Z2 (das Redesign-Werkzeug kennt die neuen Endpunkte nicht, Vertragsfrage 10), die abgehakte Vertragsliste, die tote-Klassen-Liste und die Vertragsfragen unten (alle entschieden, siehe Übersicht, Nachträge). Als **Minor** für die Liste der Kontrolle (Plan 12, Z3, `minors.md`) meldet der Bericht den doppelten Positionseditor (`RechnungPositionen` in W1, die Positionszeilen im `AuftragDialog` von W2; Vertragsfrage 12).
---
## Vertragsfragen
Alle Fragen aus der Erstfassung dieses Plans sind **entschieden (siehe Übersicht, Nachträge nach der Planrunde, V5 und V8)**; die Übersicht gilt. Die Liste hält fest, was entschieden wurde und wo dieser Plan es umsetzt.
1. **`FinancePage.jsx` braucht keine Änderung.** Entschieden (siehe Übersicht, Nachträge; V8): `RechnungDialog` sitzt in `InvoiceTable.jsx` (dort ersetzt W1 das Modal), `FinancePage.jsx` bleibt unverändert; der Knopf „Rechnung schreiben“ und der Dialog liegen heute schon in `InvoiceTable`, die `FinancePage` und der Kundenreiter nur einbinden. Umsetzung: Task 9 (`kunde` im Kundenreiter, `null` mit Kundenwahl auf der Finanzen-Seite; Beleg: `git diff --stat` für `FinancePage.jsx` ist leer). Deckt sich mit W0 Vertragsfrage 1.
2. **`zeilentexte`: was steht unter dem Schlüssel?** Entschieden (siehe Übersicht, Nachträge, Absatz L2): `zeilentexte[schluessel]` ersetzt den **ganzen** Text vor dem letzten ` – ` der Zeile, einschließlich Datum bzw. „KW …“ („06.10.2026: Startseite, Kontaktformular“); den Schluss ` – <Std.> Std. à <Satz>` hängt der Server an, unbekannte Schlüssel ignoriert er. Schlüssel von Aufwandszeilen sind `JJJJ-MM-TT` und `KW-JJJJ-WW` (Wochenzeilen heißen „KW 05/2026“, Schlüssel `KW-2026-05`). Umsetzung: Task 5 (`zeileTeilen` teilt am letzten ` – `, `zeileBearbeitbar`, `maxVorn`; Körperfeld `zeilentexte` nur bei Aufwand), Task 6 (Aktion `zeilentext`), Task 7 (`RechnungVorschau`).
3. **`RechnungDialog`: Props und Vorauswahl.** Entschieden (siehe Übersicht, Nachträge; V8): Props `{ open, onClose, onCreated(rechnung, info), kunde, projektId, auftraegeAktiv, onShowOverview }`; `kunde` ist das Kundendokument oder `null` (Kundenwahl im Dialog); `projektId`: `undefined` = keine Vorauswahl (der Nutzer muss „Projekt wählen“), `null` = „Ohne Projekt“, Text = dieses Projekt; `auftraegeAktiv` ist nur der Startwert, danach gilt `getProjektkonten`, das der Dialog selbst lädt; `onShowOverview` ist optional („Zur Übersicht“ in der Adresswarnung); `info` wird unverändert weitergereicht (`senden`, `notice`, `tone`, `finalizedFilter`). Umsetzung: Task 8 (Kopf und Abschnitte b und c; ein Kunde ganz ohne Projekte bekommt „Ohne Projekt“ von selbst), Task 9 (`InvoiceTable` lässt `projektId` und `auftraegeAktiv` weg). Deckt sich mit W0 Vertragsfragen 2, 3 und 12.
4. **„Weitere Tickets des Kunden“.** Entschieden (siehe Übersicht, Nachträge; V8, Absatz „Weitere Änderungen“): W1 liest die Tickets des Kunden und die Arbeitsblätter des gewählten Tickets direkt aus Appwrite (Muster `ProjectContextPanel` und `useWorksheets`), filtert abgerechnete über `getAbgerechnet(ticketId)` und schickt nur `arbeitsblattIds`; die Vorschau des Servers bleibt maßgeblich. Voraussetzung (L2): `arbeitsblattIds` dürfen Blätter beliebiger Tickets desselben Kunden nennen. Umsetzung: Task 6 (`rechnungDialogApi.js`, `useKundenTickets`, `useWeitereBlaetter`), Task 4 (`blattAusAppwrite`, `ticketOptionen`), Task 7 (`AufwandAuswahl`), Szenario `dlg-weitere-tickets`.
5. **Aufwand: `items` und `auftragId`.** Entschieden (siehe Übersicht, Nachträge, Absatz L2): Aufwand ohne `auftragId`; L2 nimmt die jüngste gültige Aufwands-AB des Projekts, aber nur bei eingeschaltetem Schalter (sonst gilt 100 € ohne Auftrag). `items` (Zusatzpositionen) schickt W1 bei `aufwand` und `schluss` nur, wenn der Nutzer welche eingibt; die Positionen von `festbetrag` und `frei` stehen getrennt davon. Umsetzung: Task 5 (`vorschauAnfrage` lässt bei Aufwand `auftragId` weg; Test „Aufwand sendet nur die gewählten Arbeitsblätter, Zusatzpositionen und Zeilentexte“), Task 4 (`standardAuftrag` liefert bei Aufwand `''`), Task 8 (die Aufwands-AB erscheint nur als Hinweis „laut <Nummer>“ beim Stundensatz).
6. **Anzahlung-Körper.** Entschieden (siehe Übersicht, Nachträge; V5; Grenzen wie in Plan L2): `anzahlung: { prozent | betragCents, bezeichnung }` mit genau einem der beiden Beträge; `prozent` größer als 0, höchstens 100 und höchstens zwei Nachkommastellen, `bezeichnung` 1 bis 60 Zeichen. Die obere Grenze („höchstens Auftragssumme minus Berechnetes“) und „Nichts mehr offen.“ prüft der Server, seine Meldung erscheint im Dialog. Umsetzung: Task 5 (`parseProzent`, `BEZEICHNUNG_MAX`; Tests „Anzahlung in Prozent oder in Euro mit Bezeichnung“ und „Anzahlung prüft AB, Prozent, Betrag und Bezeichnung“).
7. **Leistungszeitraum.** Entschieden (siehe Übersicht, Nachträge; V5): W1 sendet `leistungszeitraum` nur, wenn der Nutzer das Feld geändert hat und es nichtleer ist (höchstens 140 Zeichen); bei Aufwand berechnet der Server sonst den Standard (erster bis letzter Tag) und gibt ihn in der Vorschau zurück, der Dialog füllt das Feld damit vor und überschreibt nie eine Eingabe des Nutzers. Umsetzung: Task 5 (`zeitraumGeaendert`), Task 6 (Aktion `zeitraum-vorschlag`), Task 8 (Effekt auf die Vorschau).
8. **Vorschau-Fehler.** Entschieden (siehe Übersicht, Nachträge; V5, Spalte Fehler): `POST …/rechnungen/vorschau` antwortet bei unzulässiger Eingabe wie das Anlegen mit `{ error }` und 400 oder 409 (zum Beispiel „Nichts mehr offen.“, „Arbeitsblatt bereits abgerechnet …“, ein offener Entwurf zur AB; nach den Nachträgen blockiert ein Schluss-Entwurf auch weitere Anzahlungen). Der Dialog zeigt die Meldung, sperrt „Als Entwurf speichern“ und „Senden“ und lädt bei 409 das Projektkonto neu; die Regeln des Servers bildet er nicht nach (die Art „Anzahlung“ bleibt auch bei einem Schluss-Entwurf wählbar, der Server antwortet mit seiner Meldung). Umsetzung: Task 5 (`vorschauStatus`), Task 8 (Statusbereich, Effekt bei 409, Szenario `dlg-409`).
9. **`projektTyp`.** Entschieden (siehe Übersicht, Nachträge, Absatz L1): `projektTyp` ist der Anzeigename der Projektart („Website“, `''` ohne Art); W1 zeigt ihn unverändert (Projektliste, zweite Zeile unter dem Namen: `Website · #100131`). Umsetzung: Task 2 (`kontoZeile`).
10. **Redesign-Werkzeug (Z2).** Entschieden (siehe Übersicht, Nachträge; W0 Vertragsfrage 10): Das Werkzeug (`backend.mjs`, `endpunkte.md`) kennt `GET …/projektkonten`, `POST …/rechnungen/vorschau`, `GET /api/team/arbeitsblaetter/abgerechnet` und die erweiterte Rechnungsliste nicht und bleibt unverändert; W1 prüft mit eigenen Überschreibungen unter `SP`. Z2 baut seinen Lauf aus Kopien von `fixtures-w1.mjs` und `szenarien.mjs` (Plan 12, Task Z2.3: 22 Szenarien in zwei Breiten, 44 Zeilen). Umsetzung: Task 3 (Läufer und Nachbau), Task 12 Step 7 (Bericht).
11. **Projektliste und Überschrift.** Entschieden (siehe Übersicht, Nachträge; V8): Die Kindkomponenten rendern ihre `Card` selbst; `ProjektkontoListe` rendert `<Card title="Projekte">`, bei Stripe-Ausfall und bei Kunden ohne Projekte stehen die Hinweise innerhalb der Karte. Umsetzung: Task 3. Deckt sich mit W0 Vertragsfrage 4.
12. **Positionseditor doppelt.** Entschieden (siehe Übersicht, Nachträge): W1 (`RechnungPositionen`) und W2 (`AuftragDialog`) brauchen dieselbe Zeile (Beschreibung, Menge, Einzelpreis, Summe, Entfernen, Grenzen aus `INVOICE_LIMITS`); jeder Strang baut seinen eigenen Editor, der Doppelbau kommt auf die **Minor-Liste** der Kontrolle (Plan 12, Z3: `minors.md`) und wird nicht in einen gemeinsamen Baustein verlegt. Umsetzung: Task 7 (eigener Name `RechnungPositionen`, Präfix `rd-`), Task 12 Step 7 (Hinweis im Abschlussbericht).
13. **Filter nach dem Anlegen über eine Projektzeile.** Entschieden (siehe Übersicht, Nachträge; W0 Vertragsfrage 1): W1 baut keine Filter-Prop an `InvoiceTable`; nach dem Anlegen über den Container bleibt der Filter, die Meldung nennt Entwurf oder Rechnung, ein Entwurf liegt unter „Entwürfe“. Umsetzung: Task 9 (keine Änderung der Props von `InvoiceTable`).
14. **`abgerechnet` (Spec Abschnitt 6).** Entschieden (siehe Übersicht, Nachträge; V5): `{ abgerechnet: { <worksheetId>: '<Rechnungsnummer>' | 'Entwurf' } }`, ein unbekanntes Ticket liefert `{ abgerechnet: {} }`; der Wert `'Entwurf'` bekommt das Abzeichen „abgerechnet (Entwurf)“ und dieselbe Warnung wie eine Rechnungsnummer („Dieses Arbeitsblatt ist abgerechnet (Entwurf). Eine Änderung ändert die Rechnung nicht.“). Umsetzung: Task 10. Deckt sich mit W0 Vertragsfrage 7.
Mit der Planrunde kam eine Festlegung für W1 dazu, die dieser Plan umsetzt:
15. **Zeilen einer Schlussrechnung.** Entschieden (siehe Übersicht, Nachträge, Absatz L2; Global Constraints): Eine Schlussrechnung darf serverseitig bis zu 40 Zeilen haben (AB-Positionen höchstens 20, Zusatzpositionen höchstens 10, Abzüge höchstens 10), jede andere Rechnung höchstens 20. Umsetzung: `RechnungVorschau` zeigt alle Zeilen der Antwort (Task 7; die Hygiene-Prüfung sucht nach `slice` und einer Grenze von 20); der Läufer prüft im Szenario `dlg-schluss` eine Vorschau mit 40 Zeilen (Task 8); die Zeilen, die der Client schickt, bleiben bei höchstens `INVOICE_LIMITS.maxItems` (20) je Liste (Task 6, Reducer), die engere Grenze von 10 Zusatzpositionen prüft allein der Server und der Dialog zeigt seine Meldung als Vorschaufehler (Frage 8).