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

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

73 KiB
Raw Blame History

WOMS-Basis – Umsetzungsplan (Strang W0)

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: Das WOMS bekommt die gemeinsame Grundlage für „Aufträge und Rechnungen“: alle neuen Portal-Aufrufe (V8) im API-Client, den Kundenreiter „Aufträge und Rechnungen“ mit dem Container AuftraegeUndRechnungen (Ladezustände, Dialogzustand, bestehende Rechnungsliste, Adresswarnung) und vier gültige Platzhalter, die W1 und W2 jeweils durch ihre echte Komponente ersetzen.

Architecture: Der API-Client bleibt dünn: Pfade und Methoden stehen im reinen Modul src/lib/auftraegeEndpoints.js (unter Node mit Fetch-Attrappe testbar, weil financeApi.js Appwrite lädt), und financeRequest.js lernt Blob-Antworten (as: 'blob') für das PDF. Die Ansichtslogik des Reiters (Ladezustand, frische Kundenart, Meldungen) steht getestet in src/lib/auftraegeAnsicht.js; der Container verdrahtet sie mit useFinanceResource, den vier Platzhaltern und der bestehenden InvoiceTable und ersetzt CustomerInvoices im Reiter. Die Bausteine des weißen Designs werden nur benutzt, nie verändert.

Tech Stack: React 18, Vite 5, react-router-dom 6, reines CSS (keine neue Datei), Tests mit node --test und dem esbuild-Lader tests/unit/load.mjs (node:test, assert/strict), Appwrite Web SDK 13 (nur in financeApi.js und teamApi.js, nie in getesteten Modulen), Node 24 auf dem Host.

Spec: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md (Abschnitt 1 „Oberfläche in WOMS“, dazu 4 „Projektkonto“ und 6 „Schnittstellen“).

Übersicht/Verträge: /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md (verbindlich: V5 Endpunkte, V8 WOMS-Schnittstellen und der Abschnitt „Nachträge nach der Planrunde“; dazu V3 Datenformen). Die „Vertragsfragen“ am Ende dieser Datei sind dort entschieden; weicht diese Datei von der Übersicht ab, gilt die Übersicht.

Jeder Implementierer liest die Spec (Abschnitt 1), die Übersicht (V5, V8) und diese Datei.


Global Constraints

Alle Punkte der Übersicht gelten (Sprache, Git ohne Push und ohne git add -A, Ressourcen und flock, Sicherheit der Umgebung, build/ nie anfassen, vorhandene Tests bleiben grün). Für W0 kommt hinzu:

  • Startbedingung (hart): Der Strang beginnt erst, wenn die Redesign-Pakete P4 (Kunden), P6 (Kunden-Unterseiten) und P7 (Finanzen) in feature/design-weiss zusammengeführt und als design-p4, design-p6, design-p7 getaggt sind. Task 1 prüft das. Fehlt etwas, gilt STOPP: nichts anlegen, dem Orchestrator melden (er fragt den Nutzer: warten oder auf test bauen und eine Mitnahme-Notiz in /root/reports/ticketsystem-design-2026-10-04/strang/mitnehmen.md hinterlegen).
  • Anker statt Zeilennummern: Dieser Plan nennt keine Zeilennummern. Maßgeblich sind Namen und Ankertexte im zusammengeführten Stand; Task 1 Step 5 prüft sie gegen den Stand beim Start.
  • Stand von test: origin/test ist dem Redesign voraus (Öffnungszeiten: zeitenView.js, zusätzliche Zeilen in financeRequest.js und teamApi.js). Änderungen an diesen Dateien hängen sich an Ankertexte an und lassen fremde Zeilen unberührt.
  • Dateien: W0 ändert nur die Dateien der Tabelle „Dateien“. Alle anderen Redesign-Dateien (InvoiceTable.jsx, AddressWarning.jsx, InvoiceCreateModal.jsx, FinancePage.jsx, global.css, …) bleiben unberührt; sie gehören W1 und W2.
  • Regeln des Redesigns für neue Dateien: nur Bausteine aus src/components/ui und vorhandene Klassen (msg, loading, link, fin-notice, fin-load-error); keine Icon-Importe (react-icons, @tabler), kein style={{…}}, keine Farbliterale, keine KOMPAT-Klassen (ui-btn, form-control, form-group, form-label, ui-card, badge-…, overlay-content, overlay-close, spinner, kpi), keine neue CSS-Datei. P10 räumt KOMPAT und die Icon-Pakete weg; Neues darf davon nichts brauchen. Kommentare und Texte deutsch wie in den Nachbardateien.
  • Tests: node:test, assert/strict, loadModule aus tests/unit/load.mjs. financeApi.js, teamApi.js und alle Komponenten laden Appwrite und sind unter Node nicht ladbar; ihre Logik steht in reinen src/lib-Modulen. Neue Tests liegen in neuen Dateien (Ausnahme: customerTabs.test.mjs).
  • Befehle mit Speichergrenzen (Server mit wenig Arbeitsspeicher). WT ist /home/webklar/apps/ticket.webklar.com/dev-auftraege, L ist /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad. Jede Shell-Zeile steht für sich (Umgebungsvariablen bleiben nicht erhalten), deshalb stehen die Pfade ausgeschrieben in den Steps.
    • Einzeltest: cd $WT && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/<datei>.test.mjs
    • Volle Suite, npm ci und Build laufen nur hinter flock $L/auftraege-schwer.lock, vorher free -m (unter 1500 MB „available“: warten), und unter systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 (Arbeitsweise des Redesigns). Läuft das Redesign noch schwere Läufe, zusätzlich dessen Sperre /root/reports/ticketsystem-design-2026-10-04/sperren/schwer.lock (erst unsere, dann deren).
    • Build: node node_modules/vite/bin/vite.js build --outDir $L/auftraege-w0-dist --emptyOutDir mit NODE_OPTIONS=--max-old-space-size=1200 (nie npm run build, nie nach dist/ im Worktree).
    • Bash-Timeout 600000 ms; dauert ein Lauf länger, mit run_in_background starten. Nie pkill -f.
  • Git: Worktree /home/webklar/apps/ticket.webklar.com/dev-auftraege, Branch feature/auftraege. Im Repo build (Branch test) entsteht nur der Worktree-Eintrag aus Task 1, sonst wird dort nichts angefasst. Commits einzeln mit Dateinamen, deutsche Nachricht, Schlusszeile Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (zweites -m). Kein Push, nie --force.
  • Suche: In dieser Shell ist grep eine Funktion, die Dateien still auslässt. Die Steps benutzen rg (13.0.0, ohne Lookarounds); notfalls command grep -a.

Dateien

Datei Aktion Aufgabe Task
src/lib/financeRequest.js ändern request(path, { method, body, as }): as: 'blob' liefert bei Erfolg den rohen Inhalt (PDF) 2
tests/unit/financeRequestBlob.test.mjs neu Tests dazu (Fetch-Attrappe) 2
src/lib/auftraegeEndpoints.js neu rein: createAuftraegeEndpoints(request) (14 Funktionen nach V5, darunter ordneAuftragProjektZu) und abgerechnetPfad(ticketId) 3
tests/unit/auftraegeEndpoints.test.mjs neu Pfade, Methoden, Körper, Kodierung, PDF-Blob 3
src/lib/financeApi.js ändern exportiert die 14 Funktionen (Verdrahtung mit Appwrite-JWT) 3
src/lib/teamApi.js ändern getAbgerechnet(ticketId) 3
src/lib/customerTabs.js, tests/unit/customerTabs.test.mjs ändern Beschriftung „Aufträge und Rechnungen“ 4
src/lib/auftraegeAnsicht.js neu rein: Laden, Ladezustand, frische Kundenart, Meldungen 5, 6
tests/unit/auftraegeAnsicht.test.mjs neu Tests dazu 5, 6
src/components/auftraege/ProjektkontoListe.jsx, RechnungDialog.jsx, AuftragListe.jsx, AuftragDialog.jsx neu Platzhalter mit den Props aus V8 (samt konten und onShowOverview), rendern null; W1 bzw. W2 ersetzen die Dateien 7
src/components/auftraege/AuftraegeUndRechnungen.jsx neu Container des Reiters; reicht konten und onShowOverview an Liste und Dialoge durch 7
src/pages/CustomerDetailPage.jsx ändern Reiter invoices zeigt den Container 8
src/components/finance/CustomerInvoices.jsx löschen ersetzt durch den Container (keine Nutzer mehr) 8

Review Focus

Fälle, die die Spec nicht ausdrücklich nennt und die im Betrieb am ehesten auftreten, wahrscheinlichster zuerst:

  1. Schalter AUFTRAEGE_ENABLED aus (Vorgabe beim Livegang): auftraegeAktiv ist false oder fehlt. Erwartet: kein Abruf der Aufträge (er würde 404 liefern), kein Aufträge-Abschnitt, keine Fehlermeldung; Projekte und Rechnungen laufen wie sonst. Test: Task 5, „Schalter aus“ und „unvollständige Antworten“.
  2. Projektkonten-Abruf scheitert (Setup-Skript nicht gelaufen, Portal-Fehler 500, Portal ohne den Endpunkt): Erwartet: Der Abschnitt „Projekte“ zeigt die Meldung mit „Erneut versuchen“, die Rechnungsliste und ihr Knopf „Rechnung schreiben“ bleiben bedienbar, Aufträge werden nicht abgerufen. Test: Task 5, „Projektkonten scheitern“ und „vor der ersten Antwort“.
  3. Aufträge-Abruf scheitert allein (Schalter an, Collection fehlt): Erwartet: Projekte bleiben sichtbar, der Aufträge-Abschnitt zeigt seine eigene Meldung. Test: Task 5, „Aufträge scheitern allein“.
  4. Neuladen scheitert, nachdem Daten da sind (Portal antwortet nach dem Senden einer Rechnung kurz nicht): Erwartet: Die alten Zeilen bleiben stehen, darüber steht eine Warnung mit „Erneut versuchen“, nichts springt auf „Wird geladen …“. Test: Task 5, „Neuladen scheitert“.
  5. Kundenart eben gespeichert, die Kundenseite hält noch den alten Datensatz: Erwartet: Die Dialoge bekommen den frischen Wert des Portals, der AB-Dialog fragt nicht erneut. Test: Task 6, kundeMitKundenart.

Weitere Randfälle haben eigene Tests in den Tasks: PDF-Abruf mit Fehlerantwort statt Blob und Kennungen mit Sonderzeichen (Task 2 und 3).


Task 1: Startbedingung prüfen und Worktree anlegen

Files: keine Codeänderung. Legt /home/webklar/apps/ticket.webklar.com/dev-auftraege (Branch feature/auftraege) an.

Interfaces:

  • Consumes: Tags design-p4, design-p6, design-p7 (und ggf. design-p8 bis design-p10) im WOMS-Repo, erreichbar über /home/webklar/apps/ticket.webklar.com/build.

  • Produces: Worktree mit echtem node_modules (W1 und W2 verlinken es per ln -s ../dev-auftraege/node_modules node_modules), den Basis-Stand B (Tag-Name) und die Ausgangs-Testzahl N0. Beides gehört in die Abschlussmeldung (Task 9).

  • Step 1: Redesign-Tags und Basis bestimmen

Run:

cd /home/webklar/apps/ticket.webklar.com/build
for t in design-p4 design-p6 design-p7; do git rev-parse -q --verify "refs/tags/$t^{commit}" >/dev/null && echo "da $t" || echo "FEHLT $t"; done
for t in design-p10 design-p9 design-p8 design-p7; do git rev-parse -q --verify "refs/tags/$t^{commit}" >/dev/null && { echo "BASIS=$t"; break; }; done

Expected: dreimal da … und eine Zeile BASIS=design-p…. Das ist der höchste vorhandene Tag und damit B; nennt der Orchestrator ausdrücklich einen anderen Stand, gilt dieser. Fehlt einer der drei Tags: STOPP.

  • Step 2: Inhalt prüfen (Tags allein genügen nicht)

Run (mit B aus Step 1):

cd /home/webklar/apps/ticket.webklar.com/build
B=<B aus Step 1>
git merge-base --is-ancestor design-p7 "$B" && echo "design-p7 liegt im Basis-Stand"
git cat-file -e "$B:src/lib/customerList.js" && echo "P4 ok"
git cat-file -e "$B:tests/unit/creditFormat.test.mjs" && echo "P6 ok"
git cat-file -e "$B:src/lib/financeUi.js" && echo "P7 ok"
git merge-base --is-ancestor origin/test "$B" && echo "Basis enthält origin/test" || echo "HINWEIS: origin/test ist weiter als die Basis (nur melden)"

Expected: vier Bestätigungen (design-p7 …, P4 ok, P6 ok, P7 ok); die letzte Zeile ist nur ein Hinweis. Fehlt eine der vier: STOPP mit Meldung „Startbedingung nicht erfüllt: “ samt Ausgabe von git tag -l 'design-p*' 'strang-p*' und git log --oneline -3 design/kunden design/finanzen.

  • Step 3: Worktree anlegen

Run:

cd /home/webklar/apps/ticket.webklar.com
test ! -e dev-auftraege && ! git -C build rev-parse -q --verify refs/heads/feature/auftraege >/dev/null && git -C build worktree add ../dev-auftraege -b feature/auftraege <B> && git -C dev-auftraege log --oneline -1

Expected: Der Worktree entsteht, die letzte Zeile ist der Commit der Basis. Existiert Ordner oder Branch schon: STOPP (ein früherer Versuch; Orchestrator fragen).

  • Step 4: node_modules wie im Redesign (echtes npm ci, kein Symlink)

Run:

free -m | head -2
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 nice -n 10 npm ci --no-audit --no-fund && git status --short

Expected: Exit 0 und keine Zeile von git status (node_modules steht in .gitignore).

  • Step 5: Anker gegen den Basis-Stand prüfen

Run:

cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
rg -n "CustomerInvoices" src/pages/CustomerDetailPage.jsx
rg -n "label: 'Rechnungen'" src/lib/customerTabs.js
rg -n "return async function request" src/lib/financeRequest.js
rg -n "getCustomerAboStatus" src/lib/teamApi.js
rg -n "export default function (CustomerInvoices|InvoiceTable|AddressWarning)" src/components/finance
rg -n "export function (useFinanceResource|useScrollIntoView)" src/hooks
rg -n "export function (customerInvoicesGate|invoiceDoneMessage)" src/lib/financeView.js
rg -n "export function noticeProps" src/lib/financeUi.js
rg -n "export \{ default as (Card|Button|EmptyState) " src/components/ui/index.js
rg -n "^\.fin-load-error|^\.fin-notice" src/styles

Expected: jeder Befehl mindestens ein Treffer (CustomerDetailPage.jsx zwei: Import und Verwendung, export function (useFinanceResource|useScrollIntoView) zwei). Fehlt ein Anker, weil das Redesign etwas umbenannt hat: STOPP und melden. Einzige Ausnahme: fehlen .fin-load-error und .fin-notice im CSS, benutzt Task 7 nur msg … ohne diese Zusatzklassen.

  • Step 6: Ausgangslage der Tests

Run:

cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
free -m | head -2
flock $L/auftraege-schwer.lock env NODE_OPTIONS=--max-old-space-size=1024 timeout 900 nice -n 10 npm test > $L/auftraege-w0-test.log 2>&1; echo "exit $?"
rg "^(ℹ|#) (tests|pass|fail) " $L/auftraege-w0-test.log

Expected: exit 0, fail 0. Die Zahl hinter pass ist N0 (notieren; Soll am Ende: N0 + 21). Schlägt schon die Basis fehl: STOPP und melden (Basis kaputt).

Kein Commit.

Task 2: financeRequest liest Dateien (Blob)

Files:

  • Modify: src/lib/financeRequest.js
  • Test: tests/unit/financeRequestBlob.test.mjs (neu)

Interfaces:

  • Consumes: createFinanceRequest({ baseUrl, createJWT, fetchImpl }) (besteht).

  • Produces: request(path, { method = 'GET', body, as }). Mit as: 'blob' liefert eine erfolgreiche Antwort (res.ok) await res.blob(). Fehlerantworten kommen unverändert als JSON-Meldung des Portals (Fehlertexte, Status, JWT-Wiederholung bei 401). Ohne as oder mit jedem anderen Wert bleibt alles wie bisher. Das nutzt getAuftragPdf (Task 3).

  • Step 1: Failing Test schreiben

tests/unit/financeRequestBlob.test.mjs:

import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'

const { createFinanceRequest, PORTAL_DOWN_TEXT } = await loadModule('src/lib/financeRequest.js')

const BASE = 'https://portal.example.invalid/api/admin/finanzen'

/** Antwort wie fetch: { ok, status, json(), blob() }; ohne `json` ist der Körper kein JSON (json() wirft). */
const reply = (status, { json, blob } = {}) => ({
  ok: status >= 200 && status < 300,
  status,
  json: async () => {
    if (json === undefined) throw new SyntaxError('Unexpected token % in JSON')
    return json
  },
  blob: async () => blob,
})

function client({ responses = [], createJWT } = {}) {
  const calls = []
  const queue = [...responses]
  const api = createFinanceRequest({
    baseUrl: BASE,
    createJWT: createJWT || (async () => ({ jwt: 'jwt-1' })),
    fetchImpl: async (url, init) => {
      calls.push({ url, init })
      return queue.shift() || reply(200, { json: {} })
    },
  })
  return { api, calls }
}

const fails = async (promise) => promise.then(() => assert.fail('hätte scheitern müssen'), (err) => err)
const pdf = () => new Blob(['%PDF-1.7 Probe'], { type: 'application/pdf' })

test('as blob: Erfolg liefert den rohen Inhalt als Blob, mit Bearer-JWT, ohne JSON zu lesen', async () => {
  const { api, calls } = client({
    responses: [{ ...reply(200, { blob: pdf() }), json: async () => assert.fail('json() darf bei as blob nicht gelesen werden') }],
  })
  const result = await api('/auftraege/a1/pdf', { as: 'blob' })
  assert.ok(result instanceof Blob)
  assert.equal(result.type, 'application/pdf')
  assert.equal(await result.text(), '%PDF-1.7 Probe')
  assert.equal(calls[0].url, `${BASE}/auftraege/a1/pdf`)
  assert.equal(calls[0].init.method, 'GET')
  assert.equal(calls[0].init.headers.Authorization, 'Bearer jwt-1')
})

test('as blob: Fehler kommen weiter als Meldung des Portals mit Status (kein kaputtes PDF)', async () => {
  const { api } = client({ responses: [reply(404, { json: { error: 'PDF nicht gefunden.' } })] })
  const err = await fails(api('/auftraege/a1/pdf', { as: 'blob' }))
  assert.equal(err.message, 'PDF nicht gefunden.')
  assert.equal(err.status, 404)
  // Proxy-Fehlerseite ohne JSON: dieselbe Meldung wie bei JSON-Anfragen
  const { api: api2 } = client({ responses: [reply(502)] })
  assert.equal((await fails(api2('/auftraege/a1/pdf', { as: 'blob' }))).message, PORTAL_DOWN_TEXT)
})

test('as blob: bei 401 einmal mit frischem JWT wiederholt, dann kommt der Blob', async () => {
  let n = 0
  const datei = pdf()
  const { api, calls } = client({
    createJWT: async () => ({ jwt: `jwt-${++n}` }),
    responses: [reply(401, { json: { error: 'abgelaufen' } }), reply(200, { blob: datei })],
  })
  assert.equal(await api('/auftraege/a1/pdf', { as: 'blob' }), datei)
  assert.deepEqual(calls.map((c) => c.init.headers.Authorization), ['Bearer jwt-1', 'Bearer jwt-2'])
})

test('ohne as bleibt es JSON (der Blob wird nicht gelesen), ein unbekannter Wert verhält sich ebenso', async () => {
  const { api } = client({
    responses: [{ ...reply(200, { json: { ok: 1 } }), blob: async () => assert.fail('blob() darf ohne as nicht gelesen werden') }],
  })
  assert.deepEqual(await api('/x'), { ok: 1 })
  const { api: api2 } = client({ responses: [reply(200, { json: { ok: 2 } })] })
  assert.deepEqual(await api2('/x', { as: 'text' }), { ok: 2 })
})
  • Step 2: Test laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/financeRequestBlob.test.mjs Expected: FAIL in zwei Tests („Erfolg liefert den rohen Inhalt als Blob“ und „bei 401 …“); request ignoriert as und liefert {}. Die anderen beiden bestehen schon.

  • Step 3: as: 'blob' in createFinanceRequest einbauen

In src/lib/financeRequest.js: Die innere Funktion bekommt die Signatur request(path, { method = 'GET', body, as } = {}). Direkt nach dem fetchWithRetry-Aufruf und vor dem Lesen des JSON kommt if (as === 'blob' && res.ok) return res.blob() (mit einem Kommentar: Dateien wie das PDF der Auftragsbestätigung kommen roh, Fehler weiter als JSON). Header, JWT-Cache, Wiederholung bei 401 und alle Fehlertexte bleiben, ebenso die fremden Zeilen (code, fehler) im Fehlerzweig. Den Kommentarblock über createFinanceRequest um einen Satz zu as: 'blob' ergänzen.

  • Step 4: Tests laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/financeRequestBlob.test.mjs tests/unit/financeRequest.test.mjs Expected: PASS, in der neuen Datei pass 4, fail 0; die bestehende Datei bleibt grün.

  • Step 5: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/lib/financeRequest.js tests/unit/financeRequestBlob.test.mjs
git commit -m "Finanzen-Anfrage: Dateien lesen (as: 'blob'), z. B. das PDF einer Auftragsbestätigung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 3: API-Schicht – Pfade rein, financeApi.js und teamApi.js verdrahtet

Files:

  • Create: src/lib/auftraegeEndpoints.js
  • Test: tests/unit/auftraegeEndpoints.test.mjs (neu)
  • Modify: src/lib/financeApi.js, src/lib/teamApi.js

Interfaces:

  • Consumes: request(path, { method, body, as }) aus createFinanceRequest (Task 2).

  • Produces:

    • createAuftraegeEndpoints(request) → { getProjektkonten, getProjektkonto, previewInvoice, listAuftraege, getAuftrag, createAuftrag, patchAuftrag, deleteAuftrag, sendAuftrag, erinnereAuftrag, neueFassungAuftrag, storniereAuftrag, ordneAuftragProjektZu, getAuftragPdf } (14 Funktionen) und abgerechnetPfad(ticketId) → string (reine Datei ohne Importe).
    • Aus src/lib/financeApi.js exportiert (V8; Basis /api/admin/finanzen, Antwortformen nach V5):
      • getProjektkonten(kundeId) → { auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk }
      • getProjektkonto(projektId) → { konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk }
      • previewInvoice(body) → { positionen, summeCents, leistungszeitraum, customFields }
      • listAuftraege(kundeId) → { auftraege }
      • getAuftrag(id), createAuftrag(body), patchAuftrag(id, body) → { auftrag }; deleteAuftrag(id) → { ok: true }
      • sendAuftrag(id), erinnereAuftrag(id) → { auftrag, mailFehler }; neueFassungAuftrag(id), storniereAuftrag(id) → { auftrag }
      • ordneAuftragProjektZu(id, projektId) → { auftrag } (Nachtrag der Planrunde, V5 Zeile L3: ordnet einer AB ohne Projekt ein Projekt zu; 409, wenn sie schon eines hat oder storniert, ersetzt oder widerrufen ist; W2 braucht es für „Projekt zuordnen“)
      • getAuftragPdf(id) → Promise<Blob>
    • Aus src/lib/teamApi.js: getAbgerechnet(ticketId) → { abgerechnet: { <worksheetId>: '<Rechnungsnummer>' | 'Entwurf' } } (V5; für alle Mitarbeiter, ohne Beträge).
    • createInvoice(body) besteht und reicht den erweiterten Körper unverändert durch; nur der Kommentar wird nachgezogen.
  • Step 1: Failing Test schreiben

tests/unit/auftraegeEndpoints.test.mjs:

import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'

const { createAuftraegeEndpoints, abgerechnetPfad } = await loadModule('src/lib/auftraegeEndpoints.js')
const { createFinanceRequest } = await loadModule('src/lib/financeRequest.js')

const BASE = 'https://portal.example.invalid/api/admin/finanzen'

// Echte Anfrage-Schicht mit Fetch-Attrappe: geprüft wird, was das Portal erreicht (URL, Methode, Kopf, JSON-Körper)
function setup(answer = () => ({ ok: true, status: 200, json: async () => ({ ok: true }) })) {
  const calls = []
  const request = createFinanceRequest({
    baseUrl: BASE,
    createJWT: async () => ({ jwt: 'jwt-1' }),
    fetchImpl: async (url, init) => {
      calls.push({ url, method: init.method, body: init.body, auth: init.headers.Authorization })
      return answer()
    },
  })
  return { api: createAuftraegeEndpoints(request), calls }
}

// [Funktion, Argumente, Methode, Pfad ab BASE, Körper]
const FAELLE = [
  ['getProjektkonten', ['k1'], 'GET', '/projektkonten?kunde=k1', undefined],
  ['getProjektkonto', ['p1'], 'GET', '/projektkonten/p1', undefined],
  ['previewInvoice', [{ customerId: 'k1', art: 'aufwand', projektId: 'p1' }], 'POST', '/rechnungen/vorschau', { customerId: 'k1', art: 'aufwand', projektId: 'p1' }],
  ['listAuftraege', ['k1'], 'GET', '/auftraege?kunde=k1', undefined],
  ['getAuftrag', ['a1'], 'GET', '/auftraege/a1', undefined],
  ['createAuftrag', [{ kundenId: 'k1', titel: 'Website' }], 'POST', '/auftraege', { kundenId: 'k1', titel: 'Website' }],
  ['patchAuftrag', ['a1', { titel: 'Neu' }], 'PATCH', '/auftraege/a1', { titel: 'Neu' }],
  ['deleteAuftrag', ['a1'], 'DELETE', '/auftraege/a1', undefined],
  ['sendAuftrag', ['a1'], 'POST', '/auftraege/a1/senden', undefined],
  ['erinnereAuftrag', ['a1'], 'POST', '/auftraege/a1/erinnern', undefined],
  ['neueFassungAuftrag', ['a1'], 'POST', '/auftraege/a1/neue-fassung', undefined],
  ['storniereAuftrag', ['a1'], 'POST', '/auftraege/a1/stornieren', undefined],
  ['ordneAuftragProjektZu', ['a1', 'p1'], 'POST', '/auftraege/a1/projekt', { projektId: 'p1' }],
]

test('alle 14 Funktionen sind da (V8 samt „Projekt zuordnen“ aus V5)', () => {
  const { api } = setup()
  assert.deepEqual(Object.keys(api).sort(), [
    'createAuftrag', 'deleteAuftrag', 'erinnereAuftrag', 'getAuftrag', 'getAuftragPdf', 'getProjektkonten', 'getProjektkonto',
    'listAuftraege', 'neueFassungAuftrag', 'ordneAuftragProjektZu', 'patchAuftrag', 'previewInvoice', 'sendAuftrag', 'storniereAuftrag',
  ])
})

test('Pfad, Methode, Kopf und Körper jeder Funktion (die Vorschau trägt keinen Schlüssel dazu)', async () => {
  for (const [name, args, method, path, body] of FAELLE) {
    const { api, calls } = setup()
    await api[name](...args)
    assert.equal(calls.length, 1, name)
    assert.equal(calls[0].url, `${BASE}${path}`, name)
    assert.equal(calls[0].method, method, name)
    assert.equal(calls[0].auth, 'Bearer jwt-1', name)
    assert.equal(calls[0].body, body === undefined ? undefined : JSON.stringify(body), name)
  }
})

test('Kennungen werden URL-kodiert, auch im Pfad der abgerechneten Arbeitsblätter', async () => {
  const { api, calls } = setup()
  await api.getProjektkonten('k 1&x=2')
  await api.getAuftrag('a/b?c')
  await api.storniereAuftrag('a#1')
  await api.ordneAuftragProjektZu('a b', 'p/1')
  assert.deepEqual(calls.map((c) => c.url.slice(BASE.length)), [
    '/projektkonten?kunde=k%201%26x%3D2',
    '/auftraege/a%2Fb%3Fc',
    '/auftraege/a%231/stornieren',
    '/auftraege/a%20b/projekt',
  ])
  assert.equal(calls[3].body, JSON.stringify({ projektId: 'p/1' })) // die Projektkennung steht im Körper, nicht im Pfad
  assert.equal(abgerechnetPfad('100131'), '/arbeitsblaetter/abgerechnet?ticketId=100131')
  assert.equal(abgerechnetPfad('t 1/2'), '/arbeitsblaetter/abgerechnet?ticketId=t%201%2F2')
})

test('getAuftragPdf liefert das PDF als Blob; eine Fehlerantwort kommt als Meldung des Portals', async () => {
  const datei = new Blob(['%PDF-1.7'], { type: 'application/pdf' })
  const ok = setup(() => ({ ok: true, status: 200, json: async () => assert.fail('kein JSON lesen'), blob: async () => datei }))
  assert.equal(await ok.api.getAuftragPdf('a1'), datei)
  assert.equal(ok.calls[0].url, `${BASE}/auftraege/a1/pdf`)
  assert.equal(ok.calls[0].method, 'GET')
  const fehler = setup(() => ({ ok: false, status: 404, json: async () => ({ error: 'PDF nicht gefunden.' }), blob: async () => assert.fail('kein Blob lesen') }))
  await assert.rejects(fehler.api.getAuftragPdf('a1'), (e) => e.message === 'PDF nicht gefunden.' && e.status === 404)
})
  • Step 2: Test laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/auftraegeEndpoints.test.mjs Expected: FAIL, src/lib/auftraegeEndpoints.js fehlt.

  • Step 3: src/lib/auftraegeEndpoints.js implementieren

Reine Datei ohne Importe, deutscher Kopfkommentar im Stil von financeRequest.js. createAuftraegeEndpoints(request) gibt ein Objekt mit genau den 14 Funktionen zurück. Jede ruft request mit Pfad relativ zu /api/admin/finanzen; jede Kennung läuft durch encodeURIComponent; Aufrufe ohne Körper übergeben keinen Körper. Tabelle:

Funktion Methode Pfad Körper
getProjektkonten(kundeId) GET /projektkonten?kunde=<kundeId> –
getProjektkonto(projektId) GET /projektkonten/<projektId> –
previewInvoice(body) POST /rechnungen/vorschau body unverändert
listAuftraege(kundeId) GET /auftraege?kunde=<kundeId> –
getAuftrag(id) GET /auftraege/<id> –
createAuftrag(body) POST /auftraege body
patchAuftrag(id, body) PATCH /auftraege/<id> body
deleteAuftrag(id) DELETE /auftraege/<id> –
sendAuftrag(id) POST /auftraege/<id>/senden –
erinnereAuftrag(id) POST /auftraege/<id>/erinnern –
neueFassungAuftrag(id) POST /auftraege/<id>/neue-fassung –
storniereAuftrag(id) POST /auftraege/<id>/stornieren –
ordneAuftragProjektZu(id, projektId) POST /auftraege/<id>/projekt { projektId }
getAuftragPdf(id) GET /auftraege/<id>/pdf – (Option as: 'blob')

abgerechnetPfad(ticketId) liefert /arbeitsblaetter/abgerechnet?ticketId=<kodiert> (Basis /api/team, siehe Step 4).

  • Step 4: Verdrahtung in financeApi.js und teamApi.js

  • src/lib/financeApi.js: import { createAuftraegeEndpoints } from './auftraegeEndpoints' zu den Importen. Hinter dem Block „Rechnungen“ ein Abschnitt mit Kommentar „Projektkonten, Aufträge und Rechnungsvorschau (Pfade und Methoden: auftraegeEndpoints.js, getestet)“ und export const { getProjektkonten, getProjektkonto, previewInvoice, listAuftraege, getAuftrag, createAuftrag, patchAuftrag, deleteAuftrag, sendAuftrag, erinnereAuftrag, neueFassungAuftrag, storniereAuftrag, ordneAuftragProjektZu, getAuftragPdf } = createAuftraegeEndpoints(request) (die vorhandene Konstante request wird übergeben). Der Kommentar über createInvoice nennt den erweiterten Körper { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum, daysUntilDue, memo, senden }; die Funktion selbst bleibt unverändert.

  • src/lib/teamApi.js: import { abgerechnetPfad } from './auftraegeEndpoints' zu den Importen; vor dem Block „Abo-Status“ ein Abschnitt „Abgerechnete Arbeitsblätter (alle Mitarbeiter, ohne Beträge)“ mit export const getAbgerechnet = (ticketId) => request(abgerechnetPfad(ticketId)) und einem Kommentar zur Antwortform.

  • Step 5: Tests und Syntaxprüfung

Run:

cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/auftraegeEndpoints.test.mjs
node_modules/.bin/esbuild src/lib/financeApi.js src/lib/teamApi.js --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w0-syntax --log-level=warning

Expected: pass 4, fail 0; esbuild ohne Ausgabe und Exit 0 (nur Syntax, die Dateien lassen sich unter Node nicht bündeln).

  • Step 6: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/lib/auftraegeEndpoints.js tests/unit/auftraegeEndpoints.test.mjs src/lib/financeApi.js src/lib/teamApi.js
git commit -m "Finanzen-API: Projektkonten, Auftragsbestätigungen (samt Projekt zuordnen), Rechnungsvorschau und abgerechnete Arbeitsblätter (Pfade rein und getestet)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 4: Reiter-Beschriftung „Aufträge und Rechnungen“

Files:

  • Modify: src/lib/customerTabs.js, tests/unit/customerTabs.test.mjs

Interfaces:

  • Consumes: customerTabs(isAdmin) und resolveCustomerTab(tab, isAdmin) (bestehen).

  • Produces: customerTabs(true)[2] ist { id: 'invoices', label: 'Aufträge und Rechnungen' }. Die id bleibt invoices (Mails des Portals und Aufgaben der Finanzen-Seite verlinken ?tab=invoices), der Reiter bleibt nur für Admins.

  • Step 1: Test anpassen (zuerst)

In tests/unit/customerTabs.test.mjs die Zeile { id: 'invoices', label: 'Rechnungen' }, zu { id: 'invoices', label: 'Aufträge und Rechnungen' }, ändern und den Titel des ersten Tests zu 'customerTabs: volle Reiterfolge der Spec, „Aufträge und Rechnungen“ nur für Admins'. Alles andere bleibt (es pinnt unter anderem, dass customerTabs(false) keinen Reiter invoices enthält und resolveCustomerTab('invoices', false) auf overview fällt).

  • Step 2: Test laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/customerTabs.test.mjs Expected: FAIL (die Beschriftung lautet noch „Rechnungen“).

  • Step 3: Beschriftung ändern

In src/lib/customerTabs.js das Label des Eintrags invoices auf 'Aufträge und Rechnungen' setzen und die beiden Kommentare, die „Rechnungen“ als Reiternamen nennen (Kopfkommentar der Datei und Doc-Kommentar von customerTabs), auf „Aufträge und Rechnungen“ nachziehen. adminOnly und alle anderen Reiter bleiben.

  • Step 4: Test laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/customerTabs.test.mjs Expected: PASS, fail 0.

  • Step 5: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/lib/customerTabs.js tests/unit/customerTabs.test.mjs
git commit -m "Kundenreiter „Rechnungen“ heißt jetzt „Aufträge und Rechnungen“ (id bleibt invoices)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 5: Ansichtslogik – Laden und Ladezustand

Files:

  • Create: src/lib/auftraegeAnsicht.js
  • Test: tests/unit/auftraegeAnsicht.test.mjs (neu)

Interfaces:

  • Consumes: nichts (reine Datei; die API-Funktionen kommen als Argument).

  • Produces, alle aus src/lib/auftraegeAnsicht.js:

    • KONTEN_FEHLER = 'Die Projektübersicht konnte nicht geladen werden.', AUFTRAEGE_FEHLER = 'Die Aufträge konnten nicht geladen werden.'
    • ladeAuftraegeAnsicht(kundeId, { getProjektkonten, listAuftraege }) → Promise<{ konten, auftraege, auftraegeFehler }>: konten ist die Antwort von getProjektkonten, auftraege die Liste oder null, auftraegeFehler Text oder ''.
    • ansichtsZustand({ data, error } = {}) → AnsichtsZustand (data und error kommen aus useFinanceResource):
      { projekteStatus: 'laden' | 'fehler' | 'bereit', fehler: string, nachladeFehler: string,
        auftraegeAktiv: boolean, auftraegeStatus: 'aus' | 'bereit' | 'fehler', auftraegeFehler: string,
        konten: Projektkonto[], ohneProjekt: { berechnetCents, bezahltCents, anzahl }, stripeOk: boolean,
        kunde: { id, name, kundenart } | null, auftraege: AuftragTeam[] }
      
  • Step 1: Failing Test schreiben

tests/unit/auftraegeAnsicht.test.mjs:

import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'

const { KONTEN_FEHLER, AUFTRAEGE_FEHLER, ladeAuftraegeAnsicht, ansichtsZustand } = await loadModule('src/lib/auftraegeAnsicht.js')

// Antwort von getProjektkonten (V5) mit den Feldern, die die Ansicht braucht
const konten = (extra = {}) => ({
  auftraegeAktiv: false,
  kunde: { id: 'k1', name: 'Anelia Wolf', kundenart: 'privat' },
  konten: [{ projektId: 'p1', name: 'Website' }],
  ohneProjekt: { berechnetCents: 5000, bezahltCents: 0, anzahl: 1 },
  stripeOk: true,
  ...extra,
})
const OHNE_PROJEKT_LEER = { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }

// Attrappe für getProjektkonten und listAuftraege: gibt Werte zurück, wirft Fehler und merkt sich die Aufrufe
function fakeApi({ projektkonten, auftraege } = {}) {
  const calls = []
  const antwort = (wert) => {
    if (wert instanceof Error) throw wert
    return wert
  }
  return {
    calls,
    getProjektkonten: async (id) => { calls.push(['konten', id]); return antwort(projektkonten) },
    listAuftraege: async (id) => { calls.push(['auftraege', id]); return antwort(auftraege) },
  }
}

test('Schalter aus: die Aufträge werden gar nicht erst abgerufen', async () => {
  const api = fakeApi({ projektkonten: konten({ auftraegeAktiv: false }), auftraege: new Error('404 darf nie ankommen') })
  const result = await ladeAuftraegeAnsicht('k1', api)
  assert.deepEqual(api.calls, [['konten', 'k1']])
  assert.equal(result.konten.auftraegeAktiv, false)
  assert.equal(result.auftraege, null)
  assert.equal(result.auftraegeFehler, '')
  // ein fehlendes Feld gilt wie „aus“, nur true schaltet an; eine Antwort ohne Objekt wird zu {}
  const fehlt = fakeApi({ projektkonten: { konten: [] } })
  assert.equal((await ladeAuftraegeAnsicht('k1', fehlt)).auftraege, null)
  assert.deepEqual(fehlt.calls, [['konten', 'k1']])
  const nichts = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: null }))
  assert.deepEqual(nichts, { konten: {}, auftraege: null, auftraegeFehler: '' })
})

test('Schalter an: erst die Projektkonten, dann die Aufträge des Kunden', async () => {
  const liste = [{ id: 'a1', nummer: 'AB-2026-0001', status: 'versendet' }]
  const api = fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: { auftraege: liste } })
  const result = await ladeAuftraegeAnsicht('k1', api)
  assert.deepEqual(api.calls, [['konten', 'k1'], ['auftraege', 'k1']])
  assert.deepEqual(result.auftraege, liste)
  assert.equal(result.auftraegeFehler, '')
  // eine Antwort ohne Liste gibt eine leere Liste statt eines Absturzes
  const leer = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: {} }))
  assert.deepEqual(leer.auftraege, [])
})

test('Aufträge scheitern allein: die Projektkonten bleiben, der Fehler steht eigens da', async () => {
  const api = fakeApi({
    projektkonten: konten({ auftraegeAktiv: true }),
    auftraege: Object.assign(new Error('Interner Fehler.'), { status: 500 }),
  })
  const result = await ladeAuftraegeAnsicht('k1', api)
  assert.equal(result.konten.konten.length, 1)
  assert.equal(result.auftraege, null)
  assert.equal(result.auftraegeFehler, 'Interner Fehler.')
  const ohneText = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: new Error('') }))
  assert.equal(ohneText.auftraegeFehler, AUFTRAEGE_FEHLER)
  assert.equal(AUFTRAEGE_FEHLER, 'Die Aufträge konnten nicht geladen werden.')
})

test('Projektkonten scheitern: der Fehler geht weiter, die Aufträge werden nicht abgerufen', async () => {
  const fehler = Object.assign(new Error('Interner Fehler.'), { status: 500 })
  const api = fakeApi({ projektkonten: fehler, auftraege: { auftraege: [] } })
  await assert.rejects(ladeAuftraegeAnsicht('k1', api), (e) => e === fehler)
  assert.deepEqual(api.calls, [['konten', 'k1']])
})

test('ansichtsZustand: vor der ersten Antwort „laden“, nach einem Fehler „fehler“ mit der Meldung', () => {
  const laden = ansichtsZustand({ data: null, error: '' })
  assert.equal(laden.projekteStatus, 'laden')
  assert.equal(laden.auftraegeStatus, 'aus') // der Schalter ist noch unbekannt: kein Abschnitt, kein Flackern
  assert.deepEqual(laden.konten, [])
  assert.equal(ansichtsZustand().projekteStatus, 'laden')
  const fehler = ansichtsZustand({ data: null, error: KONTEN_FEHLER })
  assert.equal(fehler.projekteStatus, 'fehler')
  assert.equal(fehler.fehler, KONTEN_FEHLER)
  assert.equal(fehler.auftraegeStatus, 'aus') // ohne Projektkonten kein Schalter, also auch keine Aufträge
  assert.equal(KONTEN_FEHLER, 'Die Projektübersicht konnte nicht geladen werden.')
})

test('ansichtsZustand: Schalter aus – Projekte bereit, kein Aufträge-Abschnitt', () => {
  const z = ansichtsZustand({ data: { konten: konten({ auftraegeAktiv: false }), auftraege: null, auftraegeFehler: '' }, error: '' })
  assert.equal(z.projekteStatus, 'bereit')
  assert.equal(z.auftraegeAktiv, false)
  assert.equal(z.auftraegeStatus, 'aus')
  assert.equal(z.konten.length, 1)
  assert.deepEqual(z.ohneProjekt, { berechnetCents: 5000, bezahltCents: 0, anzahl: 1 })
  assert.equal(z.stripeOk, true)
  assert.deepEqual(z.kunde, { id: 'k1', name: 'Anelia Wolf', kundenart: 'privat' })
  assert.equal(z.nachladeFehler, '')
})

test('ansichtsZustand: Schalter an – Aufträge bereit oder mit eigenem Fehler, die Projekte bleiben', () => {
  const liste = [{ id: 'a1' }]
  const bereit = ansichtsZustand({ data: { konten: konten({ auftraegeAktiv: true }), auftraege: liste, auftraegeFehler: '' }, error: '' })
  assert.equal(bereit.auftraegeAktiv, true)
  assert.equal(bereit.auftraegeStatus, 'bereit')
  assert.deepEqual(bereit.auftraege, liste)
  const kaputt = ansichtsZustand({
    data: { konten: konten({ auftraegeAktiv: true }), auftraege: null, auftraegeFehler: 'Interner Fehler.' },
    error: '',
  })
  assert.equal(kaputt.projekteStatus, 'bereit')
  assert.equal(kaputt.auftraegeStatus, 'fehler')
  assert.equal(kaputt.auftraegeFehler, 'Interner Fehler.')
  assert.deepEqual(kaputt.auftraege, [])
})

test('ansichtsZustand: Neuladen scheitert – die alten Daten bleiben, die Meldung steht daneben', () => {
  const data = { konten: konten({ auftraegeAktiv: true }), auftraege: [{ id: 'a1' }], auftraegeFehler: '' }
  const z = ansichtsZustand({ data, error: 'Das Kundenportal ist gerade nicht erreichbar.' })
  assert.equal(z.projekteStatus, 'bereit')
  assert.equal(z.fehler, '')
  assert.equal(z.nachladeFehler, 'Das Kundenportal ist gerade nicht erreichbar.')
  assert.equal(z.konten.length, 1)
  assert.equal(z.auftraegeStatus, 'bereit')
  assert.equal(z.auftraege.length, 1)
})

test('ansichtsZustand: unvollständige Antworten brechen die Ansicht nicht', () => {
  for (const data of [{}, { konten: null }, { konten: {} }, { konten: { konten: 'x', ohneProjekt: 5, kunde: 'k' } }]) {
    const z = ansichtsZustand({ data, error: '' })
    assert.equal(z.projekteStatus, 'bereit')
    assert.deepEqual(z.konten, [])
    assert.deepEqual(z.ohneProjekt, OHNE_PROJEKT_LEER)
    assert.equal(z.stripeOk, true)
    assert.equal(z.kunde, null)
    assert.equal(z.auftraegeAktiv, false)
    assert.equal(z.auftraegeStatus, 'aus')
  }
  assert.equal(ansichtsZustand({ data: { konten: konten({ stripeOk: false }) }, error: '' }).stripeOk, false)
})
  • Step 2: Test laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/auftraegeAnsicht.test.mjs Expected: FAIL, src/lib/auftraegeAnsicht.js fehlt.

  • Step 3: src/lib/auftraegeAnsicht.js implementieren

Reine Datei (kein React, kein Appwrite, noch keine Importe), deutscher Kopfkommentar „Ansichtslogik des Reiters Aufträge und Rechnungen“. Regeln:

  • Die zwei Fehlertexte werden exportiert (Wortlaut siehe Interfaces).

  • ladeAuftraegeAnsicht: ruft zuerst getProjektkonten(kundeId); ein Fehler dort wird nicht abgefangen. Ist die Antwort kein Objekt, gilt {}. Nur wenn konten.auftraegeAktiv === true, folgt listAuftraege(kundeId): Erfolg gibt antwort.auftraege, wenn das ein Array ist, sonst []; ein Fehler gibt auftraege: null und auftraegeFehler: err.message, bei leerer Meldung AUFTRAEGE_FEHLER. Sonst auftraege: null, auftraegeFehler: ''. Rückgabe immer { konten, auftraege, auftraegeFehler }.

  • ansichtsZustand({ data, error } = {}):

    • Ohne data: projekteStatus ist 'fehler' (mit fehler = error), wenn error nicht leer ist, sonst 'laden'; auftraegeStatus ist dann 'aus'; konten [], ohneProjekt eine Kopie von { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }, stripeOk true, kunde null, auftraege [].
    • Mit data: projekteStatus: 'bereit', fehler: '', nachladeFehler: error || ''. Gelesen wird aus data.konten (nur wenn Objekt, sonst {}): auftraegeAktiv = (data.konten.auftraegeAktiv === true), konten nur wenn Array, ohneProjekt nur wenn Objekt (sonst die Null-Kopie), stripeOk = (data.konten.stripeOk !== false), kunde nur wenn Objekt (sonst null).
    • auftraegeStatus: 'aus' ohne Schalter, 'fehler' bei Schalter und nicht leerem data.auftraegeFehler, sonst 'bereit'. auftraegeFehler nur im Fall 'fehler'. auftraege ist data.auftraege, wenn Schalter an und Array, sonst [].
  • Step 4: Test laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/auftraegeAnsicht.test.mjs Expected: PASS, pass 9, fail 0.

  • Step 5: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/lib/auftraegeAnsicht.js tests/unit/auftraegeAnsicht.test.mjs
git commit -m "Aufträge und Rechnungen: Laden und Ladezustand des Reiters als reine Funktionen (mit Tests)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 6: Ansichtslogik – Kundenart und Meldungen

Files:

  • Modify: src/lib/auftraegeAnsicht.js, tests/unit/auftraegeAnsicht.test.mjs

Interfaces:

  • Consumes: invoiceDoneMessage(actionKey, rechnung) aus src/lib/financeView.js (liefert für 'create-send' „Rechnung wurde an gesendet.“ und für 'create-draft' „Der Entwurf wurde gespeichert.“).

  • Produces, aus src/lib/auftraegeAnsicht.js:

    • kundeMitKundenart(customer, kundeInfo) → customer': Ist kundeInfo.kundenart eines von 'unternehmen', 'privat', kommt { ...customer, kundenart: <dieser Wert> } zurück (das Dokument selbst bleibt unverändert). In allen anderen Fällen (kundeInfo fehlt, kein Objekt, Wert leer, unbekannt oder keine Zeichenkette) die unveränderte Referenz customer; null bleibt null.
    • erstelltMeldung(rechnung, info) → { tone, text }: wie InvoiceTable.handleCreated: mit info.notice (Zeichenkette) { tone: info.tone || 'ok', text: info.notice }, sonst { tone: 'ok', text: invoiceDoneMessage(info.senden ? 'create-send' : 'create-draft', rechnung) }. info darf fehlen oder null sein.
    • auftragMeldung(auftrag) → { tone, text } für onSaved(auftrag).
  • Step 1: Failing Tests ergänzen

In tests/unit/auftraegeAnsicht.test.mjs die loadModule-Zeile um kundeMitKundenart, erstelltMeldung, auftragMeldung erweitern und anhängen:

test('kundeMitKundenart: ein gültiger Wert des Portals gewinnt gegen das veraltete Kundendokument', () => {
  const doc = { $id: 'k1', name: 'Anelia Wolf', kundenart: '' }
  assert.deepEqual(kundeMitKundenart(doc, { id: 'k1', kundenart: 'privat' }), { ...doc, kundenart: 'privat' })
  assert.equal(doc.kundenart, '') // das Dokument der Seite wird nicht verändert
  assert.equal(kundeMitKundenart({ ...doc, kundenart: 'unternehmen' }, { kundenart: 'privat' }).kundenart, 'privat')
})

test('kundeMitKundenart: ohne gültigen Wert vom Portal bleibt das Dokument (dieselbe Referenz)', () => {
  const doc = { $id: 'k1', kundenart: 'unternehmen' }
  for (const info of [null, undefined, {}, { kundenart: '' }, { kundenart: 'firma' }, { kundenart: 5 }, 'privat']) {
    assert.equal(kundeMitKundenart(doc, info), doc)
  }
  assert.equal(kundeMitKundenart(null, { kundenart: 'privat' }), null)
})

test('erstelltMeldung: gesendet, Entwurf und die Meldung nach festgeschriebener, nicht gesendeter Rechnung', () => {
  assert.deepEqual(
    erstelltMeldung({ number: 'WEBKLAR-0009', customerEmail: 'a@b.de' }, { senden: true }),
    { tone: 'ok', text: 'Rechnung WEBKLAR-0009 wurde an a@b.de gesendet.' },
  )
  assert.deepEqual(erstelltMeldung({ id: 'in_1' }, { senden: false }), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
  assert.deepEqual(erstelltMeldung(null, undefined), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
  assert.deepEqual(erstelltMeldung(null, null), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
  const text = 'Rechnung WEBKLAR-0010 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.'
  assert.deepEqual(erstelltMeldung(null, { senden: true, notice: text, tone: 'warn', finalizedFilter: 'offen' }), { tone: 'warn', text })
  assert.deepEqual(erstelltMeldung(null, { senden: true, notice: text }), { tone: 'ok', text })
})

test('auftragMeldung: Entwurf, gesendet und gesendet ohne zugestellte Mail', () => {
  assert.deepEqual(auftragMeldung({ status: 'entwurf', nummer: '' }), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
  assert.deepEqual(
    auftragMeldung({ status: 'versendet', nummer: 'AB-2026-0001', gesendetAn: 'k@x.de', mailStatus: 'gesendet' }),
    { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0001 wurde an k@x.de gesendet.' },
  )
  assert.deepEqual(
    auftragMeldung({ status: 'versendet', nummer: 'AB-2026-0001', gesendetAn: 'k@x.de', mailStatus: 'fehler' }),
    { tone: 'warn', text: 'Auftragsbestätigung AB-2026-0001 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.' },
  )
  assert.deepEqual(auftragMeldung(null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gespeichert.' })
})
  • Step 2: Test laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/auftraegeAnsicht.test.mjs Expected: FAIL in den vier neuen Tests (kundeMitKundenart is not a function und Verwandte); die neun aus Task 5 bestehen weiter.

  • Step 3: Die drei Funktionen implementieren

In src/lib/auftraegeAnsicht.js import { invoiceDoneMessage } from './financeView' ergänzen. Die erlaubten Kundenarten sind zwei Konstanten 'unternehmen' und 'privat' (Wertebereich von customers.kundenart nach V1; bewusst lokal, auftragView.js gehört W2). auftragMeldung prüft der Reihe nach, mit a = auftrag (Objekt, sonst {}) und name = a.nummer ? 'Auftragsbestätigung <nummer>' : 'Die Auftragsbestätigung':

  1. a.status === 'entwurf' → { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' }
  2. a.mailStatus === 'fehler' → { tone: 'warn', text: '<name> ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.' }
  3. a.nummer → { tone: 'ok', text: '<name> wurde[ an <gesendetAn>] gesendet.' } (der Teil „an …“ nur bei nicht leerem gesendetAn)
  4. sonst → { tone: 'ok', text: 'Die Auftragsbestätigung wurde gespeichert.' }
  • Step 4: Test laufen lassen

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && NODE_OPTIONS=--max-old-space-size=1024 timeout 600 nice -n 10 node --test tests/unit/auftraegeAnsicht.test.mjs Expected: PASS, pass 13, fail 0.

  • Step 5: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/lib/auftraegeAnsicht.js tests/unit/auftraegeAnsicht.test.mjs
git commit -m "Aufträge und Rechnungen: frische Kundenart und Rückmeldungen nach Rechnung und Auftrag (mit Tests)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 7: Platzhalter und Container

Files:

  • Create: src/components/auftraege/ProjektkontoListe.jsx, RechnungDialog.jsx, AuftragListe.jsx, AuftragDialog.jsx, AuftraegeUndRechnungen.jsx

Interfaces:

  • Consumes: Card, Button, EmptyState (../ui), AddressWarning und InvoiceTable (../finance/…, Default-Exporte), useFinanceResource und useScrollIntoView (../../hooks/…, benannte Exporte), getProjektkonten und listAuftraege (../../lib/financeApi, Task 3), customerInvoicesGate (../../lib/financeView), noticeProps (../../lib/financeUi), alles aus ../../lib/auftraegeAnsicht (Task 5 und 6).

  • Produces (Default-Exporte, Dateiname = Komponentenname, Props nach V8):

    • AuftraegeUndRechnungen({ customer, onShowOverview })
    • ProjektkontoListe({ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung, onAuftrag })
    • RechnungDialog({ open, onClose, onCreated, kunde, projektId, auftraegeAktiv, onShowOverview })
    • AuftragListe({ auftraege, konten, onChanged, onBearbeiten, onShowOverview })
    • AuftragDialog({ open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview })

    Die Props entsprechen V8 nach den Nachträgen der Planrunde: Der Container reicht konten und onShowOverview an AuftragListe und AuftragDialog durch und onShowOverview (optional) auch an RechnungDialog.

  • Step 1: Vier Platzhalter schreiben

Jede Datei ist export default function <Dateiname>({ … }) mit der Prop-Signatur aus den Interfaces (destrukturiert, auch wenn nichts davon benutzt wird; sie dokumentiert den Vertrag) und gibt null zurück (kein React-Import nötig), mit einem Kommentarkopf in genau diesem Sinn (Platzhalter von W0, Strang W1 bzw. W2 ersetzt die Datei vollständig, Dateiname, Default-Export und Props bleiben):

  • ProjektkontoListe.jsx (W1): projektId in onRechnung(projektId) und onAuftrag(projektId) ist die Kennung des Projekts, null die Zeile „Ohne Projekt“. Die fertige Komponente rendert ihren Abschnitt selbst: <Card title="Projekte"> (ein <section>; den Abstand zu den Nachbarn liefert .page section + section).

  • RechnungDialog.jsx (W1): kunde ist das Kundendokument oder null (Kundenwahl im Dialog); projektId: undefined = keine Vorauswahl, null = „Ohne Projekt“, Text = dieses Projekt; auftraegeAktiv ist nur der Startwert (danach gilt getProjektkonten, das der Dialog selbst lädt); onShowOverview ist optional („Zur Übersicht“ in der Adresswarnung); onCreated(rechnung, info): info unverändert weiterreichen wie im bisherigen InvoiceCreateModal ({ senden }, bei festgeschriebener, nicht versendeter Rechnung { senden: true, notice, tone: 'warn', finalizedFilter }), danach ruft der Dialog onClose(). Der Dialog sitzt zusätzlich in InvoiceTable.jsx (dort ersetzt W1 das Modal), FinancePage.jsx bleibt unverändert.

  • AuftragListe.jsx (W2): auftraege sind AuftragTeam[] (V3); konten liefern den Projektnamen der Zeile und die Auswahl für „Projekt zuordnen“ (Aktion für ABs ohne Projekt, ruft ordneAuftragProjektZu(id, projektId) aus financeApi.js); onShowOverview ist der Weg „Zur Übersicht“ aus Meldungen. Die fertige Komponente rendert <Card title="Aufträge"> selbst und ruft nach jeder Aktion onChanged(); „Entwurf bearbeiten“ ruft onBearbeiten(auftrag), nach „Neue Fassung“ ruft sie onBearbeiten(neuerEntwurf).

  • AuftragDialog.jsx (W2): kunde kommt mit aktueller kundenart (der Container gleicht sie über kundeMitKundenart ab); projektId ist das vorausgewählte Projekt oder null (freier Titel); auftrag der Entwurf zum Bearbeiten oder null; konten die Projektkonten für die Projektwahl; onShowOverview der Weg „Zur Übersicht“; nach Speichern oder Senden ruft der Dialog onSaved(auftrag) (AuftragTeam, mit mailStatus) und danach onClose(). PDF-Vorschau über openInNewTab(async () => URL.createObjectURL(await getAuftragPdf(id))).

  • Step 2: Container AuftraegeUndRechnungen.jsx schreiben

Aufbau und Regeln (ersetzt CustomerInvoices; deutscher Kopfkommentar zu Aufgabe und Reihenfolge):

  • Außen export default function AuftraegeUndRechnungen({ customer, onShowOverview }): Ist customerInvoicesGate(customer).ok falsch, kommt wie bisher EmptyState mit gate.title, gate.hint und (nur mit onShowOverview) <Button variant="primary" onClick={onShowOverview}>Daten erfassen</Button>; es werden keine Abrufe gestartet. Sonst rendert sie die innere Komponente AuftraegeInhalt (die Hooks stehen erst dort).
  • Innen AuftraegeInhalt({ customer, onShowOverview }):
    • const res = useFinanceResource(() => ladeAuftraegeAnsicht(customer.$id, { getProjektkonten, listAuftraege }), KONTEN_FEHLER); const ansicht = ansichtsZustand(res); const reload = res.reload.
    • Zustand: meldung ({ tone, text } oder null), rechnungsDialog ({ projektId } oder null), auftragsDialog ({ projektId, auftrag } oder null), listeToken (Zahl, Start 0), dazu meldungRef mit useScrollIntoView(meldungRef, meldung).
    • const kunde = kundeMitKundenart(customer, ansicht.kunde) (geht an beide Dialoge).
    • Öffnen eines Dialogs setzt zuerst meldung auf null.
    • RechnungDialog.onCreated(rechnung, info): Dialogzustand null, setMeldung(erstelltMeldung(rechnung, info)), setListeToken((n) => n + 1), reload(). AuftragDialog.onSaved(auftrag): Dialogzustand null, setMeldung(auftragMeldung(auftrag)), reload(). onClose setzt den jeweiligen Zustand auf null (der Dialog ruft onCreated und danach onClose; die Handler sind in jeder Reihenfolge und mehrfach unschädlich).
  • Reihenfolge der Ausgabe (ein Fragment; die Abschnitte sind <section>, alles andere steht davor, damit .page section + section überall den Abstand liefert):
Nr. Element Bedingung Umsetzung
a Adresswarnung immer <AddressWarning customer={customer} onShowOverview={onShowOverview} variant="page" />
b Rückmeldung meldung <p ref={meldungRef} {...noticeProps(meldung.tone, 'fin-notice')}>{meldung.text}</p>
c Nachladefehler projekteStatus === 'bereit' und nachladeFehler <p {...noticeProps('warn', 'fin-notice')}>Die Übersicht konnte nicht neu geladen werden: {nachladeFehler}{' '}<button type="button" className="link" onClick={reload}>Erneut versuchen</button></p>
d Projekte laden <Card title="Projekte"><div className="loading" role="status">Projekte werden geladen …</div></Card>
fehler <Card title="Projekte"><div className="fin-load-error"><p className="msg warn" role="alert">{fehler}</p><Button onClick={reload}>Erneut versuchen</Button></div></Card>
bereit <ProjektkontoListe konten ohneProjekt auftraegeAktiv stripeOk onRechnung onAuftrag /> mit den Werten aus ansicht; onRechnung(projektId) öffnet den Rechnungsdialog, onAuftrag(projektId) den Auftragsdialog mit auftrag: null
e Aufträge auftraegeStatus === 'bereit' <AuftragListe auftraege={ansicht.auftraege} konten={ansicht.konten} onChanged={reload} onBearbeiten={(auftrag) => … Auftragsdialog mit projektId: auftrag.projektId || null und dem Entwurf} onShowOverview={onShowOverview} />
'fehler' <Card title="Aufträge"> mit demselben fin-load-error-Block wie bei Projekten, Text ansicht.auftraegeFehler
'aus' nichts
f Rechnungen immer <InvoiceTable customer={customer} onShowOverview={onShowOverview} refreshToken={listeToken} onChanged={reload} /> (nur diese bisherigen Props)
g Dialoge Zustand gesetzt <RechnungDialog open … kunde={kunde} projektId={rechnungsDialog.projektId} auftraegeAktiv={ansicht.auftraegeAktiv} onShowOverview={onShowOverview} /> bzw. <AuftragDialog open … kunde={kunde} projektId={…} auftrag={…} konten={ansicht.konten} onShowOverview={onShowOverview} />, nur gemountet, solange der Zustand gesetzt ist (frischer Zustand bei jedem Öffnen)
  • Regeln dazu: Fehlt .fin-load-error oder .fin-notice im CSS (Task 1 Step 5), entfallen diese beiden Zusatzklassen. Die Dialoge stehen als Geschwister hinter den Abschnitten, nie innerhalb von InvoiceTable oder .fin-body (dessen container-type würde position: fixed verbiegen). InvoiceTable behält seinen eigenen Knopf „Rechnung schreiben“; der Container fasst ihn nicht an. Kein style, keine neue CSS-Datei, keine Icons.

  • Step 3: Syntaxprüfung

Run:

cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
node_modules/.bin/esbuild src/components/auftraege/*.jsx --loader:.jsx=jsx --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w0-syntax --log-level=warning

Expected: keine Ausgabe, Exit 0 (reine Syntaxprüfung; den Import-Graphen prüft der Build in Task 9).

  • Step 4: Struktur gegen die Tabelle prüfen (Review Focus 1 bis 3)

Run:

cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
rg -n "<InvoiceTable|<AddressWarning|<ProjektkontoListe|<AuftragListe|<RechnungDialog|<AuftragDialog|useFinanceResource\(|ansichtsZustand\(|kundeMitKundenart\(" src/components/auftraege/AuftraegeUndRechnungen.jsx
rg -n "konten=\{ansicht\.konten\}|onShowOverview=\{onShowOverview\}" src/components/auftraege/AuftraegeUndRechnungen.jsx

Expected: im ersten Befehl mindestens ein Treffer je Muster. Im zweiten mindestens dreimal konten={ansicht.konten} (ProjektkontoListe, AuftragListe, AuftragDialog) und mindestens fünfmal onShowOverview={onShowOverview} (AddressWarning, InvoiceTable, RechnungDialog, AuftragListe, AuftragDialog). Beim Lesen der Datei gegenprüfen: <InvoiceTable und <AddressWarning stehen nicht in einem Ausdruck, der von projekteStatus oder auftraegeStatus abhängt (die Rechnungsliste bleibt bei jedem Ausfall der Projektkonten und Aufträge bedienbar); <AuftragListe steht nur im Fall auftraegeStatus === 'bereit'; es gibt keinen Aufruf von listAuftraege außerhalb von ladeAuftraegeAnsicht.

  • Step 5: Hygiene

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && rg -n "react-icons|@tabler|style=\{\{|ui-btn|form-control|form-group|form-label|ui-card|badge-|overlay-content|overlay-close|spinner|kpi|#[0-9a-fA-F]{3,8}\b|rgba?\(|console\.log" src/components/auftraege src/lib/auftraegeAnsicht.js src/lib/auftraegeEndpoints.js Expected: keine Treffer.

  • Step 6: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/components/auftraege/ProjektkontoListe.jsx src/components/auftraege/RechnungDialog.jsx src/components/auftraege/AuftragListe.jsx src/components/auftraege/AuftragDialog.jsx src/components/auftraege/AuftraegeUndRechnungen.jsx
git commit -m "Aufträge und Rechnungen: Container des Kundenreiters und vier Platzhalter für W1 und W2" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 8: Einbindung im Kundenreiter, CustomerInvoices entfällt

Files:

  • Modify: src/pages/CustomerDetailPage.jsx
  • Delete: src/components/finance/CustomerInvoices.jsx

Interfaces:

  • Consumes: AuftraegeUndRechnungen({ customer, onShowOverview }) (Task 7).

  • Produces: Der Reiter invoices der Kundenseite zeigt den Container. Die Adresswarnung (AddressWarning variant="page") und die Sperre „Noch ein Lead“ usw. stecken jetzt im Container und bleiben sichtbar. AddressWarning.jsx selbst bleibt unverändert (Nutzer: der Container und der Rechnungsdialog); CustomerInvoices.jsx hat danach keinen Nutzer mehr und wird gelöscht.

  • Step 1: Seite umstellen

In src/pages/CustomerDetailPage.jsx:

  • Den Import import CustomerInvoices from '../components/finance/CustomerInvoices' ersetzen durch import AuftraegeUndRechnungen from '../components/auftraege/AuftraegeUndRechnungen'.

  • Die Verwendung {activeTab === 'invoices' && <CustomerInvoices customer={customer} onShowOverview={() => setTab('overview')} />} ersetzen durch {activeTab === 'invoices' && <AuftraegeUndRechnungen key={customer.$id} customer={customer} onShowOverview={() => setTab('overview')} />} (der key sorgt dafür, dass ein anderer Kunde nie den Zustand eines früheren erbt).

  • Sonst bleibt die Datei unverändert (laut Übersicht ändert W0 nur Import und Reiterzeile; das Feld Kundenart in der Übersicht gehört W2).

  • Step 2: Alte Datei entfernen

Run:

cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
rg -n "CustomerInvoices" src tests
git rm src/components/finance/CustomerInvoices.jsx
rg -n "CustomerInvoices" src tests

Expected: Vor dem Löschen Treffer nur in CustomerInvoices.jsx selbst (und ggf. in Kommentaren); danach keine Treffer. Bleibt ein Import oder eine Verwendung stehen, wird sie umgestellt, bevor gelöscht wird.

  • Step 3: Syntax und Anker

Run:

cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
node_modules/.bin/esbuild src/pages/CustomerDetailPage.jsx --loader:.jsx=jsx --outdir=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-w0-syntax --log-level=warning
rg -n "AuftraegeUndRechnungen" src/pages/CustomerDetailPage.jsx

Expected: esbuild ohne Ausgabe; zwei Treffer (Import und Verwendung).

  • Step 4: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
git add src/pages/CustomerDetailPage.jsx
git commit -m "Kundenseite: Reiter „Aufträge und Rechnungen“ zeigt den neuen Container, CustomerInvoices entfällt" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

(Die mit git rm vorgemerkte Löschung gehört in denselben Commit.)

Task 9: Selbstprüfung

Files: keine Änderung, außer Korrekturen aus den Prüfungen.

Interfaces:

  • Consumes: alle Ergebnisse der Tasks 1 bis 8.

  • Produces: die Abschlussmeldung an den Orchestrator (Step 6). Von diesem Stand legt der Orchestrator die Worktrees von W1 und W2 an.

  • Step 1: Volle Suite (hinter dem flock)

Run:

cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
free -m | head -2
flock $L/auftraege-schwer.lock env NODE_OPTIONS=--max-old-space-size=1024 timeout 900 nice -n 10 npm test > $L/auftraege-w0-test.log 2>&1; echo "exit $?"
rg "^(ℹ|#) (tests|pass|fail) " $L/auftraege-w0-test.log

Expected: exit 0, fail 0, pass = N0 + 21 (4 Blob, 4 Endpunkte, 13 Ansichtslogik; customerTabs ändert nur eine Erwartung).

  • Step 2: Build (hinter dem flock, mit Speichergrenze)

Run:

cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
L=/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad
free -m | head -2
flock $L/auftraege-schwer.lock systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 nice -n 10 node node_modules/vite/bin/vite.js build --outDir $L/auftraege-w0-dist --emptyOutDir > $L/auftraege-w0-build.log 2>&1; echo "exit $?"
tail -8 $L/auftraege-w0-build.log

Expected: exit 0, die letzten Zeilen nennen die gebauten Dateien und „built in …“, keine Fehlermeldung zu fehlenden Exporten oder Importen. Schlägt der Build fehl, wird die Ursache in der Datei des zuständigen Tasks korrigiert (neuer Commit „Korrektur: …“), danach Step 1 und 2 wiederholt.

  • Step 3: Vertragsliste abhaken (V8)

Run:

cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
for n in getProjektkonten getProjektkonto previewInvoice listAuftraege getAuftrag createAuftrag patchAuftrag deleteAuftrag sendAuftrag erinnereAuftrag neueFassungAuftrag storniereAuftrag ordneAuftragProjektZu getAuftragPdf; do rg -q "\b$n\b" src/lib/financeApi.js && echo "ok $n" || echo "FEHLT $n"; done
rg -n "export const createInvoice|export const getAbgerechnet" src/lib/financeApi.js src/lib/teamApi.js
for f in AuftraegeUndRechnungen ProjektkontoListe RechnungDialog AuftragListe AuftragDialog; do rg -c "export default function $f\b" src/components/auftraege/$f.jsx; done
rg -n "label: 'Aufträge und Rechnungen'" src/lib/customerTabs.js
rg -n "AuftraegeUndRechnungen" src/pages/CustomerDetailPage.jsx

Expected: 14-mal ok …, createInvoice und getAbgerechnet gefunden, fünfmal 1, die Beschriftung und die Einbindung gefunden. Abgehakt wird außerdem (aus den Tests):

  • getAuftragPdf liefert einen Blob (Task 3, Test „getAuftragPdf …“); ordneAuftragProjektZu sendet POST …/auftraege/<id>/projekt mit { projektId } (Task 3, Test „Pfad, Methode, Kopf und Körper …“).

  • Der Container hat die Props { customer, onShowOverview }, die Platzhalter die Props aus V8 samt konten und onShowOverview.

  • Der Container reicht konten und onShowOverview an AuftragListe und AuftragDialog durch, onShowOverview auch an RechnungDialog (Task 7, Step 4).

  • AddressWarning variant="page" steht im Container (Task 7, Zeile a) und CustomerInvoices ist weg (Task 8).

  • Der Reiter bleibt nur für Admins (Test customerTabs).

  • Step 4: Hygiene der neuen Dateien

Run: cd /home/webklar/apps/ticket.webklar.com/dev-auftraege && rg -n "react-icons|@tabler|style=\{\{|ui-btn|form-control|form-group|form-label|ui-card|badge-|overlay-content|overlay-close|spinner|kpi|#[0-9a-fA-F]{3,8}\b|rgba?\(|console\.log" src/components/auftraege src/lib/auftraegeAnsicht.js src/lib/auftraegeEndpoints.js Expected: keine Treffer.

  • Step 5: Dateiliste und Git-Zustand

Run (mit B aus Task 1):

cd /home/webklar/apps/ticket.webklar.com/dev-auftraege
B=<B aus Task 1>
git diff --name-status "$B"..HEAD
git log --oneline "$B"..HEAD
git status --short

Expected: genau diese 17 Pfade (Reihenfolge egal): neu src/components/auftraege/{AuftraegeUndRechnungen,ProjektkontoListe,RechnungDialog,AuftragListe,AuftragDialog}.jsx, src/lib/auftraegeAnsicht.js, src/lib/auftraegeEndpoints.js, tests/unit/{auftraegeAnsicht,auftraegeEndpoints,financeRequestBlob}.test.mjs; geändert src/lib/{customerTabs,financeApi,financeRequest,teamApi}.js, src/pages/CustomerDetailPage.jsx, tests/unit/customerTabs.test.mjs; gelöscht src/components/finance/CustomerInvoices.jsx. Sieben Commits (plus eventuelle „Korrektur“-Commits), git status --short leer. Nichts gepusht.

  • Step 6: Abschlussmeldung an den Orchestrator

Die Antwort nennt: Basis B und N0, letzten Commit (git rev-parse --short HEAD), Testzahl und Build-Ergebnis, die 17 Pfade, Abweichungen von diesem Plan samt Grund, und den Hinweis: „node_modules im Worktree ist echt und kann von W1 und W2 verlinkt werden.“ Dazu der Hinweis, dass der Container V8 samt Nachträgen der Planrunde umsetzt (Kindkomponenten rendern ihre Card selbst; projektId undefined = keine Vorauswahl, null = „Ohne Projekt“; info von onCreated unverändert; konten und onShowOverview durchgereicht).


Vertragsfragen

Alle Fragen aus der Erstfassung dieses Plans sind entschieden (siehe Übersicht, Nachträge nach der Planrunde, V5 und V8); die Übersicht gilt. Die Liste hält fest, was entschieden wurde und wo dieser Plan es umsetzt.

  1. Wer öffnet „Rechnung schreiben“ im Kundenreiter? Entschieden (siehe Übersicht, Nachträge): RechnungDialog sitzt in InvoiceTable.jsx (dort ersetzt W1 das Modal), FinancePage.jsx bleibt unverändert. Umsetzung: Der Container öffnet RechnungDialog nur für die Projektzeilen und nutzt InvoiceTable mit seinen bisherigen Props (customer, onShowOverview, refreshToken, onChanged); der Dialog lädt Projektliste und Schalter selbst über getProjektkonten (auftraegeAktiv ist nur der Startwert). Es bleibt dabei: Nach dem Anlegen über eine Projektzeile behält die Liste ihren Filter, die Meldung des Containers nennt Entwurf oder Rechnung (Task 6 und 7); ein Entwurf liegt dann unter „Entwürfe“.
  2. projektId im RechnungDialog. Entschieden (siehe Übersicht, Nachträge; V8): undefined = keine Vorauswahl (Kartenknopf in InvoiceTable), null = „Ohne Projekt“ (Zeile „Ohne Projekt“), Text = dieses Projekt. Umsetzung: Der Container reicht die Kennung aus onRechnung(projektId) unverändert durch (Task 7, Zeile g).
  3. onCreated(rechnung, info). Entschieden (siehe Übersicht, Nachträge; V8): info unverändert weiterreichen (senden, notice, tone, finalizedFilter). Umsetzung: erstelltMeldung wertet notice und tone aus (Task 6).
  4. Abschnittsüberschriften. Entschieden (siehe Übersicht, Nachträge; V8): Die Kindkomponenten rendern ihre Card samt Titel selbst. Umsetzung: Kommentare der Platzhalter und Container (Zustände „laden“ und „fehler“ mit denselben Titeln, Task 7).
  5. PDF öffnen (W2). Entschieden (siehe Übersicht, Nachträge; V8): openInNewTab(async () => URL.createObjectURL(await getAuftragPdf(id))). Umsetzung: getAuftragPdf liefert den Blob (Task 2 und 3); das Öffnen gehört W2 (Hinweis im Kommentar von AuftragDialog.jsx).
  6. Testbefehl. Entschieden (siehe Übersicht, Nachträge; Tech Stack): Die WOMS-Tests laufen mit npm test (tests/unit/*.test.mjs). Umsetzung: Task 1 Step 6 und Task 9 Step 1.
  7. Form von abgerechnet. Entschieden (siehe Übersicht, Nachträge; V5): { abgerechnet: { <worksheetId>: '<Rechnungsnummer>' | 'Entwurf' } }, für ein unbekanntes Ticket { abgerechnet: {} }. W0 folgt V5 (Task 3); Spec Abschnitt 6 nennt die kürzere Form. Das Abzeichen für 'Entwurf' legt W1 fest.
  8. Dateizuständigkeit WOMS. Entschieden (siehe Übersicht, Nachträge, Absatz „WOMS“): W0 besitzt API-Schicht (financeApi.js, financeRequest.js, teamApi.js, auftraegeEndpoints.js), auftraegeAnsicht.js, customerTabs.js, den Container samt vier Platzhaltern, die Reiterzeile in CustomerDetailPage.jsx und die Löschung von CustomerInvoices.jsx; W1 die Projektliste, den Rechnungsdialog, InvoiceTable.jsx, WorksheetList.jsx, WorksheetTimesModal.jsx und die Löschung von InvoiceCreateModal.jsx; W2 die Auftragsliste, den Auftragsdialog, das Feld Kundenart in CustomerDetailPage.jsx und SettingsCard.jsx. W2 startet nach W0, also gibt es in CustomerDetailPage.jsx keinen Konflikt.
  9. Basis und Push-Reihenfolge. Entschieden (siehe Übersicht, Nachträge): feature/auftraege (WOMS) enthält alle Redesign-Commits bis zum Basis-Tag B. Der Nutzer pusht erst, wenn das Redesign auf test liegt; vorher wird der Zweig auf origin/test aktualisiert (Z2). Die Übergabe nennt B; Task 1 Step 2 meldet, wenn origin/test weiter ist als B.
  10. Prüfwerkzeug des Redesigns. Entschieden (siehe Übersicht, Nachträge): Das Redesign-Werkzeug wird nicht geändert; Bildschirmfotos entstehen mit eigenen Skripten im Scratchpad (W1, W2, Z2). W0 macht keine Bildschirmfotos und fasst das Werkzeug nicht an.
  11. Kundenart, Schreibweg. Entschieden (siehe Übersicht, Nachträge; V5): Die AB-Dialoge (W2) schicken kundenart mit POST und PATCH …/auftraege, das Portal setzt sie am Kunden, wenn dort leer; die Übersicht (W2) schreibt das Feld bei Änderung direkt nach Appwrite. W0 gleicht in beiden Fällen über kunde.kundenart aus der Antwort von getProjektkonten ab (kundeMitKundenart, Task 6 und 7).
  12. onShowOverview im RechnungDialog. Entschieden (siehe Übersicht, Nachträge; V8): optionale Prop des Dialogs. Umsetzung: Der Container reicht sie durch (Task 7, Zeile g).

Mit der Planrunde kamen zwei Festlegungen für W0 dazu, die dieser Plan umsetzt:

  1. ordneAuftragProjektZu(id, projektId). V5 (Zeile L3) hat die Route POST …/auftraege/:id/projekt mit { projektId }; W2 braucht sie für „Projekt zuordnen“ (Spec: „später zuordenbar“). Umsetzung: Task 3 (Pfad in auftraegeEndpoints.js, Test, Export in financeApi.js).
  2. konten und onShowOverview für AuftragListe und AuftragDialog. V8 (Nachträge): Der Container reicht beides durch (Projektname und Auswahl „Projekt zuordnen“ in der Liste, „Zur Übersicht“ in Meldungen). Umsetzung: Task 7 (Props der Platzhalter, Zeilen e und g, Step 4).