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>
154 KiB
AB-UI in WOMS – Umsetzungsplan (Strang W2)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Admins sehen in WOMS beim Kunden die Auftragsbestätigungen (AB) als Liste mit Status und Aktionen und legen sie in einem Dialog mit vier Schritten an (Projekt, Auftrag, Zahlung und Termine, Vorschau mit Entwurf oder Senden). Eine AB ohne Projekt lässt sich in der Liste nachträglich einem Projekt zuordnen. Im Dialog erscheinen zwei nicht blockierende Hinweise (fehlende Steuernummer; Papierfassung bei Aufträgen vor Ort mit Privatpersonen). Dazu kommen das Feld „Kundenart“ in der Kunden-Übersicht und die „Steuernummer“ in den Finanz-Einstellungen.
Architecture: Alle Regeln (Eingaben prüfen, Cent und Minuten, Status und Aktionen, Texte, Fehlerklassen, Doppelklick-Sperre) liegen als reine Funktionen in src/lib/auftragView.js und zwei kleinen Helfern (kundenFelder.js, auftragPdf.js) und sind mit node --test getestet. AuftragListe und AuftragDialog (mit den Schritt-Dateien) sind dünne React-Schichten darüber; sie rufen nur die financeApi.js-Funktionen aus W0 auf. Gestaltung nach den Regeln des weißen Designs (Bausteine aus src/components/ui, Dialog, eigene Datei auftrag.css, nichts in global.css).
Tech Stack: React 18, Vite 5, reines CSS mit Container Queries, Tests node --test mit esbuild-Lader (tests/unit/load.mjs, Muster tests/unit/financeView.test.mjs), Prüfwerkzeug des Redesigns (/root/reports/ticketsystem-design-2026-10-04/werkzeug, playwright-core) nur lesend.
Spec: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md (Abschnitte 1 und 2; Abschnitt 5 und 6 für Felder und Endpunkte).
Übersicht/Verträge: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md (V2 Konstanten, V3 AuftragTeam, V4 Steuernummer, V5 Endpunkte, V8 Komponenten und der Abschnitt „Nachträge nach der Planrunde“ sind verbindlich). Jeder Implementierer liest Spec, Übersicht und diese Datei.
Arbeitsort: WOMS-Worktree /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2, Branch auftraege/w2-auftraege, abgezweigt von feature/auftraege nach W0 (diese Basis steht auf dem zusammengeführten Redesign P4/P6/P7).
Global Constraints
Alle Punkte der Übersicht gelten. Für WOMS vor allem: Texte Deutsch, Beträge ganze Cent mit euro() aus src/lib/financeFormat.js, Berliner Kalendertag YYYY-MM-DD mit Anzeige TT.MM.JJJJ (dateDe), Grenzen aus V2 LIMITS, kein Push, nie --force, nie git add -A oder git add ., build/ nie anfassen, keine Live-Daten, schwere Läufe hinter dem flock der Übersicht. Zusätze für diesen Strang:
- Befehle: Jeder Run-Block beginnt mit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 &&(die Shell setzt das Verzeichnis zwischen Aufrufen zurück). Tests nur gezielt:timeout 900 nice -n 10 node --max-old-space-size=1024 --test <Dateien>. Nienpm test, nienpm ci, niepkill -f. Schwere Läufe (Build, Browser) nur hinterflock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock; läuft das Redesign noch schwere Läufe, zusätzlich dahinterflock /root/reports/ticketsystem-design-2026-10-04/sperren/schwer.lock(erst unsere, dann deren); vorherfree -m(unter 1500 MB „available“: nicht starten, später wiederholen).rgist ripgrep 13 ohne PCRE2 (kein-P). - Besitz: Dieser Strang ändert nur die Dateien der Tabelle „Dateien“. Nie anfassen:
AuftraegeUndRechnungen.jsx,ProjektkontoListe.jsx,RechnungDialog.jsx,projektkonto.css,rechnung-dialog.css,src/lib/financeApi.js,teamApi.js,customerTabs.js,financeSettings.js,financeView.js,financeUi.js,InvoiceTable.jsx,FinancePage.jsx,WorksheetList.jsx,WorksheetTimesModal.jsx,src/components/ui/*,src/styles/global.css. Fehlt dort etwas, wird es unter „Vertragsfragen“ gemeldet, nicht ergänzt. - Redesign-Regeln: Bausteine
Button,Badge,Card,EmptyState,Dialogaussrc/components/ui; Meldungen als.msg(noticePropsaussrc/lib/financeUi.js); keine Icon-Pakete (Zeichen „ד, „+“); keinstyle={{…}}; keine Farbliterale (nur Tokens wievar(--line)); kein!important;window.confirmfür Rückfragen, niewindow.alert. In einem<Dialog as="form">trägt jeder Knopftype="button", außer „Weiter“ (type="submit"). Enter sendet nie die AB. - CSS: Nur
src/components/auftraege/auftrag.css, alle Klassen beginnen mitauftrag-.main.jsxlädtAppvorglobal.css: bei gleicher Spezifität gewinntglobal.css, deshalb nie eine globale Klasse (.table,.field…) überschreiben, sondern eigene Klassen verwenden oder mit.auftrag-… .tablehöher gehen.container-typesteht nur auf.auftrag-body(die Liste), nie auf einem Vorfahren eines Dialogs. - Texte: Die Texte der Tabellen und Tests in dieser Datei gelten wörtlich. WOMS-Texte sind sachlich (keine Anrede).
- Tests: Datei je Lib unter
tests/unit/<name>.test.mjs,loadModule('src/lib/<name>.js')aus./load.mjs. Die Libs sind rein (kein React, kein Appwrite, keinimport.meta.env). - Zeit der Backends: Nichts in dieser Datei darf einen echten Host erreichen. Das Prüfwerkzeug beantwortet alles aus Testdaten und bricht fremde Hosts ab.
Review Focus
- Kundenart fehlt beim ersten Senden: Schritt 1 lässt ohne Kundenart kein „Weiter“ zu, der Server antwortet bei einem Entwurf ohne Kundenart mit 400, die Liste erklärt das und führt zur Übersicht. Tests: Task 3 (
pruefeSchritt 1,sendeGate), Task 4 (auftragFehler, 400 Kundenart), Task 8 (Dialog-Fehler „Bitte die Kundenart wählen.“). - Kunde ohne Anschrift (Server 409): Entwurf speichern geht, Senden im Dialog ist gesperrt, und ein 409 des Servers in der Liste zeigt „Adresse des Kunden unvollständig …“ mit „Zur Übersicht“. Tests: Task 3 (
sendeGate), Task 4 (auftragFehler, 409 Adresse), Task 8 (Zeileentw-adresse). - Bearbeiten nach dem Senden, „Projekt zuordnen“: Gesendete ABs bieten nie „Bearbeiten“, „Senden“ oder „Löschen“, nur „Neue Fassung“ (und PDF, Erinnerung, Stornieren je Status); der Dialog öffnet keinen Nicht-Entwurf. „Projekt zuordnen“ ändert nur das Projekt (nie Inhalt oder Status) und erscheint nur bei einer AB ohne Projekt im Status Entwurf, Versendet, Bestätigt oder Abgerechnet und nur, wenn es mindestens ein Projekt zur Auswahl gibt. Tests: Task 4 (
auftragAktionen,projektZuordnenMoeglich), Task 7 (Regel „nicht bearbeitbar“), Task 8 (Liste, Zuordnen). - Doppelklick auf Senden und Zuordnen (Liste und Dialog): genau eine Anfrage, auch wenn der Server langsam antwortet. Tests: Task 4 (
neueSperre), Task 8 (Doppelklick: ein POST…/senden, ein POST…/projekt). - Handy-Breite 390 px: Tabelle, Auswahl „Projekt zuordnen“, Positionszeilen, Zeitquelle und Dialog laufen nicht waagerecht über, Knöpfe bleiben erreichbar. Test: Task 8 (
overflow-xund Bildschirmfotos bei 390 px). - Hinweise blockieren nie: Die fehlende Steuernummer (Schritt 4) und die Papierfassung bei „Vor Ort“ mit Privatperson (Schritt 2 und 4) sind reine Texte; „Weiter“ und „Senden“ hängen nur an
pruefeSchrittundsendeGate. Sind die Einstellungen nicht ladbar, erscheint kein Hinweis (es wird nichts behauptet). Tests: Task 2 (steuernummerFehlt), Task 3 (vorOrtHinweis,auftragHinweise), Task 8 (Dialog mit und ohne Hinweise).
Dateien
| Datei | Art | Task |
|---|---|---|
src/lib/kundenFelder.js, tests/unit/kundenFelder.test.mjs |
neu | 2 |
src/pages/CustomerDetailPage.jsx (nur OverviewTab) |
ändern | 2 |
src/components/finance/SettingsCard.jsx (nur SettingsForm) |
ändern | 2 |
src/lib/auftragView.js, tests/unit/auftragView.test.mjs |
neu | 3, 4 |
src/lib/auftragPdf.js, tests/unit/auftragPdf.test.mjs |
neu | 4 |
src/components/auftraege/AuftragListe.jsx |
Platzhalter von W0 ersetzen | 5 |
src/components/auftraege/auftrag.css |
neu | 5 (Task 6 und 7 hängen an) |
src/components/auftraege/AuftragSchritte.jsx |
neu | 6 |
src/components/auftraege/AuftragDialog.jsx |
Platzhalter von W0 ersetzen | 7 |
src/components/auftraege/AuftragVorschau.jsx |
neu | 7 |
Task 1: Worktree und Ausgangslage
Files: keine Änderung (nur anlegen und prüfen).
Interfaces:
-
Consumes: Branch
feature/auftraegeim WOMS-Worktree/home/webklar/apps/ticket.webklar.com/dev-auftraegenach W0 (Container,financeApi.js-Funktionen, vier Platzhalter, echtesnode_modules). -
Produces: Worktree
/home/webklar/apps/ticket.webklar.com/dev-auftraege-w2auf Branchauftraege/w2-auftraege. -
Step 1: Worktree anlegen
Run: git -C /home/webklar/apps/ticket.webklar.com/dev-auftraege worktree add ../dev-auftraege-w2 -b auftraege/w2-auftraege feature/auftraege && cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && ln -s ../dev-auftraege/node_modules node_modules && git status --short && git log --oneline -3
Expected: git status zeigt nur ?? node_modules (Symlink, bleibt unversioniert, nie git add -A), darunter die jüngsten W0-Commits. Fehlt ../dev-auftraege/node_modules, stoppen und melden (W0 legt es an).
- Step 2: W0-Ergebnis prüfen
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && ls src/components/auftraege src/components/ui/Dialog.jsx src/lib/financeUi.js src/lib/auftraegeAnsicht.js src/components/finance/FormField.jsx src/hooks/useWorkorders.js src/hooks/useScrollIntoView.js src/lib/openInNewTab.js && for n in listAuftraege getAuftrag createAuftrag patchAuftrag deleteAuftrag sendAuftrag erinnereAuftrag neueFassungAuftrag storniereAuftrag ordneAuftragProjektZu getAuftragPdf getSettings; do rg -q "\b$n\b" src/lib/financeApi.js && echo "da $n" || echo "FEHLT $n"; done
Expected: ls nennt AuftraegeUndRechnungen.jsx, AuftragListe.jsx und AuftragDialog.jsx (Platzhalter von W0, sie rendern null) und alle übrigen Pfade; zwölfmal da … (W0 exportiert die AB-Funktionen als export const { … } = createAuftraegeEndpoints(request), darunter ordneAuftragProjektZu(id, projektId) für „Projekt zuordnen“; getSettings besteht schon). Fehlt etwas, stoppen und melden: financeApi.js, auftraegeAnsicht.js und der Container gehören W0.
- Step 3: Verdrahtung des Containers lesen
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && rg -n -B1 -A8 "<AuftragListe|<AuftragDialog" src/components/auftraege/AuftraegeUndRechnungen.jsx; head -20 src/components/auftraege/AuftragListe.jsx src/components/auftraege/AuftragDialog.jsx; ls src/components/auftraege/auftrag.css
Expected: Aufrufe mit genau den Props aus V8: <AuftragListe auftraege konten onChanged onBearbeiten onShowOverview /> und <AuftragDialog open onClose onSaved kunde projektId auftrag konten onShowOverview /> (die Übersicht, Nachträge, und Plan W0 Task 7, Zeilen e und g, legen fest, dass der Container konten={ansicht.konten} und onShowOverview an beide durchreicht). Weiter gilt laut W0: onSaved schließt den Dialog im Container und zeigt die Meldung auftragMeldung(auftrag), kunde trägt die frische Kundenart (kundeMitKundenart), onShowOverview wechselt auf den Reiter Übersicht (setTab('overview'), die Adresse wird /customers/<id> ohne tab). Weicht der Stand davon ab, wird das im Abschlussbericht gemeldet, nicht im Container behoben. Die Exportnamen der Platzhalter (export default function AuftragListe / AuftragDialog) bleiben. auftrag.css gibt es noch nicht (das ls meldet „No such file“; W0 legt keine CSS-Datei an), W2 legt sie in Task 5 an.
- Step 4: Ausgangs-Tests
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/financeView.test.mjs tests/unit/financeSettings.test.mjs tests/unit/customerTabs.test.mjs tests/unit/ticketView.test.mjs
Expected: Zeile fail 0 (prüft zugleich den esbuild-Lader über den Symlink). Kein Commit.
Task 2: Kundenart und Steuernummer
Files:
- Create:
src/lib/kundenFelder.js - Create:
tests/unit/kundenFelder.test.mjs - Modify:
src/pages/CustomerDetailPage.jsx(OverviewTab) - Modify:
src/components/finance/SettingsCard.jsx(SettingsForm)
Interfaces:
-
Consumes:
FormFieldunduseRefinSettingsCard.jsx(vorhanden);databases.updateDocumentundupgradeToCustomer(customer, fullData)inOverviewTab(vorhanden,fullDatawird in das Dokument gespreizt). -
Produces (
src/lib/kundenFelder.js):KUNDENARTEN,KUNDENART_OPTIONEN,KUNDENART_HINWEIS,kundenartLabel(value) → string,kundenartAenderung(neu, gespeichert) → {} | { kundenart },STEUERNUMMER_MAX,STEUERNUMMER_HINWEIS,steuernummerAenderung(eingabe, gespeichert) → { ok: true, payload: {} | { steuernummer } } | { ok: false, fehler: string },STEUERNUMMER_FEHLT_HINWEIS,steuernummerFehlt(einstellungen) → boolean(für den Hinweis in Schritt 4 des Dialogs, Task 7). -
Regel der Steuernummer (Übersicht V4, Plan L0 Task 12; der Client prüft wie der Server): getrimmt höchstens 40 Zeichen aus ASCII-Buchstaben, Ziffern, Leerzeichen,
/,-und.(auch eine USt-IdNr. „DE…“); leer ist erlaubt und löscht den Wert. -
Step 1: Failing Test schreiben (
tests/unit/kundenFelder.test.mjs)
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const {
KUNDENARTEN, KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartLabel, kundenartAenderung,
STEUERNUMMER_MAX, STEUERNUMMER_HINWEIS, steuernummerAenderung, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt,
} = await loadModule('src/lib/kundenFelder.js')
test('Kundenart: Werte (Vertrag V2), Auswahl und Beschriftung', () => {
assert.deepEqual(KUNDENARTEN, ['unternehmen', 'privat'])
assert.deepEqual(KUNDENART_OPTIONEN, [
{ value: '', label: 'Nicht festgelegt' },
{ value: 'unternehmen', label: 'Unternehmen' },
{ value: 'privat', label: 'Privatperson' },
])
assert.equal(KUNDENART_HINWEIS, 'Für Auftragsbestätigungen: Privatpersonen bekommen die Widerrufsbelehrung.')
assert.equal(kundenartLabel('unternehmen'), 'Unternehmen')
assert.equal(kundenartLabel('privat'), 'Privatperson')
for (const v of ['', undefined, null, 'firma']) assert.equal(kundenartLabel(v), 'Nicht festgelegt')
})
test('kundenartAenderung: nur eine geänderte, bekannte Kundenart wird geschrieben', () => {
assert.deepEqual(kundenartAenderung('', ''), {})
assert.deepEqual(kundenartAenderung('', undefined), {})
assert.deepEqual(kundenartAenderung('privat', 'privat'), {})
assert.deepEqual(kundenartAenderung('privat', ''), { kundenart: 'privat' })
assert.deepEqual(kundenartAenderung('privat', undefined), { kundenart: 'privat' })
assert.deepEqual(kundenartAenderung('unternehmen', 'privat'), { kundenart: 'unternehmen' })
assert.deepEqual(kundenartAenderung('', 'privat'), { kundenart: '' })
// unbekannte Werte werden nie geschrieben
assert.deepEqual(kundenartAenderung('firma', ''), {})
assert.deepEqual(kundenartAenderung('firma', 'privat'), {})
})
test('Steuernummer: Hinweis und Grenze', () => {
assert.equal(STEUERNUMMER_HINWEIS, 'Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.')
assert.equal(STEUERNUMMER_MAX, 40)
})
test('steuernummerAenderung: unverändert sendet nichts, geändert den getrimmten Wert, leer löscht', () => {
assert.deepEqual(steuernummerAenderung('12/345/67890', '12/345/67890'), { ok: true, payload: {} })
assert.deepEqual(steuernummerAenderung('', ''), { ok: true, payload: {} })
assert.deepEqual(steuernummerAenderung('', null), { ok: true, payload: {} })
assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', '12/345/67890'), { ok: true, payload: {} })
assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', ''), { ok: true, payload: { steuernummer: '12/345/67890' } })
assert.deepEqual(steuernummerAenderung('', '12/345/67890'), { ok: true, payload: { steuernummer: '' } })
assert.equal(steuernummerAenderung('123 456 789-0', '').ok, true)
assert.equal(steuernummerAenderung('1'.repeat(40), '').ok, true)
})
test('steuernummerAenderung: Buchstaben, Ziffern, Leerzeichen, „/“, „-“ und „.“ sind erlaubt wie auf dem Server (V4)', () => {
for (const gut of ['12/345/67890', '12.345.67890', 'DE123456789', 'de 123 456 789', 'DE-123.456/789', 'a', 'USt-IdNr 12/345']) {
assert.deepEqual(steuernummerAenderung(gut, ''), { ok: true, payload: { steuernummer: gut } }, gut)
}
assert.deepEqual(steuernummerAenderung(' DE123456789 ', ''), { ok: true, payload: { steuernummer: 'DE123456789' } })
assert.deepEqual(steuernummerAenderung('A'.repeat(40), ''), { ok: true, payload: { steuernummer: 'A'.repeat(40) } })
})
test('steuernummerAenderung: andere Zeichen und zu lang', () => {
const zeichen = 'Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.'
for (const schlecht of ['12_345', '12,345', '12#34', 'Müller 12', 'ß12', '12\t34', '12\n34', '12+34', '123']) {
assert.deepEqual(steuernummerAenderung(schlecht, ''), { ok: false, fehler: zeichen }, JSON.stringify(schlecht))
}
const LANG = 'Die Steuernummer darf höchstens 40 Zeichen lang sein.'
assert.deepEqual(steuernummerAenderung('1'.repeat(41), ''), { ok: false, fehler: LANG })
assert.deepEqual(steuernummerAenderung('A'.repeat(41), ''), { ok: false, fehler: LANG })
assert.equal(steuernummerAenderung('_'.repeat(41), '').fehler, LANG) // die Länge wird zuerst geprüft
})
test('steuernummerFehlt: Hinweis nur, wenn die Einstellungen da sind und die Steuernummer leer ist', () => {
assert.equal(STEUERNUMMER_FEHLT_HINWEIS, 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.')
for (const leer of [{ steuernummer: '' }, { steuernummer: ' ' }, { steuernummer: null }, { steuernummer: 12 }, {}, { bankDetailsText: 'IBAN' }]) {
assert.equal(steuernummerFehlt(leer), true, JSON.stringify(leer))
}
for (const da of [{ steuernummer: '12/345/67890' }, { steuernummer: ' DE123456789 ' }]) assert.equal(steuernummerFehlt(da), false)
// noch nicht geladen oder nicht ladbar: es wird nichts behauptet
for (const unbekannt of [undefined, null, 'x', 5, []]) assert.equal(steuernummerFehlt(unbekannt), false, String(unbekannt))
})
- Step 2: Test laufen lassen
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/kundenFelder.test.mjs
Expected: FAIL (esbuild: src/lib/kundenFelder.js lässt sich nicht auflösen).
- Step 3:
src/lib/kundenFelder.jsimplementieren
Reine Datei ohne Importe, deutscher Kopfkommentar im Stil von financeSettings.js. Regeln:
-
KUNDENARTEN = ['unternehmen', 'privat'];KUNDENART_OPTIONEN,KUNDENART_HINWEIS,STEUERNUMMER_HINWEIS,STEUERNUMMER_FEHLT_HINWEIS,STEUERNUMMER_MAXwörtlich wie im Test. -
kundenartLabel(value): Beschriftung der Option mit diesem Wert, für alles Unbekannte (auch'',null) „Nicht festgelegt“. -
kundenartAenderung(neu, gespeichert): beide als Text lesen (undefinedundnullgelten als''). Istneuweder''noch inKUNDENARTEN, oder gleichgespeichert:{}. Sonst{ kundenart: neu }. -
steuernummerAenderung(eingabe, gespeichert): beide trimmen (kein Text =''). Gleich:{ ok: true, payload: {} }. Länger alsSTEUERNUMMER_MAX(zuerst geprüft): Fehler „Die Steuernummer darf höchstens 40 Zeichen lang sein.“. Passt/^[A-Za-z0-9\/ .-]*$/nicht (genau die Regel des Servers: nur ASCII-Buchstaben, keine Umlaute, als Leerzeichen nur U+0020): Fehler „Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.“. Sonst{ ok: true, payload: { steuernummer: <getrimmt> } }(leer löscht den Wert). -
steuernummerFehlt(einstellungen):truenur, wenneinstellungenein Objekt ist (kein Array, nichtnull) undeinstellungen.steuernummerkein Text oder nachtrim()leer ist; für alles andere (undefined= noch nicht geladen,null= nicht ladbar)false, damit nie etwas behauptet wird, was nicht feststeht. -
Step 4: Test laufen lassen
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/kundenFelder.test.mjs
Expected: PASS, 7 Tests, fail 0.
- Step 5: Kundenart in
OverviewTab(src/pages/CustomerDetailPage.jsx)
Anker sind die Namen, Zeilennummern verschieben sich.
- Import ergänzen:
import { KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartAenderung } from '../lib/kundenFelder'. - In
OverviewTabnebenform:const [kundenart, setKundenart] = useState(customer.kundenart || '')undconst felder = () => ({ ...form, ...kundenartAenderung(kundenart, customer.kundenart) }).saveschreibtfelder()stattform,upgradeübergibtfelder()stattformanupgradeToCustomer.ready = isInvoiceReady(…)bleibt unverändert. - Markup: erste Zeile des
<dl className="kv kunde-kv">in<Card title="Rechnungsdaten">(vor „Strasse + Nr.“):
| Element | Umsetzung |
|---|---|
| Beschriftung | <dt><label htmlFor="kunde-kundenart">Kundenart</label></dt> |
| Feld | <dd> mit <select id="kunde-kundenart" className="select" value={kundenart} onChange={(e) => setKundenart(e.target.value)}> und je Eintrag von KUNDENART_OPTIONEN ein <option key value>{label}</option>; darunter <div className="hint">{KUNDENART_HINWEIS}</div> |
Gespeichert wird mit dem vorhandenen Knopf „Speichern“. kundenart geht nur ins Dokument, wenn es sich geändert hat (so scheitert das Speichern der übrigen Stammdaten nie an einem noch nicht angelegten Attribut).
-
Step 6: Steuernummer in
SettingsForm(src/components/finance/SettingsCard.jsx) -
Import ergänzen:
import { STEUERNUMMER_HINWEIS, steuernummerAenderung } from '../../lib/kundenFelder'. -
Zustand:
const [steuernummer, setSteuernummer] = useState(() => (typeof initial?.steuernummer === 'string' ? initial.steuernummer : ''))undconst gespeichertRef = useRef(typeof initial?.steuernummer === 'string' ? initial.steuernummer : ''). -
submit:const stn = steuernummerAenderung(steuernummer, gespeichertRef.current)zusätzlich zuvalidateSettingsForm(form). Ist eines von beiden nicht ok:setErrors({ ...(check.errors || {}), ...(stn.ok ? {} : { steuernummer: stn.fehler }) }), die bisherige Warnung „Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.“, Fokus auf das erste Feld aus[...SETTINGS_FIELD_ORDER, 'steuernummer']. SonstpatchSettings({ ...check.payload, ...stn.payload }). Nach Erfolg gilt als gespeichert der Text ausdata.einstellungen.steuernummer, wenn der Server einen liefert, sonst der gesendete Wert:setSteuernummer(…)undgespeichertRef.current = …. -
Markup: nach dem Zähler
fin-counter(Bankverbindung), im selben<fieldset className="fin-fieldset">:
<FormField id={fieldId('steuernummer')} label="Steuernummer" hint={STEUERNUMMER_HINWEIS} error={errors.steuernummer}>
<input className="input" autoComplete="off" value={steuernummer} onChange={…} placeholder="z. B. 12/345/67890 oder DE123456789" />
</FormField>
onChange setzt den Text, löscht errors.steuernummer und eine offene Meldung (wie change(name) für die anderen Felder).
- Step 7: Syntax und Hygiene
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && node_modules/.bin/esbuild src/pages/CustomerDetailPage.jsx src/components/finance/SettingsCard.jsx --loader:.jsx=jsx --jsx=automatic --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/syntax --log-level=warning && git diff --stat && git diff -U0 src/pages/CustomerDetailPage.jsx src/components/finance/SettingsCard.jsx | rg "^\+.*(react-icons|style=\{\{|form-control|window\.alert)"
Expected: esbuild ohne Ausgabe, git diff --stat nennt genau diese zwei Dateien mit je weniger als 40 geänderten Zeilen, der letzte rg findet nichts (Exit 1).
- Step 8: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/lib/kundenFelder.js tests/unit/kundenFelder.test.mjs src/pages/CustomerDetailPage.jsx src/components/finance/SettingsCard.jsx && git commit -m "Kundenart in der Kunden-Uebersicht und Steuernummer in den Finanz-Einstellungen (Helfer mit Tests)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 3: Dialoglogik in auftragView.js
Files:
- Create:
src/lib/auftragView.js - Create:
tests/unit/auftragView.test.mjs
Interfaces:
-
Consumes:
canInvoice,dateDe,euro,ymdBerlinaus./financeFormat;ADDRESS_INCOMPLETE_TEXT,addressComplete,invoiceTotalCents,parseQuantity,parseUnitPriceaus./financeView;euroFieldaus./financeSettings;ticketNumberaus./ticketView;KUNDENARTEN,STEUERNUMMER_FEHLT_HINWEIS,steuernummerFehltaus./kundenFelder(Task 2). -
Produces (benannte Exporte;
Formularist die Form ausneuesFormular, Schlüssel siehe Test,Kontoist{ projektId, name, tickets: [{ id, woid }] }aus V3):- Konstanten:
AUFTRAG_LIMITS,STUNDENSATZ_STANDARD_CENTS,AUFTRAG_WEGE,ZAHLUNGSPLAENE,SCHRITTE;zahlungsplanOptionen(art) → [{ value, label }]. - Eingaben:
parseStunden(text) → Minuten | null,stundenText(minuten) → string,parseProzent(text) → 1..99 | null,parseTage(text) → 0..60 | null,heuteBerlin(now?) → 'YYYY-MM-DD'. - Formular:
neuePosition(),neuesFormular({ projektId, konten, heute }) → Formular,auftragZuFormular(auftrag) → Formular,waehleProjekt(form, projektId, konten) → Formular,wechselArt(form, art) → Formular,formularSummeCents(form) → number | null. - Prüfen:
pruefeSchritt(schritt, form, { kunde }) → { ok, fehler: { feld: Meldung }, erstes },formularZuNutzlast(form, { kunde }) → { ok: true, payload, summeCents } | { ok: false, schritt, erstes, fehler },sendeGate(kunde, kundenart) → { erlaubt, grund, zurUebersicht }. - Texte und Auswahl:
zahlungsplanVorschau({ art, prozent, text, summeCents, zielTage }) → string,zeitquelleOptionen(tickets, auswahl, projektTicketIds) → [{ id, label, gewaehlt, projekt }],schalteZeitquelle(auswahl, id, an) → string[]. - Hinweise (nicht blockierend):
kundenartWirksam(kunde, kundenart) → '' | 'unternehmen' | 'privat',VOR_ORT_HINWEIS,vorOrtHinweis(weg, kundenart) → string(''= kein Hinweis),auftragHinweise({ kunde, form, einstellungen }) → [{ key: 'steuernummer' | 'vorOrt', text }].
- Konstanten:
-
Step 1: Failing Tests schreiben (
tests/unit/auftragView.test.mjs)
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const {
AUFTRAG_LIMITS, STUNDENSATZ_STANDARD_CENTS, AUFTRAG_WEGE, SCHRITTE, zahlungsplanOptionen,
parseStunden, stundenText, parseProzent, parseTage, heuteBerlin,
neuePosition, neuesFormular, auftragZuFormular, waehleProjekt, wechselArt, formularSummeCents,
pruefeSchritt, formularZuNutzlast, zahlungsplanVorschau, sendeGate, zeitquelleOptionen, schalteZeitquelle,
kundenartWirksam, VOR_ORT_HINWEIS, vorOrtHinweis, auftragHinweise,
} = await loadModule('src/lib/auftragView.js')
const KONTEN = [
{ projektId: 'p1', name: 'Website Praxis Müller', tickets: [{ id: 't1', woid: '100131' }] },
{ projektId: 'p2', name: 'Shop Relaunch', tickets: [{ id: 't2', woid: '100140' }, { id: 't3', woid: '100141' }] },
]
const KUNDE = { $id: 'c1', name: 'Praxis Müller', email: 'info@praxis.test', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', kundenart: '', customerStatus: 'customer' }
const festpreisForm = (over = {}) => ({
...neuesFormular({ heute: '2026-10-06' }),
titel: 'Website', kundenart: 'privat', auftragWeg: 'telefon',
positionen: [{ uid: 1, description: 'Startseite', quantity: '1', price: '800,00' }],
...over,
})
const aufwandForm = (over = {}) => festpreisForm({ art: 'aufwand', positionen: [], ...over })
const OK = { ok: true, fehler: {}, erstes: '' }
test('Konstanten gleichen dem Vertrag V2 und der Spec (Limits, Wege, Zahlungspläne, Schritte)', () => {
assert.deepEqual(AUFTRAG_LIMITS, {
maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000,
maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60,
standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10,
maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140,
})
assert.equal(STUNDENSATZ_STANDARD_CENTS, 10000)
assert.deepEqual(AUFTRAG_WEGE, [
{ value: 'muendlich', label: 'Mündlich' }, { value: 'telefon', label: 'Telefonisch' },
{ value: 'email', label: 'Per E-Mail' }, { value: 'vor_ort', label: 'Vor Ort' }, { value: 'chat', label: 'Im Chat' },
])
assert.deepEqual(zahlungsplanOptionen('festpreis'), [
{ value: 'nach_abnahme', label: 'Alles nach Abnahme' },
{ value: 'anzahlung', label: 'Anzahlung bei Auftrag, Rest nach Abnahme' },
{ value: 'frei', label: 'Frei (eigener Text)' },
])
assert.deepEqual(zahlungsplanOptionen('aufwand'), [
{ value: 'nach_abnahme', label: 'Alles nach Abnahme' },
{ value: 'monatlich', label: 'Monatlich nach Aufwand' },
{ value: 'frei', label: 'Frei (eigener Text)' },
])
assert.deepEqual(SCHRITTE, [
{ id: 1, label: 'Projekt' }, { id: 2, label: 'Auftrag' }, { id: 3, label: 'Zahlung und Termine' }, { id: 4, label: 'Vorschau' },
])
})
test('parseStunden: Stunden mit Komma in 15-Minuten-Schritten werden Minuten', () => {
const gut = { '10': 600, '10,25': 615, '10,5': 630, '10,50': 630, '10,75': 645, '0,25': 15, '1': 60, '12,5 h': 750, '2 Std.': 120, ' 8 ': 480, '1666': 99960 }
for (const [text, minuten] of Object.entries(gut)) assert.equal(parseStunden(text), minuten, text)
for (const schlecht of ['', '0', '0,00', '10,3', '10,30', '10,1', '10.5', '-1', 'abc', '1666,25', '1667', '10,255', '1e3', ',5', '10 Minuten', null, undefined]) {
assert.equal(parseStunden(schlecht), null, String(schlecht))
}
})
test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => {
assert.deepEqual([15, 30, 45, 60, 75, 120, 135, 150, 600, 99960].map(stundenText), ['0,25', '0,5', '0,75', '1', '1,25', '2', '2,25', '2,5', '10', '1666'])
assert.equal(stundenText(null), '–')
assert.equal(stundenText('x'), '–')
})
test('parseProzent und parseTage', () => {
assert.deepEqual(['50', ' 30 % ', '1', '99'].map(parseProzent), [50, 30, 1, 99])
for (const schlecht of ['', '0', '100', '-5', '33,3', '5a', 'x', null]) assert.equal(parseProzent(schlecht), null, String(schlecht))
assert.deepEqual(['14', '0', '60', ' 7 '].map(parseTage), [14, 0, 60, 7])
for (const schlecht of ['', '61', '-1', '1,5', 'x', '1000', null]) assert.equal(parseTage(schlecht), null, String(schlecht))
})
test('heuteBerlin: Berliner Kalendertag, nicht der UTC-Tag', () => {
assert.equal(heuteBerlin(Date.parse('2026-10-05T22:30:00.000Z')), '2026-10-06')
assert.equal(heuteBerlin(Date.parse('2026-10-05T21:59:00.000Z')), '2026-10-05')
assert.match(heuteBerlin(), /^\d{4}-\d{2}-\d{2}$/)
})
test('neuesFormular: Vorgaben; ein Projekt füllt Titel und Zeitquelle', () => {
const f = neuesFormular({ projektId: 'p2', konten: KONTEN, heute: '2026-10-06' })
const { positionen, ...rest } = f
assert.equal(positionen.length, 1)
const { uid, ...zeile } = positionen[0]
assert.equal(typeof uid, 'number')
assert.deepEqual(zeile, { description: '', quantity: '1', price: '' })
assert.deepEqual(rest, {
projektId: 'p2', titel: 'Shop Relaunch', titelGeaendert: false, kundenart: '', art: 'festpreis',
auftragVom: '2026-10-06', auftragWeg: '',
stundensatz: '100,00', schaetzung: '',
zahlungsart: 'nach_abnahme', prozent: '50', zahlungstext: '', zahlungsziel: '14',
startAm: '', fertigBis: '', zeitquelle: ['t2', 't3'], zeitquelleGeaendert: false,
})
assert.deepEqual(neuesFormular({ konten: KONTEN, heute: '2026-10-06' }).zeitquelle, [])
assert.equal(neuesFormular({ projektId: 'gibtsnicht', konten: KONTEN, heute: '2026-10-06' }).projektId, '')
assert.equal(neuesFormular({ projektId: 'p1', heute: '2026-10-06' }).projektId, '')
assert.notEqual(neuePosition().uid, neuePosition().uid)
})
test('auftragZuFormular: Entwurf vom Server wird zum Formular (Euro- und Stundentexte)', () => {
const entwurf = {
id: 'a1', status: 'entwurf', projektId: 'p1', titel: 'Website', art: 'festpreis',
positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 }],
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
auftragVom: '2026-10-05', auftragWeg: 'email', startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'],
}
const f = auftragZuFormular(entwurf)
assert.deepEqual(f.positionen.map(({ uid, ...p }) => p), [{ description: 'Startseite', quantity: '2', price: '400,00' }])
const { positionen, ...rest } = f
assert.deepEqual(rest, {
projektId: 'p1', titel: 'Website', titelGeaendert: true, kundenart: '', art: 'festpreis',
auftragVom: '2026-10-05', auftragWeg: 'email',
stundensatz: '100,00', schaetzung: '',
zahlungsart: 'anzahlung', prozent: '30', zahlungstext: '', zahlungsziel: '21',
startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'], zeitquelleGeaendert: true,
})
const aufwand = auftragZuFormular({
...entwurf, art: 'aufwand', positionen: [], stundensatzCents: 9550, schaetzungMinuten: 750, summeCents: null,
zahlungsplan: { art: 'frei', prozent: null, text: 'Nach Absprache' },
})
assert.equal(aufwand.stundensatz, '95,50')
assert.equal(aufwand.schaetzung, '12,5')
assert.equal(aufwand.positionen.length, 1) // eine leere Zeile für den Wechsel auf Festpreis
assert.deepEqual([aufwand.zahlungsart, aufwand.prozent, aufwand.zahlungstext], ['frei', '50', 'Nach Absprache'])
})
test('waehleProjekt: Titel und Zeitquelle folgen dem Projekt, bis sie von Hand geändert wurden', () => {
const f0 = neuesFormular({ konten: KONTEN, heute: '2026-10-06' })
const f1 = waehleProjekt(f0, 'p1', KONTEN)
assert.deepEqual([f1.projektId, f1.titel, f1.zeitquelle], ['p1', 'Website Praxis Müller', ['t1']])
const f2 = waehleProjekt(f1, 'p2', KONTEN)
assert.deepEqual([f2.titel, f2.zeitquelle], ['Shop Relaunch', ['t2', 't3']])
const f3 = waehleProjekt(f2, '', KONTEN)
assert.deepEqual([f3.projektId, f3.titel, f3.zeitquelle], ['', '', []])
assert.equal(waehleProjekt(f1, 'gibtsnicht', KONTEN).projektId, '')
const hand = { ...f1, titel: 'Eigener Titel', titelGeaendert: true, zeitquelle: ['t7'], zeitquelleGeaendert: true }
const f4 = waehleProjekt(hand, 'p2', KONTEN)
assert.deepEqual([f4.projektId, f4.titel, f4.zeitquelle], ['p2', 'Eigener Titel', ['t7']])
assert.equal(f0.projektId, '') // die Eingabe bleibt unverändert
})
test('wechselArt: ein unpassender Zahlungsplan wird zurückgesetzt', () => {
const f = { ...neuesFormular({ heute: '2026-10-06' }), zahlungsart: 'anzahlung' }
assert.equal(wechselArt(f, 'aufwand').art, 'aufwand')
assert.equal(wechselArt(f, 'aufwand').zahlungsart, 'nach_abnahme')
assert.equal(wechselArt(f, 'festpreis').zahlungsart, 'anzahlung')
assert.equal(wechselArt({ ...f, art: 'aufwand', zahlungsart: 'monatlich' }, 'festpreis').zahlungsart, 'nach_abnahme')
assert.equal(wechselArt({ ...f, zahlungsart: 'frei' }, 'aufwand').zahlungsart, 'frei')
assert.equal(wechselArt(f, 'quatsch'), f)
})
test('formularSummeCents: Festpreis summiert gültige Positionen, Aufwand hat keine Summe', () => {
const f = festpreisForm({
positionen: [
{ uid: 1, description: 'a', quantity: '2', price: '100,00' },
{ uid: 2, description: 'b', quantity: '1', price: '50' },
{ uid: 3, description: 'c', quantity: 'x', price: '9,99' },
],
})
assert.equal(formularSummeCents(f), 25000)
assert.equal(formularSummeCents(festpreisForm({ positionen: [] })), 0)
assert.equal(formularSummeCents(aufwandForm()), null)
})
test('pruefeSchritt 1: Titel ist Pflicht, die Kundenart nur, wenn sie am Kunden fehlt', () => {
const f = neuesFormular({ heute: '2026-10-06' })
const r = pruefeSchritt(1, f, { kunde: KUNDE })
assert.equal(r.ok, false)
assert.deepEqual(r.fehler, { titel: 'Bitte einen Titel eingeben (höchstens 200 Zeichen).', kundenart: 'Bitte die Kundenart wählen.' })
assert.equal(r.erstes, 'titel')
assert.deepEqual(pruefeSchritt(1, { ...f, titel: ' Website ', kundenart: 'privat' }, { kunde: KUNDE }), OK)
assert.deepEqual(pruefeSchritt(1, { ...f, titel: 'x' }, { kunde: { ...KUNDE, kundenart: 'privat' } }), OK)
assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(201), kundenart: 'privat' }, { kunde: KUNDE }).ok, false)
assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(200), kundenart: 'privat' }, { kunde: KUNDE }).ok, true)
assert.equal(pruefeSchritt(1, { ...f, titel: 'x', kundenart: 'firma' }, { kunde: KUNDE }).fehler.kundenart, 'Bitte die Kundenart wählen.')
})
test('pruefeSchritt 2 (Festpreis): Datum, Weg, Positionen und Summe', () => {
const pruefe = (over) => pruefeSchritt(2, festpreisForm(over), { kunde: KUNDE })
assert.deepEqual(pruefe(), OK)
const r = pruefe({ auftragVom: '', auftragWeg: '' })
assert.deepEqual(r.fehler, { auftragVom: 'Bitte das Datum der Absprache angeben.', auftragWeg: 'Bitte den Weg der Absprache wählen.' })
assert.equal(r.erstes, 'auftragVom')
for (const schlecht of ['2026-02-30', '06.10.2026', '2026-1-1', 'morgen']) {
assert.equal(pruefe({ auftragVom: schlecht }).fehler.auftragVom, 'Bitte das Datum der Absprache angeben.', schlecht)
}
assert.equal(pruefe({ auftragWeg: 'brieftaube' }).fehler.auftragWeg, 'Bitte den Weg der Absprache wählen.')
assert.equal(pruefe({ art: 'xyz' }).fehler.art, 'Bitte die Art des Auftrags wählen.')
const pos = (o = {}) => ({ uid: 1, description: 'x', quantity: '1', price: '10,00', ...o })
const meldung = (positionen) => pruefe({ positionen }).fehler.positionen
const PREIS = 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).'
assert.equal(meldung([pos({ description: ' ' })]), 'Position 1: Bitte eine Beschreibung eingeben.')
assert.equal(meldung([pos(), pos({ description: 'y'.repeat(201) })]), 'Position 2: Die Beschreibung darf höchstens 200 Zeichen lang sein.')
assert.equal(meldung([pos({ quantity: '0' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
assert.equal(meldung([pos({ quantity: '1000' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
assert.equal(meldung([pos({ price: '' })]), `Position 1: ${PREIS}`)
assert.equal(meldung([pos({ price: '100.000,01' })]), `Position 1: ${PREIS}`)
assert.equal(meldung([]), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
assert.equal(meldung(Array.from({ length: 21 }, () => pos())), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
assert.equal(meldung(Array.from({ length: 20 }, () => pos())), undefined)
// Summe: genau 999.999,99 € gehen, 1.000.000,00 € nicht
const neun = Array.from({ length: 9 }, () => pos({ price: '100.000,00' }))
assert.equal(meldung([...neun, pos({ price: '99.999,99' })]), undefined)
assert.equal(meldung([...neun, pos({ price: '100.000,00' })]), 'Die Summe ist zu hoch (höchstens 999.999,99 €).')
})
test('pruefeSchritt 2 (Aufwand): Stundensatz und Schätzung, Positionen zählen nicht', () => {
const pruefe = (over) => pruefeSchritt(2, aufwandForm(over), { kunde: KUNDE })
const SATZ = 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).'
assert.deepEqual(pruefe(), OK)
assert.equal(pruefe({ schaetzung: '12,5' }).ok, true)
assert.equal(pruefe({ stundensatz: '' }).fehler.stundensatz, SATZ)
assert.equal(pruefe({ stundensatz: '12.50' }).fehler.stundensatz, SATZ)
assert.equal(pruefe({ schaetzung: '10,3' }).fehler.schaetzung, 'Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens 1666).')
assert.equal(pruefe({ positionen: [{ uid: 1, description: '', quantity: 'x', price: '' }] }).ok, true)
})
test('pruefeSchritt 3: Zahlungsplan, Zahlungsziel, Termine, Zeitquelle', () => {
const pruefe = (f) => pruefeSchritt(3, f, { kunde: KUNDE })
assert.deepEqual(pruefe(festpreisForm()), OK)
assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: '50' })).ok, true)
for (const schlecht of ['', '0', '100', '33,3']) {
assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: schlecht })).fehler.prozent, 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.', schlecht)
}
assert.equal(pruefe(festpreisForm({ zahlungsart: 'nach_abnahme', prozent: 'x' })).ok, true) // Prozent zählt nur bei „Anzahlung“
const TEXT = 'Bitte den Zahlungsplan als Text eingeben (höchstens 500 Zeichen).'
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' ' })).fehler.zahlungstext, TEXT)
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(501) })).fehler.zahlungstext, TEXT)
assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(500) })).ok, true)
for (const schlecht of ['', '61', '-1', '1,5']) {
assert.equal(pruefe(festpreisForm({ zahlungsziel: schlecht })).fehler.zahlungsziel, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.', schlecht)
}
assert.equal(pruefe(festpreisForm({ zahlungsziel: '0' })).ok, true)
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-30' })).ok, true)
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-01' })).ok, true)
assert.equal(pruefe(festpreisForm({ startAm: '2026-11-02', fertigBis: '2026-11-01' })).fehler.fertigBis, 'Die Fertigstellung liegt vor dem Start.')
assert.equal(pruefe(festpreisForm({ startAm: '2026-13-01' })).fehler.startAm, 'Bitte ein gültiges Datum wählen.')
assert.equal(pruefe(festpreisForm({ fertigBis: 'bald' })).fehler.fertigBis, 'Bitte ein gültiges Datum wählen.')
const ids = (n) => Array.from({ length: n }, (_, i) => `t${i}`)
assert.equal(pruefe(festpreisForm({ zeitquelle: ids(10) })).ok, true)
assert.equal(pruefe(festpreisForm({ zeitquelle: ids(11) })).fehler.zeitquelle, 'Höchstens 10 Tickets als Zeitquelle.')
const PASST = 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.'
assert.equal(pruefe(festpreisForm({ zahlungsart: 'monatlich' })).fehler.zahlungsart, PASST)
assert.equal(pruefe(aufwandForm({ zahlungsart: 'anzahlung' })).fehler.zahlungsart, PASST)
})
test('formularZuNutzlast: Festpreis in Cent, getrimmte Texte, Kundenart nur wenn sie am Kunden fehlt', () => {
const f = festpreisForm({
titel: ' Website Praxis ', projektId: 'p1',
positionen: [
{ uid: 1, description: ' Startseite ', quantity: '2', price: '400,00' },
{ uid: 2, description: 'Kontaktformular', quantity: '1', price: '1.234,50' },
],
zahlungsart: 'anzahlung', prozent: '30', zahlungsziel: '21',
startAm: '2026-11-01', fertigBis: '2026-11-30', zeitquelle: ['t1', 't2'],
})
const r = formularZuNutzlast(f, { kunde: KUNDE })
assert.equal(r.ok, true)
assert.equal(r.summeCents, 203450)
assert.deepEqual(r.payload, {
projektId: 'p1', titel: 'Website Praxis', kundenart: 'privat', art: 'festpreis',
positionen: [
{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 },
{ beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 123450 },
],
stundensatzCents: null, schaetzungMinuten: null,
zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
auftragVom: '2026-10-06', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '2026-11-30',
zeitquelle: ['t1', 't2'],
})
assert.equal('kundenart' in formularZuNutzlast(f, { kunde: { ...KUNDE, kundenart: 'unternehmen' } }).payload, false)
const frei = formularZuNutzlast(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' Zahlung in zwei Raten ' }), { kunde: KUNDE })
assert.deepEqual(frei.payload.zahlungsplan, { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten' })
assert.equal(frei.payload.projektId, '')
assert.deepEqual([frei.payload.startAm, frei.payload.fertigBis], ['', ''])
})
test('formularZuNutzlast: Aufwand mit Satz in Cent und Schätzung in Minuten, ohne Positionen und Summe', () => {
const r = formularZuNutzlast(aufwandForm({ stundensatz: '95,50', schaetzung: '12,5', zahlungsart: 'monatlich' }), { kunde: { ...KUNDE, kundenart: 'privat' } })
assert.equal(r.ok, true)
assert.equal(r.summeCents, null)
assert.deepEqual(r.payload.positionen, [])
assert.equal(r.payload.art, 'aufwand')
assert.equal(r.payload.stundensatzCents, 9550)
assert.equal(r.payload.schaetzungMinuten, 750)
assert.deepEqual(r.payload.zahlungsplan, { art: 'monatlich', prozent: null, text: '' })
assert.equal(formularZuNutzlast(aufwandForm({ schaetzung: '' }), { kunde: KUNDE }).payload.schaetzungMinuten, null)
})
test('formularZuNutzlast: Fehler nennen den Schritt und das erste Feld', () => {
const r1 = formularZuNutzlast(festpreisForm({ titel: '', zahlungsziel: '99' }), { kunde: KUNDE })
assert.deepEqual([r1.ok, r1.schritt, r1.erstes], [false, 1, 'titel'])
assert.equal(r1.fehler.titel, 'Bitte einen Titel eingeben (höchstens 200 Zeichen).')
const r2 = formularZuNutzlast(festpreisForm({ auftragWeg: '' }), { kunde: KUNDE })
assert.deepEqual([r2.ok, r2.schritt, r2.erstes], [false, 2, 'auftragWeg'])
const r3 = formularZuNutzlast(festpreisForm({ zahlungsziel: '99' }), { kunde: KUNDE })
assert.deepEqual([r3.ok, r3.schritt, r3.erstes], [false, 3, 'zahlungsziel'])
})
test('zahlungsplanVorschau: Wortlaut sinngemäß wie in der Auftragsbestätigung', () => {
const v = (o) => zahlungsplanVorschau({ art: 'nach_abnahme', prozent: null, text: '', summeCents: 80000, zielTage: 14, ...o })
assert.equal(v({}), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ summeCents: null }), 'Alles nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ art: 'anzahlung', prozent: 50 }), 'Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
// Rundung wie im Vertrag V3: Anzahlung = Math.round(Summe × Prozent / 100), Rest = Summe − Anzahlung
assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: 33333 }), 'Anzahlung 50 % (166,67 €) nach Auftragsbestätigung, Rest (166,66 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ art: 'anzahlung', prozent: 33, summeCents: 33333 }), 'Anzahlung 33 % (110,00 €) nach Auftragsbestätigung, Rest (223,33 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: null }), 'Anzahlung 50 % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ art: 'anzahlung', prozent: null }), '')
assert.equal(v({ art: 'monatlich', summeCents: null }), 'Monatlich nach Aufwand; zahlbar innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ art: 'frei', text: ' Zwei Raten, je zur Hälfte ' }), 'Zwei Raten, je zur Hälfte (zahlbar innerhalb von 14 Tagen nach Rechnung).')
assert.equal(v({ art: 'frei', text: '' }), '')
assert.equal(v({ zielTage: 0 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar sofort nach Rechnung.')
assert.equal(v({ zielTage: 1 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 1 Tag nach Rechnung.')
assert.equal(v({ zielTage: null }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
assert.equal(v({ art: 'unbekannt' }), '')
})
test('sendeGate: Lead, E-Mail, Anschrift und Kundenart halten das Senden auf, der Entwurf bleibt möglich', () => {
assert.deepEqual(sendeGate(KUNDE, 'privat'), { erlaubt: true, grund: '', zurUebersicht: false })
assert.deepEqual(sendeGate({ ...KUNDE, kundenart: 'privat' }, ''), { erlaubt: true, grund: '', zurUebersicht: false })
assert.deepEqual(sendeGate(KUNDE, ''), { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false })
assert.deepEqual(sendeGate({ ...KUNDE, street: '' }, 'privat'), {
erlaubt: false, grund: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
})
assert.deepEqual(sendeGate({ ...KUNDE, email: ' ' }, 'privat'), { erlaubt: false, grund: 'E-Mail fehlt', zurUebersicht: true })
assert.deepEqual(sendeGate({ ...KUNDE, customerStatus: 'lead' }, 'privat'), { erlaubt: false, grund: 'Noch kein fester Kunde', zurUebersicht: true })
assert.deepEqual(sendeGate(null, 'privat'), { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false })
})
const TICKETS = [
{ $id: 't9', woid: '100149', topic: 'Neueste Anfrage' },
{ $id: 't2', woid: '100140', topic: 'Shop Relaunch' },
{ $id: 't1', woid: '100131', topic: 'Website Praxis' },
{ $id: 'xyz12345', woid: '', title: 'Ohne Nummer' },
]
test('zeitquelleOptionen: Projekt-Tickets zuerst, dann gewählte, dann der Rest; unbekannte Auswahl bleibt sichtbar', () => {
const o = zeitquelleOptionen(TICKETS, ['t9', 'alt77'], ['t1'])
assert.deepEqual(o.map((x) => x.id), ['t1', 't9', 'alt77', 't2', 'xyz12345'])
assert.deepEqual(o.map((x) => x.gewaehlt), [false, true, true, false, false])
assert.deepEqual(o.map((x) => x.projekt), [true, false, false, false, false])
assert.equal(o[0].label, '#100131 Website Praxis')
assert.equal(o[1].label, '#100149 Neueste Anfrage')
assert.equal(o[2].label, 'Ticket alt77')
assert.equal(o[4].label, '#12345 Ohne Nummer')
assert.deepEqual(zeitquelleOptionen(null, [], []), [])
})
test('schalteZeitquelle: höchstens 10, keine Doppelten, Reihenfolge der Auswahl', () => {
assert.deepEqual(schalteZeitquelle(['a'], 'b', true), ['a', 'b'])
assert.deepEqual(schalteZeitquelle(['a', 'b'], 'a', false), ['b'])
assert.deepEqual(schalteZeitquelle(['a'], 'a', true), ['a'])
assert.deepEqual(schalteZeitquelle(undefined, 'a', true), ['a'])
const zehn = Array.from({ length: 10 }, (_, i) => `t${i}`)
assert.deepEqual(schalteZeitquelle(zehn, 'neu', true), zehn)
assert.deepEqual(schalteZeitquelle(zehn, 't3', false), zehn.filter((id) => id !== 't3'))
})
test('kundenartWirksam: die Kundenart am Kunden gilt, sonst die im Formular gewählte', () => {
assert.equal(kundenartWirksam({ kundenart: 'unternehmen' }, 'privat'), 'unternehmen')
assert.equal(kundenartWirksam({ kundenart: '' }, 'privat'), 'privat')
assert.equal(kundenartWirksam({}, 'unternehmen'), 'unternehmen')
assert.equal(kundenartWirksam(null, 'privat'), 'privat')
assert.equal(kundenartWirksam({ kundenart: 'privat' }, ''), 'privat')
assert.equal(kundenartWirksam({ kundenart: 'firma' }, ''), '')
assert.equal(kundenartWirksam({ kundenart: '' }, 'firma'), '')
assert.equal(kundenartWirksam(null, undefined), '')
})
test('vorOrtHinweis: nur bei „Vor Ort“ und Privatperson (§ 312f Abs. 1 BGB), nie als Sperre', () => {
const TEXT = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
assert.equal(VOR_ORT_HINWEIS, TEXT)
assert.equal(vorOrtHinweis('vor_ort', 'privat'), TEXT)
for (const [weg, art] of [['vor_ort', 'unternehmen'], ['vor_ort', ''], ['telefon', 'privat'], ['muendlich', 'privat'], ['email', 'privat'], ['chat', 'privat'], ['', 'privat'], [undefined, 'privat']]) {
assert.equal(vorOrtHinweis(weg, art), '', `${weg}/${art}`)
}
})
test('auftragHinweise: fehlende Steuernummer, dann „vor Ort“ bei Privatperson; sonst nichts', () => {
const STN = 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.'
const VOR = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
const hinweise = (o) => auftragHinweise({ kunde: KUNDE, form: festpreisForm(), einstellungen: { steuernummer: '12/345/67890' }, ...o })
assert.deepEqual(hinweise(), []) // festpreisForm: Privatperson, Weg „telefon“, Steuernummer vorhanden
assert.deepEqual(hinweise({ einstellungen: { steuernummer: '' } }), [{ key: 'steuernummer', text: STN }])
assert.deepEqual(hinweise({ einstellungen: {} }).map((h) => h.key), ['steuernummer'])
// Einstellungen noch nicht geladen (undefined) oder nicht ladbar (null): keine Behauptung
assert.deepEqual(hinweise({ einstellungen: undefined }), [])
assert.deepEqual(hinweise({ einstellungen: null }), [])
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort' }) }), [{ key: 'vorOrt', text: VOR }])
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: 'unternehmen' }) }), [])
assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }), [])
// die Kundenart des Kunden hat Vorrang vor dem Formular
const privatAmKunden = { kunde: { ...KUNDE, kundenart: 'privat' }, form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }
assert.deepEqual(hinweise(privatAmKunden).map((h) => h.key), ['vorOrt'])
assert.deepEqual(hinweise({ ...privatAmKunden, einstellungen: { steuernummer: '' } }).map((h) => h.key), ['steuernummer', 'vorOrt'])
})
- Step 2: Tests laufen lassen
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs
Expected: FAIL (esbuild: src/lib/auftragView.js lässt sich nicht auflösen).
- Step 3:
src/lib/auftragView.jsimplementieren (Teil 1)
Reine Datei, deutscher Kopfkommentar im Stil von financeView.js. Alle Texte und Zahlen wörtlich wie in den Tests. Regeln:
-
Konstanten:
AUFTRAG_LIMITS(13 Schlüssel wie V2LIMITS),STUNDENSATZ_STANDARD_CENTS = 10000,AUFTRAG_WEGE(Reihenfolge und Beschriftung wie im Test,value= Schlüssel aus V2),ZAHLUNGSPLAENE = { nach_abnahme, anzahlung, monatlich, frei }mit den Beschriftungen des Tests,SCHRITTE.zahlungsplanOptionen('aufwand')liefertnach_abnahme,monatlich,frei, jede andere Artnach_abnahme,anzahlung,frei. -
parseStunden(eingabe): Text trimmen, ein Suffix/\s*(std\.?|h)$/ientfernen, dann muss/^(\d{1,4})(?:,(\d{1,2}))?$/passen. Minuten = Stunden × 60 + (Bruchteil auf zwei Stellen mit0aufgefüllt × 60 / 100), also,5= 30. Gültig nur, wenn das Ergebnis eine ganze Zahl, größer 0, durch 15 teilbar und höchstensmaxSchaetzungMinutenist, sonstnull.'',null,undefinedergebennull(leer heißt „keine Schätzung“, das entscheidet der Aufrufer). -
stundenText(minuten):Number((minuten / 60).toFixed(2))als Text mit Komma statt Punkt; keine endliche Zahl ergibt „–“. -
parseProzent: trimmen, ein Suffix\s*%entfernen,/^\d{1,2}$/, Wert 1 bis 99.parseTage: trimmen,/^\d{1,3}$/, Wert höchstensmaxZahlungszielTage. Beide: sonstnull. -
heuteBerlin(now = Date.now()):ymdBerlin(now). -
neuePosition():{ uid, description: '', quantity: '1', price: '' }mit modulweit hochgezähltemuid(Zahl, nie zweimal derselbe). -
neuesFormular({ projektId = '', konten = [], heute = heuteBerlin() }): Schlüssel und Vorgaben wie im Test (18 Schlüssel).projektIdgilt nur, wenn es inkontenvorkommt, sonst''.titelist der Name des Kontos,zeitquelledie IDs vonkonto.tickets,stundensatzisteuroField(STUNDENSATZ_STANDARD_CENTS). -
auftragZuFormular(auftrag): gleiche 18 Schlüssel.titelGeaendertundzeitquelleGeaendertsindtrue;kundenartist'';projektId,titelaus dem Entwurf (fehlend =''); Positionen als{ uid, description, quantity: String(menge), price: euroField(einzelpreisCents) }, ohne Positionen eine leere Zeile;stundensatz=euroField(stundensatzCents), ohne Wert der Standard;schaetzung=stundenText(schaetzungMinuten)oder'';zahlungsart,prozent(ohne Wert'50'),zahlungstextauszahlungsplan;zahlungsziel=String(zahlungszielTage ?? 14); Datumsfelder undzeitquelleübernehmen (fehlend''bzw.[]). -
waehleProjekt(form, projektId, konten): neues Objekt. UnbekannteprojektIdgilt als''. SolangetitelGeaendertfalsch ist, wirdtitelzum Kontonamen (ohne Konto''); solangezeitquelleGeaendertfalsch ist, wirdzeitquellezu den Ticket-IDs des Kontos (ohne Konto[]). -
wechselArt(form, art):artnichtfestpreisoderaufwand: dasselbe Objekt zurück. Sonst Kopie mitart; passtzahlungsartnicht inzahlungsplanOptionen(art), wird sienach_abnahme. -
formularSummeCents(form):nullbeiart !== 'festpreis', sonstinvoiceTotalCents(form.positionen)(ungültige Zeilen zählen 0). -
pruefeSchritt(schritt, form, { kunde }): liefert{ ok, fehler, erstes }mitfehlerals Objekt{ feld: Meldung }in der Reihenfolge der Prüfungen understesals erstem Schlüssel (''ohne Fehler). Datumsfelder prüftistTag(text):/^\d{4}-\d{2}-\d{2}$/unddateDe(text) !== '–'.- Schritt 1:
titel(getrimmt 1 bismaxTitelZeichen),kundenart(nur wennkunde.kundenartleer ist; muss inKUNDENARTENsein). - Schritt 2:
auftragVom(istTag),auftragWeg(Wert ausAUFTRAG_WEGE), dann jeart: unbekannt:art(„Bitte die Art des Auftrags wählen.“, mehr wird dann nicht geprüft).festpreis:positionen(nur die erste Meldung): Anzahl 1 bismaxPositionen, je Position der Reihe nach Beschreibung (leer, über 200), Menge (parseQuantity), Einzelpreis (parseUnitPrice) mit Präfix „Position n: “, zuletzt Summe übermaxSummeCents.aufwand:stundensatz(parseUnitPrice),schaetzung(nur wenn nicht leer:parseStunden). - Schritt 3:
zahlungsart(muss inzahlungsplanOptionen(art)stehen),prozent(nur beianzahlung),zahlungstext(nur beifrei, getrimmt 1 bis 500),zahlungsziel,startAmundfertigBis(nur wenn nicht leer:istTag; stehen beide gültig da und liegtfertigBisvorstartAm, Meldung anfertigBis),zeitquelle(höchstensmaxZeitquellen).
- Schritt 1:
-
formularZuNutzlast(form, { kunde }): prüft die Schritte 1 bis 3 der Reihe nach und liefert beim ersten Fehler{ ok: false, schritt, erstes, fehler }. Sonst{ ok: true, payload, summeCents }mitpayloadgenau wie im Test: Texte getrimmt,kundenartnur wennkunde.kundenartleer ist,positionennur bei Festpreis (beschreibung,mengeals Zahl,einzelpreisCents),stundensatzCentsundschaetzungMinutennur bei Aufwand (sonstnull, die Schätzungnullwenn leer),zahlungsplan = { art, prozent (nur anzahlung, sonst null), text (nur frei, sonst '') },zahlungszielTageals Zahl,startAm/fertigBis''wenn leer,zeitquelleals Kopie. KeinkundenId(das setzt der Aufrufer beim Anlegen). -
zahlungsplanVorschau({ art, prozent, text, summeCents, zielTage }):tage = Number.isInteger(zielTage) ? zielTage : 14;zielist „sofort nach Rechnung“ bei 0, „innerhalb von 1 Tag nach Rechnung“ bei 1, sonst „innerhalb von n Tagen nach Rechnung“. Texte je Art wörtlich wie im Test; Anzahlung =Math.round(summeCents * prozent / 100), Rest = Summe − Anzahlung; ohne ganzesummeCentsentfallen die Beträge, ohneprozentist der Text''; unbekannte Art''. -
sendeGate(kunde, kundenart = ''): Reihenfolge der Prüfungen: kein Kunde (Grund „Kein Kunde ausgewählt“,zurUebersicht: false);canInvoice(kunde)nicht ok (Grund = dessenreason,zurUebersicht: true);!addressComplete(kunde)(GrundADDRESS_INCOMPLETE_TEXT,zurUebersicht: true); wederkunde.kundenartnochkundenartgesetzt (Grund wie im Test,zurUebersicht: false). Sonst{ erlaubt: true, grund: '', zurUebersicht: false }. -
zeitquelleOptionen(tickets, auswahl, projektTicketIds): Reihenfolge: erst die IDs ausprojektTicketIds, dann die übrigen ausauswahl(in deren Reihenfolge), dann alle weiteren Tickets in der Reihenfolge vontickets. Jeder Eintrag{ id, label, gewaehlt, projekt }.label=#<ticketNumber(t)>plus Leerzeichen undtopic || title(ohne Text nur die Nummer); ein Ticket, das nicht inticketssteht, heißtTicket <letzte 5 Zeichen der ID>. Kein Array alsticketszählt als leer. -
schalteZeitquelle(auswahl, id, an): neue Liste;anhängtidan, wenn sie fehlt und weniger alsmaxZeitquellengewählt sind; sonst unverändert;!anentferntid. -
kundenartWirksam(kunde, kundenart):kunde?.kundenart, wenn der Wert inKUNDENARTENsteht; sonstkundenart, wenn der Wert inKUNDENARTENsteht; sonst''(die Kundenart am Kunden hat Vorrang, weil der Serverkundenartnur schreibt, wenn sie am Kunden leer ist). -
VOR_ORT_HINWEISwörtlich wie im Test;vorOrtHinweis(weg, kundenart):VOR_ORT_HINWEIS, wennweg === 'vor_ort'undkundenart === 'privat', sonst''. Rechtsgrund ist § 312f Abs. 1 BGB (bei außerhalb von Geschäftsräumen geschlossenen Verträgen mit Verbrauchern eine Papierfassung zur Verfügung stellen); der Hinweis ist reine Oberfläche und blockiert nichts. -
auftragHinweise({ kunde, form, einstellungen }): Liste in dieser Reihenfolge:{ key: 'steuernummer', text: STEUERNUMMER_FEHLT_HINWEIS }, wennsteuernummerFehlt(einstellungen); danach{ key: 'vorOrt', text: VOR_ORT_HINWEIS }, wennvorOrtHinweis(form.auftragWeg, kundenartWirksam(kunde, form.kundenart))nicht leer ist. Sonst[]. -
Step 4: Tests laufen lassen
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs
Expected: PASS, 24 Tests, fail 0.
- Step 5: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/lib/auftragView.js tests/unit/auftragView.test.mjs && git commit -m "Auftragsbestaetigung: Dialoglogik (Parser, Validierung, Nutzlast, Zahlungsplan-Vorschau) mit Tests" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 4: Listenlogik in auftragView.js und PDF-Hilfe
Files:
- Modify:
src/lib/auftragView.js(Teil 2 anhängen) - Modify:
tests/unit/auftragView.test.mjs(Tests anhängen, Importliste erweitern) - Create:
src/lib/auftragPdf.js - Create:
tests/unit/auftragPdf.test.mjs
Interfaces:
-
Consumes:
dateDe,euroaus./financeFormat;ADDRESS_INCOMPLETE_TEXTaus./financeView;stundenText(Task 3);openInNewTab(getUrl, { win })aus./openInNewTab(vorhanden). -
Produces: aus
auftragView.jsAUFTRAG_STATUS,PROJEKT_ZUORDNEN_STATUS,istEntwurf(auftrag),zuordnenOptionen(konten) → [{ value, label }],projektZuordnenMoeglich(auftrag, konten) → boolean,auftragAktionen(auftrag, { konten }?) → [{ key, label, danger? }],auftragNotizen(auftrag, alle?) → [{ key, tone, text }],auftragZeilen(auftraege, { konten }?) → Zeile[],auftragBestaetigung(key, auftrag, { email, projektName }?) → string,auftragErfolg(key, auftrag, { mailFehler, projektName }?) → { tone, text },auftragFehler(err) → { art, text, zurUebersicht },neueSperre() → { run(fn), belegt }. AusauftragPdf.jsPDF_FREIGABE_NACH_MS,alsPdfBlob(blob) → Blob,oeffnePdfImTab(holeBlob, { win, urlApi, timer }?) → Promise<void>. -
Konto(aus V3Projektkonto, nur diese Felder gelesen):{ projektId, name }. -
Zeile(ausauftragZeilen):{ key, id, auftrag, nummerText, istEntwurf, titel, projektText, datumText, summeText, summeSub, statusLabel, statusTone, notizen, aktionen }. Aktionsschlüssel:bearbeiten,senden,pdf,loeschen,erinnern,neueFassung,stornieren,projekt. -
Step 1: Failing Tests schreiben
Importliste am Dateianfang von tests/unit/auftragView.test.mjs um diese Namen erweitern: AUFTRAG_STATUS, PROJEKT_ZUORDNEN_STATUS, istEntwurf, zuordnenOptionen, projektZuordnenMoeglich, auftragAktionen, auftragNotizen, auftragZeilen, auftragBestaetigung, auftragErfolg, auftragFehler, neueSperre. Dann am Dateiende anhängen:
// --- Liste -------------------------------------------------------------------------------------------------------
const A = (o = {}) => ({
id: 'a1', nummer: 'AB-2026-0002', status: 'versendet', kundenId: 'c1', projektId: 'p1', titel: 'Website Praxis Müller',
art: 'festpreis', stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
gesendetAm: '2026-10-06T08:15:00.000+00:00', gesendetAn: 'info@praxis.test', mailStatus: 'gesendet', mailFehler: '',
bestaetigtAm: null, bestaetigtUeber: '', fruehbeginn: false, ersetztId: '', ersetztDurchId: '', widerrufenAm: null,
erstelltAm: '2026-10-05T16:00:00.000+00:00', geaendertAm: '2026-10-06T08:15:00.000+00:00',
pdfVorhanden: true, fruehesterBeginn: null, zahlungsplanText: '', aufwandText: null, ...o,
})
test('Status: Beschriftung und Ton für alle sieben Status (Vertrag V2)', () => {
assert.deepEqual(Object.entries(AUFTRAG_STATUS).map(([k, v]) => [k, v.label]), [
['entwurf', 'Entwurf'], ['versendet', 'Versendet'], ['bestaetigt', 'Bestätigt'], ['abgerechnet', 'Abgerechnet'],
['storniert', 'Storniert'], ['ersetzt', 'Ersetzt'], ['widerrufen', 'Widerrufen'],
])
assert.deepEqual(Object.values(AUFTRAG_STATUS).map((v) => v.tone), ['muted', 'info', 'ok', 'violet', 'plain', 'plain', 'danger'])
// „Projekt zuordnen“ gilt für diese Status (Übersicht, Nachträge; V5 `POST …/projekt` lehnt die übrigen mit 409 ab)
assert.deepEqual(PROJEKT_ZUORDNEN_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet'])
})
test('auftragZeilen: Nummer, Titel, Datum, Summe und Status', () => {
const [z] = auftragZeilen([A()])
assert.deepEqual([z.id, z.nummerText, z.titel, z.datumText], ['a1', 'AB-2026-0002', 'Website Praxis Müller', '06.10.2026'])
assert.deepEqual([z.summeText, z.summeSub, z.istEntwurf], ['800,00 €', '', false])
assert.deepEqual([z.statusLabel, z.statusTone], ['Versendet', 'info'])
assert.equal(z.auftrag.id, 'a1')
})
test('auftragZeilen: Entwurf ohne Nummer, nach Aufwand, Projektname nur wenn er vom Titel abweicht', () => {
const konten = [{ projektId: 'p1', name: 'Praxis Müller – Relaunch' }]
const entwurf = A({
id: 'a2', nummer: '', status: 'entwurf', gesendetAm: null, pdfVorhanden: false, art: 'aufwand', summeCents: null,
stundensatzCents: 10000, schaetzungMinuten: 600, erstelltAm: '2026-10-05T22:30:00.000+00:00',
})
const [z] = auftragZeilen([entwurf], { konten })
assert.deepEqual([z.nummerText, z.istEntwurf, z.datumText], ['Entwurf', true, '06.10.2026'])
assert.deepEqual([z.summeText, z.summeSub], ['nach Aufwand', '100,00 € pro Std., Schätzung etwa 10 Std.'])
assert.equal(z.projektText, 'Praxis Müller – Relaunch')
assert.equal(auftragZeilen([A()], { konten: [{ projektId: 'p1', name: 'Website Praxis Müller' }] })[0].projektText, '')
assert.equal(auftragZeilen([A({ projektId: '' })], { konten })[0].projektText, '')
assert.equal(auftragZeilen([A({ projektId: 'p9' })], { konten })[0].projektText, '')
assert.equal(auftragZeilen([A()])[0].projektText, '')
assert.equal(auftragZeilen([A({ art: 'aufwand', summeCents: null, stundensatzCents: 9550, schaetzungMinuten: null })])[0].summeSub, '95,50 € pro Std.')
assert.equal(auftragZeilen([A({ summeCents: null })])[0].summeText, '–')
})
test('auftragZeilen: Notizen und Aktionen hängen an der Zeile, die Reihenfolge des Servers bleibt', () => {
const zeilen = auftragZeilen([A({ id: 'x2', mailStatus: 'fehler' }), A({ id: 'x1', status: 'entwurf', nummer: '' })])
assert.deepEqual(zeilen.map((z) => z.id), ['x2', 'x1'])
assert.deepEqual(zeilen[0].notizen.map((n) => n.text), ['Mail nicht zugestellt'])
assert.equal(zeilen[0].aktionen[1].label, 'Erneut senden')
assert.equal(zeilen[1].istEntwurf, true)
assert.deepEqual(auftragZeilen(null), [])
assert.deepEqual(auftragZeilen([null, 'x', A()]).map((z) => z.id), ['a1'])
})
test('Zusatzzeilen: Mail, bestätigt am, Frühbeginn, Arbeitsbeginn, Fassungen, Widerruf', () => {
const texte = (o, alle) => auftragNotizen(A(o), alle).map((n) => n.text)
const BESTAETIGT = '2026-10-07T09:00:00.000+00:00'
assert.deepEqual(texte({}), [])
assert.deepEqual(texte({ mailStatus: 'fehler', mailFehler: 'SMTP' }), ['Mail nicht zugestellt'])
assert.deepEqual(auftragNotizen(A({ mailStatus: 'fehler' })).map((n) => n.tone), ['warn'])
// nach der Bestätigung zählt ein früherer Mailfehler nicht mehr
assert.deepEqual(texte({ status: 'bestaetigt', mailStatus: 'fehler', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026'])
assert.deepEqual(texte({ fruehesterBeginn: '2026-10-21' }), ['Arbeitsbeginn frühestens am 21.10.2026'])
assert.deepEqual(texte({ status: 'bestaetigt', fruehbeginn: true, bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Frühbeginn verlangt'])
assert.deepEqual(texte({ status: 'bestaetigt', fruehesterBeginn: '2026-10-21', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Arbeitsbeginn frühestens am 21.10.2026'])
// ohne Wirkung bei Entwurf und bei beendeten ABs
assert.deepEqual(texte({ status: 'entwurf', fruehesterBeginn: '2026-10-21', fruehbeginn: true }), [])
assert.deepEqual(texte({ status: 'storniert', fruehesterBeginn: '2026-10-21' }), [])
assert.deepEqual(texte({ status: 'widerrufen', widerrufenAm: '2026-10-09T10:00:00.000+00:00' }), ['Widerrufen am 09.10.2026'])
const alle = [
A({ id: 'a1', nummer: 'AB-2026-0002', status: 'ersetzt', ersetztDurchId: 'a3' }),
A({ id: 'a3', nummer: 'AB-2026-0004', ersetztId: 'a1' }),
A({ id: 'a4', nummer: '', status: 'entwurf', ersetztId: 'a3' }),
]
assert.deepEqual(auftragNotizen(alle[0], alle).map((n) => n.text), ['Ersetzt durch AB-2026-0004'])
assert.deepEqual(auftragNotizen(alle[1], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0002'])
assert.deepEqual(auftragNotizen(alle[2], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0004'])
assert.deepEqual(texte({ ersetztId: 'fremd' }, []), ['Neue Fassung'])
assert.deepEqual(texte({ status: 'ersetzt', ersetztDurchId: 'fremd' }, []), ['Ersetzt durch eine neue Fassung'])
})
test('auftragAktionen: je Status genau die erlaubten, nach dem Senden nur noch „Neue Fassung“ statt Bearbeiten', () => {
const keys = (o) => auftragAktionen(A(o)).map((a) => a.key)
assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'loeschen'])
assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'stornieren'])
assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung'])
for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status)
for (const status of ['versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) {
assert.equal(keys({ status }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false, status)
}
// ohne gespeichertes PDF keine PDF-Aktion (beim Entwurf gibt es die Vorschau immer)
assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'stornieren'])
assert.deepEqual(keys({ status: 'storniert', pdfVorhanden: false }), [])
assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).map((a) => a.label), ['Bearbeiten', 'Senden', 'Vorschau', 'Löschen'])
assert.deepEqual(auftragAktionen(A()).map((a) => a.label), ['PDF', 'Erinnerung senden', 'Neue Fassung', 'Stornieren'])
assert.equal(auftragAktionen(A({ mailStatus: 'fehler' }))[1].label, 'Erneut senden')
assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).filter((a) => a.danger).map((a) => a.key), ['loeschen'])
assert.deepEqual(auftragAktionen(A()).filter((a) => a.danger).map((a) => a.key), ['stornieren'])
assert.deepEqual(auftragAktionen(A({ status: 'xyz', pdfVorhanden: false })), [])
assert.deepEqual(auftragAktionen(null), [])
assert.equal(istEntwurf(A({ status: 'entwurf' })), true)
assert.equal(istEntwurf(A()), false)
assert.equal(istEntwurf(null), false)
})
test('Projekt zuordnen: Auswahl aus den Konten, Verfügbarkeit nach Status und Projekt', () => {
const konten = [{ projektId: 'p1', name: 'Website Praxis Müller' }, { projektId: 'p2', name: ' Shop Relaunch ' }, { projektId: 'p3', name: '' }, { name: 'ohne Kennung' }, null]
assert.deepEqual(zuordnenOptionen(konten), [
{ value: 'p1', label: 'Website Praxis Müller' }, { value: 'p2', label: 'Shop Relaunch' }, { value: 'p3', label: 'p3' },
])
assert.deepEqual(zuordnenOptionen(undefined), [])
assert.deepEqual(zuordnenOptionen('x'), [])
const ohne = (o) => A({ projektId: '', ...o })
for (const status of ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet']) {
assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), true, status)
}
for (const status of ['storniert', 'ersetzt', 'widerrufen', 'xyz']) {
assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), false, status)
}
assert.equal(projektZuordnenMoeglich(ohne({ projektId: null }), konten), true)
assert.equal(projektZuordnenMoeglich(ohne({ projektId: undefined }), konten), true)
// hat die AB schon ein Projekt (auch ein unbekanntes), gibt es die Aktion nicht: der Server lehnt sie mit 409 ab
assert.equal(projektZuordnenMoeglich(A({ projektId: 'p1' }), konten), false)
assert.equal(projektZuordnenMoeglich(A({ projektId: 'p9' }), konten), false)
// ohne Projekt zur Auswahl ist nichts zu tun
assert.equal(projektZuordnenMoeglich(ohne({}), []), false)
assert.equal(projektZuordnenMoeglich(ohne({}), undefined), false)
assert.equal(projektZuordnenMoeglich(null, konten), false)
})
test('auftragAktionen mit Konten: „Projekt zuordnen“ nur ohne Projekt, vor den gefährlichen Aktionen', () => {
const konten = [{ projektId: 'p1', name: 'Website' }]
const keys = (o, k = konten) => auftragAktionen(A({ projektId: '', ...o }), { konten: k }).map((a) => a.key)
assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'projekt', 'loeschen'])
assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren'])
assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'projekt', 'stornieren'])
assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung', 'projekt'])
assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'projekt', 'stornieren'])
for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status)
// mit Projekt, ohne Konten und ohne Angabe der Konten gibt es die Aktion nicht
assert.deepEqual(auftragAktionen(A({ status: 'versendet' }), { konten }).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
assert.deepEqual(keys({ status: 'versendet' }, []), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
assert.deepEqual(auftragAktionen(A({ projektId: '' })).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
// Beschriftung, nie gefährlich; Bearbeiten, Senden und Löschen bleiben dem Entwurf vorbehalten
const aktion = auftragAktionen(A({ projektId: '' }), { konten }).find((a) => a.key === 'projekt')
assert.equal(aktion.label, 'Projekt zuordnen')
assert.notEqual(aktion.danger, true)
assert.equal(keys({ status: 'versendet' }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false)
// die Zeilen geben die Konten weiter
assert.deepEqual(auftragZeilen([A({ projektId: '' })], { konten })[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren'])
assert.deepEqual(auftragZeilen([A({ projektId: '' })])[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
})
test('Rückfragen: Senden, Löschen, Stornieren, Erinnerung, Projekt zuordnen', () => {
assert.equal(
auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' }), { email: 'info@praxis.test' }),
'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an info@praxis.test senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.'
)
assert.equal(
auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' })),
'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an den Kunden senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.'
)
assert.equal(auftragBestaetigung('loeschen', A({ status: 'entwurf' })), 'Den Entwurf „Website Praxis Müller“ wirklich löschen? Das lässt sich nicht rückgängig machen.')
assert.equal(
auftragBestaetigung('stornieren', A()),
'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ wirklich stornieren? Der Kunde bekommt keine Mail, der Bestätigungslink wird ungültig. Das lässt sich nicht rückgängig machen.'
)
assert.equal(
auftragBestaetigung('erinnern', A()),
'Die Auftragsbestätigung AB-2026-0002 jetzt noch einmal an den Kunden senden? Der bisherige Bestätigungslink gilt danach nicht mehr.'
)
assert.equal(
auftragBestaetigung('projekt', A({ projektId: '' }), { projektName: 'Physio-Website' }),
'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ dem Projekt „Physio-Website“ zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.'
)
assert.match(auftragBestaetigung('projekt', A({ projektId: '' })), /„Website Praxis Müller“ einem Projekt zuordnen\? /)
// ein Entwurf bleibt über „Bearbeiten“ änderbar: keine Rückfrage
assert.equal(auftragBestaetigung('projekt', A({ status: 'entwurf', nummer: '', projektId: '' }), { projektName: 'Physio-Website' }), '')
for (const key of ['bearbeiten', 'pdf', 'neueFassung', 'gibtsnicht']) assert.equal(auftragBestaetigung(key, A()), '', key)
assert.match(auftragBestaetigung('senden', A({ titel: '' })), /„ohne Titel“/)
})
test('Erfolgsmeldungen (Wortlaut des Containers von W0 für „senden“)', () => {
const gesendet = A({ nummer: 'AB-2026-0003' }) // gesendetAn: info@praxis.test
assert.deepEqual(auftragErfolg('senden', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde an info@praxis.test gesendet.' })
assert.deepEqual(auftragErfolg('senden', A({ nummer: 'AB-2026-0003', gesendetAn: '' })), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde gesendet.' })
assert.deepEqual(auftragErfolg('senden', gesendet, { mailFehler: true }), {
tone: 'warn',
text: 'Auftragsbestätigung AB-2026-0003 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.',
})
assert.deepEqual(auftragErfolg('erinnern', gesendet), { tone: 'ok', text: 'Die Erinnerung zu AB-2026-0003 wurde an info@praxis.test gesendet.' })
assert.deepEqual(auftragErfolg('erinnern', gesendet, { mailFehler: true }), {
tone: 'warn', text: 'Die Erinnerung zu AB-2026-0003 konnte nicht zugestellt werden. „Erneut senden“ versucht es noch einmal.',
})
assert.deepEqual(auftragErfolg('stornieren', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde storniert.' })
assert.deepEqual(auftragErfolg('loeschen', A({ nummer: '' })), { tone: 'ok', text: 'Der Entwurf wurde gelöscht.' })
assert.deepEqual(auftragErfolg('neueFassung', A({ nummer: '' })), { tone: 'ok', text: 'Neue Fassung als Entwurf angelegt.' })
assert.deepEqual(auftragErfolg('projekt', gesendet, { projektName: 'Physio-Website' }), {
tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde dem Projekt „Physio-Website“ zugeordnet.',
})
assert.deepEqual(auftragErfolg('projekt', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde einem Projekt zugeordnet.' })
assert.deepEqual(auftragErfolg('projekt', A({ nummer: '' }), { projektName: 'Physio-Website' }), {
tone: 'ok', text: 'Die Auftragsbestätigung wurde dem Projekt „Physio-Website“ zugeordnet.',
})
assert.deepEqual(auftragErfolg('senden', null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gesendet.' })
assert.deepEqual(auftragErfolg('senden', null, { mailFehler: true }), {
tone: 'warn', text: 'Die Auftragsbestätigung ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.',
})
assert.deepEqual(auftragErfolg('gibtsnicht', gesendet), { tone: 'ok', text: '' })
})
test('auftragFehler: Kundendaten-Fehler führen zur Übersicht, andere zeigen die Meldung des Servers (Texte aus Plan L3)', () => {
const fehler = (status, message) => auftragFehler(Object.assign(new Error(message), { status }))
assert.deepEqual(fehler(409, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.'), {
art: 'adresse', text: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
})
assert.deepEqual(fehler(400, 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).'), {
art: 'kundenart',
text: 'Die Kundenart des Kunden fehlt. Bitte in der Übersicht des Kunden festlegen (Feld „Kundenart“) oder über „Bearbeiten“ und dann erneut senden.',
zurUebersicht: true,
})
assert.deepEqual(fehler(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'), {
art: 'email', text: 'Dem Kunden fehlt die E-Mail-Adresse. Bitte in der Übersicht eintragen und dann erneut senden.', zurUebersicht: true,
})
assert.deepEqual(fehler(400, 'Auftragsbestätigungen nur für feste Kunden.'), {
art: 'kunde', text: 'Das ist noch kein fester Kunde. Bitte in der Übersicht zum festen Kunden machen und dann erneut senden.', zurUebersicht: true,
})
// Entwurf unvollständig (L4, wird von L3 beim Senden und bei der Vorschau durchgereicht): Meldung unverändert, kein „aktueller Stand“
assert.deepEqual(fehler(409, 'Entwurf unvollständig: Titel fehlt.'), {
art: 'entwurf', text: 'Entwurf unvollständig: Titel fehlt.', zurUebersicht: false,
})
assert.equal(fehler(409, 'Entwurf unvollständig: Kundendaten fehlen.').art, 'entwurf')
assert.deepEqual(fehler(409, 'Nur ein Entwurf lässt sich senden.'), {
art: 'stand', text: 'Nur ein Entwurf lässt sich senden. Die Liste zeigt den aktuellen Stand.', zurUebersicht: false,
})
assert.deepEqual(fehler(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.'), {
art: 'eingabe', text: 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.', zurUebersicht: false,
})
assert.deepEqual(fehler(500, 'Das PDF konnte nicht erzeugt werden.'), {
art: 'pdf', text: 'Das PDF konnte nicht erzeugt werden. Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.', zurUebersicht: false,
})
assert.deepEqual(fehler(500, 'Interner Fehler.'), {
art: 'server',
text: 'Interner Fehler. Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.',
zurUebersicht: false,
})
assert.deepEqual(fehler(0, 'Das Kundenportal ist gerade nicht erreichbar.'), { art: 'netz', text: 'Das Kundenportal ist gerade nicht erreichbar.', zurUebersicht: false })
assert.deepEqual(fehler(404, 'Auftragsbestätigung nicht gefunden.'), { art: 'sonst', text: 'Auftragsbestätigung nicht gefunden.', zurUebersicht: false })
assert.deepEqual(auftragFehler(new Error('')), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false })
assert.deepEqual(auftragFehler(null), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false })
})
test('neueSperre: ein zweiter Aufruf während des ersten wird ignoriert (Doppelklick)', async () => {
const sperre = neueSperre()
let aufrufe = 0
let fertig
const warten = new Promise((r) => { fertig = r })
const erster = sperre.run(async () => { aufrufe += 1; await warten; return 'ok' })
const zweiter = await sperre.run(async () => { aufrufe += 1; return 'doppelt' })
assert.deepEqual(zweiter, { uebersprungen: true })
assert.equal(sperre.belegt, true)
fertig()
assert.deepEqual(await erster, { uebersprungen: false, wert: 'ok' })
assert.equal(aufrufe, 1)
assert.equal(sperre.belegt, false)
assert.deepEqual(await sperre.run(async () => 'wieder'), { uebersprungen: false, wert: 'wieder' })
})
test('neueSperre: nach einem Fehler wieder frei, der Fehler wird weitergereicht', async () => {
const sperre = neueSperre()
await assert.rejects(() => sperre.run(async () => { throw new Error('kaputt') }), /kaputt/)
assert.equal(sperre.belegt, false)
})
Dann tests/unit/auftragPdf.test.mjs:
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { PDF_FREIGABE_NACH_MS, alsPdfBlob, oeffnePdfImTab } = await loadModule('src/lib/auftragPdf.js')
// Fester Ablauf: Tab zuerst (der Browser blockiert sonst das Pop-up), dann Blob holen, Objekt-URL erzeugen, später freigeben
function umgebung() {
const ablauf = []
const tab = { location: {}, opener: 'woms', close: () => ablauf.push('schliessen') }
const win = { open: (...args) => { ablauf.push('oeffnen'); win.args = args; return tab } }
const urlApi = {
createObjectURL: () => { ablauf.push('url'); return 'blob:https://woms.test/abc' },
revokeObjectURL: (url) => ablauf.push(`frei:${url}`),
}
const timer = (fn, ms) => { ablauf.push(`timer:${ms}`); timer.fn = fn }
return { ablauf, tab, win, urlApi, timer }
}
test('alsPdfBlob: setzt den Typ application/pdf, ein passender Blob bleibt unverändert', async () => {
const roh = new Blob(['%PDF-1.4'], { type: 'application/octet-stream' })
const pdf = alsPdfBlob(roh)
assert.equal(pdf.type, 'application/pdf')
assert.equal(await pdf.text(), '%PDF-1.4')
const schon = new Blob(['x'], { type: 'application/pdf' })
assert.equal(alsPdfBlob(schon), schon)
})
test('oeffnePdfImTab: Tab zuerst, dann Blob, Objekt-URL im Tab, später freigeben', async () => {
const u = umgebung()
await oeffnePdfImTab(async () => { u.ablauf.push('holen'); return new Blob(['%PDF'], { type: 'application/pdf' }) }, u)
assert.equal(PDF_FREIGABE_NACH_MS, 300000)
assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'url', 'timer:300000'])
assert.equal(u.tab.location.href, 'blob:https://woms.test/abc')
assert.equal(u.tab.opener, null)
u.timer.fn()
assert.equal(u.ablauf.at(-1), 'frei:blob:https://woms.test/abc')
})
test('oeffnePdfImTab: Fehler beim Holen schließt den Tab, es entsteht keine URL', async () => {
const u = umgebung()
await assert.rejects(() => oeffnePdfImTab(async () => { u.ablauf.push('holen'); throw new Error('PDF nicht gefunden.') }, u), /PDF nicht gefunden/)
assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'schliessen'])
})
test('oeffnePdfImTab: blockiertes Pop-up, der Blob wird gar nicht geholt', async () => {
let geholt = false
const win = { open: () => null }
await assert.rejects(() => oeffnePdfImTab(async () => { geholt = true; return new Blob() }, { win }), { message: 'Neuer Tab wurde vom Browser blockiert.' })
assert.equal(geholt, false)
})
- Step 2: Tests laufen lassen
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs tests/unit/auftragPdf.test.mjs
Expected: FAIL: die neuen Namen sind in auftragView.js nicht definiert, src/lib/auftragPdf.js fehlt.
- Step 3: Teil 2 in
src/lib/auftragView.jsimplementieren
Regeln (Texte wörtlich wie in den Tests):
-
AUFTRAG_STATUS: Objekt in der Reihenfolge der sieben Status, je{ label, tone }; Tönemuted,info,ok,violet,plain,plain,danger(Badge-Töne des Redesigns;widerrufenist rot, weil die Erstattung von Hand folgt). -
istEntwurf(auftrag):auftrag?.status === 'entwurf'. -
PROJEKT_ZUORDNEN_STATUS:['entwurf', 'versendet', 'bestaetigt', 'abgerechnet']. -
zuordnenOptionen(konten): kein Array:[]. Sonst je Eintrag, der ein Objekt mit nichtleerer Text-projektIdist,{ value: projektId, label }in der Reihenfolge der Eingabe;labelist der getrimmtename, ohne Namen dieprojektId. -
projektZuordnenMoeglich(auftrag, konten):truenur, wennauftragein Objekt ist,statusinPROJEKT_ZUORDNEN_STATUSsteht,projektIdleer ist ('',null, fehlend; jede nichtleere Kennung zählt als „hat ein Projekt“, auch eine, die inkontenfehlt, weil der Server sie mit 409 ablehnt) undzuordnenOptionen(konten)mindestens einen Eintrag hat. -
auftragAktionen(auftrag, { konten } = {}): Liste von{ key, label }(danger: truenur beiloeschenundstornieren, sonst ohne dieses Feld), je Status:entwurf:bearbeiten,senden,pdf(Beschriftung „Vorschau“, immer),loeschen.versendet:pdf,erinnern(„Erinnerung senden“, beimailStatus === 'fehler'„Erneut senden“),neueFassung,stornieren.bestaetigt:pdf,neueFassung,stornieren.abgerechnet:pdf,neueFassung.storniert,ersetzt,widerrufenund alles Unbekannte: nurpdf. Außer beim Entwurf erscheintpdf(Beschriftung „PDF“) nur beipdfVorhanden === true. GiltprojektZuordnenMoeglich(auftrag, konten), kommt{ key: 'projekt', label: 'Projekt zuordnen' }hinzu, und zwar unmittelbar vor der ersten gefährlichen Aktion (loeschenbzw.stornieren), gibt es keine, ans Ende. Kein Objekt:[]. -
auftragNotizen(auftrag, alle = []): Liste{ key, tone, text }in genau dieser Reihenfolge: 1. „Mail nicht zugestellt“ (Tonwarn; nur StatusversendetundmailStatus === 'fehler'); 2. „Bestätigt am TT.MM.JJJJ“ (bestaetigtAmgesetzt, Statusbestaetigtoderabgerechnet); 3. „Frühbeginn verlangt“ (fruehbeginn === true, Status nichtentwurf); 4. „Arbeitsbeginn frühestens am TT.MM.JJJJ“ (fruehesterBeginngesetzt, Statusversendet,bestaetigtoderabgerechnet); 5. beiersetztId„Neue Fassung zu “ (Nummer der AB mit dieseridinalle, ohne Treffer oder Nummer nur „Neue Fassung“); 6. beiersetztDurchId„Ersetzt durch “ (ohne Treffer „Ersetzt durch eine neue Fassung“); 7. „Widerrufen am TT.MM.JJJJ“ (Statuswiderrufen,widerrufenAmgesetzt). Andere Tönemuted. Datumsangaben mitdateDe. -
auftragZeilen(auftraege, { konten = [] } = {}): nur Objekte, Reihenfolge der Eingabe,allefür die Notizen ist die Eingabeliste.nummerText=nummer || 'Entwurf';datumText=dateDe(gesendetAm || erstelltAm);projektText= Name des Kontos mitprojektIdder AB, aber nur wenn es existiert und (getrimmt) nicht demtitelgleicht, sonst''; Summe: Festpreiseuro(summeCents)(ohne ganze Zahl „–“), AufwandsummeText„nach Aufwand“ undsummeSub„<euro(stundensatzCents)> pro Std.“, mit Schätzung zusätzlich „, Schätzung etwa Std.“; Status ausAUFTRAG_STATUS(unbekannt: Rohtext, Tonmuted);notizen;aktionen=auftragAktionen(auftrag, { konten });istEntwurf;auftrag(Original);key=id. Es gibt kein FeldkundenId(die Liste braucht es nicht mehr, „Zur Übersicht“ läuft überonShowOverview). -
auftragBestaetigung(key, auftrag, { email, projektName } = {}): nur fürsenden,loeschen,stornieren,erinnernundprojektein Text, sonst''; Titel„<titel>“, ohne Titel„ohne Titel“; Nummer ausauftrag.nummer.projekt: bei einem Entwurf''(er bleibt über „Bearbeiten“ änderbar), sonstDie Auftragsbestätigung <nummer> „<titel>“ <ziel> zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.mit<ziel>=dem Projekt „<projektName>“bzw. ohneprojektNameeinem Projekt. -
auftragErfolg(key, auftrag, { mailFehler = false, projektName = '' } = {}):{ tone, text }.nameist „Auftragsbestätigung “, ohne Nummer oder ohne Objekt „Die Auftragsbestätigung“.projekt:<name> wurde dem Projekt „<projektName>“ zugeordnet., ohneprojektName<name> wurde einem Projekt zugeordnet..senden: derselbe Wortlaut wieauftragMeldungim Container von W0, also<name> wurde an <gesendetAn> gesendet.(ohnegesendetAn<name> wurde gesendet.); beimailFehlerTonwarnund<name> ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden..erinnern:Die Erinnerung zu <nummer> wurde an <gesendetAn oder „den Kunden“> gesendet.(ohne Nummer „“ = „der Auftragsbestätigung“), beimailFehlerTonwarnund der Text des Tests.stornieren:<name> wurde storniert..loeschen,neueFassungund der unbekannte Schlüssel (Text'') wie im Test. -
auftragFehler(err):status = err?.status,meldung = err?.message || ''. Der erste passende Fall gilt:409und /^Entwurf unvollständig/i →entwurf(Meldung unverändert,zurUebersicht: false; L4 meldet so einen unvollständigen Entwurf bei Vorschau und Senden, L3 reicht es durch; die Angabe „Die Liste zeigt den aktuellen Stand.“ wäre hier falsch);409und /adresse/i →adresse(TextADDRESS_INCOMPLETE_TEXT,zurUebersicht: true);400und /kundenart/i →kundenart;400und /e-?mail/i →email;400und /feste[rn]? kunde|interessent|\blead\b/i →kunde(diese vier mit den festen Texten des Tests,zurUebersicht: true);409→stand(Meldung + „ Die Liste zeigt den aktuellen Stand.“);400→eingabe(Meldung);500und /pdf/i →pdf(Meldung + „ Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.“);status >= 500→server(Meldung, ohne Meldung „Interner Fehler.“, + „ Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.“);status === 0→netz(Meldung); sonstsonst(Meldung oder „Die Aktion ist fehlgeschlagen.“).zurUebersichtist nur beiadresse,kundenart,emailundkundetrue, sonstfalse. Die Texte der Server stehen im Plan L3 (Abschnitt „Meldungstexte“) und im Plan L4 (Tabelle „Entwurf unvollständig“); die Erkennung hängt an den Wörtern „Entwurf unvollständig“ (Satzanfang), „Adresse“, „Kundenart“, „E-Mail“, „feste Kunden“. Die Fehler vonPOST …/projekt(V5: 400, 404, 409 „hat schon ein Projekt“ oder Status storniert, ersetzt, widerrufen) laufen über dieselben Fälle: 409 zeigt die Meldung des Servers mit „Die Liste zeigt den aktuellen Stand.“. -
neueSperre():{ run(fn), get belegt() }.runsetztbelegtsofort beim Aufruf (vor jedemawait) und liefert{ uebersprungen: true }(genau dieses Objekt), wenn schon eine Ausführung läuft; sonst führt esfn()aus und liefert{ uebersprungen: false, wert }. Nach Ende oder Fehler ist die Sperre wieder frei, ein Fehler wird weitergereicht. -
Step 4:
src/lib/auftragPdf.jsimplementieren
Regeln: PDF_FREIGABE_NACH_MS = 300000. alsPdfBlob(blob): hat der Blob schon den Typ application/pdf, derselbe Blob, sonst new Blob([blob], { type: 'application/pdf' }). oeffnePdfImTab(holeBlob, { win = globalThis.window, urlApi = globalThis.URL, timer = globalThis.setTimeout } = {}) ruft openInNewTab(async () => { … }, { win }) auf; darin: Blob holen, alsPdfBlob, urlApi.createObjectURL, timer(() => urlApi.revokeObjectURL(url), PDF_FREIGABE_NACH_MS), die URL zurückgeben. Fehler und blockiertes Pop-up behandelt openInNewTab.
- Step 5: Tests laufen lassen
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs tests/unit/auftragPdf.test.mjs tests/unit/kundenFelder.test.mjs
Expected: PASS, auftragView 37 Tests (24 aus Task 3, 13 neue), auftragPdf 4, kundenFelder 7, fail 0.
- Step 6: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/lib/auftragView.js tests/unit/auftragView.test.mjs src/lib/auftragPdf.js tests/unit/auftragPdf.test.mjs && git commit -m "Auftragsbestaetigung: Listenlogik (Status, Aktionen, Meldungen, Fehlerklassen, Sperre) und PDF-Hilfe mit Tests" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 5: AuftragListe mit Stil
Files:
- Modify:
src/components/auftraege/AuftragListe.jsx(Platzhalter von W0 ersetzen) - Create:
src/components/auftraege/auftrag.css(hier die Listenregeln, Task 6 und 7 hängen an)
Interfaces:
-
Consumes:
Badge,Button,Card,EmptyStateaus../ui;noticePropsaus../../lib/financeUi;auftragZeilen,auftragBestaetigung,auftragErfolg,auftragFehler,zuordnenOptionen,neueSperre(Task 3 und 4);oeffnePdfImTab(Task 4);sendAuftrag,erinnereAuftrag,neueFassungAuftrag,storniereAuftrag,deleteAuftrag,ordneAuftragProjektZu,getAuftragPdfaus../../lib/financeApi(W0, Rückgaben laut V5:{ auftrag },{ auftrag, mailFehler },{ ok: true }, Fehler mitstatusundmessage). -
Produces:
export default function AuftragListe({ auftraege, konten, onChanged, onBearbeiten, onShowOverview })mit genau den Props aus V8; der Container von W0 reichtkonten(ansicht.konten) undonShowOverview(Wechsel auf den Reiter Übersicht) durch, es gibt keinen Rückfall und keine Vorgabewerte. -
Step 1:
AuftragListe.jsxbauen
Die Logik steckt in den Libs, die Komponente verdrahtet sie. Muster: src/components/finance/InvoiceTable.jsx (Sperre, Rückfrage, Meldung, Tabelle mit Rollen).
| Element | Umsetzung |
|---|---|
| Rahmen | <Card title="Aufträge" className="auftrag-liste"> mit <div className="auftrag-body">; darin Meldung, dann Tabelle bzw. Leerzustand. Kein Dialog in dieser Datei |
| Daten | const zeilen = useMemo(() => auftragZeilen(auftraege, { konten }), [auftraege, konten]) |
| Leer | EmptyState title="Noch keine Aufträge." hint="Eine Auftragsbestätigung entsteht über „Auftragsbestätigung“ in der Projektliste." |
| Meldung | message ({ tone, text, zurUebersicht? }) als <p {...noticeProps(message.tone, 'auftrag-notice')}>; bei zurUebersicht im selben Absatz hinter dem Text (durch ein Leerzeichen getrennt) <button type="button" className="link" onClick={onShowOverview}>Zur Übersicht</button> |
| Tabelle | <div className="auftrag-table-wrap"><table className="table auftrag-table" role="table"> mit <caption className="auftrag-sr">Auftragsbestätigungen, neueste zuerst</caption>; Spalten (th scope="col"): „Nummer“, „Projekt / Titel“, „Datum“, „Summe“ (auftrag-amount), „Status“, „Aktionen“; Zellen tragen data-label („Datum“, „Summe“) für die schmale Darstellung |
| Nummer | <td className="auftrag-nummer">: nummerText; Entwürfe in <span className="muted"> |
| Titel | <td className="auftrag-titel">: <strong>{titel}</strong>, darunter <div className="cell-sub">{projektText}</div> wenn nicht leer |
| Datum | <td className="auftrag-datum" data-label="Datum">: datumText |
| Summe | <td className="auftrag-amount" data-label="Summe">: summeText, darunter <div className="cell-sub">{summeSub}</div> wenn nicht leer |
| Status | <td className="auftrag-status"> mit <Badge tone={statusTone}>{statusLabel}</Badge>, darunter je Notiz <div className={note.tone === 'warn' ? 'msg warn auftrag-note' : 'muted auftrag-note'}>{text}</div> |
| Aktionen | <td className="auftrag-aktionen"> mit <div className="auftrag-actions">; je Aktion: danger als <Button type="button" variant="danger">, sonst <button type="button" className="link">; aria-label = `${label}: ${nummerText}, ${titel}`; disabled={busy !== null}; bei der Aktion projekt zusätzlich aria-expanded (offen, wenn zuordnen?.id === zeile.id); während die eigene Aktion läuft die Laufbeschriftung: senden und erinnern „Sende …“, stornieren „Storniere …“, loeschen „Lösche …“, neueFassung „Lege an …“, pdf „Öffne …“ |
| Projekt zuordnen | Zustand const [zuordnen, setZuordnen] = useState(null) ({ id, projektId } der Zeile mit offener Auswahl oder null; es ist höchstens eine Auswahl offen). Ist zuordnen?.id === zeile.id, steht unter .auftrag-actions in derselben Zelle <div className="auftrag-zuordnen" role="group"> (mit aria-label „Projekt zuordnen: , “ wie bei den Aktionen) mit <select className="select" aria-label="Projekt wählen" value={wahl}> (erste Option value="" „Projekt wählen …“, dann je Eintrag von zuordnenOptionen(konten) <option value={value}>{label}</option>; onChange setzt projektId in zuordnen), <Button type="button" onClick={() => ordneZu(zeile)}> („Zuordnen“, während des Aufrufs „Ordne zu …“; gesperrt, solange wahl leer ist oder busy !== null) und <button type="button" className="link" disabled={busy !== null} onClick={() => setZuordnen(null)}>Abbrechen</button>. wahl ist zuordnen.projektId, aber nur, wenn der Wert in zuordnenOptionen(konten) vorkommt, sonst '' (so bleibt der Knopf gesperrt, wenn das Projekt nach einem Neuladen fehlt) |
Ablauf run(zeile, aktion):
bearbeiten:onBearbeiten(zeile.auftrag), fertig (kein Server, keine Sperre).projekt:setZuordnen({ id: zeile.id, projektId: '' })(öffnet die Auswahl dieser Zeile und schließt eine andere),messagezurücksetzen, fertig (kein Server, keine Sperre).- Ist
sperre.belegt(const sperre = useRef(neueSperre()).current), sofort zurück (so erscheint beim Doppelklick keine zweite Rückfrage). Dann die Rückfrage:const frage = auftragBestaetigung(aktion.key, zeile.auftrag);if (frage && !window.confirm(frage)) return. - Alles Weitere läuft in
sperre.run(…),busy=`${zeile.id}:${aktion.key}`,messagezurücksetzen. pdf:oeffnePdfImTab(() => getAuftragPdf(zeile.id)), ohne einawaitdavor (der neue Tab muss im Klick aufgehen); ein Fehler wird{ tone: 'warn', text: auftragFehler(err).text }.- Die Server-Aktionen (
senden→sendAuftrag,erinnern→erinnereAuftrag,stornieren→storniereAuftrag,loeschen→deleteAuftrag,neueFassung→neueFassungAuftrag) rufen mitzeile.idauf. Erfolg:message = auftragErfolg(aktion.key, data?.auftrag || zeile.auftrag, { mailFehler: data?.mailFehler }), dannonChanged(); beineueFassungdanachonBearbeiten(data.auftrag). - Fehler:
const f = auftragFehler(err);message = { tone: 'warn', text: f.text, zurUebersicht: f.zurUebersicht }; danachonChanged()(der Stand kann ein anderer sein). Nach Endebusyzurück aufnull(nur wenn die Komponente noch gemountet ist:aliveRefwird im Effekt beim Einhängentrueund beim Aushängenfalse, so übersteht es auch das doppelte Einhängen von React im Entwicklungsmodus).
Ablauf ordneZu(zeile) (Knopf „Zuordnen“; dieselbe sperre, dieselbe Fehlerbehandlung wie in run):
const gewaehlt = zuordnenOptionen(konten).find((o) => o.value === zuordnen?.projektId); ohnegewaehltoder beisperre.belegtsofort zurück.- Rückfrage vor dem Aufruf:
const frage = auftragBestaetigung('projekt', zeile.auftrag, { projektName: gewaehlt.label });if (frage && !window.confirm(frage)) return(bei einem Entwurf istfrageleer). - In
sperre.run(…):busy=`${zeile.id}:projekt`,messagezurücksetzen,data = await ordneAuftragProjektZu(zeile.id, gewaehlt.value)(nurPOST …/projektmit{ projektId }, sonst nichts: der Server setzt die Zeitquelle, das PDF bleibt unverändert). Erfolg:message = auftragErfolg('projekt', data?.auftrag || zeile.auftrag, { projektName: gewaehlt.label }),setZuordnen(null),onChanged(). Fehler wie inrunPunkt 7 (Meldung,onChanged()), dazusetZuordnen(null).
- Step 2: Listenregeln in
auftrag.css
Datei neu anlegen, nur Tokens, kein !important, Muster: Regeln zu .fin-invoices und @container im Block FINANZEN von src/styles/global.css (rg -n "fin-invoices|@container" src/styles/global.css).
| Klasse | Regel |
|---|---|
.auftrag-body |
container-type: inline-size (einzige Stelle) |
.auftrag-notice |
margin: 0 0 8px |
.auftrag-table-wrap |
overflow-x: auto |
.auftrag-sr |
nur für Screenreader sichtbar (Standardmuster: 1 px, overflow: hidden, clip, absolut) |
.auftrag-nummer |
white-space: nowrap |
.auftrag-titel |
overflow-wrap: anywhere |
.auftrag-amount |
rechtsbündig, font-variant-numeric: tabular-nums, white-space: nowrap |
.auftrag-note |
margin-top: 2px; font-size: 13px; overflow-wrap: anywhere |
.auftrag-actions |
display: flex; flex-wrap: wrap; gap: 4px 14px |
.auftrag-zuordnen |
display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 8px; .select darin flex: 1 1 180px; min-width: 0; max-width: 100% |
@container (max-width: 860px) |
thead nur für Screenreader sichtbar; jede Zeile ein Raster mit den Flächen nummer status / titel titel / datum summe / aktionen aktionen (Spalten minmax(0, 1fr) auto); die Zelle bekommt ihre Fläche über die Klasse (.auftrag-nummer, .auftrag-status, .auftrag-titel, .auftrag-datum, .auftrag-amount, .auftrag-aktionen); Zellen display: block; padding: 0; border: 0, Zeilentrenner 1px solid var(--line) mit padding: 10px 0; td[data-label]::before zeigt attr(data-label) in var(--text-2) |
- Step 3: Syntax und Hygiene
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && node_modules/.bin/esbuild src/components/auftraege/AuftragListe.jsx --loader:.jsx=jsx --jsx=automatic --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/syntax --log-level=warning; rg -n "react-icons|@tabler|style=\{\{|size=\"sm\"|form-control|form-group|ui-btn|window\.alert|#[0-9a-fA-F]{3,8}\b|rgba?\(|[!]important|console\.log|react-router" src/components/auftraege/AuftragListe.jsx src/components/auftraege/auftrag.css
Expected: esbuild ohne Ausgabe, rg ohne Treffer (Exit 1; react-router steht im Muster, weil die Liste keinen Link mehr braucht: „Zur Übersicht“ läuft über onShowOverview).
- Step 4: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/components/auftraege/AuftragListe.jsx src/components/auftraege/auftrag.css && git commit -m "Auftragsbestaetigung: Liste mit Status, Zusatzzeilen, Aktionen und Projekt zuordnen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 6: Dialog, Schritte 1 bis 3
Files:
- Create:
src/components/auftraege/AuftragSchritte.jsx - Modify:
src/components/auftraege/auftrag.css(Regeln der Felder anhängen)
Interfaces:
-
Consumes:
Badgeaus../ui;FormFieldaus../finance/FormField(FormField({ id, label, hint, error, children }), das Kind bekommtid,aria-invalid,aria-describedby);useWorkorders({ customerId, limit })aus../../hooks/useWorkorders(liefert{ workorders, loading, error });customerNameaus../../lib/financeView;lineTotalCentsaus../../lib/financeView;euroaus../../lib/financeFormat; aus../../lib/auftragViewAUFTRAG_LIMITS,AUFTRAG_WEGE,zahlungsplanOptionen,waehleProjekt,wechselArt,neuePosition,formularSummeCents,zahlungsplanVorschau,parseProzent,parseTage,zeitquelleOptionen,schalteZeitquelle,kundenartWirksam,vorOrtHinweis;KUNDENART_OPTIONEN,kundenartLabelaus../../lib/kundenFelder. -
Produces: benannte Exporte
SchrittProjekt,SchrittAuftrag,SchrittZahlung, alle mit den Props{ form, onChange, fehler, kunde, konten };onChange(neuesFormular)bekommt immer das ganze neue Formular (aus den Funktionen der Lib oder{ ...form, feld }).SchrittProjektnimmt zusätzlichneueFassung(boolean). Jedes Feld hat die idab-<Schlüssel>(Schlüssel wie inpruefeSchritt); Ausnahmen:positionenundzeitquellesind<fieldset id="ab-positionen" tabIndex={-1}>bzw.id="ab-zeitquelle",arthatab-art-festpreisundab-art-aufwand,zahlungsarthatab-zahlungsart-<value>. -
Step 1:
SchrittProjekt
| Element | Umsetzung |
|---|---|
| Kunde | <p className="muted">Kunde: <strong> mit customerName(kunde) (ohne Namen das Wort „Kunde“) </strong> und, wenn vorhanden, · {kunde.email}</p> |
| Neue Fassung | bei neueFassung: <p className="msg muted">Neue Fassung: Beim Senden ersetzt sie die bisherige Auftragsbestätigung.</p> |
| Projekt | FormField id="ab-projekt" label="Projekt" mit <select className="select" value={form.projektId}>: erste Option value="" „Kein Projekt (freier Titel)“, dann je Konto value={projektId} mit name; onChange → onChange(waehleProjekt(form, e.target.value, konten)) |
| Titel | FormField id="ab-titel" label="Titel" hint="Steht als Titel auf der Auftragsbestätigung, z. B. „Website für Praxis Müller“." error={fehler.titel} mit <input className="input" maxLength={AUFTRAG_LIMITS.maxTitel}>; onChange → { ...form, titel, titelGeaendert: true } |
| Kundenart | nur wenn !kunde.kundenart: FormField id="ab-kundenart" label="Kundenart" hint="Wird am Kunden gespeichert. Privatpersonen bekommen die Widerrufsbelehrung in der Auftragsbestätigung." error={fehler.kundenart} mit <select className="select">, Optionen „Bitte wählen“ (value=""), „Unternehmen“, „Privatperson“ (aus KUNDENART_OPTIONEN ohne den ersten Eintrag). Sonst <p className="muted">Kundenart: {kundenartLabel(kunde.kundenart)}. Änderbar in der Übersicht des Kunden.</p> |
- Step 2:
SchrittAuftrag
| Element | Umsetzung |
|---|---|
| Datum und Weg | <div className="field-row"> mit FormField id="ab-auftragVom" label="Datum der Absprache" error={fehler.auftragVom} (<input className="input" type="date">) und FormField id="ab-auftragWeg" label="Weg der Absprache" error={fehler.auftragWeg} (<select className="select">, erste Option value="" „Bitte wählen“, dann AUFTRAG_WEGE) |
| Hinweis „Vor Ort“ | const vorOrt = vorOrtHinweis(form.auftragWeg, kundenartWirksam(kunde, form.kundenart)); ist er nicht leer, steht direkt unter der Zeile mit Datum und Weg <p className="msg warn auftrag-hinweis">{vorOrt}</p> (nicht blockierend: kein role, kein Fehler am Feld, „Weiter“ bleibt möglich; derselbe Text steht in Schritt 4, Task 7) |
| Art | <fieldset className="auftrag-art"> mit <legend>Art des Auftrags</legend> und zwei <label className="check"> mit Radio (name="ab-art", ids ab-art-festpreis, ab-art-aufwand): „Festpreis“ mit <span className="hint">Feste Positionen mit Preisen.</span> und „Nach Aufwand“ mit <span className="hint">Stundensatz, abgerechnet nach erfasster Arbeitszeit.</span>; onChange → onChange(wechselArt(form, art)); ein Fehler fehler.art als <div className="msg err" role="alert"> |
| Festpreis | <fieldset id="ab-positionen" tabIndex={-1} className="auftrag-items"> mit <legend>Positionen</legend>; je Position eine .auftrag-item: Beschreibung (<input className="input" maxLength={200} aria-label="Position n: Beschreibung" placeholder="z. B. Startseite">), Menge (inputMode="numeric", aria-label="Position n: Menge"), Einzelpreis (inputMode="decimal", aria-label="Position n: Einzelpreis in Euro", placeholder="0,00"), Summe (lineTotalCents als euro, ungültig „–“), „Entfernen“ (<button type="button" className="link" aria-label="Position n entfernen">, gesperrt bei nur einer Position). Sichtbare Mini-Beschriftungen „Beschreibung“, „Menge“, „Einzelpreis in €“, „Summe“ (.auftrag-item-label). Darunter <button type="button" className="btn">+ Position</button> (gesperrt ab 20; hängt neuePosition() an) und <div className="auftrag-total" aria-live="polite">Summe und euro(formularSummeCents(form)), danach <p className="hint">Gemäß § 19 UStG ohne Umsatzsteuer.</p>; fehler.positionen als <div className="msg err" role="alert"> im Fieldset |
| Aufwand | <div className="field-row"> mit FormField id="ab-stundensatz" label="Stundensatz in €" hint="Steht mit der Aufwandsregel in der Auftragsbestätigung." error={fehler.stundensatz} (<input className="input" inputMode="decimal">) und FormField id="ab-schaetzung" label="Geschätzter Umfang in Stunden (optional)" hint="In Schritten von 15 Minuten, z. B. 10 oder 12,5. Unverbindlich." error={fehler.schaetzung} (<input className="input" inputMode="decimal" placeholder="z. B. 10">) |
Positionen ändern ersetzt nur die Zeile mit gleicher uid im Array. Die Felder beider Arten bleiben im Formular erhalten, wenn die Art wechselt.
- Step 3:
SchrittZahlung
| Element | Umsetzung |
|---|---|
| Zahlungsplan | <fieldset className="auftrag-art"> mit <legend>Zahlungsplan</legend> und je Eintrag von zahlungsplanOptionen(form.art) ein <label className="check"> mit Radio (name="ab-zahlungsart", id ab-zahlungsart-<value>); onChange → { ...form, zahlungsart }; fehler.zahlungsart als msg err |
| Anzahlung | bei zahlungsart === 'anzahlung': FormField id="ab-prozent" label="Anzahlung in %" error={fehler.prozent} (<input className="input auftrag-narrow" inputMode="numeric">) |
| Frei | bei zahlungsart === 'frei': FormField id="ab-zahlungstext" label="Zahlungsplan als Text" error={fehler.zahlungstext} (<textarea className="textarea" rows={3} maxLength={500}>), darunter <div className="hint">{länge} / 500 Zeichen</div> |
| Vorschau | const text = zahlungsplanVorschau({ art: form.zahlungsart, prozent: parseProzent(form.prozent), text: form.zahlungstext, summeCents: formularSummeCents(form), zielTage: parseTage(form.zahlungsziel) }); ist er nicht leer: <p className="msg muted auftrag-plan-vorschau">Sinngemäß: {text}</p> |
| Zahlungsziel | FormField id="ab-zahlungsziel" label="Zahlungsziel in Tagen" hint="0 bis 60 Tage nach Rechnung." error={fehler.zahlungsziel} (<input className="input auftrag-narrow" inputMode="numeric">) |
| Termine | <div className="field-row"> mit FormField id="ab-startAm" label="Start (optional)" error={fehler.startAm} und FormField id="ab-fertigBis" label="Voraussichtliche Fertigstellung (optional)" error={fehler.fertigBis}, beide <input className="input" type="date"> |
| Zeitquelle | Unterkomponente ZeitquelleAuswahl({ kundeId, form, onChange, projektTicketIds, fehler }): <fieldset id="ab-zeitquelle" tabIndex={-1} className="auftrag-zeitquelle">, <legend>Zeiterfassung aus diesen Tickets</legend>, <p className="hint">Arbeitsblätter dieser Tickets zählen zum Auftrag (Projektkonto, Aufwandsrechnung). Höchstens 10.</p>. Daten über useWorkorders({ customerId: kundeId, limit: 100 }), die Liste über zeitquelleOptionen(workorders, form.zeitquelle, projektTicketIds) mit projektTicketIds aus dem Konto des gewählten Projekts. Je Option <label className="check"><input type="checkbox" checked disabled={!gewaehlt && form.zeitquelle.length >= 10}> {label}</label>, bei projekt dahinter <Badge tone="muted">Ticket des Projekts</Badge>; Umschalten → onChange({ ...form, zeitquelle: schalteZeitquelle(form.zeitquelle, id, an), zeitquelleGeaendert: true }). Zustände: laden <div className="loading" role="status">Tickets werden geladen …</div>; Fehler <p className="msg warn" role="alert">Die Tickets konnten nicht geladen werden: {error}. Die bisherige Auswahl bleibt.</p>; ohne Tickets und ohne Auswahl <p className="muted">Dieser Kunde hat noch keine Tickets.</p>; fehler.zeitquelle als msg err. Gewählte Tickets stehen auch bei Ladefehler in der Liste |
- Step 4: Feldregeln in
auftrag.cssanhängen
| Klasse | Regel |
|---|---|
.auftrag-art |
border: 0; display: grid; gap: 8px; legend mit font-weight: 500; .check { flex-wrap: wrap } und .check .hint { flex-basis: 100%; padding-left: 23px } (der Hinweis steht in eigener Zeile unter der Beschriftung) |
.auftrag-items |
border: 0; display: grid; gap: 12px |
.auftrag-item |
Raster minmax(0, 1fr) 80px 120px 110px auto, gap: 8px 12px, align-items: end; .auftrag-item .input width: 100%; Menge, Preis und Summe rechtsbündig mit tabular-nums |
.auftrag-item-label |
font-size: 12px; color: var(--text-2); ab der zweiten Zeile nur für Screenreader sichtbar (Muster .auftrag-sr) |
.auftrag-total |
display: flex; justify-content: space-between; gap: 16px; font-weight: 600 |
.auftrag-narrow |
max-width: 120px |
.auftrag-plan-vorschau |
overflow-wrap: anywhere |
.auftrag-hinweis |
margin: 0; overflow-wrap: anywhere (Hinweise in Schritt 2 und 4) |
.auftrag-zeitquelle |
border: 0; display: grid; gap: 6px; Liste der Optionen in einem Block mit max-height: 220px; overflow-y: auto; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 6px 0 |
@media (max-width: 860px) |
.auftrag-item zweizeilig minmax(0, .7fr) minmax(0, 1fr) minmax(0, 1fr) auto mit den Flächen desc desc desc desc / qty price sum remove, alle .auftrag-item-label sichtbar; .auftrag-narrow max-width: none |
- Step 5: Syntax und Hygiene
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && node_modules/.bin/esbuild src/components/auftraege/AuftragSchritte.jsx --loader:.jsx=jsx --jsx=automatic --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/syntax --log-level=warning; rg -n "react-icons|@tabler|style=\{\{|size=\"sm\"|form-control|form-group|ui-btn|window\.alert|#[0-9a-fA-F]{3,8}\b|rgba?\(|[!]important|console\.log" src/components/auftraege/AuftragSchritte.jsx src/components/auftraege/auftrag.css; rg -n -A3 "<button" src/components/auftraege/AuftragSchritte.jsx
Expected: esbuild ohne Ausgabe, das erste rg ohne Treffer. Beim zweiten rg von Hand prüfen: jedes <button trägt type="button" (in der Zeile oder den drei Folgezeilen).
- Step 6: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/components/auftraege/AuftragSchritte.jsx src/components/auftraege/auftrag.css && git commit -m "Auftragsbestaetigung: Dialog-Schritte 1 bis 3 (Projekt, Auftrag, Zahlung und Termine)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 7: Dialog, Schritt 4 (Vorschau, Entwurf, Senden)
Files:
- Modify:
src/components/auftraege/AuftragDialog.jsx(Platzhalter von W0 ersetzen) - Create:
src/components/auftraege/AuftragVorschau.jsx - Modify:
src/components/auftraege/auftrag.css(Regeln anhängen)
Interfaces:
-
Consumes:
Dialog,Buttonaus../ui;useScrollIntoViewaus../../hooks/useScrollIntoView;createAuftrag,patchAuftrag,sendAuftrag,getAuftragPdfaus../../lib/financeApi(W0) undgetSettings(besteht dort, liefert{ einstellungen });alsPdfBlob(Task 4); aus../../lib/auftragViewSCHRITTE,neuesFormular,auftragZuFormular,heuteBerlin,pruefeSchritt,formularZuNutzlast,zahlungsplanOptionen,sendeGate,auftragHinweise,kundenartWirksam,auftragBestaetigung,auftragFehler,neueSperre;SchrittProjekt,SchrittAuftrag,SchrittZahlung(Task 6). -
Produces:
export default function AuftragDialog({ open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview })mit genau den Props aus V8; der Container von W0 reichtkonten(ansicht.konten) undonShowOverview(Wechsel auf den Reiter Übersicht) durch, es gibt keinen Rückfall und keine Vorgabewerte.AuftragVorschau({ phase, pdfUrl, meldung, gate, privat, hinweise, onRetry, onShowOverview })(Schritt 4, ohne eigenen Zustand). -
Vertrag mit dem Container (Plan W0, Task 7): Nach Speichern oder Senden ruft der Dialog
onSaved(auftrag)und danach immeronClose(). Der Container schließt inonSavedselbst, lädt neu und zeigtauftragMeldung(auftrag)(„Der Entwurf wurde gespeichert.“, „Auftragsbestätigung AB-… wurde an … gesendet.“ oder die Warnung zumailStatus: 'fehler'); beide Aufrufe sind in jeder Reihenfolge und mehrfach unschädlich. Der Dialog hat deshalb keine eigene Ergebnisansicht. -
Step 1:
AuftragVorschau.jsx
Schritt 4 als reine Darstellung:
| Element | Umsetzung |
|---|---|
| Laden | phase === 'speichern': <div className="loading" role="status">Entwurf wird gespeichert …</div>; phase === 'pdf': <div className="loading" role="status">Vorschau wird erstellt …</div> |
| Fehler | meldung als <div className="msg err" role="alert" ref={errorRef}> (useScrollIntoView(errorRef, meldung)) mit meldung.text und bei meldung.zurUebersicht dahinter <button type="button" className="link" onClick={onShowOverview}>Zur Übersicht</button>; ohne pdfUrl und ohne laufende Phase zusätzlich <Button type="button" onClick={onRetry}>Erneut versuchen</Button> |
| Gespeichert | mit pdfUrl: <p className="msg ok">Entwurf gespeichert. Er bleibt in der Liste, bis er gesendet oder gelöscht wird.</p> |
| Sperre | gate && !gate.erlaubt: <div className="msg warn" role="alert"><span>{gate.grund}</span> und bei gate.zurUebersicht dahinter <button type="button" className="link" onClick={onShowOverview}>Zur Übersicht</button></div>; der Entwurf lässt sich trotzdem speichern |
| Hinweise | je Eintrag von hinweise (aus auftragHinweise: fehlende Steuernummer, „Vor Ort“ bei Privatperson) <p className="msg warn auftrag-hinweis" key={key}>{text}</p>; nicht blockierend (kein role="alert", der Knopf „Senden“ hängt nur an gate und phase); ohne Einträge nichts |
| Privatperson | bei privat: <p className="hint">Privatperson: Die Auftragsbestätigung enthält die Widerrufsbelehrung. Mit der Arbeit darf erst nach Ablauf der Widerrufsfrist begonnen werden, es sei denn, der Kunde verlangt es ausdrücklich (Häkchen auf der Bestätigungsseite).</p> |
| Vorschau | <iframe className="auftrag-pdf" title="Vorschau der Auftragsbestätigung" src={pdfUrl} /> und darunter <a className="link" href={pdfUrl} target="_blank" rel="noopener noreferrer">PDF in neuem Tab öffnen</a> |
Die Reihenfolge in der Datei ist: Laden, Fehler, Gespeichert, Sperre, Hinweise, Privatperson, Vorschau. Alles, was vor dem Senden zu lesen ist, steht über dem hohen PDF-Rahmen.
- Step 2:
AuftragDialog.jsxbauen
Aufbau und Regeln:
-
Hülle (
AuftragDialog, ohne Hooks):!opengibtnull. Istauftraggesetzt undauftrag.status !== 'entwurf', zeigt sie<Dialog title="Auftragsbestätigung" onClose={onClose} footer={<Button type="button" onClick={onClose}>Schließen</Button>}>mit<p className="msg warn" role="alert">Nur Entwürfe lassen sich bearbeiten. Eine gesendete Auftragsbestätigung lässt sich nur über „Neue Fassung“ in der Liste ändern.</p>. Sonst die nicht exportierte KomponenteAuftragForm(alle Hooks und der Zustand) mitkey={auftrag?.id || projektId || 'neu'}: frischer Zustand bei jedem Öffnen, MusterInvoiceCreateModal. -
Zustand:
schritt(1 bis 4),form(Start:auftrag ? auftragZuFormular(auftrag) : neuesFormular({ projektId: projektId || '', konten, heute: heuteBerlin() })),fehler({}),meldung(nulloder{ text, zurUebersicht }),entwurf(Startauftrag || null),signatur(''),pdfUrl(''),phase('','speichern','pdf','senden'),einstellungen(Startundefined= noch nicht geladen,null= nicht ladbar, sonst das ObjekteinstellungenausgetSettings()). Refs:sperre = useRef(neueSperre()).current,aliveRef(wie inAuftragListe: im Effekt beim Einhängentrue, beim Aushängenfalse),schmutzigRef(es wurde gespeichert und noch nicht gemeldet),urlRef(aktuelle Objekt-URL). Beim UnmountURL.revokeObjectURL(urlRef.current). -
Einstellungen laden (einmal): ein
useEffectbeim Einhängen:getSettings().then((d) => { if (aliveRef.current) setEinstellungen(d?.einstellungen ?? null) }).catch(() => { if (aliveRef.current) setEinstellungen(null) }). Kein Fehlertext, kein zweiter Abruf (AuftragFormhängt je Öffnen neu ein): Der Hinweis auf die fehlende Steuernummer ist freiwillig, und ein Ladefehler zeigt keinen Hinweis.const hinweise = auftragHinweise({ kunde, form, einstellungen })wird bei jedem Rendern berechnet (reine Funktion); die Einstellungen ändern wederweiter()nochsenden(). -
Kopf:
<Dialog as="form" wide title={auftrag ? 'Auftragsbestätigung bearbeiten' : 'Auftragsbestätigung'} busy={phase !== ''} onClose={schliessen} onSubmit={(e) => { e.preventDefault(); if (schritt < 4) weiter() }} footer={…}>. Im Körper zuerst die Schrittleiste<ol className="auftrag-schritte" aria-label="Schritte">mit jeSCHRITTE-Eintrag<li aria-current={id === schritt ? 'step' : undefined}><span className="auftrag-schritt-nr">{id}</span><span className="auftrag-schritt-text">{label}</span></li>, dann der Inhalt des Schritts. -
weiter():pruefeSchritt(schritt, form, { kunde }); bei FehlernsetFehler(r.fehler)und Fokus aufdocument.getElementById('ab-' + r.erstes)(Ausnahmen laut Task 6:art→ab-art-festpreis,zahlungsart→ab-zahlungsart-plus der erste Wert vonzahlungsplanOptionen(form.art)); sonstsetFehler({})undschritt + 1. Aus Schritt 3 wirdzurVorschau(). -
zurVorschau():formularZuNutzlast(form, { kunde }); beiok: falsespringt der Dialog zur.schrittund zeigtr.fehler. SonstsetSchritt(4)undvorschauLaden(r.payload). -
vorschauLaden(payload):const neu = JSON.stringify(payload); istneu === signatur && pdfUrl, nichts tun. Sonst insperre.run:phase 'speichern',meldung null;data = entwurf ? await patchAuftrag(entwurf.id, payload) : await createAuftrag({ kundenId: kunde.$id, ...payload });setEntwurf(data.auftrag),schmutzigRef.current = true,setSignatur(neu);phase 'pdf';blob = await getAuftragPdf(data.auftrag.id);url = URL.createObjectURL(alsPdfBlob(blob)); alte URL freigeben,setPdfUrl(url). Fehler:setMeldung(auftragFehler(err))(der Entwurf bleibt, falls er schon gespeichert war; „Erneut versuchen“ istonRetry={zurVorschau}). Zuletztphase '', alles nur wenn noch gemountet. -
zurueck():schritt - 1,meldung null. Wer von Schritt 4 zurückgeht und etwas ändert, löst beim nächsten Weg nach Schritt 4 ein neuesPATCHaus (anderesignatur). -
„Als Entwurf speichern“: der Entwurf ist in Schritt 4 schon gespeichert; der Knopf ruft
onSaved(entwurf), setztschmutzigRefzurück undonClose(). -
senden(): Istsperre.belegt, sofort zurück (keine zweite Rückfrage beim Doppelklick).const gate = sendeGate(kunde, form.kundenart); ist es nicht erlaubt,meldung = { text: gate.grund, zurUebersicht: gate.zurUebersicht }und Ende. Dannwindow.confirm(auftragBestaetigung('senden', entwurf, { email: kunde.email })), bei Abbruch Ende. Insperre.run:phase 'senden',data = await sendAuftrag(entwurf.id),schmutzigRef.current = false,onSaved(data.auftrag),onClose(); Fehler:setMeldung(auftragFehler(err)); zuletztphase ''(nur wenn noch gemountet: nachonSavedist der Dialog meist schon weg). Alle Knöpfe sind währendphase !== ''gesperrt. Eine fehlgeschlagene Mail ist kein Fehler dieses Aufrufs:data.auftrag.mailStatusist dann'fehler', und der Container zeigt die Warnung. -
schliessen()(Dialog-Schließen, „Abbrechen“): istschmutzigRef.currentwahr, zuerstonSaved(entwurf); dannonClose(). Beiphase !== ''tut der Dialog nichts (busy). -
Fuß je Zustand in
<div className="auftrag-foot">, alle Knöpfetype="button"außer „Weiter“: Schritt 1:<span className="grow" />, „Abbrechen“, „Weiter“ (variant="primary",type="submit"). Schritt 2 und 3: „Zurück“,<span className="grow" />, „Abbrechen“, „Weiter“ (in Schritt 3 beschriftet „Zur Vorschau“). Schritt 4: „Zurück“,<span className="grow" />, „Als Entwurf speichern“, „Senden“ (variant="primary"; Beschriftung „Sende …“ beiphase === 'senden';disabledbeiphase !== '', ohneentwurf, oder wennsendeGatenicht erlaubt, dann mittitle={gate.grund}). -
Schrittinhalt: 1
SchrittProjekt(mitneueFassung={Boolean(auftrag?.ersetztId)}), 2SchrittAuftrag(zeigt den Hinweis „Vor Ort“ selbst, Task 6), 3SchrittZahlung, 4AuftragVorschaumitprivat={kundenartWirksam(kunde, form.kundenart) === 'privat'},gate={sendeGate(kunde, form.kundenart)}undhinweise={hinweise}.onShowOverviewdes Dialogs wird als() => { schliessen(); onShowOverview() }anAuftragVorschaugereicht: erst den Dialog schließen (ein gespeicherter Entwurf wird dabei gemeldet), dann auf den Reiter Übersicht wechseln. -
Step 3: Regeln in
auftrag.cssanhängen
| Klasse | Regel |
|---|---|
.auftrag-schritte |
list-style: none; display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; color: var(--text-2); li[aria-current="step"] color: var(--text); font-weight: 600; .auftrag-schritt-nr mit margin-right: 6px |
.auftrag-pdf |
width: 100%; height: min(70vh, 640px); border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-subtle) |
.auftrag-foot |
display: flex; flex: 1; flex-wrap: wrap; align-items: center; gap: 8px; .grow { flex: 1 } |
@media (max-width: 860px) |
.auftrag-schritt-text ausgeblendet außer bei [aria-current="step"]; .auftrag-pdf height: 50vh; .auftrag-foot .btn { flex: 1 1 auto } |
- Step 4: Syntax und Hygiene
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && node_modules/.bin/esbuild src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragVorschau.jsx --loader:.jsx=jsx --jsx=automatic --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/syntax --log-level=warning; rg -n "react-icons|@tabler|style=\{\{|size=\"sm\"|form-control|form-group|ui-btn|window\.alert|#[0-9a-fA-F]{3,8}\b|rgba?\(|[!]important|console\.log|react-router|UebersichtLink" src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragVorschau.jsx src/components/auftraege/auftrag.css; rg -n "<button|<Button" src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragVorschau.jsx
Expected: esbuild ohne Ausgabe, das erste rg ohne Treffer. Beim zweiten rg von Hand prüfen: jeder Knopf trägt type="button", nur „Weiter“ trägt type="submit".
- Step 5: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && git add src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragVorschau.jsx src/components/auftraege/auftrag.css && git commit -m "Auftragsbestaetigung: Dialog Schritt 4 (Vorschau, Entwurf speichern, Senden mit Rueckfrage und Sperre)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 8: Selbstprüfung
Files: keine Änderung außer Korrekturen an eigenen Dateien (je Korrektur ein eigener Commit mit deutscher Nachricht und der Schlusszeile).
Interfaces:
-
Consumes: alles aus Task 1 bis 7; das Prüfwerkzeug
/root/reports/ticketsystem-design-2026-10-04/werkzeug(nur lesend:backend.mjs,browser.mjs,checks.mjs,fixtures/); Schlüssel der schweren Läufe/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock. -
Produces: grüne Tests, sauberer Worktree, Bildschirmfotos unter
/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/shots/<Breite>x<Höhe>/und der Abschlussbericht. -
Step 1: Eigene und verwandte Tests
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test tests/unit/auftragView.test.mjs tests/unit/auftragPdf.test.mjs tests/unit/kundenFelder.test.mjs tests/unit/financeView.test.mjs tests/unit/financeSettings.test.mjs tests/unit/customerTabs.test.mjs tests/unit/ticketView.test.mjs
Expected: fail 0.
- Step 2: Hygiene über alle eigenen Dateien
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && rg -n "react-icons|@tabler|style=\{\{|size=\"sm\"|form-control|form-group|ui-btn|window\.alert|#[0-9a-fA-F]{3,8}\b|rgba?\(|[!]important|console\.log|react-router|UebersichtLink" src/components/auftraege/AuftragListe.jsx src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftragSchritte.jsx src/components/auftraege/AuftragVorschau.jsx src/components/auftraege/auftrag.css; git status --short; git diff --stat feature/auftraege...HEAD
Expected: rg ohne Treffer; git status zeigt nur ?? node_modules; git diff --stat (drei Punkte: ab dem Abzweig) nennt ausschließlich die Dateien der Tabelle „Dateien“ (10 Quelldateien und 3 Testdateien). Eine andere Datei wird mit git checkout <Abzweig-Commit> -- <Datei> zurückgenommen (<Abzweig-Commit> aus git merge-base feature/auftraege HEAD) und gemeldet.
- Step 3: Vite-Build hinter dem
flock
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && free -m | head -2 && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock env VITE_APPWRITE_ENDPOINT=https://ticket.test/v1 VITE_APPWRITE_PROJECT_ID=test VITE_PROJECT_ADMIN_URL=https://portal.test NODE_OPTIONS=--max-old-space-size=1200 systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 node_modules/.bin/vite build --outDir /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/dist --emptyOutDir
Expected: Vorher zeigt free -m mindestens 1500 MB bei „available“ (sonst warten). Der Build endet mit Exit 0 und listet index.html und assets/*. Bei Fehlern in eigenen Dateien beheben und neu bauen. Läuft das Redesign noch schwere Läufe, steht flock /root/reports/ticketsystem-design-2026-10-04/sperren/schwer.lock direkt hinter der ersten Sperre (also flock <unsere> flock <deren> env …).
- Step 4: Harness schreiben (
/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/shots-w2.mjs, nicht im Repo)
Das Skript läuft mit dem Test-Backend des Prüfwerkzeugs (alles aus Testdaten, kein Request erreicht einen echten Host) und ergänzt nur die Portal-Endpunkte, die das Werkzeug noch nicht kennt. Modell für Kontext (serviceWorkers: 'block', isMobile und deviceScaleFactor 2 bei 390 px), Schrift und Uhr ist runOne in /root/reports/ticketsystem-design-2026-10-04/werkzeug/shots.mjs; die Funktionen buildFontCss und installFonts dort in das Skript kopieren, die Schriftdateien liegen unter /root/reports/ticketsystem-design-2026-10-04/werkzeug/fonts. Gerüst:
import { mkdirSync } from 'node:fs'
import { createBackend, loadFixtures } from '/root/reports/ticketsystem-design-2026-10-04/werkzeug/backend.mjs'
import { launchChromium } from '/root/reports/ticketsystem-design-2026-10-04/werkzeug/browser.mjs'
import { runChecks } from '/root/reports/ticketsystem-design-2026-10-04/werkzeug/checks.mjs'
import { jetzt } from '/root/reports/ticketsystem-design-2026-10-04/werkzeug/fixtures/helfer.mjs'
const OUT = '/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2'
const PORTAL = 'https://portal.test'
const CORS = { 'access-control-allow-origin': 'https://ticket.test', 'access-control-allow-credentials': 'true' }
const PDF = '%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 100]>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF\n'
// Je Ansicht und Viewport ein frischer Zustand (Entwürfe, Einstellungen) und ein frisches Backend
function neuerZustand() { /* auftraege: 9 ABs wie unten (mit und ohne projektId), einstellungen mit steuernummer '' (eine Ansicht setzt sie) */ }
function mitEigenenRouten(backend, zustand, meine) {
return async (route, request) => {
const url = new URL(request.url())
if (url.origin === PORTAL && url.pathname.startsWith('/api/admin/finanzen/') && request.method() !== 'OPTIONS') {
const antwort = await beantworte(zustand, request.method(), url, request.method() === 'GET' ? null : request.postDataJSON())
if (antwort) {
meine.push({ method: request.method(), path: url.pathname, body: request.postData() })
return route.fulfill({ status: antwort.status, headers: { ...CORS, 'content-type': antwort.pdf ? 'application/pdf' : 'application/json' }, body: antwort.pdf ? PDF : JSON.stringify(antwort.json) })
}
}
return backend.handle(route, request) // Preflights und alles Übrige beantwortet das Werkzeug
}
}
// beantworte(zustand, methode, url, body) -> { status, json | pdf: true, verzoegerungMs? } | null (null = Werkzeug antwortet)
context.route('**/*', mitEigenenRouten(…)), await context.clock.setFixedTime(jetzt()), Viewports [1440, 900] und [390, 844], reducedMotion: 'reduce', locale: 'de-DE', timezoneId: 'Europe/Berlin', Browser über launchChromium(['--lang=de-DE', '--host-resolver-rules=MAP * ~NOTFOUND']), createBackend({ fixtures: await loadFixtures('standard'), scenario: 'angemeldet', dist: ${OUT}/dist }) (ein Pfad mit / gilt als Build-Ordner). Kunde ist c4 („Physiotherapie am Markt“, hat im Werkzeug schon Reiter und Rechnungen). Nach jeder Ansicht runChecks(page) aufrufen und die Treffer overflow-x, bad-text und icon-svg ausgeben (overflow-x und bad-text müssen leer sein).
Antworten von beantworte (Formen laut V5; Pfad nach /api/admin/finanzen):
GET /projektkonten?kunde=c4:{ auftraegeAktiv: true, kunde: { id: 'c4', name: 'Physiotherapie am Markt', kundenart: '' }, konten: [<ein Konto 'p-physio' mit Name „Physio-Website“ undticketsaus den Tickets des Kunden c4 in den Testdaten>], ohneProjekt: { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }, stripeOk: true }.GET /auftraege?kunde=c4:{ auftraege }mit neun ABs (Felder wie V3AuftragTeam; wo unten nichts anderes stehtprojektId: 'p-physio',auftragWeg: 'telefon'und ein Titel, der vom Kontonamen „Physio-Website“ abweicht, damit die Zeile den Projektnamen zeigt):entw-1Entwurf Festpreis;entw-adresseEntwurf ohne Projekt (projektId: ''), dessenPOST …/sendenmit 409{ error: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.' }antwortet;ab-1versendet mitmailStatus: 'fehler';ab-2versendet ohne Projekt,fruehesterBeginn: '2026-10-21';ab-3bestätigt,fruehbeginn: true, Aufwand mit Schätzung;ab-4abgerechnet;ab-5storniert ohne Projekt (an dieser Zeile darf es kein „Projekt zuordnen“ geben);ab-6ersetzt (ersetztDurchId: 'ab-1') undab-7widerrufen ohne Projekt. NummernAB-2026-0001bis-0007, Entwürfe ohne Nummer. Die AB-Liste ist Zustand des Läufers: eine Zuordnung (unten) verändert spätereGET-Antworten.POST /auftraege/<id>/senden:{ auftrag: <AB mit Status 'versendet', Nummer 'AB-2026-0008', gesendetAn 'praxis@physio.test', mailStatus 'gesendet'>, mailFehler: false }nach 800 ms Verzögerung (macht den Doppelklick wirksam), fürentw-adresseder 409.POST …/erinnern,…/stornieren,…/neue-fassung(neuer Entwurfentw-neumitersetztId) liefern{ auftrag };PATCH /auftraege/<id>mischt den Körper in den Entwurf und liefert{ auftrag };POST /auftraegelegt einen Entwurf an;DELETEliefert{ ok: true };GET …/pdfliefertPDF.POST /auftraege/<id>/projektmit{ projektId }: nach 800 ms Verzögerung (macht den Doppelklick wirksam){ auftrag: <die AB mit gesetzter projektId> }und die AB bleibt im Zustand zugeordnet; hat die AB schon eineprojektId, 409{ error: 'Die Auftragsbestätigung hat schon ein Projekt.' }(Text nur im Läufer erfunden).GETundPATCH /einstellungen:{ einstellungen: { minBufferCents: 50000, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: 'Bankverbindung: WEBklar GbR · IBAN DE00 0000 0000 0000 0000 00', steuernummer: '' } };PATCHmischt die gesendeten Felder ein. Die Ansichtdialog-ohne-hinweisestartet mitsteuernummer: '12/345/67890'.
Ansichten, Schritte und Zusicherungen (jede je Viewport, ein werfender Schritt ist ein Fehler; window.confirm mit page.on('dialog', (d) => d.accept()) bestätigen):
| Name | Adresse | Schritte | Zusicherungen |
|---|---|---|---|
kunde-uebersicht |
/customers/c4 |
in „Kundenart“ „Privatperson“ wählen, „Speichern“, danach noch einmal „Speichern“ | das Feld hat die Optionen „Nicht festgelegt“, „Unternehmen“, „Privatperson“ und den Hinweis; der erste Schreibaufruf auf customers/documents/c4 enthält "kundenart":"privat", der zweite enthält kundenart nicht |
einstellungen-steuernummer |
/finance/einstellungen |
„12_345“ eintragen, „Speichern“; „DE123456789“ eintragen, „Speichern“; „Speichern“ | Hinweistext „Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.“ sichtbar; nach „12_345“ steht die Fehlermeldung „Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.“ am Feld und es gibt keinen PATCH; danach ein PATCH mit "steuernummer":"DE123456789" (Buchstaben sind erlaubt wie auf dem Server); der dritte PATCH ohne steuernummer |
auftraege-liste |
/customers/c4?tab=invoices |
auf die Überschrift „Aufträge“ warten | neun Zeilen mit Statusabzeichen (Text); „Mail nicht zugestellt“, „Arbeitsbeginn frühestens am 21.10.2026“, „Frühbeginn verlangt“ sichtbar; „Bearbeiten“, „Senden“ und „Löschen“ nur in den beiden Entwurfszeilen; „Erinnerung“ oder „Erneut senden“ nur bei ab-1 und ab-2; „Neue Fassung“ bei ab-1 bis ab-4; „Projekt zuordnen“ genau bei entw-adresse und ab-2 (nicht bei ab-5 und ab-7, deren Status es ausschließt, und nicht bei den Zeilen mit Projekt) |
auftrag-projekt |
wie auftraege-liste |
in der Zeile ab-2 „Projekt zuordnen“, „Abbrechen“, wieder „Projekt zuordnen“; im Feld „Projekt wählen“ die Option „Physio-Website“ wählen; doppelt auf „Zuordnen“ klicken; danach in der Zeile entw-adresse „Projekt zuordnen“, „Physio-Website“ wählen, einmal „Zuordnen“ |
vor der Wahl ist „Zuordnen“ gesperrt, „Abbrechen“ schließt die Auswahl; genau ein POST …/auftraege/ab-2/projekt mit Körper {"projektId":"p-physio"} und nur eine Rückfrage (die AB ist kein Entwurf); Meldung „Auftragsbestätigung AB-2026-0002 wurde dem Projekt „Physio-Website“ zugeordnet.“; danach hat ab-2 kein „Projekt zuordnen“ mehr und zeigt „Physio-Website“ unter dem Titel; bei entw-adresse kommt dieselbe Aktion ohne Rückfrage durch (Entwurf); kein waagerechter Überlauf bei 390 px |
auftrag-doppelklick |
wie auftraege-liste |
Doppelklick (dblclick) auf „Senden“ in der Zeile entw-1 |
genau ein POST …/auftraege/entw-1/senden, nur eine Rückfrage; Meldung „Auftragsbestätigung AB-2026-0008 wurde an praxis@physio.test gesendet.“ im Abschnitt „Aufträge“ |
auftrag-adresse |
wie auftraege-liste |
„Senden“ in der Zeile entw-adresse, danach „Zur Übersicht“ |
Meldung „Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.“ mit dem Knopf „Zur Übersicht“; der Klick wechselt auf den Reiter Übersicht (onShowOverview), die Adresse ist danach /customers/c4 ohne tab |
dialog-schritte |
wie auftraege-liste |
„Bearbeiten“ in der Zeile entw-1; in Schritt 1 „Weiter“ ohne Kundenart; Kundenart „Privatperson“ wählen; „Weiter“; Schritt 2 Weg „Vor Ort“ wählen, „Nach Aufwand“ und wieder „Festpreis“; „Weiter“; Schritt 3 „Anzahlung bei Auftrag, Rest nach Abnahme“ wählen; „Zur Vorschau“ |
nach dem ersten „Weiter“ steht „Bitte die Kundenart wählen.“ am Feld und der Schritt bleibt; Aufnahmen von Schritt 1, 2, 3 und 4; in Schritt 2 erscheint erst nach der Wahl „Vor Ort“ der Hinweis „Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.“; in Schritt 3 steht „Sinngemäß: Anzahlung 50 % …“; in Schritt 4 hat iframe[title="Vorschau der Auftragsbestätigung"] ein src mit blob:, es gibt „Zurück“, „Als Entwurf speichern“, „Senden“ (nicht gesperrt), und über der Vorschau stehen beide Hinweise („Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.“ und der „Vor Ort“-Hinweis); es gab genau einen Abruf GET …/einstellungen, ein PATCH …/auftraege/entw-1 und ein GET …/pdf |
dialog-ohne-hinweise |
wie auftraege-liste, Einstellungen mit Steuernummer |
„Bearbeiten“ in der Zeile entw-1; Kundenart „Unternehmen“ wählen; „Weiter“; Weg „Vor Ort“ wählen; „Weiter“, „Zur Vorschau“ |
in Schritt 2 und 4 kein Hinweis (ein Unternehmen braucht keine Papierfassung, die Steuernummer ist eingetragen); es gibt keinen Knoten mit der Klasse auftrag-hinweis; „Senden“ ist bedienbar |
dialog-senden |
wie dialog-schritte |
in Schritt 4 doppelt auf „Senden“ klicken | genau ein POST …/senden, nur eine Rückfrage; der Dialog ist danach geschlossen und der Container zeigt „Auftragsbestätigung AB-2026-0008 wurde an praxis@physio.test gesendet.“ (auftragMeldung von W0) |
neue-fassung |
wie auftraege-liste |
„Neue Fassung“ in der Zeile ab-1 |
POST …/neue-fassung, dann öffnet der Container den Dialog mit dem neuen Entwurf (Hinweis „Neue Fassung: Beim Senden ersetzt sie die bisherige Auftragsbestätigung.“). Öffnet der Container ihn nicht, ist das ein Befund für „Vertragsfragen“ (W0), kein Grund, den Container zu ändern |
Läuft ein Teil nicht, weil das Werkzeug oder W0 etwas anders macht als hier beschrieben, wird das Skript angepasst (nicht der Container) und der Unterschied im Bericht genannt.
- Step 5: Harness ausführen und Bilder ansehen
Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege-w2 && free -m | head -2 && FIXTURE_TODAY=2026-10-05 flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 node /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w2/shots-w2.mjs
Expected: Jede Ansicht endet ohne geworfene Zusicherung, overflow-x und bad-text sind in allen Aufnahmen leer, es gibt keine Konsolenfehler außer den bewusst erzeugten 409. Die Bilder liegen unter …/auftraege-w2/shots/1440x900/ und …/390x844/. Mit dem Werkzeug „Read“ mindestens ansehen: auftraege-liste, auftrag-projekt (offene Auswahl), dialog-schritte (alle vier Schritte, in Schritt 2 und 4 mit Hinweisen), kunde-uebersicht, einstellungen-steuernummer, jeweils bei 1440 und 390 px. Prüfen: Zusatzzeilen lesbar, Knöpfe erreichbar, Auswahl „Projekt zuordnen“ bei 390 px in der Zelle „Aktionen“ ohne Überlauf, Tabelle bei 390 px als gestapelte Zeilen, Positionszeilen bei 390 px zweizeilig, Hinweise lesbar und über der Vorschau, Dialog als Bottom-Sheet ohne waagerechten Überlauf. Funde werden in der Datei des zuständigen Tasks behoben (eigener Commit), danach Step 1 bis 5 wiederholen. Die zweite Sperre des Redesigns gilt wie in Step 3.
- Step 6: Vertragsliste abhaken
Vor dem Abschlussbericht jede Zeile mit Datei und Zeile belegen:
- V8:
AuftragListemit{ auftraege, konten, onChanged, onBearbeiten, onShowOverview }undAuftragDialogmit{ open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview }(alle Props aus V8, keine Rückfallwerte, keinLink, keinUebersichtLink); Aktion „Projekt zuordnen“ (Auswahl auskonten,ordneAuftragProjektZu, danachonChanged()); Hinweise (Steuernummer fehlt in Schritt 4, „Vor Ort“ bei Privatperson in Schritt 2 und 4, beide nicht blockierend);src/lib/auftragView.jsmit Tests;auftrag.css; Kundenart inCustomerDetailPage.jsx; Steuernummer inSettingsCard.jsx. - V2:
AUFTRAG_LIMITS(13 Schlüssel),STUNDENSATZ_STANDARD_CENTS, sieben Status, fünf Wege, vier Zahlungsplan-Arten, zwei Kundenarten (durch Tests festgehalten). - V4 Finanzen
steuernummer: Client-Prüfung wie der Server (höchstens 40 Zeichen aus ASCII-Buchstaben, Ziffern, Leerzeichen,/,-,.; leer löscht),kundenFelder.jsund Test. - V5 über
financeApi.js:createAuftrag,patchAuftrag,deleteAuftrag,sendAuftrag,erinnereAuftrag,neueFassungAuftrag,storniereAuftrag,ordneAuftragProjektZu(POST …/auftraege/:id/projektmit{ projektId }),getAuftragPdf,getSettings;PATCH /einstellungenmitsteuernummer;customers.kundenartdirekt über Appwrite. - V3
AuftragTeam: gelesen werdenid, nummer, status, projektId, titel, art, stundensatzCents, schaetzungMinuten, summeCents, gesendetAm, erstelltAm, mailStatus, bestaetigtAm, fruehbeginn, ersetztId, ersetztDurchId, widerrufenAm, pdfVorhanden, fruehesterBeginnsowie beim Bearbeiten die Eingabefelder des Entwurfs. - Abschlussbericht: Pfad des Worktrees, letzter Commit, Testzahlen, Pfad der Bilder und des Harness-Skripts, Abweichungen von W0, V8 und den Nachträgen der Planrunde, Befunde für Z2 (etwa ein Container, der
kontenoderonShowOverviewdoch nicht durchreicht, oder die neuen Dateien aus „Vertragsfragen“ Nr. 6).
Vertragsfragen
Alle Fragen dieses Strangs sind in der Planrunde entschieden (Übersicht, Abschnitt „Nachträge nach der Planrunde“, dazu V4, V5 und V8); der Plan setzt die Entscheidungen um, offen ist nichts. Abgeglichen mit den Plänen W0 (09), L0 (01), L3 (04) und L4 (05); was dort schon festliegt, steht unten unter „Bereits abgestimmt“.
- Zusätzliche Props
kontenundonShowOverview(V8): Entschieden (siehe Übersicht, Nachträge): V8 nennt beide Props, und der Container von W0 reichtkontenundonShowOverviewanAuftragListeundAuftragDialogdurch (Plan W0, Task 7, Zeilen e und g). Umsetzung: beide sind Pflicht-Props ohne Rückfallwerte; die Liste zeigt den Projektnamen auskonten, „Zur Übersicht“ ruftonShowOverview(Task 5 und 7). Der frühere Rückfall auf einen Link/customers/<id>ist entfallen (keinUebersichtLink, keinreact-router-domin diesem Strang). - Vorschau ohne gespeicherten Entwurf: Entschieden (siehe Übersicht, Nachträge): V5 liefert die Vorschau eines Entwurfs über
GET …/auftraege/:id/pdf(409 „Entwurf unvollständig“), das PDF gibt es also nur zu einem gespeicherten Entwurf. Umsetzung: W2 legt den Entwurf beim Wechsel nach Schritt 4 an bzw. aktualisiert ihn (POST/PATCH) und holt dann das PDF; „nicht gespeichert“ in der Spec heißt „das PDF wird nicht archiviert“. Ein so entstandener Entwurf bleibt in der Liste, auch wenn der Dialog danach ohne Senden geschlossen wird (der Container meldet „Der Entwurf wurde gespeichert.“). Antwortet der Server 409 „Entwurf unvollständig: …“, zeigt der Dialog den Satz unverändert (Task 4,auftragFehler, Klasseentwurf). - Steuernummer: Entschieden (siehe Übersicht, Nachträge): V4 erlaubt Buchstaben, Ziffern, Leerzeichen,
/,-und., höchstens 40 Zeichen, leer löscht (auch eine USt-IdNr. „DE…“); L0 setzt in Task 12/^[A-Za-z0-9\/ .-]*$/(nur ASCII-Buchstaben, keine Umlaute). Umsetzung: Der Client prüft genauso (Task 2,steuernummerAenderung) und sendetsteuernummernur bei Änderung, damit Speichern nie an einem noch nicht aktualisierten Portal scheitert. customers.kundenart: Entschieden (siehe Übersicht, Nachträge): Laut Abschnitt „Livegang“ setzt der WOMS-Push das Attribut voraus, das Setup-Skript (L0) legt es vorher an. Umsetzung: W2 schreibt das Feld in der Übersicht nur bei Änderung direkt in Appwrite (Task 2).- „Später zuordenbar“ (Spec Abschnitt 1, Schritt 1): Entschieden (siehe Übersicht, Nachträge): V5 hat
POST …/auftraege/:id/projekt, V8 die Aktion „Projekt zuordnen“; eine AB ohne Projekt lässt sich in der Liste einem Projekt zuordnen. Umsetzung: Aktion „Projekt zuordnen“ für Entwurf, Versendet, Bestätigt und Abgerechnet, nur ohne Projekt und nur, wenn es inkontenmindestens ein Projekt gibt (Task 4 und 5); sie ruftordneAuftragProjektZu(id, projektId)und danachonChanged(). Die Statusliste schließt Entwürfe ein; ein Entwurf bleibt zusätzlich über „Bearbeiten“ änderbar. Die Rückfrage vor dem Zuordnen gibt es nur bei einer schon versendeten oder späteren AB, weil sich deren Projekt danach nur noch über eine neue Fassung ändern lässt. - Dateien, Abhängigkeiten, Zusätze: Entschieden (siehe Übersicht, Nachträge): Der Abschnitt „Zusätzliche Dateien je Strang“ nennt für W2
AuftragListe.jsx,AuftragDialog.jsx,auftrag.css,auftragView.js,auftragPdf.js,kundenFelder.js,CustomerDetailPage.jsx(Feld Kundenart) undSettingsCard.jsx. Dieser Plan legt dazuAuftragSchritte.jsx,AuftragVorschau.jsxund die drei Testdateien an (neu, im Verzeichnis von W2; kein anderer Strang legt dort Dateien mit diesen Namen an); die Dateiprüfung in Plan Z (12) führt sie in der Liste von W2 schon (UebersichtLink.jsxsteht dort noch, wird aber nicht mehr angelegt: unschädlich).CustomerDetailPage.jsxändern W0 (Reiterinvoices) und W2 (OverviewTab) an verschiedenen Stellen; Z2 prüft den Merge. W2 nutztFormField,noticeProps,Dialog,useWorkorders,useScrollIntoView,openInNewTabundgetSettings; fällt eine davon im Redesign (P10) weg, müssen die Importe nachgezogen werden. Der Zahlungsplan-Text im Dialog (zahlungsplanVorschau) ist sinngemäß, maßgeblich bleibt der Wortlaut im PDF (L7). Auch „Erinnerung senden“ fragt vorher nach, weil der bisherige Bestätigungslink dadurch ungültig wird. Der Wortlaut von „senden“ inauftragErfolgfolgtauftragMeldungaus W0, damit Liste und Container dieselben Sätze zeigen. - Prüfwerkzeug: Entschieden (siehe Übersicht, Nachträge): Laut Absatz „WOMS“ entstehen Bildschirmfotos nur mit eigenen Skripten im Scratchpad, das Werkzeug des Redesigns wird nicht geändert. Umsetzung: eigener Läufer unter
…/scratchpad/auftraege-w2(Skript und Daten dort), der die neuen Endpunkte ergänzt (Vertragsfrage 10 von W0); Z2 kann ihn wiederverwenden. - Hinweise im Dialog: Entschieden (siehe Übersicht, Nachträge): V8 verlangt nicht blockierende Hinweise. Schritt 4 weist auf eine fehlende Steuernummer in den Finanz-Einstellungen hin (die Einstellungen werden einmal über
getSettingsgeladen), und bei Weg „Vor Ort“ mit Kundenart Privatperson steht „Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.“ (§ 312f Abs. 1 BGB). Umsetzung: Task 2, 3, 6 und 7. V8 nennt für den zweiten Hinweis keinen Schritt; dieser Plan zeigt ihn in Schritt 2 unter dem Weg (sobald Weg und Kundenart feststehen) und noch einmal in Schritt 4 über der Vorschau.
Bereits abgestimmt (keine Aktion): kunde im AuftragDialog ist das Kundendokument mit frischer Kundenart (kundeMitKundenart, W0 Task 6 und 7), nie null; nach Speichern oder Senden folgt onSaved(auftrag) und dann onClose(), der Container schließt und meldet über auftragMeldung (W0 Task 7); W0 reicht konten und onShowOverview an Liste und Dialog durch (W0 Task 7, Zeilen e und g) und exportiert ordneAuftragProjektZu (W0 Task 3, Vertragsfrage 13); getSettings besteht in financeApi.js; die financeApi.js-Funktionen liefern die JSON-Körper aus V5 und getAuftragPdf einen Blob, Fehler tragen status und message (W0 Task 2 und 3); die Wertebereiche (Anzahlung 1 bis 99 Prozent und nur bei Festpreis, „Monatlich“ nur bei Aufwand, Stundensatz 0,01 bis 100.000,00 €, Schätzung Vielfaches von 15 bis 99.960, Zahlungsziel 0 bis 60, Weg Pflicht) und die Meldungstexte beim Senden stehen im Plan L3 und decken sich mit der Oberfläche; „Entwurf unvollständig: …“ kommt aus L4 und wird von L3 durchgereicht; aendereEntwurf schreibt kundenart wie legeEntwurfAn; der PDF-Tab folgt dem Vorschlag von W0 (Vertragsfrage 5).