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

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

175 KiB
Raw Blame History

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:

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:

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:

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:

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
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:

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):

/* 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:

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):

#!/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:

// 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
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:

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
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:

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
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:

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):

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:

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
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

/* 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:

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
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: “ (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 → „: . 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 .“ (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: “ 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:

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
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:

/* 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:

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
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

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:

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
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:

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:

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
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:

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:

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:

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 “ 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:

  1. 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).