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>
175 KiB
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 infeature/auftraegedes 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 untersrc/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), keinstyle={{…}}, keine Farbliterale (nur Tokens wievar(--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ägttype="button". Pro Abschnitt höchstens ein schwarzer Knopf (Button variant="primary"). - CSS-Reihenfolge:
src/main.jsximportiertAppvor./styles/global.css; von Komponenten importierte Stylesheets stehen im Bundle vorglobal.css. Bei gleicher Spezifität gewinntglobal.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-sizemacht ein Element zum Bezugsrahmen fürposition: fixed. Es steht in W1 nur auf.pk(Projektliste).RechnungDialogrendert nie als Nachfahre eines Elements mitcontainer-type(weder inProjektkontoListenoch inInvoiceTable/.fin-body). - Verhalten des bisherigen Dialogs bleibt: Enter sendet nie (Formular ohne Submit-Knopf,
onSubmitverhindert), „Senden“ fragt vorher mitwindow.confirm(invoiceSendConfirmText(…)), Adress-Sperre über 250 € (invoiceSendGate), Doppelklick-Sperre, Esc schließt zuerst die Vorschlagsliste des Kundenfelds,onCreated(rechnung, info)mitinfo = { senden }bzw. bei festgeschriebener, nicht versendeter Rechnung (502 mitinvoiceId){ senden: true, notice, tone: 'warn', finalizedFilter }, danachonClose(). - 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öchstensINVOICE_LIMITS.maxItems(20)itemsje 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 überstundenDe(Komma, „Std.“), Datum überdateDe. - 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{…}oderString.fromCodePoint(…). Task 12 Step 3 sucht danach. - Tests:
tests/unit/<name>.test.mjsmitloadModule(Mustertests/unit/financeView.test.mjs). Lib-Module von W1 importieren nie Appwrite oderfinanceApi/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 hinterflock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock; vorherfree -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 untersystemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0. Niepkill -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
grepeine Funktion, die Dateien still auslässt. Die Steps benutzenrg(13.0.0, ohne Lookarounds); notfallscommand grep -a. - Git: Worktree
/home/webklar/apps/ticket.webklar.com/dev-auftraege-w1, Branchauftraege/w1-rechnungen. Commits einzeln mit Dateinamen, deutsche Nachricht, SchlusszeileCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>(zweites-m). Kein Push, nie--force. Der Symlinknode_modulesist 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:
- 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 einPOST …/rechnungen). - 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. - 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 Szenariodlg-ohne-projekte; Task 9 Szenariodlg-projekte-fehler. - 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 Szenariokonten-stripe; Task 8 Szenariodlg-stripe. - 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); Szenarienkonten-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-TestzahlN0und 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_modulesverlinken
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,euroWholeaussrc/lib/financeFormat.js. Eingaben haben die Form V3:Projektkonto(projektTypist 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 beistripeOk === false)vereinbartText(vereinbart) → stringkontoZeile(konto, { stripeOk = true } = {}) → Zeile,ohneProjektZeile(ohne, { stripeOk = true } = {}) → ZeilemitZeile = { 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[]
- Konstanten
-
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.jsimplementieren
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, SuffixStd.; keine Zahl → 0. -
satzText(cents): keine Zahl →'–'; sonst ganze Euro übereuroWhole('100 €'), mit Cent übereuro('85,50 €'), Suffix/Std.. -
ticketsText(tickets): je Ticket mit nichtleeremwoid#<woid>, mit', 'verbunden; sonst''. -
vereinbartText(v): Teil 1euro(festpreisCents)bei einer Zahl; Teil 2nach Aufwand, <satzText>plus, Schätzung <stundenDe>nur beischaetzungMinuten > 0; Teile mit' und 'verbunden; kein Teil →'keine AB'. -
kontoZeile(konto, { stripeOk = true }):stripe = stripeOk !== false.keyundprojektId=konto.projektId;namegetrimmt (leer →'Projekt');meta=[projektTyp getrimmt, ticketsText(tickets)]ohne Leere mit' · ';berechnet=euro(berechnetCents),bezahlt=bezahlt <euro(bezahltCents)>(bei Stripe-Ausfall'–'bzw.'');offen: bei Festpreis (festpreisCentsist eine Zahl)euro(offenCents)(bei Stripe-Ausfall'–'), sonst bei Aufwandeuro(zeit.offenerWertCents), sonst'–';zeit.stunden=stundenDe(zeit.minuten),zeit.wert=Wert <euro(zeit.wertCents)>,zeit.abo=über Abo: <stundenDe(zeit.aboMinuten)>nur beiaboMinuten > 0, sonst''. -
ohneProjektZeile(ohne, { stripeOk = true }): feste Wertekey 'ohne-projekt',projektId '',name 'Ohne Projekt',vereinbart '–',offen '–',zeit { stunden: '–', wert: '', abo: '' };metaausanzahl(0 oder fehlendkeine Rechnungen, 11 Rechnung, sonst<n> Rechnungen);berechnet/bezahltwie 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):projektNamegetrimmt oder'–'. -
abgerechnetText(map, id): nur bei eigenem Schlüsselidin einem Objekt und nichtleerem Wertabgerechnet (<Wert>), sonstnull;abgerechnetWarnungwie der Test, gleiche Bedingung. -
ticketIdsOf(worksheets): eindeutige, getrimmte, nichtleereworkorderIdin 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);Cardaus../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)undonAuftrag(projektId)bekommen die Projektkennung, die Zeile „Ohne Projekt“ ruft sie mitnull. Der Prüfläufer (lauf.sh [--ohne-build] [--only id,id]) liefert Bilder unterSP/shots/<w>x<h>/<id>.pngund die Zeilenok …/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.cssschreiben
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.jsxschreiben
Kommentarkopf deutsch (Aufgabe, Props, „Ohne Projekt“ = null); import './projektkonto.css'. Aufbau:
-
zeilen = [...konten.map((k) => kontoZeile(k, { stripeOk })), ohneProjektZeile(ohneProjekt, { stripeOk })].kontendarf fehlen (dann[]). -
Wurzel
<Card title="Projekte"><div className="pk">…</div></Card>. -
In
.pk, in dieser Reihenfolge: iststripeHinweis(stripeOk)nichtleer,<p className="msg warn pk-hinweis" role="status">{hinweis}</p>; istkontenleer,<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 erfasstund 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">:pk-name:<div className="cell-title">{z.name}</div>und, wennz.meta,<div className="cell-sub">{z.meta}</div>.pk-vereinbart,data-label="Vereinbart":{z.vereinbart}.pk-berechnet,data-label="Berechnet":<div className="num">{z.berechnet}</div>und, wennz.bezahlt,<div className="cell-sub">{z.bezahlt}</div>.pk-offen,data-label="Noch offen":<span className="num">{z.offen}</span>.pk-zeit,data-label="Zeit erfasst":<div className="num">{z.zeit.stunden}</div>und je nichtleeremz.zeit.wertundz.zeit.aboein<div className="cell-sub">.pk-actions-cell:<div className="pk-actions">mit<button type="button" className="link" aria-label=… onClick={() => onAuftrag(id)}>Auftragsbestätigung</button>(nur wennauftraegeAktiv) und<button type="button" className="link" aria-label=… onClick={() => onRechnung(id)}>Rechnung</button>.idistz.projektIdbzw.nullbei der Zeile „Ohne Projekt“.aria-label:Auftragsbestätigung für <Name>undRechnung für <Name>, bei „Ohne Projekt“Auftragsbestätigung ohne ProjektundRechnung 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
wo100133mit Blattws100148undwo100128mit Blattws100149).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: [],ohneProjektmit Nullen,auftraegeAktiv: ab. konten(neueste zuerst;projektTypist 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 }.auftraegenur mitab: beip-festdie 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 }] }, beip2die 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: beip-festmitanzahlungBerechnet[{ id: 'in_a', number: 'WEBKLAR-0007', status: 'offen', createdDate: '2026-10-08', totalCents: 40000, paidCents: 0, art: 'anzahlung', auftragId: 'a1' }], sonst[](beistripeOk: falseimmer[]).offeneArbeitsblaetternur beip2:ws100148(ticketwo100133,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/vorschauundPOST …/rechnungenfüllen Task 8; bis dahin antworten beideroute.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 }]
- Konstanten
-
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.jsimplementieren
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→'', sonstString(p). -
projektOptionen(konten): je Konto mit nichtleererprojektId{ 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: istkontenkein Array (noch nicht geladen) bleibt er, sonst bleibt er nur, wenn ein Konto dieseprojektIdhat, sonstnull;null/undefined: istkontenein leeres Array →'', sonstnull. -
gueltigeFestpreisAbs:art === 'festpreis'und Statusversendetoderbestaetigt, sortiert nachDate.parse(gesendetAm)absteigend (fehlend = 0), bei Gleichstand nachnummerabsteigend;aufwandAuftrag: dasselbe mitart === 'aufwand', das erste odernull. -
abLabel:<nummer>plusvom <dateDe(gesendetAm)>(nur wenn lesbar) plus· <euro(summeCents)>, beiart === 'aufwand'· nach Aufwand. -
berechnetFuerAuftrag: SummetotalCentsder Rechnungen mit dieserauftragIdund Statusoffen,ueberfaellig,bezahltoderuneinbringlich;hatEntwurfZurAuftrag: irgendeine Rechnung mit dieserauftragIdund Statusentwurf. -
artOptionen:projektIdnull/undefined→[];''→ eine Optionfrei(enabled: true,hint: ''); sonst die vier Optionen in der Reihenfolgeanzahlung,schluss,aufwand,festbetrag.abs = auftraegeAktiv ? gueltigeFestpreisAbs(auftraege) : [].anzahlung:enabled = abs.length > 0, sonsthint = AB_HINWEIS.schluss:enablednur, wenn mindestens eine AB ausabskeinen Entwurf hat (hatEntwurfZurAuftrag);hintistAB_HINWEISohneabs,ENTWURF_HINWEIS, wennabsda ist, aber jede einen Entwurf hat, sonst''.aufwandundfestbetragimmer wählbar. -
standardArt(optionen, { auftraege, rechnungen, offeneMinuten }): nurfrei→'frei'; leere Liste →''; istanzahlungwählbar und die jüngste gültige Festpreis-AB hatzahlungsplan.art === 'anzahlung', nichts Berechnetes (berechnetFuerAuftrag === 0) und keinen Entwurf →'anzahlung'; sonst istschlusswählbar →'schluss'; sonstoffeneMinuten > 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 beiaufwand: die Aufwands-AB ordnet der Server zu); ohne Treffer''. -
anzahlungStandard(auftrag):prozentauszahlungsplan, wennzahlungsplan.art === 'anzahlung'undprozent > 0, sonst 50;wertals Text mit Dezimalkomma;modus 'prozent',bezeichnung 'Anzahlung'. -
ausgewaehlteIds(blaetter, abgewaehlt): alleidin Reihenfolge der Liste ohne die abgewählten;blaetterSumme: Anzahl und Minutensumme der Blätter, derenidinidssteht. -
blattAusAppwrite(ws, abgerechnet):nullohne$id, beiisComment, beicountsToPlan, wennabgerechnetdie$idals eigenen Schlüssel kennt (Rechnung oder'Entwurf') oder wennworksheetMinutes(ws)nicht über 0 liegt. Sonst{ id: $id, ticketId: workorderId, woid, datum, minuten, text, aboZeit: false, kommentar: false }mitminuten = Math.ceil(worksheetMinutes(ws) / 15) * 15(nichtroundUpToStep, das 0 auf 15 hebt),datum = germanDateToIso(startDate || endDate), sonstymdBerlin($createdAt), sonst'', undtext= erste nichtleere Zeile vondetails, getrimmt, auf 120 Zeichen gekürzt. -
ticketOptionen(tickets, { ausgeschlossen = [] }): nur Tickets mit$idundwoid, 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.jsINVOICE_LIMITS,ADDRESS_INCOMPLETE_TEXT,customerName,finalizedFilter,invoiceFailureMessage,invoiceSendConfirmText,invoiceSendGate,isFinalizedNotSent,parseQuantity,parseUnitPrice,validateInvoiceForm;ausgewaehlteIds(Task 4). Die Eingabeedes 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ürpreviewInvoice: wierechnungAnfrageohnedaysUntilDueundmemo;error: ''heißt „keine Vorschau für diese Art“)rechnungAnfrage(e) → { ok: true, body, totalCents: number | null } | { ok: false, error }(Körper fürcreateInvoiceohnesenden;totalCentsnur bei freien Positionen, sonstnull)vorschauSchluessel(body) → string,vorschauStatus({ art, anfrage, vorschau, schluessel }) → { state, message }mitstateausnicht-noetig | ungueltig | laedt | fehler | bereit,darfSpeichern(status) → booleanzeileTeilen(beschreibung) → { vorn, hinten },zeileBearbeitbar(position) → boolean,maxVorn(hinten) → numbercreateSubmitter({ 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.jsimplementieren
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 wievalidateInvoiceFormgeprü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 ausINVOICE_LIMITS, Zahlen überparseQuantityundparseUnitPrice); 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, sonstnull. -
vorschauAnfrage(e), Reihenfolge der Prüfungen: Art nicht inVORSCHAU_ARTEN→{ ok: false, error: '' }; ohnekunde→'Bitte einen Kunden wählen.'. Grundkörper{ customerId: kunde.$id, art, projektId }. Aufwand:ids = ausgewaehlteIds(blaetter, abgewaehlt), leer →'Bitte mindestens ein Arbeitsblatt auswählen.', sonstarbeitsblattIds: ids(Reihenfolge der Liste); eineauftragIdschickt W1 bei Aufwand nicht mit. Anzahlung und Schluss: ohneauftragId→'Bitte eine Auftragsbestätigung wählen.', sonstauftragId. Anzahlung: Betrag prüfen (ModusprozentüberparseProzent→'Der Prozentsatz muss größer als 0 und höchstens 100 sein.', ModusbetragüberparseUnitPrice→'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.'), danach die Bezeichnung (getrimmt, 1 bisBEZEICHNUNG_MAXZeichen, sonst'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).'); Körperfeldanzahlung: { prozent | betragCents, bezeichnung }mit genau einem der beiden Beträge. Aufwand und Schluss:zusatzüberzusatzPruefen, bei mindestens einer gültigen Zeileitems. Nur bei Aufwand:zeilentextemit den nichtleeren, getrimmten Einträgen (fehlt der Schlüssel im Körper, wenn nichts übrig bleibt). Leistungszeitraum: nur wennzeitraumGeaendertund getrimmt nichtleer; länger alsLEISTUNGSZEITRAUM_MAX→'Der Leistungszeitraum darf höchstens 140 Zeichen lang sein.', sonstleistungszeitraum. Die Zahl der Zeilen begrenzt diese Funktion nicht:itemsundzusatzhalten höchstensINVOICE_LIMITS.maxItemsZeilen je Liste (Reducer, Task 6); engere Grenzen des Servers (Schlussrechnung: höchstens 10 Zusatzpositionen) zeigt die Vorschau als Fehler. -
rechnungAnfrage(e):freiundfestbetrag: zuerstvalidateInvoiceForm({ customer: kunde, items, days, memo })(deren Fehlertext gilt unverändert), dann beifestbetragohne Projekt'Bitte ein Projekt wählen.', dann der Leistungszeitraum wie oben; Körper{ ...payload, art }plusprojektId(nur beifestbetrag) undleistungszeitraum;totalCentsausvalidateInvoiceForm. Alle anderen Arten:vorschauAnfrage(e)(Fehler gelten unverändert), dannvalidateInvoiceFormmit 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 plusdaysUntilDueund, wenn nichtleer,memo;totalCents: null. -
vorschauSchluessel(body):JSON.stringifydes Körpers ohnezeilentexteundleistungszeitraum, mit sortiertenarbeitsblattIds. 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→ungueltigmitanfrage.error;!vorschau, fremderkeyoderstatus 'laedt'→laedt;status 'fehler'→fehlermitmessage(Ersatz'Die Vorschau konnte nicht geladen werden.');daten.summeCentsnicht über 0 →fehlermit'Nichts mehr offen.'; sonstbereit).messageist sonst''.darfSpeichern(status):nicht-noetigoderbereit. -
zeileTeilen(beschreibung): teilt am letzten' – '(Leerzeichen, Gedankenstrich U+2013, Leerzeichen):vorndavor,hintenab diesem Zeichen inklusive; ohne Treffer{ vorn: beschreibung, hinten: '' }.zeileBearbeitbar(position):schluesselpasst auf^(\d{4}-\d{2}-\d{2}|KW-\d{4}-\d{2})$undzeileTeilen(beschreibung).hintenist nichtleer.maxVorn(hinten):Math.max(1, 200 - hinten.length). -
createSubmitter({ create, confirm }): Innerhalb der Closure steht ein Merkerrunning.submit({ anfrage, senden, kunde, totalCents }), in dieser Reihenfolge und ohneawaitvor dem Setzen vonrunning:running→{ status: 'laeuft' };!anfrage.ok→{ status: 'fehler', message: anfrage.error }; beisenden:invoiceSendGate(kunde, totalCents).sendAllowedfalsch →{ status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT },confirm(invoiceSendConfirmText({ name: customerName(kunde), email: kunde.email, totalCents }))falsch →{ status: 'abgebrochen' }; dannrunning = 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) }undhttpStatus: err.statusnur, wennerr.statuseine ganze Zahl ist.runningwird infinallyzurü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 übersrc/lib/appwrite.js. -
Produces:
anfangszustand({ projektId = null } = {}) → Zustandmit den FeldernprojektId(null= noch zu wählen,''= Ohne Projekt),art,auftragId,items,zusatz,abgewaehlt,weitereTickets,anzahlung(Teilwerte überanzahlungStandard),zeilentexte,zeitraum,zeitraumGeaendert,days,memo;reduce(zustand, aktion) → Zustandmit den Aktionenprojekt,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 | nullmitvorschau = { 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.jsimplementieren
Rein, einziger Import INVOICE_LIMITS aus ./financeView. Regeln:
-
Leere Zeile:
{ uid, description: '', quantity: '1', price: '' }.anfangszustandwie im Test;daysistString(INVOICE_LIMITS.defaultDays). -
Neue
uideiner Zeile:1 +größteuidbeider Listen.position+ändert nichts, wenn die Liste schonINVOICE_LIMITS.maxItemsZeilen hat;position-ändert nichts, wenn die Liste ihr Minimum erreicht hat (items1,zusatz0).positionakzeptiert nur die Listenitemsundzusatzund nur die Felderdescription,quantity,price. -
projekt:{ ...anfangszustand({ projektId: a.projektId }), days: s.days, memo: s.memo }.art: setztart, leertauftragIdundzeilentexteund, solangezeitraumGeaendertfalsch ist,zeitraum.auftrag: setztauftragId, leertanzahlung.blatt: schaltet die Kennung inabgewaehltum.ticket+: hängt an, wennticketIdnoch fehlt.ticket-: entfernt das Ticket und alleblattIdsausabgewaehlt.anzahlung: mischtpatchinanzahlung.zeilentext: bei leerem (getrimmtem) Text oder Text gleichoriginalwird der Schlüssel entfernt, sonst der unveränderte Text gesetzt.zeitraum: setzt Text undzeitraumGeaendert: true.zeitraum-vorschlag: nur wenn!zeitraumGeaendert; bei gleichem Text derselbe Zustand.tage,notiz: setzendays,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): ohneschluessel(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 sofortdata: nullundloading: true(nie Daten eines anderen Kunden oder Projekts zeigen). Nur die jüngste Antwort zählt (createRequestGuard), nach dem Ausbau des Hooks gar keine.datableibt beim Neuladen mitreload()stehen;erroristerr.message, sonstfallbackError.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ältvorschau(Startnull). Der Körper wird über einen Ref gelesen (bodyRef.current = anfrage.bodyin jedem Render), der Effekt hängt nur an[aktiv, schluessel]. Istaktivundanfrage.okundschluesselnichtleer: sofort{ key: schluessel, status: 'laedt', daten: <vorherige Daten> }, nach 350 ms WartezeitpreviewInvoice(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 jekundeId, sobaldaktivwahr ist; Rückgabe{ tickets, loading, error }(error: 'Die Tickets konnten nicht geladen werden.'). -
useWeitereBlaetter(weitereTickets): je Ticket (nachticketId) einmalPromise.all([listTicketBlaetter(woid), getAbgerechnet(ticketId)]);blaetter= Dokumente überblattAusAppwrite(ws, antwort.abgerechnet)ohnenull, sortiert nachdatum, dannid; Zustandlaedt, dannokoderfehlermitmessage: '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 })mitzeileauskontoZeileundhinweisausstripeHinweisRechnungPositionen({ 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 })mitweitereTickets = [{ ticketId, woid, titel }]undweitereZustand = { [ticketId]: { status, message } }
-
Step 1:
rechnung-dialog.cssschreiben
/* 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ürzeile.zeit.wertund, wenn nichtleer,zeile.zeit.abo). Beschriftung<div className="k">, Wert<div className="v">. Isthinweisnichtleer, 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üsseluid) 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 dieselbeid; 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", Platzhalter0,00,aria-label… Einzelpreis in Euro), Summe (rd-item-field rd-item-sumfeld, Beschriftung „Summe“,<span className="fin-amount rd-item-sum">miteuro(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 rufenonChange(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, beizeigeSumme,<div className="rd-total" aria-live="polite"><span>Summe</span><span className="fin-amount">{euro(invoiceTotalCents(zeilen))}</span></div>; beizeigeSummedanach<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>, KopfPositionundBetrag(className="fin-amount"). Die Tabelle zeigt alle Positionen der Antwort, bei einer Schlussrechnung bis zu 40 (keinslice, keine Grenze von 20, keine feste Höhe). Je Position eine Zeile: Textzellerd-line-textmit{ vorn, hinten } = zeileTeilen(position.beschreibung): istbearbeitbar && 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 beimenge > 1<div className="cell-sub">{menge} × {euro(einzelpreisCents)}</div>; BetragszelleclassName={'fin-amount' + (isNegText(text) ? ' fin-neg' : '')}miteuro(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>; istbearbeitbarund 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 ausblaetterSumme(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 jeticketOptioneneine Option (value=value, Text =label); Wahl ruftonAddTicket(option);ticketsFehlerals<p className="msg warn" role="alert">{ticketsFehler}</p>. JeweitereTicketseine 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-labelTicket #<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); ausfinanceViewADDRESS_INCOMPLETE_TEXT,INVOICE_LIMITS,customerPickerOptions,customerSuggestions,invoiceSendGate,invoiceTotalCents,resolveCustomerText,customerName; ausfinanceFormatcanInvoice,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).auftraegeAktivist nur der Startwert, bisgetProjektkontenantwortet; danach gilt dessen Wert.onCreated(rechnung, info)und danachonClose()wie im bisherigen Dialog. -
Step 1:
RechnungDialog.jsxschreiben
Kommentarkopf deutsch (Aufgabe, Props, Vorauswahl, Rückgabe an onCreated); import './rechnung-dialog.css'. Aufbau:
-
Äußere Komponente: ohne
opennull. Mitkunde:<RechnungFormular festerKunde={kunde} quelle={null} … />. Sonst<KundenWahl …/>, dieuseCustomers()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 }));useStatefürcustomerText(''),error(''),busy(null | 'draft' | 'send');submitter = useMemo(() => createSubmitter({ create: createInvoice, confirm: (t) => window.confirm(t) }), []);errorRefmituseScrollIntoView(errorRef, error);aliveRef;kundeIdRef.act(aktion)leerterror, danndispatch(aktion). -
Abgeleitete Werte (nichts davon steht im Zustand), in dieser Reihenfolge:
options = customerPickerOptions(quelle?.customers),resolved = resolveCustomerText(options, customerText),kundeDoc = festerKunde || resolved.option?.customer || null,kunde = kundeDoc && canInvoice(kundeDoc).ok ? kundeDoc : null;blockiertheißt: Kunde gefunden, abercanInvoicenicht ok.konten = useProjektkonten(kunde?.$id);projekt = wirksamesProjekt(state.projektId, konten.data?.konten);aktiv = konten.data ? Boolean(konten.data.auftraegeAktiv) : Boolean(auftraegeAktiv).detail = useProjektkontoDetail(projekt || '');auftraege,rechnungenausdetail.data(sonst[]).optionen: beiprojekt === null[]; bei''artOptionen({ projektId: '', … }); bei einem Projekt[], solangedetail.datafehlt, sonstartOptionen({ projektId: projekt, auftraege, rechnungen, auftraegeAktiv: aktiv }).art = optionen.some((o) => o.key === state.art && o.enabled) ? state.art : standardArt(optionen, { auftraege, rechnungen, offeneMinuten: detail.data?.konto?.zeit?.offeneMinuten || 0 }).auftragId:state.auftragId, wenn sie untergueltigeFestpreisAbs(auftraege)steht (nur beianzahlungundschluss), sonststandardAuftrag(art, auftraege, rechnungen);ab = auftraege.find((a) => a.id === auftragId).weitereTickets = state.weitereTickets;weitere = useWeitereBlaetter(weitereTickets);blaetter = [...(detail.data?.offeneArbeitsblaetter || []), ...weitereTickets.flatMap((t) => weitere[t.ticketId]?.blaetter || [])];kundenTickets = useKundenTickets(kunde?.$id, art === 'aufwand').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 }.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).totalCents: beifreiundfestbetraginvoiceTotalCents(state.items), bei den übrigen Artenvorschau.daten.summeCents, wennstatus.state === 'bereit', sonstnull;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:
errorals<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>(ohnehtmlFor); sonst Ladetext „Kunden werden geladen …“ (role="status"), bei Fehler „Die Kunden konnten nicht geladen werden: “ (msg warn,role="alert"), sonstAutocompleteInput id="rd-kunde" placeholder="Kunde suchen …"mitgetSuggestions={(t) => customerSuggestions(options, t)}. Eine Änderung setztcustomerTextund, wenn sich die aufgelöste Kunden-$idgegenüberkundeIdRefä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 .“ (ebensord-hint);gate.warn→<AddressWarning customer={kunde} variant="dialog" onShowOverview={…} />, der Handler tut beibusynichts, sonstonClose()und dannonShowOverview(); ohneonShowOverviewbleibt die Prop weg. Ohnekundeendet die Anzeige hier. - c. Projekt (
Field label="Projekt" htmlFor="rd-projekt"):<select id="rd-projekt" className="select">. Solangekonten.loading: Select gesperrt und darunter „Projekte werden geladen …“ (role="status"). OptionenprojektOptionen(konten.data?.konten); istprojekt === null, steht davor<option value="?" disabled>Projekt wählen …</option>und der Select hatvalue="?". Eine Wahl ruftact({ type: 'projekt', projektId: value }). Beikonten.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_HINWEISals<p className="rd-hint">. Solangeprojekt === 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 nichtleeremhintder gesperrten Optionen ein<p className="rd-hint">. - f. Inhalt je Art (nur wenn
artnichtleer):frei,festbetrag:RechnungPositionen legende="Positionen" labelPrefix="Position" idPrefix="rd-pos" minZeilen={1} addLabel="+ Position" zeigeSummeüberstate.items;onChange/onAdd/onRemovesetzenact({ type: 'position' | 'position+' | 'position-', liste: 'items', … }).aufwand:AufwandAuswahlmitkonto = detail.data.konto(satzInfo=Stundensatz <satzText(konto.zeit.satzCents)>, ergänzt um, laut <Nummer>, wennaufwandAuftrag(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 })}; danachRechnungPositionen legende="Zusätzliche Positionen" labelPrefix="Zusatzposition" idPrefix="rd-zus" minZeilen={0} addLabel="+ Zusatzposition"überstate.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 OptionenabLabel(Wahl →act({ type: 'auftrag', auftragId })), sonst eine ZeileabLabel(ab); darunter<p className="rd-hint">Auftragssumme <euro(ab.summeCents)> · bereits berechnet <euro(berechnet)> · noch nicht berechnet <euro(max(0, ab.summeCents − berechnet))></p>mitberechnet = 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, setzenmodusüberact({ type: 'anzahlung', patch }), der Wert bleibt),Field„Anzahlung in %“ bzw. „Betrag in €“ (input.input,inputMode="decimal",id="rd-anzahlung-wert") undField„Bezeichnung“ (input.input,maxLength={BEZEICHNUNG_MAX},id="rd-anzahlung-bez"). - Statusbereich (Aufwand, Anzahlung, Schluss):
status.statelaedt→<p className="msg muted" role="status" aria-live="polite">Vorschau wird aktualisiert …</p>;ungueltigmit nichtleeremmessage→<p className="msg muted">{message}</p>;fehler→<p className="msg err" role="alert">{message}</p>. Die Vorschauzeilen (vorschau?.daten) stehen beibereitnormal, beilaedtabgeblendet (abgeblendet), beiungueltigundfehlergar nicht.
- g. Gemeinsame Felder (nur wenn
artnichtleer):Field label="Leistungszeitraum (optional)" htmlFor="rd-zeitraum"(input.input, Platzhalterz. B. 01.10.–20.10.2026, Änderungact({ type: 'zeitraum', text }));<div className="field-row">mitField label="Zahlungsziel in Tagen" htmlFor="rd-tage"(input.input,inputMode="numeric",act({ type: 'tage', wert })) undField 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>.
- a. Fehler:
-
Verhalten:
abschicken(senden):setBusy(senden ? 'send' : 'draft'),setError(''),r = await submitter({ anfrage, senden, kunde, totalCents }), danach (wenn der Dialog noch lebt)setBusy(null). Jer.status:laeuft,abgebrochen→ nichts;fehler→setError(r.message), beir.httpStatus === 409zusätzlichdetail.reload()(das Blatt ist inzwischen abgerechnet);festgeschrieben→onCreated(null, { senden: true, notice: r.notice, tone: 'warn', finalizedFilter: r.finalizedFilter })undonClose();ok→onCreated(r.rechnung, { senden: r.senden })undonClose().- Effekt auf
vorschau: Istart === 'aufwand'undstatus.state === 'bereit',dispatch({ type: 'zeitraum-vorschlag', text: vorschau.daten.leistungszeitraum || '' })(der Vorschlag überschreibt nie eine Eingabe des Nutzers). - Effekt auf
vorschau: Ein Fehler mithttpStatus === 409ruft einmal je Fehlerobjektdetail.reload(). - Alle Knöpfe tragen
type="button"; keine Icons, keinestyle-Attribute; der Dialog steht nie in einem Element mitcontainer-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(wartetstate.vorschauDelayMs; beistate.vorschauFehlerStatus und{ error }): Antwort 200{ positionen, summeCents, leistungszeitraum, customFields: [] }je Art:aufwand: Tabelle der Blätter = die drei Blätter vonp2plusws100149 = { datum: '2026-10-04', minuten: 30, text: 'SPF-Eintrag ergänzt, DKIM-Schlüssel fehlt noch beim Mailanbieter.' }; eine unbekannte Kennung inarbeitsblattIds→ 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 }mitb = Math.round(Minuten × 10000 / 60);zeilentexte[datum]ersetzt den Teil vor dem letzten–;itemsfolgen alszusatz-<n>(menge,einzelpreisCents = unitAmountCents);leistungszeitraumist der Wert des Körpers, sonst04.10.–05.10.2026(erster bis letzter Tag, bei einem Tag04.10.2026).anzahlung: ABa1(Summe 120000):betrag = anzahlung.betragCents, sonstMath.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 dieitemsalszusatz-<n>, dann beianzahlungBerechnetdie Minuszeile{ schluessel: 'abzug-in_a', beschreibung: 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026', menge: 1, einzelpreisCents: -40000, betragCents: -40000 }. Iststate.schluss40gesetzt (Optionschluss40oder vom Szenario gesetzt), liefert die Antwort stattdessen 40 Zeilen wie eine ausgebaute Schlussrechnung des Servers:ab-1bisab-20(Fachanwaltsseiten: Baustein <n>, je 50,00 €), dieitemsalszusatz-<n>, mit erzeugten NachträgenNachtrag <n>zu je 10,00 € auf zehn Zeilen aufgefüllt, dann zehn Abzugszeilenabzug-in_<n>(abzüglich Teilrechnung, Rechnung WEBKLAR-00<20 + n> vom <nn>.10.2026, je 20,00 € abgezogen:betragCents: -2000).summeCentsist die Summe derbetragCents;leistungszeitraum'', wo nichts gilt.
-
POST …/rechnungen(wartetstate.postDelayMs; beistate.postFehlerdessen 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: '' } }mittotalCentsaus denselben Regeln (beifreiundfestbetragΣ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).mapInvoicedes Portals liefert laut V4 je Rechnung zusätzlichprojektName,art(frei | festbetrag | aufwand | anzahlung | schluss | abo),projektId,auftragId,auftragNummer; ein älteres Portal liefert sie nicht, dann zeigt die Tabelle „–“. -
Produces:
InvoiceTablebehält seine Props und ihren Vertrag (onChanged,refreshToken,customer,onShowOverview, Filter, Seiten). Der Knopf „Rechnung schreiben“ öffnet jetztRechnungDialog(kunde={customer}, im Kundenreiter fest, auf der Finanzen-Seitenullmit Kundenwahl;projektIdundauftraegeAktivbleiben weg: keine Vorauswahl, der Dialog fragtgetProjektkontenselbst). 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 durchimport RechnungDialog from '../auftraege/RechnungDialog'; neuimport { 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} />.handleCreatedbleibt 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.jsxunverä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;ticketIdist die Ticket-$id, im ArbeitsblattworkorderId);abgerechnetText,abgerechnetWarnung,ticketIdsOf(Task 2). -
Produces:
useAbgerechnet(ticketIds: string[]) → { [worksheetId]: string }(ein Fehler liefert{}, der Hook wirft nie).WorksheetListundWorksheetTimesModalbehalten Props und Verhalten; sie laden den Stand selbst,TicketPage.jsxbleibt unverändert. -
Step 1:
src/hooks/useAbgerechnet.jsschreiben
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.jsxundWorksheetTimesModal.jsxändern -
WorksheetList.jsx: ImporteuseAbgerechnetund{ abgerechnetText, ticketIdsOf }. Als erste Zeile der Komponente (vor den frühen Rückgaben fürloadingund leere Liste, Hooks müssen immer laufen)const abgerechnet = useAbgerechnet(ticketIdsOf(worksheets)). In der Zeilenschleifeconst billed = abgerechnetText(abgerechnet, ws.$id); in.ws-headdirekt hinter dem Abzeichen ausworksheetBadgeund vor dem Abo-Umschalter{billed && <span className="badge blue" title={…}>{billed}</span>}mittitle„Dieses Arbeitsblatt steht in einem Rechnungsentwurf.“ beiabgerechnet (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 Komponenteconst abgerechnet = useAbgerechnet(ticketIdsOf([worksheet]))undconst warnung = abgerechnetWarnung(abgerechnet, worksheet.$id). Direkt hinter dem Absatz<p className="muted">WSID …</p>und vor dem erstenField:{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 })(onShowOverviewoptional, Vertragsfrage 3);projektId:undefinedkeine Vorauswahl,null„Ohne Projekt“. -
V8 Lib-Dateien
projektkontoView.js,rechnungDialogView.jsmit Tests; Stileprojektkonto.css,rechnung-dialog.css. -
V8
InvoiceTablemit Spalten Projekt und Art (Art-Beschriftungen: frei „Ohne Projekt“, festbetrag „Festbetrag“, aufwand „Aufwand“, anzahlung „Anzahlung“, schluss „Schlussrechnung“, abo „Abo“). -
V8
WorksheetList(Abzeichenabgerechnet (<Nr>)/abgerechnet (Entwurf), für alle Mitarbeiter) undWorksheetTimesModal(Warnung wörtlich nach Spec). -
V8
InvoiceCreateModalgelöscht;FinancePage.jsxbewusst 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 ohneauftragId),getAbgerechnet. -
Nachträge:
zeilentexteersetzen den ganzen Text vor dem letzten–(Vertragsfrage 2); weitere Tickets direkt aus Appwrite,arbeitsblattIds(4); Aufwand ohneauftragId(5); die Vorschau zeigt alle Zeilen der Antwort, bei einer Schlussrechnung bis zu 40,itemsbleiben 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.
FinancePage.jsxbraucht keine Änderung. Entschieden (siehe Übersicht, Nachträge; V8):RechnungDialogsitzt inInvoiceTable.jsx(dort ersetzt W1 das Modal),FinancePage.jsxbleibt unverändert; der Knopf „Rechnung schreiben“ und der Dialog liegen heute schon inInvoiceTable, dieFinancePageund der Kundenreiter nur einbinden. Umsetzung: Task 9 (kundeim Kundenreiter,nullmit Kundenwahl auf der Finanzen-Seite; Beleg:git diff --statfürFinancePage.jsxist leer). Deckt sich mit W0 Vertragsfrage 1.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 sindJJJJ-MM-TTundKW-JJJJ-WW(Wochenzeilen heißen „KW 05/2026“, SchlüsselKW-2026-05). Umsetzung: Task 5 (zeileTeilenteilt am letzten–,zeileBearbeitbar,maxVorn; Körperfeldzeilentextenur bei Aufwand), Task 6 (Aktionzeilentext), Task 7 (RechnungVorschau).RechnungDialog: Props und Vorauswahl. Entschieden (siehe Übersicht, Nachträge; V8): Props{ open, onClose, onCreated(rechnung, info), kunde, projektId, auftraegeAktiv, onShowOverview };kundeist das Kundendokument odernull(Kundenwahl im Dialog);projektId:undefined= keine Vorauswahl (der Nutzer muss „Projekt wählen“),null= „Ohne Projekt“, Text = dieses Projekt;auftraegeAktivist nur der Startwert, danach giltgetProjektkonten, das der Dialog selbst lädt;onShowOverviewist optional („Zur Übersicht“ in der Adresswarnung);infowird 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 (InvoiceTablelässtprojektIdundauftraegeAktivweg). Deckt sich mit W0 Vertragsfragen 2, 3 und 12.- „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
ProjectContextPanelunduseWorksheets), filtert abgerechnete übergetAbgerechnet(ticketId)und schickt nurarbeitsblattIds; die Vorschau des Servers bleibt maßgeblich. Voraussetzung (L2):arbeitsblattIdsdürfen Blätter beliebiger Tickets desselben Kunden nennen. Umsetzung: Task 6 (rechnungDialogApi.js,useKundenTickets,useWeitereBlaetter), Task 4 (blattAusAppwrite,ticketOptionen), Task 7 (AufwandAuswahl), Szenariodlg-weitere-tickets. - Aufwand:
itemsundauftragId. Entschieden (siehe Übersicht, Nachträge, Absatz L2): Aufwand ohneauftragId; 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 beiaufwandundschlussnur, wenn der Nutzer welche eingibt; die Positionen vonfestbetragundfreistehen getrennt davon. Umsetzung: Task 5 (vorschauAnfragelässt bei AufwandauftragIdweg; Test „Aufwand sendet nur die gewählten Arbeitsblätter, Zusatzpositionen und Zeilentexte“), Task 4 (standardAuftragliefert bei Aufwand''), Task 8 (die Aufwands-AB erscheint nur als Hinweis „laut “ beim Stundensatz). - 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;prozentgrößer als 0, höchstens 100 und höchstens zwei Nachkommastellen,bezeichnung1 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“). - Leistungszeitraum. Entschieden (siehe Übersicht, Nachträge; V5): W1 sendet
leistungszeitraumnur, 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 (Aktionzeitraum-vorschlag), Task 8 (Effekt auf die Vorschau). - Vorschau-Fehler. Entschieden (siehe Übersicht, Nachträge; V5, Spalte Fehler):
POST …/rechnungen/vorschauantwortet 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, Szenariodlg-409). projektTyp. Entschieden (siehe Übersicht, Nachträge, Absatz L1):projektTypist der Anzeigename der Projektart („Website“,''ohne Art); W1 zeigt ihn unverändert (Projektliste, zweite Zeile unter dem Namen:Website · #100131). Umsetzung: Task 2 (kontoZeile).- Redesign-Werkzeug (Z2). Entschieden (siehe Übersicht, Nachträge; W0 Vertragsfrage 10): Das Werkzeug (
backend.mjs,endpunkte.md) kenntGET …/projektkonten,POST …/rechnungen/vorschau,GET /api/team/arbeitsblaetter/abgerechnetund die erweiterte Rechnungsliste nicht und bleibt unverändert; W1 prüft mit eigenen Überschreibungen unterSP. Z2 baut seinen Lauf aus Kopien vonfixtures-w1.mjsundszenarien.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). - Projektliste und Überschrift. Entschieden (siehe Übersicht, Nachträge; V8): Die Kindkomponenten rendern ihre
Cardselbst;ProjektkontoListerendert<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. - Positionseditor doppelt. Entschieden (siehe Übersicht, Nachträge): W1 (
RechnungPositionen) und W2 (AuftragDialog) brauchen dieselbe Zeile (Beschreibung, Menge, Einzelpreis, Summe, Entfernen, Grenzen ausINVOICE_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 NameRechnungPositionen, Präfixrd-), Task 12 Step 7 (Hinweis im Abschlussbericht). - 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 vonInvoiceTable). 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:
- 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:
RechnungVorschauzeigt alle Zeilen der Antwort (Task 7; die Hygiene-Prüfung sucht nachsliceund einer Grenze von 20); der Läufer prüft im Szenariodlg-schlusseine Vorschau mit 40 Zeilen (Task 8); die Zeilen, die der Client schickt, bleiben bei höchstensINVOICE_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).