84 KiB
Kundenansicht neu, Paket P9: WOMS (Kundenart, Abo-Vorgabe, Funktionen-Reiter) Implementation Plan
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 Ticketsystem (WOMS) erfasst die Kundenart als Pflicht, setzt bei Einrichtungs-Arbeitsblättern die Abo-Vorgabe, zeigt Stundenkonto und Vorschuss und aktiviert Funktionen ohne Preis mit der Wahl „aus dem Abo“ oder „im Projekt enthalten“ (V9 komplett).
Architecture: Neue Logik liegt als reine Module in src/lib (unter Node mit tests/unit/load.mjs testbar), die React-Komponenten rufen sie nur auf. Die Oberfläche bleibt im weißen WOMS-Design und benutzt nur vorhandene Bausteine (.seg, .check, .msg, Stat, Field, Dialog). Die Sichtprüfung läuft im WOMS-Werkzeug, in einem eigenen Strang werkzeug-kundenart mit Testdaten für Kundenart, Stundenkonto und Einrichtungs-Marker und einem Backend-Nachbau von customerAdmin.js (V4) und der Team-Aktivierung (V10).
Tech Stack: React 18, Vite, Appwrite-SDK (direkt) und Portal-API /api/admin, /api/team; Tests node --test über tests/unit/load.mjs (esbuild); Playwright nur über das WOMS-Werkzeug.
Spec: docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md (E3, E4, E9, E17, E21, 5.5, 5.8, C6, C7, C13)
Übersicht/Verträge: /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md (gilt wörtlich; dieses Paket liefert V9 komplett und konsumiert V4, V7, V8, V10)
Worktree und Branch: /home/webklar/apps/ticket.webklar.com/dev-kundenart, Branch feature/kundenart (von origin/test). Tags: kn-p9-kundenart nach Task 7, kn-p9 am Ende. Werkzeug-Strang: /root/reports/ticketsystem-design-2026-10-04/werkzeug-kundenart, Branch strang/kundenart, Tag kn-p9-werkzeug. Alle Pfade im Plan sind relativ zum WOMS-Worktree, Werkzeug-Dateien stehen mit $W/.
Global Constraints
Es gelten alle Global Constraints der Übersicht wörtlich (Aussehen, „du“, keine Preise für Funktionen, Kundenart nur vom Server, Testbefehle WOMS, Commits, Sicherheitsblock, Prod-Daten). Paketeigene Zusätze:
- Vorbereitung (einmalig, kein Commit):
git -C /home/webklar/apps/ticket.webklar.com/build worktree add ../dev-kundenart -b feature/kundenart origin/test, dann im neuen Worktreenpm cimit Speicherbremse (timeout 900 nice -n 10 systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1024 npm ci). Vorherfree -m(mindestens 2000 MB verfügbar, sonst warten und den Controller melden). Die Ausgangszahl N0 der Tests misst Task 1 Schritt 2. Inbuild/wird nichts geändert. - Testbefehle (exakt der WOMS-Befehl der Übersicht;
EINZEL <datei>undALLEstehen für diese Zeilen):ALLE:export TMPDIR=/root/reports/ticketsystem-design-2026-10-04/werkzeug-kundenart/tmp && cd /home/webklar/apps/ticket.webklar.com/dev-kundenart && timeout 900 nice -n 10 systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1024 npm testEINZEL <datei>: derselbe Befehl, am Endenode --test <datei>stattnpm test.
- Werkzeug (Playwright nur hierüber, eine Instanz, die Läufe warten auf die gemeinsame Sperre: Bash-Timeout 600000 ms, nicht abbrechen).
WENVsteht fürsource /root/reports/ticketsystem-design-2026-10-04/werkzeug-kundenart/env.sh &&(setztW,WT=dev-kundenart,TMPDIR=$W/tmp). Ingit -C $W …steht$Wfür diesen absoluten Strang-Pfad. Alle neuen Ansichten stehen im ArrayKUNDENART_ANSICHTENin$W/views.mjs(Task 4 legt es an, die späteren Tasks ergänzen es).SHOTS <ids>:WENV "$W/build.sh" "$WT" kn && "$W/shots.sh" kn "$W/shots/kn" --gate p9 --only <ids>. Exit 0 = grün, Exit 1 = ein Schritt oder eine Prüfung ist rot ($W/shots/kn/report.json), Exit 2 = Fehlbedienung. Ab Task 12 gilt das Gatekn9stattp9.- Nie im Haupt-Werkzeug
/root/reports/ticketsystem-design-2026-10-04/werkzeugarbeiten, nievorschau/vorschau.shmit Ziel aufrufen, Hilfsdateien nur unter$TMPDIR. Nach schweren Läufenls -l /dev/null(musscrw-rw-rw-zeigen).
- Weißes Design: nur vorhandene Klassen und Bausteine, keine neuen Farben, keine Icons, keine neuen Karten (Abschnitt statt Karte). Ladezustand als Text. Texte deutsch, sachlich, mit Umlauten.
- Schnitt: Reine Logik (Entscheidungen, Texte, Rechnen) steht in
src/libund hat einen Unit-Test; Komponenten und Hooks rufen sie nur auf. Das WOMS hat keine DOM-Tests (test/support/fakeDom.jsundh()gehören zum Portal, das Werkzeug aus P0 prüft nur dessen Oberfläche): Eine Komponente ist nur über eine Ansicht des WOMS-Werkzeugs ($W/views.mjs) prüfbar, und diese Ansicht ist ihr fehlschlagender Test. - Kundenart-Werte: nur
'unternehmen'oder'privat'(V4). Alles andere (leer, Großschreibung, Leerzeichen,null) gilt im WOMS als „nicht gesetzt“. Es gibt nie eine Vorauswahl. - Altes Portal: P9 darf gegen das heutige Portal nichts Hartes brechen:
GET …/stundenkontomit 404 bleibt still, ein unbekanntes Feldabrechnungignoriert der alte Server. fix/akquise-sichtbar(5f13553, ändertCreateLeadForm,createLead,leads.js) ist nicht gepusht. P9 baut auforigin/testund hält seine Änderungen an diesen drei Stellen klein (Zustandsfeld, Prüfzeile im Submit, eine Zeile im Payload). Rebase nur auf Anweisung des Controllers; Konfliktstellen: Importzeilen und Signatur voncreateLead, Feldliste inCreateLeadForm.- Commit: Dateien einzeln mit
git add <Datei>,git commit -m "<Betreff>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>". Im Werkzeug-Strang genauso.
Review Focus
Die fünf wahrscheinlichsten Fehlerfälle, häufigster zuerst; jede Zeile nennt den Task, dessen Tests sie festnageln.
- Kundenart fehlt oder ist unbrauchbar (leer,
'Unternehmen',' privat',null): Lead, Kunde, Hochstufen legen nichts an, kein Request geht hinaus, die Meldung steht im Dialog. Tests: Task 3, 4, 5, 6. - „Zugesagt“ bei einem Lead ohne Kundenart oder bei nicht ladbarem Kunden: Arbeitsblatt und Ticketstatus bleiben gespeichert, der Lead bleibt Lead, das Team sieht einen Hinweis. „Abgesagt“ braucht keine Kundenart. Test: Task 6.
- Abo-Vorgabe: Der Abo-Status kommt erst nach dem Öffnen des Dialogs; ein von Hand abgehakter Haken bleibt aus; Kommentar, Hosting-Kunde und
'projekt'bekommen nie eincountsToPlan; das Nachtragen der Zeit eines Git-Blatts überschreibt keine bestehende Entscheidung. Test: Task 8. - Stundenkonto-Anzeige:
ledger: null(Hosting, kein Abo), Nullwerte, krumme Minuten, Ausfall oder 404 des Portals (die Seite bleibt bedienbar, nirgends „undefined“). Test: Task 9. - Funktion aktivieren: „Ja“ ohne Wahl, Wahl bleibt nach „Abbrechen“ stehen, Wiederaktivierung nach Team-Sperre fragt nicht,
abo_fehltmit jedem Grund inklusive unbekanntem, nirgends ein Preis. Tests: Task 10, 11.
Dateiübersicht
| Datei | Aufgabe | Task |
|---|---|---|
src/lib/kundenFelder.js |
Kundenart-Kern: Auswahl, Prüfung, Texte | 3 |
src/components/KundenartAuswahl.jsx (neu), src/styles/global.css (eine Regel nach Z. 413) |
Segment-Schalter .seg |
3 |
src/lib/leads.js (createLead, Z. 58-67), src/pages/CustomersPage.jsx (CreateLeadForm Z. 137-194) |
Pflichtfeld im Lead-Dialog | 4 |
src/pages/CustomersPage.jsx (CreateCustomerForm Z. 197-260; Aufrufer src/pages/AssignProjectPage.jsx:12,284 unverändert) |
Pflichtfeld im Kunden-Dialog | 5 |
src/pages/CustomerDetailPage.jsx (upgrade Z. 198-208), src/lib/worksheetFlow.js, src/pages/TicketPage.jsx (Flow, Hinweis) |
Hochstufen nur mit Kundenart | 6 |
src/lib/customerList.js, src/pages/CustomersPage.jsx (Toolbar Z. 92-101) |
Schalter „Kundenart fehlt“ | 7 |
src/lib/planVorgabe.js (neu), src/components/CreateWorksheetModal.jsx (Z. 34, 133, 221-231), src/hooks/useWorksheets.js (updateWorksheetTimes Z. 233-259), src/pages/TicketPage.jsx (Z. 300-306) |
Abo-Vorgabe Modal und Git-Nachtrag | 8 |
src/lib/stundenkontoView.js, src/hooks/useStundenkonto.js, src/components/customer/StundenkontoLeiste.jsx (neu), src/lib/teamApi.js, src/components/customer/AboStatusCard.jsx, src/pages/TicketPage.jsx |
Stundenkonto und Vorschuss | 9 |
src/lib/funktionenView.js (Z. 22 und neue Funktionen), src/lib/financeRequest.js (Z. 66-68) |
Preis raus, Abrechnung, Fehlertext, grund |
10 |
src/components/customer/CustomerFunktionen.jsx (Z. 306-334, 415-426), src/lib/teamApi.js (setFeature) |
Wahl „Aus dem Abo / Im Projekt enthalten“, abo_fehlt |
11 |
Pins: tests/unit/funktionenView.test.mjs:62 und :109-114, tests/unit/worksheetFlow.test.mjs:5-16 |
Preis weg; Flow braucht getCustomer |
10, 6 |
Tests neu/erweitert: kundenFelder, customerList, worksheetFlow, planVorgabe (neu), stundenkontoView (neu), funktionenView, financeRequest unter tests/unit/*.test.mjs |
3-10 | |
Werkzeug: fixtures/standard.mjs, fixtures/portal.mjs, backend.mjs, endpunkte.md, selftest.mjs (Z. 626-635), views.mjs (Z. 1739-1757, 3567-3734), gates.mjs (Z. 161-174) |
Testdaten, Backend-Nachbau, Ansichten, Gate kn9 |
1, 2, 4-12 |
Task 1: Werkzeug-Strang, Ausgangslage, Lücken
Files:
- Create: Strang-Ordner
/root/reports/ticketsystem-design-2026-10-04/werkzeug-kundenart(git worktree, Branchstrang/kundenartvonmain),strang.env, Symlinknode_modules - Modify:
$W/backend.mjs(neben den Shop-Handlern, Z. 358-360),$W/endpunkte.md - Test:
$W/selftest.mjs
Interfaces:
-
Produces: Strang mit
$W,$WT; MocksGET /api/team/arbeitsblaetter/abgerechnet?ticketId=→{ abgerechnet: {} },GET /api/team/unterkunft/:customerId→{ projects: [] },POST /api/team/unterkunft/:customerId/anfrage→{ ok: true, anfrage: { projectId, status, begruendung } }(Form wie beim Shop); Basisdateien$TMPDIR/basis-selftest.logund$TMPDIR/basis-report.json. -
Step 1: Strang anlegen
R=/root/reports/ticketsystem-design-2026-10-04
git -C $R/werkzeug worktree add ../werkzeug-kundenart -b strang/kundenart main
printf 'export WT=/home/webklar/apps/ticket.webklar.com/dev-kundenart\n' > $R/werkzeug-kundenart/strang.env
ln -s $R/werkzeug/node_modules $R/werkzeug-kundenart/node_modules
Prüfung: WENV pwd druckt /home/webklar/apps/ticket.webklar.com/dev-kundenart; git -C $R/werkzeug-kundenart status --short ist leer.
- Step 2: Ausgangslage messen (Basis, nichts beheben)
Das Werkzeug stammt vom 06.10. und kennt Endpunkte und Reiter von test nicht (Aufträge, Unterkunft, abgerechnet, Reiter „Abgesagt“). Darum zuerst messen, was schon rot ist:
WENV node "$W/selftest.mjs" > "$TMPDIR/basis-selftest.log" 2>&1; echo "Exit $?"
WENV "$W/run-browser.sh" "$W/checks-selftest.mjs" > "$TMPDIR/basis-checks.log" 2>&1; echo "Exit $?"
WENV "$W/build.sh" "$WT" basis && "$W/shots.sh" basis "$W/shots/basis" --gate p9 --only kunden,dlg-lead-neu,dlg-kunde-neu,dlg-kunde-neu-fehler,zuordnen-kunde-neu,kunde-lead,kunde-funktionen,kunde-funktionen-panels,kunde-abo,ticket,ticket-arbeitsblaetter,dlg-arbeitsblatt,dlg-zeiten > "$TMPDIR/basis-shots.log" 2>&1; echo "Exit $?"
cp "$W/shots/basis/report.json" "$TMPDIR/basis-report.json"
Dazu ALLE einmal ausführen und die Zahl # tests als N0 notieren. Die roten Testnamen und Ansichten stehen danach in den Logs. Ab jetzt gilt: P9 macht nichts zusätzlich rot und seine eigenen Tests grün; die Basis wird nicht repariert, ausgenommen Task 1 Schritt 3-5.
- Step 3: Failing test in
$W/selftest.mjs(neben den Backend-Tests, nutztnewBackend,ask,status,parsed,ticket100131)
test('P9-Lücken: abgerechnet und Unterkunft sind nachgebildet', async () => {
const b = newBackend()
const a = await ask(b, 'GET', `${PORTAL}/api/team/arbeitsblaetter/abgerechnet?ticketId=${encodeURIComponent(ticket100131.$id)}`)
assert.deepEqual([status(a), parsed(a)], [200, { abgerechnet: {} }])
const u = await ask(b, 'GET', `${PORTAL}/api/team/unterkunft/c4`)
assert.deepEqual([status(u), parsed(u)], [200, { projects: [] }])
const z = await ask(b, 'POST', `${PORTAL}/api/team/unterkunft/c4/anfrage`, { json: { projectId: 'p4', entscheidung: 'annehmen' } })
assert.deepEqual([status(z), parsed(z).anfrage.status], [200, 'angenommen'])
assert.deepEqual(b.violations, [])
})
- Step 4: Rot
Run: WENV node "$W/selftest.mjs"
Expected: FAIL bei „P9-Lücken“ (Status 404, Verstoß unhandled-request).
- Step 5: Handler und Tabellenzeilen
backend.mjs: drei team(...)-Handler nach dem Muster der Shop-Zeilen (abgerechnet liest c.data.abgerechnet?.[c.query.get('ticketId')] ?? {}; die Anfrage-Antwort kopiert die des Shops). endpunkte.md: drei Zeilen (Quelldatei lib/teamApi.js bzw. hooks/useAbgerechnet.js, Fixture-Schlüssel abgerechnet bzw. unterkunft, Szenario fehler: GET 500, POST normal). Platzhalter nur :customerId und :documentId (stehen in PARAMS).
- Step 6: Grün und Vergleich mit der Basis
Run: WENV node "$W/selftest.mjs" > "$TMPDIR/neu-selftest.log" 2>&1; diff <(rg -N '^FAIL' "$TMPDIR/basis-selftest.log") <(rg -N '^FAIL' "$TMPDIR/neu-selftest.log")
Expected: „P9-Lücken“ ist nicht mehr rot; sonst kein Unterschied.
- Step 7: Commit (Werkzeug)
git -C $W add backend.mjs endpunkte.md selftest.mjs
git -C $W commit -m "Werkzeug P9: abgerechnet und Unterkunft nachgebildet" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
($W hier und unten: /root/reports/ticketsystem-design-2026-10-04/werkzeug-kundenart.)
Task 2: Testdaten und Backend-Nachbau (Kundenart, Stundenkonto, Marker, Aktivieren)
Files:
- Modify:
$W/fixtures/standard.mjs(Kunden Z. 39-75; Tickets nach Z. 121),$W/fixtures/portal.mjs(Z. 36-40, Rückgabe Z. 497),$W/backend.mjs(Z. 203, 349-355, 376-385),$W/endpunkte.md - Test:
$W/selftest.mjs(Pin Z. 626-635 und neue Tests)
Interfaces:
-
Produces (Testdaten):
customers[].kundenart(c1, c2, c4, c5, c8'unternehmen'; c6'privat'; c3, c7, c9, c10'');workorders100131 miteinrichtungFuer: 'termine',einrichtungAbrechnung: 'abo'und 100133 mit'news','projekt'; Schlüsselstundenkontoim Datenobjekt: c1{ produkt: 'hosting', ledger: null }, c2{ produkt: 'wartung', ledger: { balanceMinutes: 240, vorschussMinutes: 0, overageMinutes: 0, nextExpiry: { minutes: 60, at: <ISO in 300 Tagen> } } }, c4{ produkt: 'wartung', ledger: { balanceMinutes: 0, vorschussMinutes: 150, overageMinutes: 30, nextExpiry: null } }. -
Produces (Backend, V4/V8/V10):
GET /api/team/customers/:id/stundenkonto(ohne Eintrag{ produkt: null, ledger: null });POST /api/admin/customersnur mitcode, name, location, email, phone, password, kundenart, ohne gültige Kundenart 400{ error: 'Kundenart ist erforderlich (unternehmen oder privat).', code: 'kundenart_fehlt' };PATCHgleiche Whitelist,kundenartoptional;POST /api/team/funktionen/:id/:keymitabrechnungim Body: ohne passendes Abo 409{ error, code: 'abo_fehlt', grund }(Reihenfolge der Prüfung: kein Eintrag inaboStatus'kein_abo', dannactive: false'nicht_aktiv', dannproductKey: 'hosting''falsches_abo'), sonst ZustandsetupmitticketWoid(sechsstellig); ohneabrechnungbleibt das alte Verhalten (active). -
Step 1: Failing tests in
$W/selftest.mjs
test('P9-Testdaten: Kundenart, Einrichtungs-Tickets, Stundenkonto', () => {
const art = Object.fromEntries(standard.customers.map((c) => [c.$id, c.kundenart]))
assert.deepEqual(art, { c1: 'unternehmen', c2: 'unternehmen', c3: '', c4: 'unternehmen', c5: 'unternehmen', c6: 'privat', c7: '', c8: 'unternehmen', c9: '', c10: '' })
const wo = (woid) => standard.workorders.find((t) => t.woid === woid)
assert.deepEqual([wo('100131').einrichtungFuer, wo('100131').einrichtungAbrechnung], ['termine', 'abo'])
assert.deepEqual([wo('100133').einrichtungFuer, wo('100133').einrichtungAbrechnung], ['news', 'projekt'])
assert.equal(standard.workorders.filter((t) => t.einrichtungAbrechnung).length, 2)
assert.deepEqual(standard.stundenkonto.c4, { produkt: 'wartung', ledger: { balanceMinutes: 0, vorschussMinutes: 150, overageMinutes: 30, nextExpiry: null } })
assert.deepEqual(standard.stundenkonto.c1, { produkt: 'hosting', ledger: null })
})
test('P9-Backend: Stundenkonto, Kundenanlage mit Whitelist und Pflicht, Aktivieren mit Abrechnung', async () => {
const b = newBackend()
assert.equal(parsed(await ask(b, 'GET', `${PORTAL}/api/team/customers/c4/stundenkonto`)).ledger.vorschussMinutes, 150)
assert.deepEqual(parsed(await ask(b, 'GET', `${PORTAL}/api/team/customers/c3/stundenkonto`)), { produkt: null, ledger: null })
const neu = { name: 'Testfirma', email: 'x@example.test', password: 'geheim-1234' }
for (const kundenart of [undefined, '', 'Unternehmen', 'firma', 5]) {
const out = await ask(b, 'POST', `${PORTAL}/api/admin/customers`, { json: { ...neu, kundenart } })
assert.deepEqual([status(out), parsed(out)], [400, { error: 'Kundenart ist erforderlich (unternehmen oder privat).', code: 'kundenart_fehlt' }])
}
const vorher = b.data.customers.length
const ok = await ask(b, 'POST', `${PORTAL}/api/admin/customers`, { json: { ...neu, kundenart: 'privat', customerStatus: 'lost', archived: true, foo: 1 } })
const k = parsed(ok).customer
assert.deepEqual([status(ok), k.kundenart, k.customerStatus, k.archived, 'foo' in k, b.data.customers.length], [201, 'privat', 'customer', false, false, vorher + 1])
const bad = await ask(b, 'PATCH', `${PORTAL}/api/admin/customers/c4`, { json: { kundenart: 'x' } })
assert.deepEqual([status(bad), parsed(bad).code], [400, 'kundenart_fehlt'])
assert.equal(parsed(await ask(b, 'PATCH', `${PORTAL}/api/admin/customers/c4`, { json: { name: 'Neu' } })).customer.kundenart, 'unternehmen')
const akt = (kunde, projectId, key, body = {}) => ask(b, 'POST', `${PORTAL}/api/team/funktionen/${kunde}/${key}`, { json: { projectId, action: 'activate', note: '', ...body } })
const gut = parsed(await akt('c4', 'p4-lang', 'news', { abrechnung: 'projekt' })).projects[0].features.news
assert.deepEqual([gut.state, /^\d{6}$/.test(gut.ticketWoid)], ['setup', true])
for (const [kunde, projekt, grund] of [['c1', 'p1', 'falsches_abo'], ['c5', 'p5', 'nicht_aktiv'], ['c3', 'p3', 'kein_abo']]) {
const out = await akt(kunde, projekt, 'termine', { abrechnung: 'abo' })
assert.deepEqual([status(out), parsed(out).code, parsed(out).grund], [409, 'abo_fehlt', grund], kunde)
}
assert.equal(parsed(await akt('c4', 'p4', 'shop')).projects[0].features.shop.state, 'active') // alter Weg ohne abrechnung
})
Pin selftest.mjs:626-635 („jedes Muster liefert eine Antwort“): POST-Zeilen schicken {}; für POST https://portal.test/api/admin/customers nun den Körper { name: 'Test', email: 'test@example.test', password: 'geheim-1234', kundenart: 'unternehmen' } (kleine Tabelle ZEILEN_BODY nach PARAMS); alles andere bleibt wörtlich.
- Step 2: Rot
Run: WENV node "$W/selftest.mjs"
Expected: FAIL bei beiden neuen Tests (standard.stundenkonto ist undefined, Stundenkonto-Route unbekannt).
- Step 3: Testdaten und Backend
Testdaten wie in Interfaces (Kundenart über eine Tabelle KUNDENART in standard.mjs; die beiden Marker per Object.assign(ticket('100131'), …) nach const ticket Z. 121, damit die Ticketzahl gleich bleibt; stundenkonto in portal.mjs und im zurückgegebenen Objekt). Backend: Whitelist-Konstante ERLAUBT = ['code','name','location','email','phone','password','kundenart'] für POST und PATCH, Fehler über ok({ error, code }, 400) (Muster zeiten/team); die abo_fehlt-Prüfung liest c.data.aboStatus[kunde]. endpunkte.md: neue Zeile für GET …/customers/:customerId/stundenkonto (Fixture stundenkonto, Szenario fehler 500), Antwortform der Zeilen POST/PATCH …/api/admin/customers um kundenart ergänzt, Zeile POST …/funktionen/:customerId/:featureKey um abrechnung und den 409 ergänzt.
- Step 4: Grün und Vergleich mit der Basis
Run: WENV node "$W/selftest.mjs" > "$TMPDIR/neu-selftest.log" 2>&1; diff <(rg -N '^FAIL' "$TMPDIR/basis-selftest.log") <(rg -N '^FAIL' "$TMPDIR/neu-selftest.log")
Expected: beide neuen Tests ok, kein neuer FAIL. Bricht eine bestehende Erwartung durch die neuen Felder, wird sie nur ergänzt, nie gelockert.
- Step 5: Commit (Werkzeug)
git -C $W add fixtures/standard.mjs fixtures/portal.mjs backend.mjs endpunkte.md selftest.mjs
git -C $W commit -m "Werkzeug P9: Testdaten Kundenart, Stundenkonto, Einrichtungs-Marker und Backend-Nachbau" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 3: Kundenart-Kern und Auswahl-Baustein
Files:
- Modify:
src/lib/kundenFelder.js(nachKUNDENART_HINWEIS, Z. 13),src/styles/global.css(eine Regel nach Z. 413) - Create:
src/components/KundenartAuswahl.jsx - Test:
tests/unit/kundenFelder.test.mjs(neue Tests am Ende, Importzeile erweitern; Pins Z. 12-26 bleiben)
Interfaces:
- Produces:
export const KUNDENART_AUSWAHL // [{ value: 'unternehmen', label: 'Unternehmen' }, { value: 'privat', label: 'Privatperson' }]
export const KUNDENART_FEHLT_FEHLER // 'Bitte die Kundenart wählen.'
export const KUNDENART_AUSWAHL_HINWEIS // 'Unternehmen bestellt als Betrieb, Privatperson als Verbraucher mit Widerrufsrecht. Das Portal fragt den Kunden nicht danach.'
export const KUNDENART_HOCHSTUFEN_HINWEIS // 'Bitte zuerst die Kundenart festlegen (unter „Rechnungsdaten“), dann „Zum festen Kunden machen“.'
export function kundenartGesetzt(value) // boolean, nur exakt 'unternehmen' | 'privat'
export function kundenartPruefen(value) // null | KUNDENART_FEHLT_FEHLER
// KundenartAuswahl.jsx
export default function KundenartAuswahl({ value, onChange, disabled = false })
- Step 1: Failing test
test('KUNDENART_AUSWAHL: zwei Einträge ohne den Leer-Eintrag', () => {
assert.deepEqual(KUNDENART_AUSWAHL, [{ value: 'unternehmen', label: 'Unternehmen' }, { value: 'privat', label: 'Privatperson' }])
})
test('kundenartGesetzt und kundenartPruefen: nur exakt unternehmen oder privat', () => {
for (const ok of ['unternehmen', 'privat']) { assert.equal(kundenartGesetzt(ok), true); assert.equal(kundenartPruefen(ok), null) }
for (const bad of ['', ' ', 'Unternehmen', 'unternehmen ', ' privat', 'firma', null, undefined, 5, {}]) {
assert.equal(kundenartGesetzt(bad), false, String(bad))
assert.equal(kundenartPruefen(bad), 'Bitte die Kundenart wählen.', String(bad))
}
})
test('Texte der Kundenart sind festgelegt', () => {
assert.equal(KUNDENART_AUSWAHL_HINWEIS, 'Unternehmen bestellt als Betrieb, Privatperson als Verbraucher mit Widerrufsrecht. Das Portal fragt den Kunden nicht danach.')
assert.equal(KUNDENART_HOCHSTUFEN_HINWEIS, 'Bitte zuerst die Kundenart festlegen (unter „Rechnungsdaten“), dann „Zum festen Kunden machen“.')
})
- Step 2: Rot
Run: EINZEL tests/unit/kundenFelder.test.mjs
Expected: FAIL (kundenartPruefen is not a function).
- Step 3: Implementieren
kundenFelder.js: Konstanten und beide Funktionen; KUNDENART_AUSWAHL ist KUNDENART_OPTIONEN ohne den Eintrag mit value === '' (V9). KundenartAuswahl.jsx: <div className="seg" role="group" aria-label="Kundenart"> mit einem type="button" je Eintrag, aria-pressed={value === o.value}, disabled, Klick ruft onChange(o.value). global.css: .field > .seg { align-self: flex-start; }.
- Step 4: Grün
Run: EINZEL tests/unit/kundenFelder.test.mjs
Expected: PASS (auch die bestehenden Tests dieser Datei).
- Step 5: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-kundenart
git add src/lib/kundenFelder.js src/components/KundenartAuswahl.jsx src/styles/global.css tests/unit/kundenFelder.test.mjs
git commit -m "Kundenart: Prüfung, Texte und Segment-Schalter" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 4: Lead anlegen mit Pflicht-Kundenart
Files:
- Modify:
src/lib/leads.js(createLead),src/pages/CustomersPage.jsx(CreateLeadForm) - Modify (Werkzeug):
$W/views.mjs(neues ArrayKUNDENART_ANSICHTENdirekt überexport const VIEWSZ. 3567, dort...KUNDENART_ANSICHTEN,nach Z. 3734)
Interfaces:
-
Consumes:
kundenartPruefen,KUNDENART_AUSWAHL_HINWEIS(Task 3),KundenartAuswahl(Task 3). -
Produces:
createLead({ company, email, phone, kundenart }, user); wirftError(KUNDENART_FEHLT_FEHLER)bei ungültiger Kundenart (vor jedem Schreibzugriff) und schreibtkundenartins Kundendokument. -
Step 1: Failing Ansichten (
$W/views.mjs; Hilfenbutton,heading,check,orFail,callsTo,dlg,dialogZusind dort im Gültigkeitsbereich)
const KN_LISTE = (page) => orFail(page.waitForFunction(() => document.querySelectorAll('.tabs .tab .count').length >= 4 && !!document.querySelector('tbody tr.row'), undefined, { timeout: 10_000 }), 'Kundenliste: Reiterzähler oder Zeilen fehlen nach 10 s')
const KN_POSTS = (ctx, pfad) => ctx.backend.calls.filter((c) => c.method === 'POST' && new URL(c.url).pathname === pfad)
const KN_GRUPPE = (page) => page.getByRole('dialog').getByRole('group', { name: 'Kundenart' })
const KN_PRESSED = (page) => KN_GRUPPE(page).getByRole('button').evaluateAll((l) => l.map((b) => b.getAttribute('aria-pressed')).join())
const KUNDENART_ANSICHTEN = [
dlg('dlg-lead-kundenart', '/customers', async (page, ctx) => {
await KN_LISTE(page)
await button(page, 'Neuer Lead').click()
await heading(page, 'Neuer Lead (potenzieller Kunde)').waitFor()
const d = page.getByRole('dialog')
check((await KN_GRUPPE(page).getByRole('button').allTextContents()).join('|') === 'Unternehmen|Privatperson', 'dlg-lead-kundenart: Knöpfe Unternehmen und Privatperson fehlen')
check((await KN_PRESSED(page)) === 'false,false', 'dlg-lead-kundenart: eine Kundenart ist vorausgewählt')
await d.locator('#lead-company').fill('Testfirma')
await d.locator('#lead-email').fill('test@example.test')
await d.getByRole('button', { name: 'Lead anlegen + Akquise-Ticket', exact: true }).click()
const m = d.locator('.msg.err')
await orFail(m.first().waitFor({ timeout: 3000 }), 'dlg-lead-kundenart: ohne Kundenart steht keine Meldung im Dialog')
check((await m.first().textContent()).trim() === 'Bitte die Kundenart wählen.', 'dlg-lead-kundenart: falsche Meldung')
check(callsTo(ctx, 'POST', 'customers').length === 0 && callsTo(ctx, 'POST', 'workorders').length === 0, 'dlg-lead-kundenart: es ging ein Schreibzugriff hinaus')
}),
{
id: 'lead-anlegen-kundenart',
path: '/customers',
steps: async (page, ctx) => {
await KN_LISTE(page)
await button(page, 'Neuer Lead').click()
const d = page.getByRole('dialog')
await d.locator('#lead-company').fill('Testfirma')
await d.locator('#lead-email').fill('test@example.test')
await KN_GRUPPE(page).getByRole('button', { name: 'Privatperson', exact: true }).click()
check((await KN_PRESSED(page)) === 'false,true', 'lead-anlegen-kundenart: Privatperson ist nicht gewählt')
await d.getByRole('button', { name: 'Lead anlegen + Akquise-Ticket', exact: true }).click()
await dialogZu(page, 'lead-anlegen-kundenart', 'der Dialog bleibt nach dem Anlegen offen')
const posts = callsTo(ctx, 'POST', 'customers')
check(posts.length === 1 && posts[0].body.data.kundenart === 'privat' && posts[0].body.data.customerStatus === 'lead', 'lead-anlegen-kundenart: das Kundendokument trägt nicht kundenart "privat" und customerStatus "lead"')
check(callsTo(ctx, 'POST', 'workorders').length === 1, 'lead-anlegen-kundenart: kein Akquise-Ticket')
},
},
]
- Step 2: Rot
Run: SHOTS dlg-lead-kundenart,lead-anlegen-kundenart
Expected: Exit 1, step-failed für beide (Gruppe „Kundenart“ fehlt).
- Step 3: Implementieren
CreateLeadForm: Zustand kundenart (Start ''); unter der Zeile field-row-3 <Field label="Kundenart" hint={KUNDENART_AUSWAHL_HINWEIS}><KundenartAuswahl … disabled={busy} /></Field>; im Submit nach der Prüfung des Unternehmens const f = kundenartPruefen(kundenart); if (f) { setError(f); return }; onDone({ …, kundenart }). createLead: dieselbe Prüfung als Wurf am Anfang, kundenart im Payload des Kundendokuments.
- Step 4: Grün
Run: SHOTS dlg-lead-kundenart,lead-anlegen-kundenart,dlg-lead-neu
Expected: Exit 0, Gate p9: ok (dlg-lead-neu aus der Basis darf nicht schlechter werden).
- Step 5: Commit
cd /home/webklar/apps/ticket.webklar.com/dev-kundenart
git add src/lib/leads.js src/pages/CustomersPage.jsx
git commit -m "Leads: Kundenart ist beim Anlegen Pflicht" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git -C $W add views.mjs
git -C $W commit -m "Werkzeug P9: Ansichten Lead mit Kundenart" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 5: Neuer Kunde mit Pflicht-Kundenart (auch im Zuordnen-Assistenten)
Files:
- Modify:
src/pages/CustomersPage.jsx(CreateCustomerForm, exportiert;AssignProjectPage.jsxnutzt dieselbe Komponente und bleibt unverändert) - Modify (Werkzeug):
$W/views.mjs(zwei Ansichten im Array, Ansichtzuordnen-kunde-neuZ. 1739-1757 um eine Zusicherung erweitert)
Interfaces:
-
Consumes: Task 3. Produces: Body an
POST /api/admin/customers={ name, email, code, phone, location, password, kundenart }(V4); die Passwortprüfung („Das Portal-Passwort muss mindestens 8 Zeichen haben.“) bleibt vor der Kundenartprüfung, damitdlg-kunde-neu-fehlersamt Attrappe (checks-selftest.mjs:480-497) unverändert bleibt. -
Step 1: Failing Ansichten
dlg('dlg-kunde-neu-kundenart', '/customers', async (page, ctx) => {
await KN_LISTE(page)
await button(page, 'Neuer Kunde').click()
await heading(page, 'Neuer Kunde mit Portal-Zugang').waitFor()
const d = page.getByRole('dialog')
await d.locator('#customer-name').fill('Testfirma GmbH')
await d.locator('#customer-email').fill('test@example.test')
await d.locator('#customer-password').fill('geheim-1234')
await d.getByRole('button', { name: 'Kunde anlegen', exact: true }).click()
const m = d.locator('.msg.err')
await orFail(m.first().waitFor({ timeout: 3000 }), 'dlg-kunde-neu-kundenart: keine Meldung')
check((await m.first().textContent()).trim() === 'Bitte die Kundenart wählen.', 'dlg-kunde-neu-kundenart: falsche Meldung')
check(KN_POSTS(ctx, '/api/admin/customers').length === 0, 'dlg-kunde-neu-kundenart: es ging ein POST hinaus')
}),
{
id: 'kunde-anlegen-kundenart',
path: '/customers',
steps: async (page, ctx) => {
await KN_LISTE(page)
await button(page, 'Neuer Kunde').click()
const d = page.getByRole('dialog')
await d.locator('#customer-name').fill('Testfirma GmbH')
await d.locator('#customer-email').fill('test@example.test')
await d.locator('#customer-password').fill('geheim-1234')
await KN_GRUPPE(page).getByRole('button', { name: 'Unternehmen', exact: true }).click()
await d.getByRole('button', { name: 'Kunde anlegen', exact: true }).click()
await dialogZu(page, 'kunde-anlegen-kundenart', 'der Dialog bleibt nach dem Anlegen offen')
const posts = KN_POSTS(ctx, '/api/admin/customers')
const b = posts[0]?.body ?? {}
check(posts.length === 1 && posts[0].status === 201 && b.kundenart === 'unternehmen' && Object.keys(b).length === 7, `kunde-anlegen-kundenart: Body ${JSON.stringify(b)}, erwartet 7 Felder mit kundenart "unternehmen"`)
check(ctx.backend.data.customers.some((c) => c.name === 'Testfirma GmbH' && c.kundenart === 'unternehmen'), 'kunde-anlegen-kundenart: der Kunde steht nicht mit Kundenart in den Testdaten')
},
},
In zuordnen-kunde-neu vor submitReachable: check((await KN_GRUPPE(page).getByRole('button').count()) === 2, 'zuordnen-kunde-neu: der Schalter Kundenart fehlt im Dialog').
- Step 2: Rot
Run: SHOTS dlg-kunde-neu-kundenart,kunde-anlegen-kundenart,zuordnen-kunde-neu
Expected: Exit 1, step-failed für alle drei.
- Step 3: Implementieren
CreateCustomerForm: kundenart: '' im Zustand form, Feld „Kundenart“ (wie in Task 4) unter den beiden field-row-3; im Submit nach der Passwortprüfung kundenartPruefen(form.kundenart); onCreate({ ...form, name, email }) trägt kundenart mit (useCustomers.createCustomer reicht alle Felder an customerAdminApi durch).
- Step 4: Grün
Run: SHOTS dlg-kunde-neu-kundenart,kunde-anlegen-kundenart,zuordnen-kunde-neu,dlg-kunde-neu,dlg-kunde-neu-fehler
Expected: Exit 0.
- Step 5: Commit (
src/pages/CustomersPage.jsxim WOMS-Worktree;views.mjsim Strang; Nachrichten „Kunden: Kundenart ist beim Anlegen Pflicht“ und „Werkzeug P9: Ansichten Kunde anlegen mit Kundenart“, ZeileCo-Authored-Bywie oben)
Task 6: Hochstufen nur mit Kundenart („Zum festen Kunden machen“ und „Zugesagt“)
Files:
- Modify:
src/pages/CustomerDetailPage.jsx(upgradeZ. 198-208),src/lib/worksheetFlow.js,src/pages/TicketPage.jsx(Z. 135-147 und Kopf der Seite) - Modify (Werkzeug):
$W/views.mjs - Test:
tests/unit/worksheetFlow.test.mjs(Pin Z. 5-16:runreichtgetCustomerdurch; Z. 18-22 bleiben gültig)
Interfaces:
- Consumes:
kundenartGesetzt,kundenartPruefen,KUNDENART_HOCHSTUFEN_HINWEIS(Task 3). - Produces:
// worksheetFlow.js
export const KUNDENART_ZUGESAGT_HINWEIS // 'Das Arbeitsblatt ist gespeichert, der Lead wurde aber noch nicht zum festen Kunden gemacht: Die Kundenart fehlt. Lege sie beim Kunden unter „Rechnungsdaten“ fest und wähle dann „Zum festen Kunden machen“.'
export async function createWorksheetFlow({ ticket, worksheetData, user, createWorksheet, updateTicket, updateCustomer, getCustomer, now })
// → result von createWorksheet; bei „Zugesagt“ ohne gültige Kundenart { ...result, hinweis: KUNDENART_ZUGESAGT_HINWEIS } und kein updateCustomer
getCustomer(customerId) liefert das Kundendokument. Wirft es, liefert null oder fehlt es (alter Aufrufer), gilt die Kundenart als nicht gesetzt (fail-closed).
- Step 1: Failing tests (Importzeile um
KUNDENART_ZUGESAGT_HINWEISerweitern; inrunStandardgetCustomer: async () => ({ kundenart: 'unternehmen' }), überschreibbar überover.getCustomer, auch mitundefined)
test('Zugesagt mit gesetzter Kundenart (unternehmen, privat): Kunde fest, kein Hinweis', async () => {
for (const kundenart of ['unternehmen', 'privat']) {
const { calls, result } = await run(akq, 'Zugesagt', { getCustomer: async () => ({ kundenart }) })
assert.deepEqual(calls.customer, [['c1', { customerStatus: 'customer', updatedAt: '2026-10-05T10:00:00.000Z' }]], kundenart)
assert.equal(result.hinweis, undefined)
}
})
test('Zugesagt ohne gültige Kundenart: Blatt und Ticketstatus bleiben, der Lead bleibt Lead, Hinweis', async () => {
for (const kundenart of [undefined, '', 'Unternehmen', 'firma', ' privat']) {
const { calls, result } = await run(akq, 'Zugesagt', { getCustomer: async () => ({ kundenart }) })
assert.deepEqual(calls, { ticket: [['t1', { status: 'Zugesagt' }]], customer: [] }, String(kundenart))
assert.equal(result.success, true)
assert.equal(result.hinweis, KUNDENART_ZUGESAGT_HINWEIS)
}
})
test('Kunde nicht ladbar, null oder ohne getCustomer: wie ohne Kundenart', async () => {
for (const getCustomer of [async () => { throw new Error('weg') }, async () => null, undefined]) {
const { calls, result } = await run(akq, 'Zugesagt', { getCustomer })
assert.deepEqual(calls.customer, [])
assert.equal(result.hinweis, KUNDENART_ZUGESAGT_HINWEIS)
}
})
test('Abgesagt und andere Ergebnisse brauchen keine Kundenart und laden den Kunden nicht', async () => {
let geladen = 0
const getCustomer = async () => { geladen += 1; return {} }
assert.deepEqual((await run(akq, 'Abgesagt', { getCustomer })).calls.customer, [['c1', { customerStatus: 'lost', updatedAt: '2026-10-05T10:00:00.000Z' }]])
await run(akq, 'In Kontakt', { getCustomer })
assert.equal(geladen, 0)
})
Failing Ansichten ($W/views.mjs):
{
id: 'kunde-lead-kundenart', path: '/customers/c7', // Lead ohne Kundenart, ohne Adresse
steps: async (page, ctx) => {
await heading(page, 'Gärtnerei Grünblick').waitFor()
await button(page, 'Zum festen Kunden machen').click()
await orFail(seen(page, 'Bitte zuerst die Kundenart festlegen (unter „Rechnungsdaten“), dann „Zum festen Kunden machen“.'), 'kunde-lead-kundenart: der Hinweis zur Kundenart fehlt')
await page.locator('#kunde-kundenart').selectOption('unternehmen')
await button(page, 'Zum festen Kunden machen').click()
await orFail(seen(page, 'Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.'), 'kunde-lead-kundenart: mit Kundenart folgt die bisherige Adressprüfung nicht')
check(callsTo(ctx, 'PATCH', 'customers').length === 0, 'kunde-lead-kundenart: es wurde geschrieben')
},
},
{
id: 'ticket-zugesagt-kundenart', path: '/tickets/100125', // Akquise-Ticket von c7
steps: async (page, ctx) => {
const blatt = async () => {
await ticketReady(page)
await button(page, 'Arbeitsblatt hinzufügen').click()
await heading(page, 'Neues Arbeitsblatt').waitFor()
const d = page.getByRole('dialog')
await d.getByRole('checkbox', { name: 'Nur Kommentar (keine Arbeitszeit)' }).check()
await d.locator('#worksheet-new-status').selectOption('Zugesagt')
await d.locator('#worksheet-details').fill('Kunde hat zugesagt.')
await d.getByRole('button', { name: 'Speichern', exact: true }).click()
await dialogZu(page, 'ticket-zugesagt-kundenart', 'der Dialog bleibt nach dem Speichern offen')
}
await blatt()
await orFail(seen(page, 'Das Arbeitsblatt ist gespeichert, der Lead wurde aber noch nicht zum festen Kunden gemacht'), 'ticket-zugesagt-kundenart: der Hinweis fehlt')
check(callsTo(ctx, 'PATCH', 'customers').length === 0, 'ticket-zugesagt-kundenart: der Lead wurde ohne Kundenart hochgestuft')
check(callsTo(ctx, 'PATCH', 'workorders').some((c) => c.body?.data?.status === 'Zugesagt') && callsTo(ctx, 'POST', 'worksheets').length === 1, 'ticket-zugesagt-kundenart: Blatt oder Ticketstatus fehlen')
kundeNachId(ctx, 'c7').kundenart = 'unternehmen'
await blatt()
const p = callsTo(ctx, 'PATCH', 'customers')
check(p.length === 1 && p[0].body.data.customerStatus === 'customer', 'ticket-zugesagt-kundenart: mit Kundenart wurde der Lead nicht hochgestuft')
check((await page.getByText('noch nicht zum festen Kunden gemacht').count()) === 0, 'ticket-zugesagt-kundenart: der Hinweis steht nach dem Hochstufen noch da')
},
},
- Step 2: Rot
Run: EINZEL tests/unit/worksheetFlow.test.mjs → FAIL (result.hinweis ist undefined, KUNDENART_ZUGESAGT_HINWEIS fehlt). Run: SHOTS kunde-lead-kundenart,ticket-zugesagt-kundenart → Exit 1.
- Step 3: Implementieren
worksheetFlow.js: Ergebnis 'customer' lädt zuerst getCustomer?.(ticket.customerId) in einem try/catch und prüft kundenartGesetzt(doc?.kundenart); ohne Kundenart kein updateCustomer und Rückgabe { ...result, hinweis }; die Zweige „lost“ und „keine Änderung“ laden nichts. TicketPage.jsx: getCustomer: (id) => databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id) an den Flow, Zustand hinweis (mit result?.hinweis || '' bei jedem Blatt neu gesetzt), Anzeige <p className="msg warn" role="alert"> über den Reitern. CustomerDetailPage.upgrade: vor der Adressprüfung if (kundenartPruefen(kundenart)) { setMsg({ tone: 'warn', text: KUNDENART_HOCHSTUFEN_HINWEIS }); return } (kundenart ist der gewählte Zustand der Seite). Hinweistext im Feld „Akquise-Ergebnis“ von CreateWorksheetModal.jsx:196: „Zugesagt“ macht den Lead zum festen Kunden, sobald seine Kundenart festgelegt ist.
- Step 4: Grün
Run: EINZEL tests/unit/worksheetFlow.test.mjs → PASS. Run: SHOTS kunde-lead-kundenart,ticket-zugesagt-kundenart,kunde-lead,ticket → Exit 0.
- Step 5: Commit (
src/lib/worksheetFlow.js,src/pages/TicketPage.jsx,src/pages/CustomerDetailPage.jsx,src/components/CreateWorksheetModal.jsx,tests/unit/worksheetFlow.test.mjs;views.mjsim Strang; Nachrichten „Lead zum festen Kunden nur mit Kundenart“ und „Werkzeug P9: Ansichten Hochstufen“)
Task 7: Kundenliste: Schalter „Kundenart fehlt“
Files:
- Modify:
src/lib/customerList.js,src/pages/CustomersPage.jsx(Toolbar Z. 92-101,filtered/countsZ. 21-24) - Modify (Werkzeug):
$W/views.mjs - Test:
tests/unit/customerList.test.mjs(neue Tests;CUSTOMER_TABSund die bestehenden Tests bleiben unverändert)
Interfaces:
- Consumes:
kundenartGesetzt(Task 3;customerList.jsimportiert es, die Datei bleibt frei von React und Appwrite). - Produces:
export function kundenartFehlt(customer) // boolean, !kundenartGesetzt(customer?.kundenart)
export function kundenartFehltAnzahl(customers) // nicht archivierte Leads und feste Kunden ohne Kundenart
export function filterCustomers(customers, { stage = 'customer', search = '', nurKundenartFehlt = false } = {})
export function stageCounts(customers, search = '', { nurKundenartFehlt = false } = {})
- Step 1: Failing tests
const KA = [
{ $id: 'k1', name: 'Eins', customerStatus: 'customer', kundenart: 'unternehmen' },
{ $id: 'k2', name: 'Zwei', customerStatus: 'customer' },
{ $id: 'k3', name: 'Drei', customerStatus: 'lead', kundenart: '' },
{ $id: 'k4', name: 'Vier', customerStatus: 'lead', kundenart: 'privat' },
{ $id: 'k5', name: 'Fünf', customerStatus: 'lost' },
{ $id: 'k6', name: 'Sechs', customerStatus: 'customer', archived: true },
{ $id: 'k7', name: 'Sieben', customerStatus: 'customer', kundenart: ' unternehmen' },
]
test('kundenartFehlt und kundenartFehltAnzahl: Lead und fester Kunde, ohne Archiv und Abgesagte', () => {
assert.equal(kundenartFehlt({}), true); assert.equal(kundenartFehlt({ kundenart: 'privat' }), false); assert.equal(kundenartFehlt(null), true)
assert.equal(kundenartFehltAnzahl(KA), 3) // k2, k3, k7
assert.equal(kundenartFehltAnzahl([]), 0)
})
test('filterCustomers mit nurKundenartFehlt: je Reiter, mit Suche; ohne Schalter unverändert', () => {
const f = (opts) => ids(filterCustomers(KA, opts))
assert.deepEqual(f({ stage: 'customer', nurKundenartFehlt: true }), ['k2', 'k7'])
assert.deepEqual(f({ stage: 'lead', nurKundenartFehlt: true }), ['k3'])
assert.deepEqual(f({ stage: 'lost', nurKundenartFehlt: true }), ['k5'])
assert.deepEqual(f({ stage: 'archived', nurKundenartFehlt: true }), ['k6'])
assert.deepEqual(f({ stage: 'all', nurKundenartFehlt: true }), ['k2', 'k3', 'k5', 'k7'])
assert.deepEqual(f({ stage: 'all', search: 'zwei', nurKundenartFehlt: true }), ['k2'])
assert.deepEqual(f({ stage: 'customer' }), ['k1', 'k2', 'k7'])
})
test('stageCounts mit nurKundenartFehlt: dieselbe Filterfunktion wie die Liste', () => {
assert.deepEqual(stageCounts(KA, '', { nurKundenartFehlt: true }), { customer: 2, lead: 1, lost: 1, archived: 1, all: 4 })
assert.deepEqual(stageCounts(KA), { customer: 3, lead: 2, lost: 1, archived: 1, all: 6 })
})
Failing Ansicht:
{
id: 'kunden-kundenart-fehlt', path: '/customers',
steps: async (page) => {
await KN_LISTE(page)
const schalter = page.getByLabel('Kundenart fehlt', { exact: false })
await orFail(seen(page, 'Kundenart fehlt (2)'), 'kunden-kundenart-fehlt: der Schalter "Kundenart fehlt (2)" fehlt (c3 und c7)')
await schalter.check()
const titel = async () => (await kundenZeilen(page)).map((z) => z.titel)
await poll(page, async () => ({ ok: (await titel()).join('|') === 'Autohaus Lindner', detail: (await titel()).join('|') }), 'kunden-kundenart-fehlt: Feste Kunden', 5000)
await kundenReiter(page, 'Leads')
await poll(page, async () => ({ ok: (await titel()).join('|') === 'Gärtnerei Grünblick', detail: (await titel()).join('|') }), 'kunden-kundenart-fehlt: Leads', 5000)
await kundenReiter(page, 'Alle')
await poll(page, async () => ({ ok: (await titel()).length === 3, detail: (await titel()).join('|') }), 'kunden-kundenart-fehlt: Alle (c3, c7, c10)', 5000)
await schalter.uncheck(); await kundenReiter(page, 'Feste Kunden')
await poll(page, async () => ({ ok: (await page.locator('tbody tr.row').count()) === 6, detail: 'ohne Schalter nicht 6 feste Kunden' }), 'kunden-kundenart-fehlt: zurück', 5000)
},
},
- Step 2: Rot
Run: EINZEL tests/unit/customerList.test.mjs → FAIL (kundenartFehltAnzahl is not a function). Run: SHOTS kunden-kundenart-fehlt → Exit 1.
- Step 3: Implementieren
customerList.js wie in den Signaturen; CUSTOMER_TABS bleibt unangetastet. CustomersPage: Zustand nurFehlt, <label className="check"><input type="checkbox" … />Kundenart fehlt{loading ? '' : (${kundenartFehltAnzahl(customers)})}</label> in der .toolbar, die Schalterstellung geht in filterCustomers und stageCounts.
- Step 4: Grün
Run: EINZEL tests/unit/customerList.test.mjs tests/unit/customerTabs.test.mjs → PASS. Run: SHOTS kunden-kundenart-fehlt,kunden → Exit 0.
- Step 5: Commit und Zwischentag
cd /home/webklar/apps/ticket.webklar.com/dev-kundenart
git add src/lib/customerList.js src/pages/CustomersPage.jsx tests/unit/customerList.test.mjs
git commit -m "Kundenliste: Schalter Kundenart fehlt" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git -C $W add views.mjs && git -C $W commit -m "Werkzeug P9: Ansicht Kundenart fehlt" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git tag kn-p9-kundenart
Mit diesem Tag ist die Kundenart-Pflicht in sich fertig: Der Inhaber kann sie vor dem Portal pushen (Vertragsfrage 3).
Task 8: Abo-Vorgabe für Einrichtungs-Arbeitsblätter
Files:
- Create:
src/lib/planVorgabe.js - Modify:
src/components/CreateWorksheetModal.jsx(Z. 34, 133, 221-231),src/hooks/useWorksheets.js(updateWorksheetTimesZ. 233-259),src/pages/TicketPage.jsx(onSaveZ. 304) - Modify (Werkzeug):
$W/views.mjs - Test:
tests/unit/planVorgabe.test.mjs
Interfaces:
- Consumes:
isGitWorksheet,needsTimeEntryaussrc/lib/ticketForm.js; Ticket-FeldereinrichtungAbrechnung(V7) undaboHoursAllowedaususeCustomerAbo. - Produces:
export const EINRICHTUNG_ABRECHNUNGEN // ['abo', 'projekt']
export function einrichtungAbrechnung(ticket) // 'abo' | 'projekt' | '' (nur exakt diese Werte)
export function countsToPlanVorgabe(ticket, aboHoursAllowed) // true nur bei 'abo' und aboHoursAllowed === true
export function countsToPlanWert({ gewaehlt, ticket, aboHoursAllowed, isComment }) // boolean; gewaehlt: null (unberührt) | boolean
export function nachtragPlanFelder({ worksheet, ticket, aboHoursAllowed }) // {} | { countsToPlan: true }
// useWorksheets: updateWorksheetTimes(worksheet, times, currentUser, employee, extra = {}) // extra wird in die Felder des Updates gemischt
- Step 1: Failing tests (
tests/unit/planVorgabe.test.mjs)
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { EINRICHTUNG_ABRECHNUNGEN, einrichtungAbrechnung, countsToPlanVorgabe, countsToPlanWert, nachtragPlanFelder } = await loadModule('src/lib/planVorgabe.js')
const abo = { einrichtungAbrechnung: 'abo' }, projekt = { einrichtungAbrechnung: 'projekt' }
test('einrichtungAbrechnung: nur exakt abo oder projekt', () => {
assert.deepEqual(EINRICHTUNG_ABRECHNUNGEN, ['abo', 'projekt'])
assert.deepEqual([abo, projekt, {}, null, undefined, { einrichtungAbrechnung: 'Abo' }, { einrichtungAbrechnung: 5 }].map(einrichtungAbrechnung), ['abo', 'projekt', '', '', '', '', ''])
})
test('countsToPlanVorgabe: nur Abo-Einrichtung mit erlaubten Stunden', () => {
assert.equal(countsToPlanVorgabe(abo, true), true)
for (const [t, ok] of [[abo, false], [abo, undefined], [projekt, true], [{}, true], [null, true]]) assert.equal(countsToPlanVorgabe(t, ok), false)
})
test('countsToPlanWert: Vorgabe, späte Antwort des Abo-Status, Handwahl, Kommentar', () => {
const w = (o) => countsToPlanWert({ gewaehlt: null, ticket: abo, aboHoursAllowed: true, isComment: false, ...o })
assert.equal(w({}), true) // unberührt: Vorgabe
assert.equal(w({ aboHoursAllowed: false }), false) // Abo-Status lädt noch
assert.equal(w({ aboHoursAllowed: true }), true) // …und kommt danach: die Vorgabe greift trotzdem
assert.equal(w({ gewaehlt: false }), false) // von Hand abgehakt bleibt aus
assert.equal(w({ gewaehlt: true, ticket: projekt }), true) // von Hand anhaken ist immer möglich
assert.equal(w({ gewaehlt: true, aboHoursAllowed: false }), false)
assert.equal(w({ ticket: projekt }), false)
assert.equal(w({ ticket: {} }), false)
for (const gewaehlt of [null, true, false]) assert.equal(w({ gewaehlt, isComment: true }), false)
})
test('nachtragPlanFelder: nur das erste Nachtragen einer Git-Zeit auf einer Abo-Einrichtung', () => {
const git = { serviceType: 'GIT', isComment: true, startTime: '', totalTime: 0, countsToPlan: false }
const f = (o) => nachtragPlanFelder({ worksheet: git, ticket: abo, aboHoursAllowed: true, ...o })
assert.deepEqual(f({}), { countsToPlan: true })
assert.deepEqual(f({ ticket: projekt }), {})
assert.deepEqual(f({ ticket: {} }), {})
assert.deepEqual(f({ aboHoursAllowed: false }), {})
assert.deepEqual(f({ worksheet: { ...git, startTime: '0900', totalTime: 45 } }), {}) // Zeit ist schon eingetragen: Entscheidung bleibt
assert.deepEqual(f({ worksheet: { ...git, countsToPlan: true } }), {})
assert.deepEqual(f({ worksheet: { serviceType: 'Remote', totalTime: 0, startTime: '' } }), {})
})
Failing Ansichten:
dlg('dlg-arbeitsblatt-einrichtung', '/tickets/100131', async (page) => { // c4, Abo-Einrichtung, Wartungs-Abo aktiv
await ticketReady(page)
await button(page, 'Arbeitsblatt hinzufügen').click()
await heading(page, 'Neues Arbeitsblatt').waitFor()
const abo = page.getByRole('checkbox', { name: 'Vom Abo-Stundenkonto abziehen' })
await orFail(abo.waitFor({ timeout: 5000 }), 'dlg-arbeitsblatt-einrichtung: der Haken fehlt')
await poll(page, async () => ({ ok: await abo.isChecked(), detail: 'der Haken ist nicht vorbelegt' }), 'dlg-arbeitsblatt-einrichtung', 5000)
await abo.uncheck(); await page.waitForTimeout(500)
check(!(await abo.isChecked()), 'dlg-arbeitsblatt-einrichtung: ein abgehakter Haken springt zurück')
await page.getByRole('checkbox', { name: 'Nur Kommentar (keine Arbeitszeit)' }).check()
check((await abo.isDisabled()) && !(await abo.isChecked()), 'dlg-arbeitsblatt-einrichtung: bei Kommentar muss der Haken aus und gesperrt sein')
}),
dlg('dlg-arbeitsblatt-projekt', '/tickets/100133', async (page) => { // c2, im Projekt enthalten: kein Haken
await ticketReady(page)
await button(page, 'Arbeitsblatt hinzufügen').click()
await heading(page, 'Neues Arbeitsblatt').waitFor()
const abo = page.getByRole('checkbox', { name: 'Vom Abo-Stundenkonto abziehen' })
await orFail(abo.waitFor({ timeout: 5000 }), 'dlg-arbeitsblatt-projekt: der Haken fehlt')
await page.waitForTimeout(500)
check(!(await abo.isChecked()), 'dlg-arbeitsblatt-projekt: bei "im Projekt enthalten" darf der Haken nicht gesetzt sein')
}),
{
id: 'zeit-nachtragen-einrichtung', path: TICKET_ARBEITSBLAETTER, // Git-Blatt 100143 von knso ohne Zeit
steps: async (page, ctx) => {
await worksheetsReady(page)
await button(page, 'Zeit nachtragen').first().click()
await heading(page, 'Zeit nachtragen').waitFor()
const d = page.getByRole('dialog')
await d.locator('#worksheet-times-start').fill('2026-10-02')
await d.getByLabel('Uhrzeit Beginn').fill('09:00')
await d.locator('#worksheet-times-end').fill('2026-10-02')
await d.getByLabel('Uhrzeit Ende').fill('10:30')
await d.getByRole('button', { name: 'Speichern', exact: true }).click()
await dialogZu(page, 'zeit-nachtragen-einrichtung', 'der Dialog bleibt nach dem Speichern offen')
const p = callsTo(ctx, 'PATCH', 'worksheets')
const data = p[0]?.body?.data ?? {}
check(p.length === 1 && data.countsToPlan === true && data.totalTime === 90 && data.isComment === false, `zeit-nachtragen-einrichtung: Update ${JSON.stringify(data)}, erwartet countsToPlan true, totalTime 90, isComment false`)
},
},
- Step 2: Rot
Run: EINZEL tests/unit/planVorgabe.test.mjs → FAIL (Modul fehlt). Run: SHOTS dlg-arbeitsblatt-einrichtung,dlg-arbeitsblatt-projekt,zeit-nachtragen-einrichtung → Exit 1.
- Step 3: Implementieren
planVorgabe.js nach den Signaturen und Tests (reine Vergleiche, keine Zeitlogik). CreateWorksheetModal: initialForm.countsToPlan wird null (= unberührt); Haken checked={countsToPlanWert({ gewaehlt: formData.countsToPlan, ticket: workorder, aboHoursAllowed, isComment: formData.isComment })}, onChange schreibt den Haken als Boolean; worksheetData.countsToPlan ist derselbe Aufruf von countsToPlanWert. useWorksheets.updateWorksheetTimes: fünfter Parameter extra = {}, die Felder des Updates werden { startDate, startTime, endDate, endTime, totalTime, ...extra }. TicketPage: onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee, nachtragPlanFelder({ worksheet: ws, ticket, aboHoursAllowed }))}.
- Step 4: Grün
Run: EINZEL tests/unit/planVorgabe.test.mjs tests/unit/worksheetView.test.mjs → PASS. Run: SHOTS dlg-arbeitsblatt-einrichtung,dlg-arbeitsblatt-projekt,zeit-nachtragen-einrichtung,dlg-arbeitsblatt,dlg-zeiten,ticket-arbeitsblaetter → Exit 0.
- Step 5: Commit (
src/lib/planVorgabe.js,src/components/CreateWorksheetModal.jsx,src/hooks/useWorksheets.js,src/pages/TicketPage.jsx,tests/unit/planVorgabe.test.mjs;views.mjsim Strang; Nachrichten „Arbeitsblätter: Abo-Vorgabe bei Einrichtungs-Tickets“ und „Werkzeug P9: Ansichten Abo-Vorgabe“)
Task 9: Stundenkonto und Vorschuss am Ticket und beim Kunden
Files:
- Create:
src/lib/stundenkontoView.js,src/hooks/useStundenkonto.js,src/components/customer/StundenkontoLeiste.jsx - Modify:
src/lib/teamApi.js(nachgetCustomerAboStatus, Z. 138),src/components/customer/AboStatusCard.jsx,src/pages/TicketPage.jsx - Modify (Werkzeug):
$W/views.mjs - Test:
tests/unit/stundenkontoView.test.mjs
Interfaces:
- Consumes: V8
GET /api/team/customers/:id/stundenkonto→{ produkt: 'hosting'|'wartung'|null, ledger: { balanceMinutes, vorschussMinutes, overageMinutes, nextExpiry: { minutes, at } | null } | null };stepLabel(timeSteps.js),fmtDate(format.js); Ticket-FeldeinrichtungAbrechnungundeinrichtungAbrechnung()(Task 8). - Produces:
export function stundenkontoAnsicht(antwort) // null (alles, was kein Objekt ist, auch Arrays) | { art: 'konto'|'hosting'|'keins', hinweis: string, eintraege: [{ key, label, wert, sub, ton: ''|'warn' }] }
export function einrichtungText(ticket) // '' | Satz zur Abrechnung der Einrichtung
export function useStundenkonto(customerId, { enabled = true, reloadKey = '' } = {}) // → { antwort, loading }; Fehler (auch 404) bleiben still (console.warn), veraltete Antworten werden verworfen
export const getCustomerStundenkonto = (customerId) => request(`/customers/${enc(customerId)}/stundenkonto`) // teamApi.js
export default function StundenkontoLeiste({ customerId, reloadKey = '' }) // .stats mit <Stat>; nichts bei null
- Step 1: Failing tests (
tests/unit/stundenkontoView.test.mjs, Kopf wie beiplanVorgabe.test.mjs,loadModule('src/lib/stundenkontoView.js'))
test('Vorschuss ohne Guthaben (Beispiel der Spec: 2,5 Stunden)', () => {
assert.deepEqual(stundenkontoAnsicht({ produkt: 'wartung', ledger: { balanceMinutes: 0, vorschussMinutes: 150, overageMinutes: 30, nextExpiry: null } }), {
art: 'konto', hinweis: '', eintraege: [
{ key: 'guthaben', label: 'Guthaben', wert: '0 min', sub: '', ton: '' },
{ key: 'vorschuss', label: 'Vorschuss (Einrichtung)', wert: '2 h 30 min', sub: 'wird mit den nächsten Gutschriften verrechnet', ton: 'warn' },
{ key: 'mehraufwand', label: 'Mehraufwand', wert: '30 min', sub: '', ton: 'warn' },
] })
})
test('Guthaben mit Verfall; Vorschuss und Mehraufwand fehlen bei 0', () => {
const a = stundenkontoAnsicht({ produkt: 'wartung', ledger: { balanceMinutes: 240, vorschussMinutes: 0, overageMinutes: 0, nextExpiry: { minutes: 60, at: '2027-08-01T12:00:00.000Z' } } })
assert.deepEqual(a.eintraege.map((e) => [e.key, e.wert, e.sub]), [['guthaben', '4 h', ''], ['verfall', '1 h', 'am 01.08.2027']])
})
test('Hosting, kein Abo, unbrauchbare Antworten und Minuten', () => {
assert.deepEqual(stundenkontoAnsicht({ produkt: 'hosting', ledger: null }), { art: 'hosting', hinweis: 'Hosting-Abo: kein Stundenkonto.', eintraege: [] })
assert.deepEqual(stundenkontoAnsicht({ produkt: null, ledger: null }), { art: 'keins', hinweis: 'Kein Abo: kein Stundenkonto.', eintraege: [] })
for (const x of [null, undefined, 'x', 5, []]) assert.equal(stundenkontoAnsicht(x), null)
const k = stundenkontoAnsicht({ produkt: 'wartung', ledger: { balanceMinutes: 85, vorschussMinutes: -5, overageMinutes: 'abc', nextExpiry: { minutes: 0, at: 'kaputt' } } })
assert.deepEqual(k.eintraege.map((e) => [e.key, e.wert]), [['guthaben', '1 h 25 min']])
})
test('einrichtungText', () => {
assert.equal(einrichtungText({ einrichtungAbrechnung: 'abo' }), 'Einrichtung aus dem Abo: Die Arbeitszeit geht aufs Stundenkonto des Kunden, auch wenn das Guthaben nicht reicht.')
assert.equal(einrichtungText({ einrichtungAbrechnung: 'projekt' }), 'Einrichtung im Projekt enthalten: Die Arbeitszeit zählt nicht aufs Abo-Stundenkonto.')
assert.equal(einrichtungText({}), '')
})
Failing Ansichten:
{
id: 'ticket-einrichtung-abo', path: TICKET_ARBEITSBLAETTER, // c4: Vorschuss 150, Mehraufwand 30
steps: async (page) => {
await worksheetsReady(page)
await orFail(seen(page, 'Einrichtung aus dem Abo'), 'ticket-einrichtung-abo: der Satz zur Abrechnung fehlt')
const text = await page.evaluate(() => document.body.innerText.replace(/\s+/g, ' '))
for (const t of ['Guthaben 0 min', 'Vorschuss (Einrichtung) 2 h 30 min', 'wird mit den nächsten Gutschriften verrechnet', 'Mehraufwand 30 min']) check(text.includes(t), `ticket-einrichtung-abo: "${t}" fehlt`)
await keinNull(page, 'ticket-einrichtung-abo')
},
},
{
id: 'ticket-einrichtung-projekt', path: '/tickets/100133', // c2: im Projekt enthalten, kein Stundenkonto-Abruf
steps: async (page, ctx) => {
await ticketReady(page)
await orFail(seen(page, 'Einrichtung im Projekt enthalten'), 'ticket-einrichtung-projekt: der Satz fehlt')
check(!(await page.evaluate(() => document.body.innerText)).includes('Vorschuss'), 'ticket-einrichtung-projekt: zeigt einen Vorschuss')
check(!ctx.backend.calls.some((c) => new URL(c.url).pathname.endsWith('/stundenkonto')), 'ticket-einrichtung-projekt: ruft das Stundenkonto ab')
},
},
{
id: 'ticket-einrichtung-ausfall', path: TICKET_ARBEITSBLAETTER, scenario: 'fehler-portal', // jede Portal-Antwort ist 500, wie ein altes Portal ohne die Route
steps: async (page) => {
await worksheetsReady(page)
await orFail(seen(page, 'Einrichtung aus dem Abo'), 'ticket-einrichtung-ausfall: der Satz zur Abrechnung fehlt (er kommt aus dem Ticket, nicht aus dem Portal)')
const text = await page.evaluate(() => document.body.innerText)
check(!/Guthaben|Vorschuss \(Einrichtung\)/.test(text), 'ticket-einrichtung-ausfall: Stundenkonto-Werte trotz Ausfall')
await keinNull(page, 'ticket-einrichtung-ausfall')
},
},
{
id: 'kunde-abo-stundenkonto', path: '/customers/c4?tab=abo',
steps: async (page) => {
await heading(page, 'Physiotherapie am Markt').waitFor()
await orFail(seen(page, 'Vorschuss (Einrichtung)'), 'kunde-abo-stundenkonto: der Vorschuss steht nicht beim Kunden')
},
},
- Step 2: Rot
Run: EINZEL tests/unit/stundenkontoView.test.mjs → FAIL (Modul fehlt). Run: SHOTS ticket-einrichtung-abo,ticket-einrichtung-projekt,ticket-einrichtung-ausfall,kunde-abo-stundenkonto → Exit 1.
- Step 3: Implementieren
stundenkontoView.js nach Signatur und Tests: Minuten über Math.max(0, Number(x) || 0), stepLabel für die Werte, fmtDate für at; Zeilen in der Reihenfolge Guthaben, Vorschuss (nur > 0), Mehraufwand (nur > 0), Verfall (nur bei minutes > 0 und lesbarem at). useStundenkonto: Zustand mit Schlüssel `${customerId}|${reloadKey}`, cancelled-Flag im Effekt, catch setzt antwort: null und ruft console.warn. TicketPage: unter den Reitern, wenn einrichtungAbrechnung(ticket) gesetzt ist, <p className="muted">{einrichtungText(ticket)}</p>, bei 'abo' zusätzlich <StundenkontoLeiste customerId={ticket.customerId} reloadKey={${worksheets.length}:${worksheetSums(worksheets).aboMinutes}} />. AboStatusCard: bei abo.productKey === 'wartung' && abo.active die Leiste unter der dl. Prüft die bestehende Ansicht kunde-abo den Kartentext, wird sie nur ergänzt.
- Step 4: Grün
Run: EINZEL tests/unit/stundenkontoView.test.mjs → PASS. Run: SHOTS ticket-einrichtung-abo,ticket-einrichtung-projekt,ticket-einrichtung-ausfall,kunde-abo-stundenkonto,kunde-abo,ticket,ticket-arbeitsblaetter → Exit 0.
- Step 5: Commit (neue und geänderte
src/…-Dateien einzeln,tests/unit/stundenkontoView.test.mjs;views.mjsim Strang; Nachrichten „Stundenkonto und Vorschuss am Einrichtungs-Ticket und beim Kunden“ und „Werkzeug P9: Ansichten Stundenkonto“)
Task 10: Funktionen-Reiter, Logik: Preis raus, Abrechnung, Fehlertext, grund
Files:
- Modify:
src/lib/funktionenView.js(Z. 22,featureBody, neue Funktionen),src/lib/financeRequest.js(Z. 66-68) - Test:
tests/unit/funktionenView.test.mjs(Pins Z. 62 und Z. 109-114 angepasst, neue Tests),tests/unit/financeRequest.test.mjs
Interfaces:
- Produces:
// funktionenView.js
export const ABRECHNUNG_OPTIONEN // [{ value: 'abo', label: 'Aus dem Abo' }, { value: 'projekt', label: 'Im Projekt enthalten' }]
export const ABRECHNUNG_HINWEIS // 'Aus dem Abo: Die Einrichtungszeit geht aufs Stundenkonto des Kunden. Im Projekt enthalten: Sie zählt nicht aufs Stundenkonto und läuft über die Projektkosten.'
export function fragtAbrechnung(key, action, feature) // true: action 'activate', key aus FUNKTIONEN, nicht (state 'disabled' und disabledReason 'team')
export function abrechnungBereit({ key, action, feature, abrechnung }) // !fragtAbrechnung(...) || abrechnung ∈ {'abo','projekt'}
export function aboFehltText(grund, key) // 'Terminplaner: Das Hosting-Abo …' (Texte unten)
export function featureFehlerText(err, key) // abo_fehlt → aboFehltText(err.grund, key); sonst err.message; ohne beides 'Speichern fehlgeschlagen.'
export function featureBody({ projectId, action, note, textformDate, textformChannel, abrechnung }) // + abrechnung nur bei activate und gültigem Wert
// financeRequest.js: der Fehler trägt zusätzlich err.grund (String), wenn das Portal es sendet
-
Texte
aboFehltText(<Name>=labelausFUNKTIONEN, sonst der Schlüssel):kein_abo: „: Der Kunde hat kein laufendes Abo. Die Funktion läuft nur mit passendem Abo.“,falsches_abo: „: Das Hosting-Abo des Kunden enthält diese Funktion nicht. Sie ist im Wartungs-Abo enthalten.“,gekuendigt: „: Das Abo des Kunden ist gekündigt. Eingeschaltet wird nicht, solange die Kündigung vorgemerkt ist.“,nicht_aktiv: „: Das Abo des Kunden läuft nicht (Status nicht aktiv). Bitte zuerst das Abo klären.“, sonst „: Das passende Abo fehlt.“ -
Step 1: Failing tests
Pins: In der deepEqual-Liste (Z. 62) lautet der Abschalten-Text des Terminplaners jetzt 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Einrichtung möglich.'; der Test W1 (Z. 109-114) heißt „… nennt Buchungsstopp, Löschung nach 30 Tagen und die neue Einrichtung, aber keinen Preis“ und prüft assert.match(text, /nur mit einer neuen Einrichtung möglich/) und assert.doesNotMatch(text, /€|Euro|Bestellung/); seine übrigen Zusicherungen bleiben. Neue Tests (Importzeile erweitern):
test('Kein Text der Funktionen nennt einen Preis', () => {
for (const f of FUNKTIONEN) for (const t of Object.values(f.bestaetigen)) assert.doesNotMatch(t, /€|\bEuro\b/, f.key)
})
test('ABRECHNUNG_OPTIONEN und fragtAbrechnung: nur Aktivieren, nicht nach Team-Sperre', () => {
assert.deepEqual(ABRECHNUNG_OPTIONEN, [{ value: 'abo', label: 'Aus dem Abo' }, { value: 'projekt', label: 'Im Projekt enthalten' }])
for (const key of ['news', 'termine', 'shop', 'zeiten', 'unterkunft']) {
assert.equal(fragtAbrechnung(key, 'activate', { state: 'off' }), true, key)
assert.equal(fragtAbrechnung(key, 'activate', undefined), true, key)
assert.equal(fragtAbrechnung(key, 'activate', { state: 'disabled', disabledReason: 'abo_ende' }), true, key)
assert.equal(fragtAbrechnung(key, 'activate', { state: 'disabled', disabledReason: 'team' }), false, key)
for (const action of ['disable', 'abnahme', 'ticket']) assert.equal(fragtAbrechnung(key, action, { state: 'setup' }), false, `${key} ${action}`)
}
assert.equal(fragtAbrechnung('analyse', 'activate', { state: 'off' }), false)
})
test('abrechnungBereit: ohne Frage immer bereit, mit Frage nur mit abo oder projekt', () => {
const b = (o) => abrechnungBereit({ key: 'news', action: 'activate', feature: { state: 'off' }, abrechnung: '', ...o })
assert.equal(b({}), false); assert.equal(b({ abrechnung: 'gratis' }), false); assert.equal(b({ abrechnung: undefined }), false)
assert.equal(b({ abrechnung: 'abo' }), true); assert.equal(b({ abrechnung: 'projekt' }), true)
assert.equal(b({ action: 'disable' }), true)
assert.equal(b({ feature: { state: 'disabled', disabledReason: 'team' } }), true)
})
test('featureBody trägt abrechnung nur beim Aktivieren und nur mit gültigem Wert', () => {
assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '', abrechnung: 'projekt' }), { projectId: 'p1', action: 'activate', note: '', abrechnung: 'projekt' })
assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '', abrechnung: 'gratis' }), { projectId: 'p1', action: 'activate', note: '' })
assert.deepEqual(featureBody({ projectId: 'p1', action: 'disable', note: 'x', abrechnung: 'abo' }), { projectId: 'p1', action: 'disable', note: 'x' })
assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '', textformDate: '2026-10-06', textformChannel: 'chat', abrechnung: 'abo' }), { projectId: 'p1', action: 'activate', note: '', textformDate: '2026-10-06', textformChannel: 'chat', abrechnung: 'abo' })
})
test('aboFehltText und featureFehlerText: jeder Grund, unbekannter Grund, andere Fehler', () => {
const fehler = (grund, code = 'abo_fehlt') => Object.assign(new Error('Text des Portals'), { status: 409, code, grund })
assert.equal(featureFehlerText(fehler('kein_abo'), 'news'), 'News: Der Kunde hat kein laufendes Abo. Die Funktion läuft nur mit passendem Abo.')
assert.equal(featureFehlerText(fehler('falsches_abo'), 'termine'), 'Terminplaner: Das Hosting-Abo des Kunden enthält diese Funktion nicht. Sie ist im Wartungs-Abo enthalten.')
assert.equal(featureFehlerText(fehler('gekuendigt'), 'shop'), 'Webshop: Das Abo des Kunden ist gekündigt. Eingeschaltet wird nicht, solange die Kündigung vorgemerkt ist.')
assert.equal(featureFehlerText(fehler('nicht_aktiv'), 'zeiten'), 'Öffnungszeiten live: Das Abo des Kunden läuft nicht (Status nicht aktiv). Bitte zuerst das Abo klären.')
assert.equal(featureFehlerText(fehler('unbekannt'), 'unterkunft'), 'Buchungskalender: Das passende Abo fehlt.')
assert.equal(featureFehlerText(fehler(undefined), 'news'), 'News: Das passende Abo fehlt.')
assert.equal(featureFehlerText(fehler('kein_abo', 'andere'), 'news'), 'Text des Portals')
assert.equal(featureFehlerText(null, 'news'), 'Speichern fehlgeschlagen.')
})
tests/unit/financeRequest.test.mjs (neben dem Test Z. 129):
test('Der Grund einer Abo-Sperre bleibt am Fehler (409 abo_fehlt mit grund)', async () => {
const { api } = client({ responses: [reply(409, { error: 'x', code: 'abo_fehlt', grund: 'kein_abo' }), reply(409, { error: 'y', code: 'abo_fehlt', grund: 5 })] })
const a = await fails(api('/funktionen/c1/news', { method: 'POST', body: {} }))
assert.deepEqual([a.status, a.code, a.grund], [409, 'abo_fehlt', 'kein_abo'])
assert.equal((await fails(api('/funktionen/c1/news', { method: 'POST', body: {} }))).grund, undefined)
})
- Step 2: Rot
Run: EINZEL tests/unit/funktionenView.test.mjs tests/unit/financeRequest.test.mjs
Expected: FAIL (Preis steht noch im Text, fragtAbrechnung is not a function, grund undefined).
- Step 3: Implementieren
funktionenView.js:22: Text wie im Pin (nur der Schluss „nur mit einer neuen Einrichtung möglich.“ ändert sich); AKTION_LABEL und die Aktivieren-Texte („… ohne Zahlung aktivieren?“) bleiben bewusst unverändert. Neue Exporte nach den Signaturen; featureBody ergänzt abrechnung, wenn action === 'activate' und der Wert in ABRECHNUNG_OPTIONEN steht. financeRequest.js: if (typeof info.grund === 'string') err.grund = info.grund neben code und fehler.
- Step 4: Grün
Run: EINZEL tests/unit/funktionenView.test.mjs tests/unit/financeRequest.test.mjs tests/unit/analyseView.test.mjs → PASS.
- Step 5: Commit (
src/lib/funktionenView.js,src/lib/financeRequest.js, beide Testdateien; Nachricht „Funktionen: Preis entfernt, Abrechnung und Abo-Fehlertext als reine Logik“)
Task 11: Funktionen-Reiter, Oberfläche: Abrechnung wählen, abo_fehlt zeigen
Files:
- Modify:
src/components/customer/CustomerFunktionen.jsx(saveZ. 306-334, Bestätigungszeile Z. 415-426,askZ. 337),src/lib/teamApi.js(setFeatureZ. 102) - Modify (Werkzeug):
$W/views.mjs
Interfaces:
-
Consumes: Task 10; Backend-Nachbau Task 2 (c4, c1).
-
Produces:
setFeature(customerId, key, { projectId, action, note, textformDate, textformChannel, abrechnung }); in der Bestätigungszeile beifragtAbrechnung(...)eine Gruppe<div className="seg" role="group" aria-label="Abrechnung der Einrichtung">mit den Knöpfen ausABRECHNUNG_OPTIONEN(ohne Vorauswahl) und dahinterABRECHNUNG_HINWEIS; „Ja“ ist gesperrt, solangeabrechnungBereit(...)falsch ist; die Wahl wird inaskzurückgesetzt; ein Fehler setzt die Statuszeile auffeatureFehlerText(err, key). -
Step 1: Failing Ansichten
{
id: 'kunde-funktionen-abrechnung', path: '/customers/c4?tab=funktionen',
steps: async (page, ctx) => {
await heading(page, 'Physiotherapie am Markt').waitFor()
const zeile = (text) => page.locator('tbody').nth(1).locator('tr.funktionen-zeile').filter({ hasText: text }).first() // zweites Projekt (p4-lang): News aus, Terminplaner aktiv
const gruppe = page.getByRole('group', { name: 'Abrechnung der Einrichtung' })
await zeile('News').getByRole('button', { name: 'Aktivieren (ohne Zahlung)' }).click()
check((await gruppe.getByRole('button').allTextContents()).join('|') === 'Aus dem Abo|Im Projekt enthalten', 'kunde-funktionen-abrechnung: die Wahl fehlt')
check((await gruppe.getByRole('button').evaluateAll((l) => l.map((b) => b.getAttribute('aria-pressed')).join())) === 'false,false', 'kunde-funktionen-abrechnung: eine Wahl ist vorausgewählt')
check(await button(page, 'Ja').isDisabled(), 'kunde-funktionen-abrechnung: "Ja" ist ohne Wahl nicht gesperrt')
await gruppe.getByRole('button', { name: 'Im Projekt enthalten', exact: true }).click()
check(!(await button(page, 'Ja').isDisabled()), 'kunde-funktionen-abrechnung: "Ja" bleibt nach der Wahl gesperrt')
await button(page, 'Abbrechen').click()
await zeile('News').getByRole('button', { name: 'Aktivieren (ohne Zahlung)' }).click()
check((await gruppe.getByRole('button').evaluateAll((l) => l.map((b) => b.getAttribute('aria-pressed')).join())) === 'false,false', 'kunde-funktionen-abrechnung: die Wahl bleibt nach Abbrechen stehen')
await gruppe.getByRole('button', { name: 'Aus dem Abo', exact: true }).click()
await button(page, 'Ja').click()
await orFail(seen(page, 'Gespeichert.'), 'kunde-funktionen-abrechnung: keine Rückmeldung')
const posts = ctx.backend.calls.filter((c) => c.method === 'POST' && new URL(c.url).pathname === '/api/team/funktionen/c4/news')
check(posts.length === 1 && posts[0].body.abrechnung === 'abo' && posts[0].body.action === 'activate', 'kunde-funktionen-abrechnung: Body ohne abrechnung "abo"')
await orFail(zeile('News').getByText('Einrichtung', { exact: true }).waitFor({ timeout: 3000 }), 'kunde-funktionen-abrechnung: News steht nicht in "Einrichtung"')
// Abschalten fragt nicht nach der Abrechnung und nennt keinen Preis
await zeile('Terminplaner').getByRole('button', { name: 'Abschalten' }).click()
check((await gruppe.count()) === 0, 'kunde-funktionen-abrechnung: Abschalten fragt nach der Abrechnung')
const text = await page.locator('tr.funktionen-confirm').first().innerText()
check(text.includes('nur mit einer neuen Einrichtung möglich') && !text.includes('€'), 'kunde-funktionen-abrechnung: der Abschalten-Text nennt noch einen Preis')
},
},
{
id: 'kunde-funktionen-abo-fehlt', path: '/customers/c1?tab=funktionen', // Hosting-Abo, Terminplaner aus
steps: async (page) => {
await heading(page, 'Bäckerei Sonnenschein').waitFor()
await page.locator('tr.funktionen-zeile').filter({ hasText: 'Terminplaner' }).first().getByRole('button', { name: 'Aktivieren (ohne Zahlung)' }).click()
await page.getByRole('group', { name: 'Abrechnung der Einrichtung' }).getByRole('button', { name: 'Aus dem Abo', exact: true }).click()
await button(page, 'Ja').click()
const meldung = page.locator('.funktionen-msg[role="alert"]')
await orFail(meldung.waitFor({ timeout: 3000 }), 'kunde-funktionen-abo-fehlt: keine Fehlermeldung')
check((await meldung.textContent()).trim() === 'Terminplaner: Das Hosting-Abo des Kunden enthält diese Funktion nicht. Sie ist im Wartungs-Abo enthalten.', 'kunde-funktionen-abo-fehlt: falscher Text')
check((await page.locator('tr.funktionen-zeile').filter({ hasText: 'Terminplaner' }).first().getByText('Aus', { exact: true }).count()) === 1, 'kunde-funktionen-abo-fehlt: der Zustand hat sich geändert (Pille "Aus" fehlt)')
},
},
{ id: 'kunde-funktionen-abrechnung-tablet', path: '/customers/c4?tab=funktionen', viewports: TABLET,
steps: async (page) => { await tablet(page); await heading(page, 'Physiotherapie am Markt').waitFor(); await page.locator('tbody').nth(1).locator('tr.funktionen-zeile').filter({ hasText: 'News' }).first().getByRole('button', { name: 'Aktivieren (ohne Zahlung)' }).click(); await page.getByRole('group', { name: 'Abrechnung der Einrichtung' }).waitFor() } },
- Step 2: Rot
Run: SHOTS kunde-funktionen-abrechnung,kunde-funktionen-abo-fehlt,kunde-funktionen-abrechnung-tablet
Expected: Exit 1, step-failed (Gruppe „Abrechnung der Einrichtung“ fehlt).
- Step 3: Implementieren
CustomerFunktionen.jsx: Zustand abrechnung (Start ''), in ask zurückgesetzt; save liest feature des Projekts über funktionOf(projects.find(p => p.projectId === projectId), key) und gibt abrechnung an setFeature (nur wenn fragtAbrechnung); der catch setzt text: featureFehlerText(err, key); die kleine Komponente AbrechnungWahl({ value, onChange, disabled }) steht in derselben Datei (.seg, aria-pressed, danach <span className="muted small">{ABRECHNUNG_HINWEIS}</span>); Ja bekommt disabled={busy || textformFehlt || !abrechnungBereit({ key: f.key, action: confirm.action, feature, abrechnung })}. teamApi.setFeature reicht abrechnung an featureBody durch.
- Step 4: Grün
Run: SHOTS kunde-funktionen-abrechnung,kunde-funktionen-abo-fehlt,kunde-funktionen-abrechnung-tablet,kunde-funktionen,kunde-funktionen-panels
Expected: Exit 0.
- Step 5: Commit (
src/components/customer/CustomerFunktionen.jsx,src/lib/teamApi.js;views.mjsim Strang; Nachrichten „Funktionen: Abrechnung der Einrichtung wählen, Abo-Fehler anzeigen“ und „Werkzeug P9: Ansichten Funktionen“)
Task 12: Gate kn9 und Gesamtlauf der Sichtprüfung
Files:
- Modify (Werkzeug):
$W/gates.mjs(nachp9, Z. 161; Eintrag inGATES, Z. 163-174)
Interfaces:
-
Produces: Gate
kn9 = kette(p9, KN9_ANSICHTEN)mit allen Ansichten dieses Pakets:dlg-lead-kundenart,lead-anlegen-kundenart,dlg-kunde-neu-kundenart,kunde-anlegen-kundenart,kunde-lead-kundenart,ticket-zugesagt-kundenart,kunden-kundenart-fehlt,dlg-arbeitsblatt-einrichtung,dlg-arbeitsblatt-projekt,zeit-nachtragen-einrichtung,ticket-einrichtung-abo,ticket-einrichtung-projekt,ticket-einrichtung-ausfall,kunde-abo-stundenkonto,kunde-funktionen-abrechnung,kunde-funktionen-abo-fehlt,kunde-funktionen-abrechnung-tablet. -
Step 1: Failing test (
$W/selftest.mjs, neben den Gate-Tests)
test('Gate kn9: kette(p9) mit allen Ansichten von P9, jeder Eintrag passt auf eine Ansicht', () => {
assert.ok(GATES.kn9, 'Gate kn9 fehlt')
assert.deepEqual(validateGate('kn9', GATES.kn9, VIEWS.map((v) => v.id)), [])
for (const id of ['dlg-lead-kundenart', 'zeit-nachtragen-einrichtung', 'kunde-funktionen-abrechnung-tablet']) assert.ok(GATES.kn9.scoped['icon-svg'][0].views.includes(id), id)
})
- Step 2: Rot
Run: WENV node "$W/selftest.mjs" 2>&1 | rg -N 'Gate kn9' → FAIL „Gate kn9 fehlt“.
-
Step 3: Gate anlegen (Liste
KN9_ANSICHTENwie in Interfaces,const kn9 = kette(p9, KN9_ANSICHTEN),kn9inGATES) -
Step 4: Grün und Gesamtlauf
WENV node "$W/selftest.mjs" > "$TMPDIR/neu-selftest.log" 2>&1; diff <(rg -N '^FAIL' "$TMPDIR/basis-selftest.log") <(rg -N '^FAIL' "$TMPDIR/neu-selftest.log")
WENV "$W/run-browser.sh" "$W/checks-selftest.mjs" > "$TMPDIR/neu-checks.log" 2>&1; diff <(rg -N '^FAIL' "$TMPDIR/basis-checks.log") <(rg -N '^FAIL' "$TMPDIR/neu-checks.log")
Expected: kein neuer FAIL. Danach alle Ansichten des Pakets plus die berührten bestehenden in Handy, Desktop (Dialoge vierfach) und Tablet:
WENV "$W/build.sh" "$WT" kn9 && "$W/shots.sh" kn9 "$W/shots/kn9" --gate kn9 --only dlg-lead-kundenart,lead-anlegen-kundenart,dlg-kunde-neu-kundenart,kunde-anlegen-kundenart,kunde-lead-kundenart,ticket-zugesagt-kundenart,kunden-kundenart-fehlt,dlg-arbeitsblatt-einrichtung,dlg-arbeitsblatt-projekt,zeit-nachtragen-einrichtung,ticket-einrichtung-abo,ticket-einrichtung-projekt,ticket-einrichtung-ausfall,kunde-abo-stundenkonto,kunde-funktionen-abrechnung,kunde-funktionen-abo-fehlt,kunde-funktionen-abrechnung-tablet,kunden,dlg-lead-neu,dlg-kunde-neu,dlg-kunde-neu-fehler,zuordnen-kunde-neu,kunde-lead,kunde-funktionen,kunde-funktionen-panels,kunde-abo,ticket,ticket-arbeitsblaetter,dlg-arbeitsblatt,dlg-zeiten
Expected: Exit 0 für alle Ansichten des Pakets. Ansichten, die schon in $TMPDIR/basis-report.json rot waren, bleiben mit denselben Verstößen und werden im Abschlussbericht genannt; jeder neue Verstoß (overflow-x, low-contrast, icon-svg, bad-text) wird behoben. Dann ls -l /dev/null.
- Step 5: Commit (Werkzeug)
git -C $W add gates.mjs selftest.mjs
git -C $W commit -m "Werkzeug P9: Gate kn9 mit den Ansichten des Pakets" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 13: Selbstprüfung
Files: keine Änderung außer Korrekturen aus den Läufen.
- Step 1: Alle Tests des WOMS
Run: ALLE
Expected: # fail 0, # tests mindestens N0 plus die neuen Tests (Task 3: 3, Task 6: 4, Task 7: 3, Task 8: 4, Task 9: 4, Task 10: 6), keine Zählzeile fehlt.
- Step 2: Eigene Testdateien einzeln und Werkzeug
Run: EINZEL tests/unit/kundenFelder.test.mjs tests/unit/customerList.test.mjs tests/unit/worksheetFlow.test.mjs tests/unit/planVorgabe.test.mjs tests/unit/stundenkontoView.test.mjs tests/unit/funktionenView.test.mjs tests/unit/financeRequest.test.mjs
Expected: PASS. Dann Selbsttest, Browser-Selbsttest und Gate kn9 wie in Task 12 Schritt 4 noch einmal: Exit 0 beziehungsweise keine Abweichung zur Basis.
-
Step 3: Verträge abhaken
-
V9 Kundenart-Pflicht per
.seginCreateLeadForm/createLead(Task 4),CreateCustomerFormsamtAssignProjectPage(Task 5), „Zum festen Kunden machen“ undworksheetFlow„Zugesagt“ mit Hinweis (Task 6) -
V9 Kundenliste: Schalter „Kundenart fehlt“,
CUSTOMER_TABSunverändert (Task 7) -
V9 Abo-Vorgabe im Modal und beim Nachtragen der Zeit eines Git-Blatts;
'projekt'bleibt aus (Task 8) -
V9 Ticket-Seite zeigt Kontostand und Vorschuss aus V8, ebenso der Kunde (Task 9)
-
V9 Funktionen-Reiter: kein Preis (Task 10), Wahl „aus dem Abo“ oder „im Projekt enthalten“,
abrechnungim Body,abo_fehltmit Grund (Task 10, 11) -
V4 (nur
kundenartim Body, Antwortkundenart_fehlt), V7 (Marker am Ticket gelesen), V8 (Antwortform), V10 (abrechnung,abo_fehltmitgrund) so konsumiert wie in den Vertragsfragen beschrieben -
Testvorschau: Fixtures mit
kundenart, Whitelist und Pflicht im Backend-Nachbau (Task 1, 2) -
Step 4: Sauberkeit und Tags
cd /home/webklar/apps/ticket.webklar.com/dev-kundenart && git status --short # leer (ein Symlink node_modules darf als ?? erscheinen)
git -C /root/reports/ticketsystem-design-2026-10-04/werkzeug-kundenart status --short # leer
git tag kn-p9
git -C /root/reports/ticketsystem-design-2026-10-04/werkzeug-kundenart tag kn-p9-werkzeug
ls -l /dev/null # crw-rw-rw-
Kein Push, kein Merge, nichts in build/ (git -C /home/webklar/apps/ticket.webklar.com/build status --short bleibt leer).
Vertragsfragen an den Controller
- Werkzeug-Stand. Das WOMS-Werkzeug (
ba04dd3, 06.10.) kennt Endpunkte und Reiter vontestnicht: Aufträge und Projektkonten (/api/admin/finanzen/projektkonten,/auftraege,/rechnungen/vorschau), Unterkunft,arbeitsblaetter/abgerechnetund den Reiter „Abgesagt“ (KUNDEN_REITERhat vier statt fünf). P9 gleicht nur die drei Endpunkte ab, die seine Ansichten berühren (Task 1), und misst vorher die Basis. Alle übrigen Abweichungen bleiben in der Basisliste rot (Selbsttest „deckt alles ab“, Kundenlisten-Ansichten, Rechnungs-Ansichten). Vorschlag: ein eigener kleiner Auftrag „Werkzeug auftestheben“ vor Phase E, sonst läuft das Gesamtgate der Phase E für die alten Ansichten rot. - Antwortformen des Portals, auf die WOMS baut (P5/P6). P6 (Plan 07) baut (a) und (b) schon so; bitte in V8/V10 festschreiben: (a)
GET /api/team/customers/:id/stundenkontoliefertnextExpiryals{ minutes, at }odernull, beiprodukt: 'hosting'und ohne Aboledger: null; (b) die Team-Aktivierung antwortet bei fehlendem Abo flach{ error, code: 'abo_fehlt', grund, wechsel }mitgrundauskein_abo | falsches_abo | gekuendigt | nicht_aktiv(WOMS zeigt eigene Texte, nie den Kundentext in „du“-Form), bei Erfolg wie bisherprojects[0].features[key]mitstate: 'setup'undticketWoid; (c)workorders.einrichtungFuerundeinrichtungAbrechnungsind für Mitarbeiter lesbar (Setup-Skript P5). - Push-Reihenfolge. P5 (Vertragsfrage 1) verlangt WOMS vor dem Portal, weil das Portal die Kundenart beim Anlegen verlangt. P9 liefert dafür den Zwischentag
kn-p9-kundenart(Tasks 3-7). Die Tasks 8-11 sind gegen das heutige Portal harmlos (404 beim Stundenkonto bleibt still), aber P6 wertetabrechnungnur für Kunden mit neuer Ansicht aus (aboModellFuerKunde); für alle anderen aktiviert der Server wie heute direkt, und die Frage in WOMS bleibt folgenlos. Vorschlag:kn-p9-kundenartzuerst,kn-p9zusammen mit P6, und im Übergabetext für das Team vermerken, dass die Wahl erst für Pilotkunden wirkt. fix/akquise-sichtbar. Landet der Zweig vor P9 intest, rebased P9 darauf (Konflikte nur increateLead,CreateLeadFormund den Importen vonleads.js, siehe Global Constraints). Vorschlag: den Zweig zuerst pushen lassen, dannkn-p9-kundenart.- Bewusst nicht geändert. Der Knopf „Aktivieren (ohne Zahlung)“ und seine Bestätigungstexte bleiben;
zeitenStatusTextsagt weiter „falls der Kunde die Funktion nicht behält“, was mit dem Wegfall des 30-€-Behaltens (Spec 5.5) veraltet. Vorschlag: P10 räumt beides gemeinsam mit den Mailtexten K29/K30 auf.