1099 lines
84 KiB
Markdown
1099 lines
84 KiB
Markdown
# 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 Worktree `npm ci` mit 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`). Vorher `free -m` (mindestens 2000 MB verfügbar, sonst warten und den Controller melden). Die Ausgangszahl N0 der Tests misst Task 1 Schritt 2. In `build/` wird nichts geändert.
|
|
- **Testbefehle** (exakt der WOMS-Befehl der Übersicht; `EINZEL <datei>` und `ALLE` stehen 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 test`
|
|
- `EINZEL <datei>`: derselbe Befehl, am Ende `node --test <datei>` statt `npm test`.
|
|
- **Werkzeug** (Playwright nur hierüber, eine Instanz, die Läufe warten auf die gemeinsame Sperre: Bash-Timeout 600000 ms, nicht abbrechen). `WENV` steht für `source /root/reports/ticketsystem-design-2026-10-04/werkzeug-kundenart/env.sh &&` (setzt `W`, `WT=dev-kundenart`, `TMPDIR=$W/tmp`). In `git -C $W …` steht `$W` für diesen absoluten Strang-Pfad. Alle neuen Ansichten stehen im Array `KUNDENART_ANSICHTEN` in `$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 Gate `kn9` statt `p9`.
|
|
- Nie im Haupt-Werkzeug `/root/reports/ticketsystem-design-2026-10-04/werkzeug` arbeiten, nie `vorschau/vorschau.sh` mit Ziel aufrufen, Hilfsdateien nur unter `$TMPDIR`. Nach schweren Läufen `ls -l /dev/null` (muss `crw-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/lib` und hat einen Unit-Test; Komponenten und Hooks rufen sie nur auf. Das WOMS hat keine DOM-Tests (`test/support/fakeDom.js` und `h()` 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 …/stundenkonto` mit 404 bleibt still, ein unbekanntes Feld `abrechnung` ignoriert der alte Server.
|
|
- **`fix/akquise-sichtbar`** (`5f13553`, ändert `CreateLeadForm`, `createLead`, `leads.js`) ist nicht gepusht. P9 baut auf `origin/test` und 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 von `createLead`, Feldliste in `CreateLeadForm`.
|
|
- **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.
|
|
|
|
1. **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.
|
|
2. **„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.
|
|
3. **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 ein `countsToPlan`; das Nachtragen der Zeit eines Git-Blatts überschreibt keine bestehende Entscheidung. Test: Task 8.
|
|
4. **Stundenkonto-Anzeige:** `ledger: null` (Hosting, kein Abo), Nullwerte, krumme Minuten, Ausfall oder 404 des Portals (die Seite bleibt bedienbar, nirgends „undefined“). Test: Task 9.
|
|
5. **Funktion aktivieren:** „Ja“ ohne Wahl, Wahl bleibt nach „Abbrechen“ stehen, Wiederaktivierung nach Team-Sperre fragt nicht, `abo_fehlt` mit 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, Branch `strang/kundenart` von `main`), `strang.env`, Symlink `node_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`; Mocks `GET /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.log` und `$TMPDIR/basis-report.json`.
|
|
|
|
- [ ] **Step 1: Strang anlegen**
|
|
|
|
```bash
|
|
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:
|
|
```bash
|
|
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, nutzt `newBackend`, `ask`, `status`, `parsed`, `ticket100131`)
|
|
|
|
```js
|
|
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)**
|
|
|
|
```bash
|
|
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 `''`); `workorders` 100131 mit `einrichtungFuer: 'termine'`, `einrichtungAbrechnung: 'abo'` und 100133 mit `'news'`, `'projekt'`; Schlüssel `stundenkonto` im 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/customers` nur mit `code, name, location, email, phone, password, kundenart`, ohne gültige Kundenart 400 `{ error: 'Kundenart ist erforderlich (unternehmen oder privat).', code: 'kundenart_fehlt' }`; `PATCH` gleiche Whitelist, `kundenart` optional; `POST /api/team/funktionen/:id/:key` mit `abrechnung` im Body: ohne passendes Abo 409 `{ error, code: 'abo_fehlt', grund }` (Reihenfolge der Prüfung: kein Eintrag in `aboStatus` `'kein_abo'`, dann `active: false` `'nicht_aktiv'`, dann `productKey: 'hosting'` `'falsches_abo'`), sonst Zustand `setup` mit `ticketWoid` (sechsstellig); ohne `abrechnung` bleibt das alte Verhalten (`active`).
|
|
|
|
- [ ] **Step 1: Failing tests in `$W/selftest.mjs`**
|
|
|
|
```js
|
|
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)**
|
|
|
|
```bash
|
|
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` (nach `KUNDENART_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:
|
|
```js
|
|
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**
|
|
|
|
```js
|
|
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**
|
|
|
|
```bash
|
|
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 Array `KUNDENART_ANSICHTEN` direkt über `export const VIEWS` Z. 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)`; wirft `Error(KUNDENART_FEHLT_FEHLER)` bei ungültiger Kundenart (vor jedem Schreibzugriff) und schreibt `kundenart` ins Kundendokument.
|
|
|
|
- [ ] **Step 1: Failing Ansichten** (`$W/views.mjs`; Hilfen `button`, `heading`, `check`, `orFail`, `callsTo`, `dlg`, `dialogZu` sind dort im Gültigkeitsbereich)
|
|
|
|
```js
|
|
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**
|
|
|
|
```bash
|
|
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.jsx` nutzt dieselbe Komponente und bleibt unverändert)
|
|
- Modify (Werkzeug): `$W/views.mjs` (zwei Ansichten im Array, Ansicht `zuordnen-kunde-neu` Z. 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, damit `dlg-kunde-neu-fehler` samt Attrappe (`checks-selftest.mjs:480-497`) unverändert bleibt.
|
|
|
|
- [ ] **Step 1: Failing Ansichten**
|
|
|
|
```js
|
|
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.jsx` im WOMS-Worktree; `views.mjs` im Strang; Nachrichten „Kunden: Kundenart ist beim Anlegen Pflicht“ und „Werkzeug P9: Ansichten Kunde anlegen mit Kundenart“, Zeile `Co-Authored-By` wie oben)
|
|
|
|
---
|
|
|
|
### Task 6: Hochstufen nur mit Kundenart („Zum festen Kunden machen“ und „Zugesagt“)
|
|
|
|
**Files:**
|
|
- Modify: `src/pages/CustomerDetailPage.jsx` (`upgrade` Z. 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: `run` reicht `getCustomer` durch; Z. 18-22 bleiben gültig)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `kundenartGesetzt`, `kundenartPruefen`, `KUNDENART_HOCHSTUFEN_HINWEIS` (Task 3).
|
|
- Produces:
|
|
```js
|
|
// 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_HINWEIS` erweitern; in `run` Standard `getCustomer: async () => ({ kundenart: 'unternehmen' })`, überschreibbar über `over.getCustomer`, auch mit `undefined`)
|
|
|
|
```js
|
|
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`):
|
|
```js
|
|
{
|
|
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.mjs` im 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`/`counts` Z. 21-24)
|
|
- Modify (Werkzeug): `$W/views.mjs`
|
|
- Test: `tests/unit/customerList.test.mjs` (neue Tests; `CUSTOMER_TABS` und die bestehenden Tests bleiben unverändert)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `kundenartGesetzt` (Task 3; `customerList.js` importiert es, die Datei bleibt frei von React und Appwrite).
|
|
- Produces:
|
|
```js
|
|
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**
|
|
|
|
```js
|
|
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:
|
|
```js
|
|
{
|
|
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**
|
|
|
|
```bash
|
|
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` (`updateWorksheetTimes` Z. 233-259), `src/pages/TicketPage.jsx` (`onSave` Z. 304)
|
|
- Modify (Werkzeug): `$W/views.mjs`
|
|
- Test: `tests/unit/planVorgabe.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `isGitWorksheet`, `needsTimeEntry` aus `src/lib/ticketForm.js`; Ticket-Felder `einrichtungAbrechnung` (V7) und `aboHoursAllowed` aus `useCustomerAbo`.
|
|
- Produces:
|
|
```js
|
|
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`)
|
|
|
|
```js
|
|
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:
|
|
```js
|
|
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.mjs` im 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` (nach `getCustomerAboStatus`, 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-Feld `einrichtungAbrechnung` und `einrichtungAbrechnung()` (Task 8).
|
|
- Produces:
|
|
```js
|
|
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 bei `planVorgabe.test.mjs`, `loadModule('src/lib/stundenkontoView.js')`)
|
|
|
|
```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:
|
|
```js
|
|
{
|
|
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.mjs` im 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:
|
|
```js
|
|
// 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>` = `label` aus `FUNKTIONEN`, sonst der Schlüssel): `kein_abo`: „<Name>: Der Kunde hat kein laufendes Abo. Die Funktion läuft nur mit passendem Abo.“, `falsches_abo`: „<Name>: Das Hosting-Abo des Kunden enthält diese Funktion nicht. Sie ist im Wartungs-Abo enthalten.“, `gekuendigt`: „<Name>: Das Abo des Kunden ist gekündigt. Eingeschaltet wird nicht, solange die Kündigung vorgemerkt ist.“, `nicht_aktiv`: „<Name>: Das Abo des Kunden läuft nicht (Status nicht aktiv). Bitte zuerst das Abo klären.“, sonst „<Name>: 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):
|
|
```js
|
|
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):
|
|
```js
|
|
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` (`save` Z. 306-334, Bestätigungszeile Z. 415-426, `ask` Z. 337), `src/lib/teamApi.js` (`setFeature` Z. 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 bei `fragtAbrechnung(...)` eine Gruppe `<div className="seg" role="group" aria-label="Abrechnung der Einrichtung">` mit den Knöpfen aus `ABRECHNUNG_OPTIONEN` (ohne Vorauswahl) und dahinter `ABRECHNUNG_HINWEIS`; „Ja“ ist gesperrt, solange `abrechnungBereit(...)` falsch ist; die Wahl wird in `ask` zurückgesetzt; ein Fehler setzt die Statuszeile auf `featureFehlerText(err, key)`.
|
|
|
|
- [ ] **Step 1: Failing Ansichten**
|
|
|
|
```js
|
|
{
|
|
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.mjs` im 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` (nach `p9`, Z. 161; Eintrag in `GATES`, 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)
|
|
|
|
```js
|
|
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_ANSICHTEN` wie in Interfaces, `const kn9 = kette(p9, KN9_ANSICHTEN)`, `kn9` in `GATES`)
|
|
|
|
- [ ] **Step 4: Grün und Gesamtlauf**
|
|
|
|
```bash
|
|
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)**
|
|
|
|
```bash
|
|
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 `.seg` in `CreateLeadForm`/`createLead` (Task 4), `CreateCustomerForm` samt `AssignProjectPage` (Task 5), „Zum festen Kunden machen“ und `worksheetFlow` „Zugesagt“ mit Hinweis (Task 6)
|
|
- [ ] V9 Kundenliste: Schalter „Kundenart fehlt“, `CUSTOMER_TABS` unverä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“, `abrechnung` im Body, `abo_fehlt` mit Grund (Task 10, 11)
|
|
- [ ] V4 (nur `kundenart` im Body, Antwort `kundenart_fehlt`), V7 (Marker am Ticket gelesen), V8 (Antwortform), V10 (`abrechnung`, `abo_fehlt` mit `grund`) 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**
|
|
|
|
```bash
|
|
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
|
|
|
|
1. **Werkzeug-Stand.** Das WOMS-Werkzeug (`ba04dd3`, 06.10.) kennt Endpunkte und Reiter von `test` nicht: Aufträge und Projektkonten (`/api/admin/finanzen/projektkonten`, `/auftraege`, `/rechnungen/vorschau`), Unterkunft, `arbeitsblaetter/abgerechnet` und den Reiter „Abgesagt“ (`KUNDEN_REITER` hat 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 auf `test` heben“ vor Phase E, sonst läuft das Gesamtgate der Phase E für die alten Ansichten rot.
|
|
2. **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/stundenkonto` liefert `nextExpiry` als `{ minutes, at }` oder `null`, bei `produkt: 'hosting'` und ohne Abo `ledger: null`; (b) die Team-Aktivierung antwortet bei fehlendem Abo flach `{ error, code: 'abo_fehlt', grund, wechsel }` mit `grund` aus `kein_abo | falsches_abo | gekuendigt | nicht_aktiv` (WOMS zeigt eigene Texte, nie den Kundentext in „du“-Form), bei Erfolg wie bisher `projects[0].features[key]` mit `state: 'setup'` und `ticketWoid`; (c) `workorders.einrichtungFuer` und `einrichtungAbrechnung` sind für Mitarbeiter lesbar (Setup-Skript P5).
|
|
3. **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 wertet `abrechnung` nur 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-kundenart` zuerst, `kn-p9` zusammen mit P6, und im Übergabetext für das Team vermerken, dass die Wahl erst für Pilotkunden wirkt.
|
|
4. **`fix/akquise-sichtbar`.** Landet der Zweig vor P9 in `test`, rebased P9 darauf (Konflikte nur in `createLead`, `CreateLeadForm` und den Importen von `leads.js`, siehe Global Constraints). Vorschlag: den Zweig zuerst pushen lassen, dann `kn-p9-kundenart`.
|
|
5. **Bewusst nicht geändert.** Der Knopf „Aktivieren (ohne Zahlung)“ und seine Bestätigungstexte bleiben; `zeitenStatusText` sagt 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.
|