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

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

2185 lines
158 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Kundenportal – Umsetzungsplan (Strang L6)
> **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:** Kunden bestätigen ihre Auftragsbestätigung (AB) auf einer öffentlichen Seite (`auftrag.html#t=<Token>`, Rückfall `?t=`; Link aus der Mail K31; Sie-Form) und im angemeldeten Portal (Abschnitt „Aufträge“ im Reiter Rechnungen; Du-Form wie das übrige Portal); die Rechnungsliste nennt das Projekt; die Widerrufsseite kennt die Vertragsart „Projektauftrag (Auftragsbestätigung)“ mit der freiwilligen Nummer der AB.
**Architecture:** Reine Ansichtslogik (`public/auftrag-ansicht.js`, ohne DOM) und DOM-Bausteine (`public/auftrag-dom.js`, nur `textContent`, Muster `shop-dom.js`) bedienen drei Oberflächen: die öffentliche Seite (`auftrag.html` + `auftrag.js`, eigenes `fetch` mit `credentials: 'omit'`), den Portal-Abschnitt (`auftrag-portal.js`, per `import()` aus `app.js`, bekommt `api` als `request`) und die Widerrufsseite (`auftrag-widerruf.js` zeigt das freiwillige Feld `abNummer` („falls zur Hand“) nur bei „Projektauftrag“ und prüft eine eingegebene Nummer beim Klick auf „Widerruf bestätigen“, bevor `initLegalForm` sendet). Die Seitentexte stehen in zwei Anreden in `auftrag-ansicht.js` (`texte('sie')` für die öffentliche Seite, `texte('du')` für das Portal). `customerInvoices.js` liefert das Feld `projekt`. Die Antworten kommen vom Gegenstück L5 (Übersicht V3/V5); L6 baut gegen deren Form und braucht keinen Servercode von L5.
**Tech Stack:** Vanilla-JS als ES-Module ohne Build (`public/`, ausgeliefert von `express.static`), `node --test` mit `test/support/fakeDom.js`, Playwright (`playwright-core`, Chromium) für die Oberflächenprüfung, Stripe-Metadaten im Server (`customerInvoices.js`).
**Spec:** `docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md` (Abschnitte 2 „Bestätigung“, 2 „Widerruf“, 3 „Kundenportal“, 6, 9, 10)
**Übersicht/Verträge:** `docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md` (verbindlich: Abschnitt „Nachträge nach der Planrunde“, Global Constraints, V3 `AuftragOeffentlich`, V5 Endpunkte, V6 Link `auftrag.html#t=<Token>`, V7 Texte; Dateizuständigkeit)
**Worktree:** `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6`, Branch `auftraege/l6-kundenportal` (aus `feature/auftraege` nach L0). Kein Push.
## Dateien dieses Strangs
| Datei | Aufgabe | Task |
|---|---|---|
| `server/services/customerInvoices.js` | Feld `projekt` in `customerStripeInvoice` | 2 |
| `public/auftrag-ansicht.js` (neu) | Reine Logik: Token, Beträge, Daten, Zustände, Status, AB-Nummer, Texte | 3 |
| `public/auftrag-dom.js` (neu) | DOM-Bausteine: Zusammenfassung, Bestätigt-Block, Unterlagen, Bestätigen-Formular | 4 |
| `public/auftrag.js` (neu) | Seitenlogik der öffentlichen Seite (laden, Zustände, bestätigen) | 5 |
| `public/auftrag.html`, `public/auftrag.css` (neu) | Hülle und Gestaltung (CSS auch für den Portal-Abschnitt und `.inv-project`) | 6, 7, 8 |
| `public/auftrag-portal.js` (neu) | Abschnitt „Aufträge“ im Portal | 7 |
| `public/dashboard.html`, `public/app.js` | Abschnitt einhängen, Projekt unter der Rechnungsnummer | 8 |
| `public/auftrag-widerruf.js` (neu), `public/widerrufen.html` | Vertragsart „Projektauftrag“, freiwilliges Feld `abNummer` | 9 |
| `test/auftraegeKundenportal*.test.js`, `test/auftraegeKundenportalDaten.js` (Hilfsmodul, kein Test), `test/ui/auftraege-kundenportal.ui.mjs` | Tests, gemeinsame Testdaten, Playwright-Prüfung | 2–10 |
| `test/customerInvoices.test.js`, `test/publicForms.test.js` | je **eine** Zeile angepasst (Übersicht, Nachträge) | 2, 9 |
## Befunde aus der Codeprüfung (gelten für alle Tasks)
- **Service Worker** (`public/sw.js`): legt nur `/offline.html` ab und fängt nur Seitenaufrufe (`navigate`) ab (Netz zuerst, ohne Verbindung die Offline-Seite). Es gibt keine Seitenliste: `auftrag.html` braucht keinen Eintrag, `CACHE` bleibt `kb-offline-v1` (nur bei Änderungen am Worker hochzählen). Token-Seiten und API-Antworten werden nie zwischengespeichert.
- **Manifest** (`scope: "/"`): Ein Link aus der Mail kann in der installierten App öffnen. `auftrag.html` muss deshalb im `display-mode: standalone` bedienbar sein: Abstand für die durchsichtige Statusleiste (`env(safe-area-inset-top)`) und ein Zurück-Link zum Kundenbereich, nur dort sichtbar (Muster `legal-form.css`). Kein Eintrag im Manifest.
- **Fußzeile**: `login.html` führt „Datenschutz · Impressum“, `termin.html` „Impressum WEBklar“, das Dashboard `.kb-legal`; `widerrufen.html` und `kuendigen.html` haben keine (Unterseiten des Kundenbereichs). `auftrag.html` ist von außen erreichbar und verarbeitet personenbezogene Daten: Fußzeile mit **Datenschutz** (`/datenschutz.html`) und **Impressum** (`https://webklar.com/impressum`), wie auf `login.html` (§ 5 DDG „leicht erkennbar, unmittelbar erreichbar“, Art. 13 DSGVO).
- **Kundenansicht-Wächter** (`server/middleware/kundenansichtGuard.js`): `/api/auftrag/…` steht nicht in `PASS_PREFIXES`; ein Kundenansicht-Cookie im Browser würde dort Schreibzugriffe ohne Kennung mit 409 `view_replaced` abweisen. Die öffentliche Seite sendet deshalb **ohne Cookies** (`credentials: 'omit'`, Muster `termin.js`). Im Portal sperrt der Wächter `POST /api/auftraege/:id/bestaetigen` mit 403 `view_blocked`; die Oberfläche sperrt vorher selbst (Ansichtsmodus: `chatState.viewAs`, Wortwahl „Ansichtsmodus“ wie bei der Übernahmegutschrift).
- **Express** hat kein Zugriffsprotokoll; `express.static(public)` liefert `auftrag.html` ohne eigene Route. K31 verlinkt `auftrag.html#t=<Token>` (V6): das Fragment geht nie an Server oder Proxy; nur die API-Pfade `/api/auftrag/<Token>…` tragen den Token (V5).
## Global Constraints
- Alle Punkte der Übersicht gelten (Sprache und Anrede, Schreibweise im Code, Cent-Beträge, Berliner Datum, Ressourcen, Git, Sicherheit der Umgebung), ebenso der Abschnitt „Nachträge nach der Planrunde“.
- **Nur eigene Dateien** (Tabelle oben). Nie ändern: `public/legal-form.js`, `legal-form.css`, `sw.js`, `manifest.webmanifest`, `kundenbereich.css`, `kundenansicht.js`, `termine*`, `shop*`, `zeiten*`, `funktionen*`. Neue Dateien nur unter `public/auftrag*`; Tests nur `test/auftraegeKundenportal*`.
- **Kein HTML aus Daten:** Kundentext und Servertext nur über `textContent`/`h()`; einzige Ausnahme sind die bestehenden `escapeHtml`-Vorlagen in `app.js` (Rechnungsnummer-Zelle). Kein `confirm`/`alert`/`prompt`, kein Browserspeicher, keine Cookies, kein `console.*` (der Token!), nur relative Importe `./auftrag-*.js` (je Modul genau die, die sein `pruefeQuelltext`-Aufruf nennt, in der Reihenfolge `./auftrag-ansicht.js`, dann `./auftrag-dom.js`), kein dynamischer Import in den Modulen, keine Kundentexte außerhalb von `auftrag-ansicht.js` (die Anrede wählt der Aufrufer über `ANREDE_SIE`/`ANREDE_DU`, nie als Wort `du` im Code). `pruefeQuelltext` (Task 3) erzwingt das in jedem Modultest.
- **Schreibweise im Code** (Übersicht, Global Constraints): nie ein Unicode-Escape mit genau vier Hexziffern schreiben (Backslash, `u`, vier Ziffern): das Schreibwerkzeug macht daraus das Zeichen selbst, z. B. ein unsichtbares geschütztes Leerzeichen. Stattdessen `\u{a0}` im String, `String.fromCodePoint(0xa0)` oder im Regex-Literal mit `u`-Flag `\u{2010}`. `pruefeQuelltext` lehnt ein verstecktes geschütztes Leerzeichen im Quelltext ab.
- **Fake-DOM der Tests** (`test/support/fakeDom.js`): Elemente nur mit `h()`/`createElement`, `append`, `replaceChildren`, `insertBefore`, `remove`, `setAttribute`, `classList`, `dataset`, `focus`; Ziele von Links und Formularfeldern immer als **Attribute** setzen (`href`, `target`, `rel`, `type`, `value` über `h` bzw. `setAttribute`). Nicht vorhanden und daher nicht zu benutzen: `innerHTML`, `style`, `replaceWith`, `replaceChild`, `cloneNode`, `toggleAttribute`, `insertAdjacentElement`, `window.scrollTo`; `fetch` liefert nur `ok`, `status` und `json()`. `null` nie an `append`/`replaceChildren` geben (Text „null“): vorher mit `.filter(Boolean)` bereinigen.
- **Öffentliche Seite:** genau ein `fetch`, `credentials: 'omit'`, nur an `/api/auftrag/<Token>` und `…/bestaetigen`. Der Token steht nur in Adresse, Links und Speicher der Seite, **nie** in Text, Ansage, Fehlermeldung oder Protokoll. Meldungen sind feste eigene Texte; vom Server wird nur der Grund eines 410 am Stichwort erkannt.
- **Texte:** die öffentliche Seite und die Widerrufsseite in der **Sie**-Form, der Abschnitt „Aufträge“ im angemeldeten Portal in der **Du**-Form wie das übrige Portal (Übersicht, Global Constraints). Alle Seitentexte stehen als Konstanten in `auftrag-ansicht.js`: neutrale gemeinsame Texte (`TEXTE_GEMEINSAM`), Sie-Texte (`TEXTE_SIE`) und die Du-Varianten für das Portal (`TEXTE_DU`); `texte('sie' | 'du')` liefert die Auswahl, `renderBestaetigung` und `renderBestaetigtBlock` bekommen `anrede`. Der Kleinunternehmer-Satz kommt aus `auftrag.texte.kleinunternehmer` der API, nie aus einer eigenen Konstante. Beträge im Format des Servers (`Intl` de-DE EUR, geschütztes Leerzeichen vor „€“); Datum `TT.MM.JJJJ`, Zeitpunkt `TT.MM.JJJJ, HH:MM Uhr` in **Berliner Zeit**, nie nach der Zeitzone des Browsers. Die rechtlich gefärbten Sätze der Seiten sammelt L7 im Freigabe-Dokument (Übersicht, Nachträge).
- **Gestaltung:** öffentliche Seite im Stil der Mailvorlagen (Farben aus `server/mail-templates/K05 …`, hell und dunkel per `prefers-color-scheme`); Portal-Abschnitt mit den vorhandenen `--kb-*`-Farben. Touch-Ziele mindestens 44 px, Hauptknopf 48 px; lange Wörter brechen um (`overflow-wrap: anywhere`); kein seitliches Scrollen bei 390 px.
- **Shell und Läufe:** `command grep -a` (`grep` ist hier eine Funktion); Node-Läufe mit `--test-concurrency=1`; Chromium nur hinter dem `flock` der Übersicht und nach `free -m` (mindestens 1500 MB „available“).
## Review Focus
1. **Kaputter, fehlender oder doppelter Token in der Adresse** (`auftrag.html` ohne `t`, `#t=abc`, 44 Zeichen, zweimal `t`; Fragment vor `?t=`): Seite sagt „Link ungültig“, nennt den Kontaktweg, stellt keine Anfrage; der Token steht nie im Text, in der Ansage oder in einer Fehlermeldung. → Test: Task 3 `Token: …`, Task 5 `Ohne gültigen Token …` und `Zustände …`.
2. **Doppelklick oder Enter während des Sendens, zweite Bestätigung aus einem anderen Tab, Serverantwort weicht vom Häkchen ab:** genau ein POST; angezeigt wird, was der Server zurückgibt (z. B. Frühbeginn „nein“ trotz Häkchen). → Test: Task 4 `Doppelter Absenden-Versuch …`, Task 5 `Privatperson: … was der Server meldet …`.
3. **Fremdtext** (Projekttitel, Positionstexte, Zahlungsplan-Freitext mit `<`, `&`, `"`, 200 Zeichen ohne Leerzeichen): immer Text, nie HTML, kein seitliches Scrollen auf dem Handy. → Test: Task 4 `Fremdtext bleibt Text`, Task 7 `Fremdtext …`, Task 8 `invoiceNumberHtml …`, Task 6 (CSS `overflow-wrap`), Task 10 (kein Überlauf bei 390 px).
4. **Kundenansicht und Ausfälle im Portal:** Bestätigen gesperrt (Häkchen, Knopf, kein POST), Abschnitt bleibt lesbar; Abschnitt unsichtbar bei `aktiv: false`, leerer Liste und Ladefehler, ohne die Rechnungen zu stören; Status wechselt (410) → Liste lädt neu. → Test: Task 7 `Ansichtsmodus …`, `Der Abschnitt bleibt weg …`, `Fehler beim Bestätigen …`.
5. **Freiwillige AB-Nummer auf der Widerrufsseite:** Schreibweisen der Praxis („ab 2026 0001“, Halbgeviertstrich, Leerzeichen am Rand) werden vereinheitlicht, eine eingegebene, aber unpassende Nummer wird gemeldet und stoppt das Absenden, ein leeres Feld lässt den Widerruf durch (Widerruf bleibt formfrei); Wechsel der Vertragsart (Feld nur bei „Projektauftrag“, sonst leer gesendet). → Test: Task 3 `AB-Nummer …`, Task 9 `Projektauftrag: eine eingegebene, aber unpassende Nummer …` und `Ohne Nummer geht der Projektauftrag durch`.
---
### Task 1: Worktree einrichten und Ausgangslage prüfen
**Files:** keine Änderung an Dateien (Worktree, Symlink).
**Interfaces:**
- Consumes: Branch `feature/auftraege` mit dem Stand nach L0 (`server/services/auftraege/schema.js` existiert, `dev-auftraege/node_modules` ist ein echtes Verzeichnis).
- Produces: Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6` auf Branch `auftraege/l6-kundenportal`; Liste der Tests, die schon vor der ersten Änderung rot sind.
- [ ] **Step 1: Prüfen, dass L0 fertig ist**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && test -f server/services/auftraege/schema.js && test -d node_modules && git log --oneline -3 && git status --short
```
Erwartet: beide `test` ohne Ausgabe, drei Commits, `git status --short` ohne Einträge außer ggf. unversionierten Plan-Dateien unter `docs/superpowers/plans/`. Fehlt `schema.js` oder `node_modules`: **stoppen** und dem Auftraggeber melden (L0 ist noch nicht fertig).
- [ ] **Step 2: Worktree und Symlink anlegen**
```bash
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege worktree add ../dev-auftraege-l6 -b auftraege/l6-kundenportal feature/auftraege
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && ln -s ../dev-auftraege/node_modules node_modules && git status --short && git branch --show-current
```
Erwartet: `git status --short` ohne Eintrag (L0 stellt `.gitignore` auf `node_modules` ohne Schrägstrich, darum wird der Symlink ignoriert; steht trotzdem `?? node_modules` da, ist das nur der Symlink und bleibt unversioniert) und `auftraege/l6-kundenportal`. **Nie `git add -A`**, immer Dateien einzeln.
- [ ] **Step 3: Ausgangslage der berührten bestehenden Tests**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && free -m | head -2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/customerInvoices.test.js test/publicForms.test.js test/shopWiring.test.js test/termineView.test.js test/zeitenBestellen.test.js test/analyseView.test.js test/funktionenRoutes.test.js test/termineFragebogenView.test.js 2>&1 | tail -15
```
Erwartet: `fail 0`. Ist ein Test schon jetzt rot, Namen und Fehler für den Bericht in Task 11 notieren (dort keine Regression). Unter 1500 MB „available“: warten.
Kein Commit.
---
### Task 2: Rechnungsliste nennt das Projekt (`customerInvoices.js`)
**Files:**
- Modify: `server/services/customerInvoices.js` (`customerStripeInvoice`)
- Modify: `test/customerInvoices.test.js` (genau eine Zeile im Test „Liste: Felder einer bezahlten Abo-Rechnung …“; Übersicht, Nachträge)
- Create: `test/auftraegeKundenportalRechnungen.test.js`
**Interfaces:**
- Consumes: Stripe-Metadaten `metadata.projektName` (schreibt Strang L2 laut Übersicht V4; Abo- und Altrechnungen haben keine).
- Produces: `customerStripeInvoice(raw).projekt: string` (`''` ohne Projekt); `GET /api/invoices` liefert `projekt` an jedem Stripe-Eintrag (InvoiceNinja-Einträge bleiben ohne das Feld). Die Oberfläche (Task 8) liest `i.projekt`.
- [ ] **Step 1: Failing Test schreiben**
`test/auftraegeKundenportalRechnungen.test.js`:
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { useTestEnv } from './support/testEnv.js'
// Reiner Test von customerStripeInvoice: config.js liest die Umgebung beim ersten Import, die Adressen werden nie angefragt.
useTestEnv({ appwriteUrl: 'http://127.0.0.1:9', stripeUrl: 'http://127.0.0.1:9' })
const { customerStripeInvoice } = await import('../server/services/customerInvoices.js')
const roh = (over = {}) => ({
id: 'in_1', number: 'WEBKLAR-0009', status: 'open', effective_at: 1_791_000_000, due_date: 1_792_000_000,
total: 50000, amount_remaining: 50000, hosted_invoice_url: 'https://invoice.stripe.com/i/in_1',
invoice_pdf: 'https://pay.stripe.com/invoice/in_1/pdf', metadata: {}, ...over,
})
test('Rechnung der Kunden-Liste: projekt kommt aus metadata.projektName, sonst leer', () => {
assert.equal(customerStripeInvoice(roh({ metadata: { projektName: 'Website Anelia Wolf' } })).projekt, 'Website Anelia Wolf')
assert.equal(customerStripeInvoice(roh()).projekt, '')
assert.equal(customerStripeInvoice(roh({ metadata: { webklarCustomerId: 'c1', art: 'abo' } })).projekt, '')
const ohneMetadaten = roh()
delete ohneMetadaten.metadata
assert.equal(customerStripeInvoice(ohneMetadaten).projekt, '')
})
test('Rechnung der Kunden-Liste: die bisherigen Felder bleiben, projekt kommt dazu', () => {
assert.deepEqual(Object.keys(customerStripeInvoice(roh())).sort(), [
'amount', 'balance', 'cancelled', 'date', 'dueDate', 'hasPdf', 'id', 'number', 'overdue', 'paid', 'portalUrl', 'projekt', 'source', 'status',
])
})
```
In `test/customerInvoices.test.js` im Test „Liste: Felder einer bezahlten Abo-Rechnung (Betraege in Euro, Link zur Rechnungsseite, PDF)“ der erwarteten Form (`assert.deepEqual(abo, { … })`) die Zeile ` projekt: '',` nach `hasPdf: true,` hinzufügen. Sonst nichts an dieser Datei.
- [ ] **Step 2: Lauf, erwartet FAIL**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeKundenportalRechnungen.test.js test/customerInvoices.test.js
```
Erwartet: FAIL (`projekt` fehlt: neuer Test und der angepasste `deepEqual`).
- [ ] **Step 3: Implementieren in `server/services/customerInvoices.js`**
In `customerStripeInvoice(raw, { today })` dem zurückgegebenen Objekt `projekt: raw.metadata?.projektName || ''` hinzufügen (Stripe-Metadaten sind immer Text; Übersicht V4 „L6“). Den Doc-Kommentar der Funktion um einen Satz ergänzen: „`projekt` ist der Projektname aus den Stripe-Metadaten (Projektrechnungen aus dem WOMS), sonst leer.“ `fromInvoiceNinja` bleibt unverändert.
- [ ] **Step 4: Lauf, erwartet PASS**
Derselbe Befehl wie in Step 2. Erwartet: `fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add server/services/customerInvoices.js test/customerInvoices.test.js test/auftraegeKundenportalRechnungen.test.js && git commit -m "Kundenportal: Rechnungsliste kennt das Projekt (Feld projekt aus den Stripe-Metadaten)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: Reine Ansichtslogik `public/auftrag-ansicht.js`
**Files:**
- Create: `public/auftrag-ansicht.js`
- Create: `test/auftraegeKundenportalDaten.js` (gemeinsame Testdaten und Quelltext-Regeln; heißt bewusst nicht `*.test.js`)
- Create: `test/auftraegeKundenportalAnsicht.test.js`
**Interfaces:**
- Consumes: nichts (reines Modul ohne Importe, ohne DOM, ohne `fetch`). Datenformen: Übersicht V3 `AuftragOeffentlich`.
- Produces (alle Exporte von `public/auftrag-ansicht.js`; spätere Tasks verlassen sich auf genau diese Namen):
- `TOKEN_MUSTER`, `KONTAKT` (`{ email: 'support@webklar.com', telefon: '0176 23726355' }`), `BESTAETIGUNG_FALLBACK`, `ANREDE_SIE` (`'sie'`), `ANREDE_DU` (`'du'`), `TEXTE_GEMEINSAM`, `TEXTE_SIE`, `TEXTE_DU` (Tabellen in Step 3), `texte(anrede = ANREDE_SIE) → object`
- `tokenAusAdresse(suche = '', hash = '') → string` (`''` = ungültig; das Fragment `#t=` gilt zuerst, dann `?t=`)
- `euroDe(cents) → string`, `datumDe(wert) → string`, `zeitpunktDe(iso) → string`
- `pdfPfad(token) → string`, `agbPfad(agbUrl) → string` (`''` = kein sicherer Link)
- `zeigtFruehbeginn(auftrag) → boolean`, `darfBestaetigen(auftrag) → boolean`
- `statusAnzeige(auftrag) → { text, ton }` mit `ton ∈ 'ok' | 'warn' | 'muted' | 'rot'`
- `kartenMeta(auftrag) → string`, `sortiereAuftraege(liste) → Auftrag[]`
- `zusammenfassung(auftrag) → { kopf, art, positionen, summe, aufwand, zahlung, termine, hinweis }` (`hinweis` = `auftrag.texte.kleinunternehmer` von der API, sonst `''`)
- `bestaetigtZeilen(auftrag, anrede = ANREDE_SIE) → string[]`
- `zustandAusAntwort(status, body) → { zustand: 'ungueltig' | 'beendet' | 'limit' | 'fehler', titel, text }` (nur die öffentliche Seite, Sie-Form)
- `fehlerMeldung(status, anrede = ANREDE_SIE) → string`
- `abNummerNormalisiert(eingabe) → string` (`'AB-JJJJ-NNNN'` oder `''`)
- aus `test/auftraegeKundenportalDaten.js`: `TOKEN`, `BESTAETIGT_AM`, `KLEINUNTERNEHMER`, `ab(over)`, `privat(over)`, `bestaetigt(auftrag)`, `eintrag(over)`, `hatDuForm(text)`, `hatSieForm(text)`, `pruefeQuelltext(datei, { fetchAnzahl, importe, mitTexten })`
- [ ] **Step 1: Failing Test schreiben**
`test/auftraegeKundenportalDaten.js`:
```js
// Gemeinsame Testdaten und Quelltext-Regeln des Strangs L6 (kein Test, daher nicht *.test.js).
// Formen laut Übersicht V3 (AuftragOeffentlich) und V5 (Liste im Portal: plus id und kannBestaetigen).
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
export const TOKEN = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG' // 43 Zeichen wie ein echter Token (base64url)
export const BESTAETIGT_AM = '2026-10-07T08:15:00.000Z' // = 07.10.2026, 10:15 Uhr Berliner Zeit (MESZ)
export const KLEINUNTERNEHMER = 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' // wie AB_TEXTE.kleinunternehmer; die API liefert ihn als texte.kleinunternehmer
/** Festpreis-AB eines Unternehmens, gesendet, noch nicht bestätigt. */
export const ab = (over = {}) => ({
nummer: 'AB-2026-0002', datum: '2026-10-06', titel: 'Website Anelia Wolf', art: 'festpreis',
positionen: [
{ beschreibung: 'Erstellung der Website anelia-wolf.com', menge: 1, einzelpreisCents: 60000, summeCents: 60000 },
{ beschreibung: 'Texte und Bildbearbeitung', menge: 2, einzelpreisCents: 10000, summeCents: 20000 },
],
summeCents: 80000, stundensatzCents: null, schaetzungMinuten: null, aufwandText: null,
zahlungsplanText: 'Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.',
startAm: '2026-10-12', fertigBis: '2026-11-30', kundenart: 'unternehmen', status: 'versendet',
bestaetigtAm: null, fruehbeginn: false, fruehesterBeginn: null, agbUrl: '/rechtliches/agb-2026-09-30.pdf',
texte: { bestaetigung: 'Ich bestätige den Auftrag zu diesen Bedingungen.', fruehbeginn: null, kleinunternehmer: KLEINUNTERNEHMER },
...over,
})
/** Dieselbe AB für eine Privatperson (zweites, freiwilliges Häkchen; Texte sind Testtexte, nicht die der Rechtsrunde). */
export const privat = (over = {}) => ab({
kundenart: 'privat', fruehesterBeginn: '2026-10-21',
texte: {
bestaetigung: 'Ich bestätige den Auftrag zu diesen Bedingungen.',
fruehbeginn: 'Ich verlange, dass Sie schon vor Ablauf der Widerrufsfrist mit der Arbeit beginnen.',
kleinunternehmer: KLEINUNTERNEHMER,
},
...over,
})
/** Die übergebene AB im Zustand „bestätigt“. */
export const bestaetigt = (auftrag) => ({ ...auftrag, status: 'bestaetigt', bestaetigtAm: BESTAETIGT_AM })
/** Eintrag der Portal-Liste (GET /api/auftraege): AB plus id und kannBestaetigen. */
export const eintrag = (over = {}) => ({ id: 'ab1', ...ab(), kannBestaetigen: true, ...over })
const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
const DU = /\b(?:du|dein(?:e[nmrs]?)?|dich|dir)\b/i
const SIE = /\b(?:Sie|Ihr(?:e[nmrs]?)?|Ihnen)\b/
/** Steht eine Du- bzw. Sie-Form im Text? (Anrede-Prüfungen der Tests) */
export const hatDuForm = (text) => DU.test(String(text))
export const hatSieForm = (text) => SIE.test(String(text))
/**
* Quelltext-Regeln für jedes Modul public/auftrag*.js; gibt den Quelltext ohne Kommentare zurück.
* mitTexten: nur auftrag-ansicht.js enthält Kundentexte (ihre Anrede prüft der Test selbst); alle anderen Module enthalten weder Du- noch Sie-Formen.
*/
export function pruefeQuelltext(datei, { fetchAnzahl = 0, importe = [], mitTexten = false } = {}) {
const quelle = ohneKommentare(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8'))
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|\beval\(|new Function/, `${datei}: Daten nie als HTML`)
assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie|indexedDB/, `${datei}: kein Browserspeicher`)
assert.doesNotMatch(quelle, /\bconsole\./, `${datei}: nichts ins Protokoll (der Token!)`)
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: keine Browser-Rückfragen`)
assert.doesNotMatch(quelle, /import\s*\(/, `${datei}: kein dynamischer Import`)
assert.ok(!quelle.includes(String.fromCodePoint(0xa0)), `${datei}: kein verstecktes geschütztes Leerzeichen im Quelltext`)
if (!mitTexten) assert.ok(!DU.test(quelle) && !hatSieForm(quelle), `${datei}: keine Kundentexte im Modul (sie stehen in auftrag-ansicht.js; Anrede über ANREDE_SIE/ANREDE_DU)`)
assert.equal([...quelle.matchAll(/\bfetch\(/g)].length, fetchAnzahl, `${datei}: Anzahl fetch`)
assert.deepEqual([...quelle.matchAll(/\bfrom\s+'([^']+)'/g)].map((m) => m[1]), importe, `${datei}: Importe`)
return quelle
}
```
`test/auftraegeKundenportalAnsicht.test.js`:
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
TOKEN_MUSTER, KONTAKT, ANREDE_SIE, ANREDE_DU, TEXTE_GEMEINSAM, TEXTE_SIE, TEXTE_DU, texte, tokenAusAdresse, euroDe, datumDe, zeitpunktDe,
pdfPfad, agbPfad, zeigtFruehbeginn, darfBestaetigen, statusAnzeige, kartenMeta, zusammenfassung, bestaetigtZeilen, zustandAusAntwort,
fehlerMeldung, sortiereAuftraege, abNummerNormalisiert,
} from '../public/auftrag-ansicht.js'
import { TOKEN, BESTAETIGT_AM, KLEINUNTERNEHMER, ab, privat, bestaetigt, hatDuForm, hatSieForm, pruefeQuelltext } from './auftraegeKundenportalDaten.js'
const NB = '\u{a0}' // geschütztes Leerzeichen vor „€“ (wie euroDe auf dem Server)
test('Token: 43 Zeichen base64url, zuerst aus #t= (so verlinkt K31), sonst aus ?t=, nie mehrdeutig', () => {
assert.equal(TOKEN.length, 43)
assert.match(TOKEN, TOKEN_MUSTER)
assert.equal(tokenAusAdresse('', `#t=${TOKEN}`), TOKEN)
assert.equal(tokenAusAdresse(`?t=${TOKEN}`, ''), TOKEN)
assert.equal(tokenAusAdresse(`?x=1&t=${TOKEN}`, ''), TOKEN)
const anderer = 'y'.repeat(43)
assert.equal(tokenAusAdresse(`?t=${TOKEN}`, `#t=${anderer}`), anderer, 'das Fragment gewinnt')
assert.equal(tokenAusAdresse(`?t=${TOKEN}`, '#t=abc'), TOKEN, 'ungültiges Fragment: weiter mit der Suche')
for (const hash of ['', '#', '#t=', '#t=abc', `#t=${TOKEN}x`, `#t=${TOKEN.slice(0, 42)}!`, `#t=${TOKEN}&t=${TOKEN}`, `#token=${TOKEN}`]) {
assert.equal(tokenAusAdresse('', hash), '', hash)
}
assert.equal(tokenAusAdresse(`?t=${TOKEN}&t=${TOKEN}`, ''), '')
assert.equal(tokenAusAdresse(undefined, undefined), '')
})
test('Beträge: ganze Cent als deutscher Euro-Betrag wie auf dem Server', () => {
assert.equal(euroDe(80000), `800,00${NB}€`)
assert.equal(euroDe(123456), `1.234,56${NB}€`)
assert.equal(euroDe(5), `0,05${NB}€`)
assert.equal(euroDe(0), `0,00${NB}€`)
assert.equal(euroDe(-449), `−4,49${NB}€`)
for (const kaputt of [1.5, '800', null, undefined, Number.NaN]) assert.equal(euroDe(kaputt), '–')
})
test('Datum und Zeit: Berliner Zeit, unabhängig von der Zeitzone des Browsers', () => {
assert.equal(datumDe('2026-10-06'), '06.10.2026')
assert.equal(datumDe('06.10.2026'), '06.10.2026')
assert.equal(datumDe('2026-10-06T22:30:00.000Z'), '07.10.2026') // 00:30 MESZ am Folgetag
for (const kaputt of ['', null, undefined, 'morgen', '2026-10', 20261006]) assert.equal(datumDe(kaputt), '')
assert.equal(zeitpunktDe(BESTAETIGT_AM), '07.10.2026, 10:15 Uhr')
assert.equal(zeitpunktDe('2026-10-07T08:15:00.000+00:00'), '07.10.2026, 10:15 Uhr') // so liefert Appwrite
assert.equal(zeitpunktDe('2026-12-01T08:15:00Z'), '01.12.2026, 09:15 Uhr') // Winterzeit
assert.equal(zeitpunktDe('2026-10-06T22:05:00Z'), '07.10.2026, 00:05 Uhr') // Mitternacht ohne „24“
for (const kaputt of ['', null, undefined, 'bald']) assert.equal(zeitpunktDe(kaputt), '')
})
test('Links: PDF-Pfad aus dem Token, AGB nur als PDF unter /rechtliches/', () => {
assert.equal(pdfPfad(TOKEN), `/api/auftrag/${TOKEN}/pdf`)
assert.equal(agbPfad('/rechtliches/agb-2026-09-30.pdf'), '/rechtliches/agb-2026-09-30.pdf')
for (const boese of ['https://evil.example/agb.pdf', '//evil.example/agb.pdf', '/rechtliches/../x.pdf', '/rechtliches/agb.pdf?x=1', '/rechtliches/agb.html', 'javascript:alert(1)', '', null, undefined, 5]) {
assert.equal(agbPfad(boese), '', String(boese))
}
})
test('Häkchen: Frühbeginn nur mit Text vom Server; Bestätigen nur, solange nicht bestätigt', () => {
assert.equal(zeigtFruehbeginn(privat()), true)
assert.equal(zeigtFruehbeginn(ab()), false)
assert.equal(zeigtFruehbeginn(privat({ texte: { bestaetigung: 'x', fruehbeginn: ' ' } })), false)
assert.equal(zeigtFruehbeginn({}), false)
assert.equal(darfBestaetigen(ab()), true)
assert.equal(darfBestaetigen(ab({ status: 'abgerechnet' })), true) // Schlussrechnung vor der Bestätigung (Übersicht V2)
assert.equal(darfBestaetigen(bestaetigt(ab())), false)
assert.equal(darfBestaetigen(ab({ status: 'abgerechnet', bestaetigtAm: BESTAETIGT_AM })), false)
for (const status of ['storniert', 'ersetzt', 'widerrufen', 'entwurf']) assert.equal(darfBestaetigen(ab({ status })), false, status)
})
test('Status: Beschriftung und Ton; „Bitte bestätigen“ nur mit kannBestaetigen und ohne Bestätigung', () => {
assert.deepEqual(statusAnzeige({ status: 'versendet', kannBestaetigen: true, bestaetigtAm: null }), { text: 'Bitte bestätigen', ton: 'warn' })
assert.deepEqual(statusAnzeige({ status: 'abgerechnet', kannBestaetigen: true, bestaetigtAm: null }), { text: 'Bitte bestätigen', ton: 'warn' })
assert.deepEqual(statusAnzeige({ status: 'versendet' }), { text: 'Versendet', ton: 'muted' })
assert.deepEqual(statusAnzeige({ status: 'bestaetigt', bestaetigtAm: BESTAETIGT_AM, kannBestaetigen: false }), { text: 'Bestätigt', ton: 'ok' })
assert.deepEqual(statusAnzeige({ status: 'abgerechnet', bestaetigtAm: BESTAETIGT_AM }), { text: 'Abgerechnet', ton: 'ok' })
assert.deepEqual(statusAnzeige({ status: 'storniert' }), { text: 'Storniert', ton: 'muted' })
assert.deepEqual(statusAnzeige({ status: 'ersetzt' }), { text: 'Ersetzt', ton: 'muted' })
assert.deepEqual(statusAnzeige({ status: 'widerrufen' }), { text: 'Widerrufen', ton: 'rot' })
assert.deepEqual(statusAnzeige({ status: 'unbekannt' }), { text: '–', ton: 'muted' })
assert.deepEqual(statusAnzeige(undefined), { text: '–', ton: 'muted' })
})
test('Karten-Zeile und Sortierung der Portalliste', () => {
assert.equal(kartenMeta(ab()), `vom 06.10.2026 · 800,00${NB}€`)
assert.equal(kartenMeta(ab({ art: 'aufwand', positionen: [], summeCents: null })), 'vom 06.10.2026 · nach Aufwand')
assert.equal(kartenMeta(ab({ datum: '' })), `800,00${NB}€`)
assert.equal(kartenMeta({}), '')
const liste = [
{ nummer: 'AB-2026-0001', kannBestaetigen: false }, { nummer: 'AB-2026-0003', kannBestaetigen: false },
{ nummer: 'AB-2026-0002', kannBestaetigen: true }, { nummer: 'AB-2025-0009', kannBestaetigen: false },
]
assert.deepEqual(sortiereAuftraege(liste).map((a) => a.nummer), ['AB-2026-0002', 'AB-2026-0003', 'AB-2026-0001', 'AB-2025-0009'])
assert.equal(liste[0].nummer, 'AB-2026-0001', 'die Eingabe bleibt unverändert')
assert.deepEqual(sortiereAuftraege(undefined), [])
})
test('Zusammenfassung (Festpreis): Kopf, Positionen, Summe, Zahlungsplan, Termine, § 19 aus der API', () => {
assert.deepEqual(zusammenfassung(ab()), {
kopf: [{ label: 'Nummer', wert: 'AB-2026-0002' }, { label: 'Datum', wert: '06.10.2026' }, { label: 'Projekt', wert: 'Website Anelia Wolf' }],
art: 'festpreis',
positionen: [
{ beschreibung: 'Erstellung der Website anelia-wolf.com', menge: '1', einzelpreis: `600,00${NB}€`, summe: `600,00${NB}€` },
{ beschreibung: 'Texte und Bildbearbeitung', menge: '2', einzelpreis: `100,00${NB}€`, summe: `200,00${NB}€` },
],
summe: `800,00${NB}€`,
aufwand: '',
zahlung: ab().zahlungsplanText,
termine: [{ label: 'Start', wert: '12.10.2026' }, { label: 'Voraussichtliche Fertigstellung', wert: '30.11.2026' }],
hinweis: KLEINUNTERNEHMER,
})
})
test('Zusammenfassung (Aufwand): Aufwandstext statt Positionen; Termine nur, wenn gesetzt; ohne Satz der API kein § 19-Hinweis; leere Eingabe wirft nicht', () => {
const z = zusammenfassung(ab({
art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.',
zahlungsplanText: 'Monatlich nach Aufwand.', startAm: '2026-10-12', fertigBis: '',
}))
assert.equal(z.art, 'aufwand')
assert.deepEqual(z.positionen, [])
assert.equal(z.summe, '')
assert.equal(z.aufwand, '100,00 € pro Stunde in Schritten von 15 Minuten.')
assert.equal(z.zahlung, 'Monatlich nach Aufwand.')
assert.deepEqual(z.termine, [{ label: 'Start', wert: '12.10.2026' }])
assert.equal(z.hinweis, KLEINUNTERNEHMER)
assert.equal(zusammenfassung(ab({ texte: { bestaetigung: 'x', fruehbeginn: null } })).hinweis, '', 'keine eigene Ersatzkonstante: der Satz kommt von der API')
assert.deepEqual(zusammenfassung({}), { kopf: [], art: 'festpreis', positionen: [], summe: '', aufwand: '', zahlung: '', termine: [], hinweis: '' })
assert.doesNotThrow(() => zusammenfassung(undefined))
})
test('Bestätigt-Zeilen: Zeitpunkt, bei Privatpersonen Frühbeginn bzw. frühester Beginn, in beiden Anreden', () => {
assert.deepEqual(bestaetigtZeilen(ab()), [])
assert.deepEqual(bestaetigtZeilen(bestaetigt(ab())), ['Bestätigt am 07.10.2026, 10:15 Uhr'])
assert.deepEqual(bestaetigtZeilen(bestaetigt(ab({ fruehesterBeginn: '2026-10-21' }))), ['Bestätigt am 07.10.2026, 10:15 Uhr'], 'Unternehmen: keine Zeile zum Beginn')
assert.deepEqual(bestaetigtZeilen(bestaetigt(privat())), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.'])
const wunsch = bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null }))
assert.deepEqual(bestaetigtZeilen(wunsch), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.'])
assert.deepEqual(bestaetigtZeilen(wunsch, ANREDE_DU), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.'])
assert.deepEqual(bestaetigtZeilen(bestaetigt(privat({ fruehesterBeginn: null }))), ['Bestätigt am 07.10.2026, 10:15 Uhr'])
assert.deepEqual(bestaetigtZeilen({ bestaetigtAm: 'kaputt' }), ['Bestätigt'])
})
test('Zustände aus der Serverantwort: feste Texte, der 410-Grund nur aus dem Stichwort', () => {
const kontakt = 'Bitte melden Sie sich bei uns. Wir senden Ihnen gern einen neuen Link.'
assert.deepEqual(zustandAusAntwort(404, { error: 'Dieser Link ist ungültig oder abgelaufen.' }), { zustand: 'ungueltig', titel: 'Dieser Link ist ungültig oder abgelaufen.', text: kontakt })
assert.deepEqual(zustandAusAntwort(410, { error: 'Diese Auftragsbestätigung wurde ersetzt.' }), {
zustand: 'beendet', titel: 'Diese Auftragsbestätigung wurde ersetzt.', text: 'Bitte verwenden Sie den Link in der neuesten Auftragsbestätigung, die wir Ihnen geschickt haben.',
})
for (const grund of ['storniert', 'widerrufen']) {
assert.deepEqual(zustandAusAntwort(410, { error: `Diese Auftragsbestätigung wurde ${grund}.` }), {
zustand: 'beendet', titel: `Diese Auftragsbestätigung wurde ${grund}.`, text: 'Bei Fragen melden Sie sich gern bei uns.',
})
}
for (const body of [{ error: `Irgendwas mit ${TOKEN}` }, {}, undefined, null]) { // fremder Text (auch mit Token) wird nie angezeigt
const z = zustandAusAntwort(410, body)
assert.equal(z.titel, 'Diese Auftragsbestätigung ist nicht mehr gültig.')
assert.equal(z.text, 'Bei Fragen melden Sie sich gern bei uns.')
}
assert.deepEqual(zustandAusAntwort(429, { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }), { zustand: 'limit', titel: 'Zu viele Anfragen', text: 'Bitte versuchen Sie es in einigen Minuten noch einmal.' })
for (const status of [0, 400, 401, 500, 502, 503]) {
assert.deepEqual(zustandAusAntwort(status, { error: 'Interner Fehler.' }), {
zustand: 'fehler', titel: 'Das hat leider nicht geklappt', text: 'Ihre Auftragsbestätigung konnte gerade nicht geladen werden. Bitte versuchen Sie es in einigen Minuten noch einmal.',
})
}
})
test('Fehlermeldungen beim Bestätigen: feste eigene Texte nach Status, Sie auf der Seite und Du im Portal', () => {
assert.equal(fehlerMeldung(0), 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.')
assert.equal(fehlerMeldung(400), 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.')
assert.equal(fehlerMeldung(401), 'Ihre Anmeldung ist abgelaufen. Bitte laden Sie die Seite neu und melden Sie sich erneut an.')
for (const status of [403, 409]) assert.equal(fehlerMeldung(status), 'Das Bestätigen ist gerade nicht möglich. Bitte laden Sie die Seite neu.')
assert.equal(fehlerMeldung(429), 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.')
for (const status of [500, 502, 503, undefined]) assert.equal(fehlerMeldung(status), 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.')
assert.equal(fehlerMeldung(0, ANREDE_DU), 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.')
assert.equal(fehlerMeldung(400, ANREDE_DU), 'Bitte setze das Häkchen, um den Auftrag zu bestätigen.')
assert.equal(fehlerMeldung(401, ANREDE_DU), 'Deine Anmeldung ist abgelaufen. Bitte lade die Seite neu und melde dich erneut an.')
for (const status of [403, 409]) assert.equal(fehlerMeldung(status, ANREDE_DU), 'Das Bestätigen ist gerade nicht möglich. Bitte lade die Seite neu.')
assert.equal(fehlerMeldung(429, ANREDE_DU), 'Zu viele Anfragen. Bitte versuche es in einigen Minuten noch einmal.')
for (const status of [500, 502, 503, undefined]) assert.equal(fehlerMeldung(status, ANREDE_DU), 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.')
})
const werte = (objekt) => Object.values(objekt).flatMap((wert) => (wert && typeof wert === 'object' ? werte(wert) : [wert])).filter((wert) => typeof wert === 'string')
test('Anrede: gemeinsame Texte neutral, öffentliche Seite Sie, Portal Du; jede Du-Variante hat ihr Sie-Gegenstück', () => {
assert.equal(ANREDE_SIE, 'sie')
assert.equal(ANREDE_DU, 'du')
for (const text of werte(TEXTE_GEMEINSAM)) assert.ok(!hatDuForm(text) && !hatSieForm(text), text)
for (const text of werte(TEXTE_SIE)) assert.ok(!hatDuForm(text), text)
for (const text of werte(TEXTE_DU)) assert.ok(!hatSieForm(text), text)
assert.deepEqual(Object.keys(TEXTE_DU).sort(), ['fehler', 'formular', 'fruehbeginnWunsch', 'speicherHinweis'])
for (const schluessel of Object.keys(TEXTE_DU)) assert.ok(schluessel in TEXTE_SIE, schluessel)
assert.deepEqual(Object.keys(TEXTE_DU.fehler), Object.keys(TEXTE_SIE.fehler))
assert.equal(texte().formular, 'Ihre Bestätigung', 'ohne Angabe gilt Sie')
assert.equal(texte(ANREDE_SIE).speicherHinweis, 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.')
assert.equal(texte(ANREDE_DU).formular, 'Deine Bestätigung')
assert.equal(texte(ANREDE_DU).speicherHinweis, 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.')
assert.equal(texte(ANREDE_DU).knopf, 'Auftrag bestätigen', 'gemeinsame Texte gibt es in beiden Anreden')
assert.equal(texte(ANREDE_DU).ansichtsmodus, 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.')
})
test('AB-Nummer: Schreibweisen der Praxis werden zu AB-JJJJ-NNNN, alles andere zu leer', () => {
assert.equal(abNummerNormalisiert('AB-2026-0001'), 'AB-2026-0001')
assert.equal(abNummerNormalisiert(' ab-2026-0001 '), 'AB-2026-0001')
assert.equal(abNummerNormalisiert('ab 2026 0001'), 'AB-2026-0001')
assert.equal(abNummerNormalisiert('AB – 2026 – 0001'), 'AB-2026-0001') // Halbgeviertstrich mit Leerzeichen
assert.equal(abNummerNormalisiert('AB−2026−0001'), 'AB-2026-0001') // Minuszeichen
for (const kaputt of ['', ' ', 'AB-2026-01', 'AB-26-0001', 'RE-2026-0001', 'AB-2026-0001-1', 'AB20260001', undefined, null]) assert.equal(abNummerNormalisiert(kaputt), '', String(kaputt))
})
test('Quelltext und Kontakt: reines Modul ohne Importe, fetch und DOM; Kontaktdaten wie in den Rechtstexten', () => {
pruefeQuelltext('auftrag-ansicht.js', { mitTexten: true })
assert.deepEqual(KONTAKT, { email: 'support@webklar.com', telefon: '0176 23726355' })
})
```
- [ ] **Step 2: Lauf, erwartet FAIL**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKundenportalAnsicht.test.js
```
Erwartet: FAIL (`Cannot find module '../public/auftrag-ansicht.js'`).
- [ ] **Step 3: `public/auftrag-ansicht.js` implementieren**
ES-Modul ohne Importe, ohne `document`/`window`/`fetch`. Formatierer einmal anlegen: `Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })` wie `euroDe` in `server/services/finance/format.js` (negativ mit dem Minuszeichen „−“ (U+2212) davor); `Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })` mit `formatToParts`; Berliner Tag eines Zeitpunkts mit `Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' })` wie `berlinDate`. Jede Funktion verträgt `undefined`/`null` und fehlende Felder (kein Wurf). Kundentexte stehen **nur** in diesem Modul.
| Export | Verhalten |
|---|---|
| `TOKEN_MUSTER` | `/^[A-Za-z0-9_-]{43}$/` |
| `tokenAusAdresse(suche, hash)` | Kandidaten in dieser Reihenfolge: Fragment (ohne `#`, so verlinkt K31, V6), Suche (ohne `?`, Rückfall); je `new URLSearchParams(teil).getAll('t')`: genau ein Wert und `TOKEN_MUSTER` → Token; sonst weiter; am Ende `''` |
| `euroDe(cents)` | `Number.isSafeInteger` sonst `'–'`; sonst Format wie oben |
| `datumDe(wert)` | `YYYY-MM-DD` → `TT.MM.JJJJ`; `TT.MM.JJJJ` bleibt; ISO-Zeitpunkt (`YYYY-MM-DDT…`) → Berliner Tag; sonst `''` |
| `zeitpunktDe(iso)` | `'TT.MM.JJJJ, HH:MM Uhr'` in Berliner Zeit; leer/ungültig → `''` |
| `pdfPfad(token)` | `` `/api/auftrag/${encodeURIComponent(token)}/pdf` `` |
| `agbPfad(agbUrl)` | nur Text, der `^/rechtliches/[A-Za-z0-9][A-Za-z0-9._-]*\.pdf$` erfüllt, sonst `''` |
| `zeigtFruehbeginn(a)` | `typeof a?.texte?.fruehbeginn === 'string'` und nach `trim()` nicht leer |
| `darfBestaetigen(a)` | `!a.bestaetigtAm` und `status` ist `versendet` oder `abgerechnet` (V3: `kannBestaetigen` ohne die Kundenansicht) |
| `statusAnzeige(a)` | `a.kannBestaetigen === true && !a.bestaetigtAm` → `{ 'Bitte bestätigen', 'warn' }`; sonst Tabelle: `versendet` Versendet/muted, `bestaetigt` Bestätigt/ok, `abgerechnet` Abgerechnet/ok, `storniert` Storniert/muted, `ersetzt` Ersetzt/muted, `widerrufen` Widerrufen/rot, sonst `{ '–', 'muted' }` |
| `kartenMeta(a)` | Teile `vom <datumDe>` (nur mit Datum) und `nach Aufwand` (art `aufwand`) bzw. `euroDe(summeCents)` (nur bei ganzer Zahl), verbunden mit `' · '` |
| `sortiereAuftraege(liste)` | Kopie; zuerst `kannBestaetigen`, dann `nummer` absteigend per Zeichenvergleich (nicht `localeCompare`) |
| `zusammenfassung(a)` | Form wie im Test: `kopf` nur Zeilen mit Wert (Labels Nummer, Datum, Projekt = `titel`); `art` `aufwand` oder sonst `festpreis`; `positionen` (menge als Text, Beträge `euroDe`); `summe` nur Festpreis mit ganzer Zahl; `aufwand` nur bei Aufwand (`aufwandText`); `zahlung` = `zahlungsplanText`; `termine` Start / Voraussichtliche Fertigstellung nur mit Datum; `hinweis` = `a.texte.kleinunternehmer`, wenn ein nicht leerer Text (V3), sonst `''` – **keine** eigene Ersatzkonstante |
| `bestaetigtZeilen(a, anrede)` | leer ohne `bestaetigtAm`; sonst `Bestätigt am <zeitpunktDe>` (ist die Zeit ungültig: `Bestätigt`); nur bei `kundenart === 'privat'`: `fruehbeginn === true` → `texte(anrede).fruehbeginnWunsch`, sonst mit `fruehesterBeginn` → `Wir beginnen frühestens am <datumDe>.` |
| `zustandAusAntwort(status, body)` | 404 → `ungueltig`; 410 → `beendet` (Grund aus `body?.error` per `/ersetzt/i`, `/storniert/i`, `/widerrufen/i`, Titel `Diese Auftragsbestätigung wurde <grund>.`, ohne Grund `…ist nicht mehr gültig.`; Text bei `ersetzt` der Hinweis auf die neueste AB, sonst „Bei Fragen …“); 429 → `limit`; alles andere (auch 0) → `fehler`. Angezeigt wird nie Servertext. Nur öffentliche Seite, Sie-Form |
| `fehlerMeldung(status, anrede)` | aus `texte(anrede).fehler`: 0 → `keine`, 400 → `haken`, 401 → `anmeldung`, 403/409 → `gesperrt`, 429 → `zuviele`, sonst `sonst` |
| `abNummerNormalisiert(e)` | `String(e ?? '')`, `trim`, groß, Folgen aus Leerraum und Strichen (`/[\s\-\u{2010}-\u{2015}\u{2212}]+/gu`) zu einem `-`; Ergebnis nur, wenn es `^AB-\d{4}-\d{4}$` erfüllt, sonst `''` |
| `ANREDE_SIE`, `ANREDE_DU` | `'sie'`, `'du'` (die anderen Module benutzen diese Konstanten, nie die Wörter) |
| `texte(anrede)` | `{ ...TEXTE_GEMEINSAM, ...(anrede === ANREDE_DU ? TEXTE_DU : TEXTE_SIE) }`; ohne Angabe Sie |
| `KONTAKT`, `BESTAETIGUNG_FALLBACK` | `{ email: 'support@webklar.com', telefon: '0176 23726355' }`, `'Ich bestätige den Auftrag zu diesen Bedingungen.'` (gleich `BESTAETIGUNG_TEXT`, V7) |
Texte (eingefroren; Task 4 bis 9 nutzen genau diese Schlüssel, die Texte sind wörtlich so zu übernehmen). **`TEXTE_GEMEINSAM`** (neutral, in beiden Anreden; weder Du- noch Sie-Formen):
| Schlüssel | Text |
|---|---|
| `titelOffen` | Auftragsbestätigung (die Seite zeigt `Auftragsbestätigung <Nummer>`, also Text + Leerzeichen + Nummer) |
| `leistung`, `aufwand`, `zahlung`, `termine`, `unterlagen` | Leistungsumfang · Abrechnung nach Aufwand · Zahlung · Termine · Unterlagen |
| `spalten` | `['Beschreibung', 'Menge', 'Einzelpreis', 'Summe']` |
| `gesamtpreis` | Gesamtpreis |
| `linkPdf`, `linkAgb` | Auftragsbestätigung als PDF ansehen · Allgemeine Geschäftsbedingungen (PDF) |
| `freiwillig` | (freiwillig) |
| `knopf`, `sendet` | Auftrag bestätigen · Wird gesendet … |
| `ansichtsmodus` | Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab. |
| `erneut` | Erneut versuchen |
| `kartePdf`, `karteDetails`, `karteDetailsBestaetigen` | PDF ansehen · Details ansehen · Details ansehen und bestätigen |
**`TEXTE_SIE`** (öffentliche Seite und Widerrufsseite; enthält auch die Schlüssel, die das Portal in Du überschreibt):
| Schlüssel | Text |
|---|---|
| `laedt` | Ihre Auftragsbestätigung wird geladen … |
| `geladen` | Ihre Auftragsbestätigung wurde geladen. |
| `titelBestaetigt` | Ihr Auftrag ist bestätigt. |
| `intro` | Bitte prüfen Sie die Angaben. Mit einem Klick bestätigen Sie den Auftrag. |
| `kontaktVor` | Sie erreichen uns unter |
| `keinJs` | Bitte aktivieren Sie JavaScript, um Ihren Auftrag zu bestätigen. |
| `abNummerUngueltig` | Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfen Sie die Nummer oder lassen Sie das Feld leer. |
| `formular` | Ihre Bestätigung |
| `speicherHinweis` | Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert. |
| `fruehbeginnWunsch` | Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist. |
| `fehler.keine` | Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut. |
| `fehler.haken` | Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen. |
| `fehler.anmeldung` | Ihre Anmeldung ist abgelaufen. Bitte laden Sie die Seite neu und melden Sie sich erneut an. |
| `fehler.gesperrt` | Das Bestätigen ist gerade nicht möglich. Bitte laden Sie die Seite neu. |
| `fehler.zuviele` | Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal. |
| `fehler.sonst` | Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal. |
**`TEXTE_DU`** (Portal, Du-Form; nur die Schlüssel, die dort anders lauten, mit derselben Struktur):
| Schlüssel | Text |
|---|---|
| `formular` | Deine Bestätigung |
| `speicherHinweis` | Deine Bestätigung wird mit Datum und Uhrzeit gespeichert. |
| `fruehbeginnWunsch` | Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist. |
| `fehler.keine` | Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut. |
| `fehler.haken` | Bitte setze das Häkchen, um den Auftrag zu bestätigen. |
| `fehler.anmeldung` | Deine Anmeldung ist abgelaufen. Bitte lade die Seite neu und melde dich erneut an. |
| `fehler.gesperrt` | Das Bestätigen ist gerade nicht möglich. Bitte lade die Seite neu. |
| `fehler.zuviele` | Zu viele Anfragen. Bitte versuche es in einigen Minuten noch einmal. |
| `fehler.sonst` | Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal. |
Die Texte der Zustände (`zustandAusAntwort`) stehen nur dort (wörtlich aus dem Test); sie gelten nur auf der öffentlichen Seite (Sie-Form).
- [ ] **Step 4: Lauf, erwartet PASS**
Derselbe Befehl wie in Step 2. Erwartet: `fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add public/auftrag-ansicht.js test/auftraegeKundenportalDaten.js test/auftraegeKundenportalAnsicht.test.js && git commit -m "Kundenportal Aufträge: reine Ansichtslogik (Token, Beträge, Zustände, Status, AB-Nummer)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: DOM-Bausteine `public/auftrag-dom.js`
**Files:**
- Create: `public/auftrag-dom.js`
- Create: `test/auftraegeKundenportalDom.test.js`
**Interfaces:**
- Consumes: `texte`, `ANREDE_SIE`, `zusammenfassung`, `bestaetigtZeilen`, `zeigtFruehbeginn`, `fehlerMeldung`, `BESTAETIGUNG_FALLBACK` aus `./auftrag-ansicht.js` (Task 3).
- Produces (Exporte von `public/auftrag-dom.js`):
- `h(tag, attrs, ...kinder) → HTMLElement` (eigene Kopie des Bausteins aus `shop-dom.js`: Texte nur als Textknoten, Attribute mit `on…` als Ereignis, `true` setzt ein Attribut ohne Wert, `false`/`null`/`undefined` lassen es weg)
- `renderZusammenfassung(auftrag, { ebene = 2 } = {}) → HTMLElement`
- `renderBestaetigtBlock(auftrag, { anrede = ANREDE_SIE } = {}) → HTMLElement | null`
- `renderDokumente({ pdfHref = '', agbHref = '' }) → HTMLElement | null`
- `renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde, gesperrt = '', anrede = ANREDE_SIE }) → HTMLFormElement` (`anrede` wählt die Texte: Sie auf der öffentlichen Seite, `ANREDE_DU` im Portal), mit `senden(koerper) → Promise<{ ok: true, auftrag } | { ok: false, status, body }>`, `nachErfolg(auftrag)`, `nachEnde(status, body)` (nur bei 404/410)
- **Vokabular für CSS (Task 6, 7) und Tests:** Klassen `ab-zusammenfassung`, `ab-liste`, `ab-zeile`, `ab-abschnitt`, `ab-h2` (für alle Überschriftenebenen), `ab-tabelle`, `ab-gesamt`, `ab-hinweis`, `ab-text`, `ab-bestaetigt`, `ab-links`, `ab-link`, `ab-bestaetigen`, `ab-feldgruppe`, `ab-haken`, `ab-frei`, `ab-fehler`, `ab-knopf`, `ab-notiz`, `ab-gesperrt`; Datenmarken `data-art`, `data-abschnitt` (`leistung|aufwand|zahlung|termine`), `data-positionen`, `data-position`, `data-gesamt`, `data-hinweis`, `data-aufwand`, `data-zahlung`, `data-bestaetigt`, `data-link` (`pdf|agb`), `data-feld` (`bestaetigung|fruehbeginn`), `data-aktion="bestaetigen"`, `data-fehler`, `data-gesperrt`.
- [ ] **Step 1: Failing Test schreiben**
`test/auftraegeKundenportalDom.test.js`:
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { installFakeDom } from './support/fakeDom.js'
import { renderZusammenfassung, renderBestaetigtBlock, renderDokumente, renderBestaetigung } from '../public/auftrag-dom.js'
import { KLEINUNTERNEHMER, ab, privat, bestaetigt, pruefeQuelltext } from './auftraegeKundenportalDaten.js'
import { ANREDE_DU } from '../public/auftrag-ansicht.js'
const NB = '\u{a0}'
const mit = (fn) => async () => {
const fx = installFakeDom()
try {
await fn(fx)
} finally {
fx.restore()
}
}
const paare = (fx, baum) => fx.alle(baum, '.ab-zeile').map((z) => [fx.finde(z, 'dt').textContent, fx.finde(z, 'dd').textContent])
test('Zusammenfassung (Festpreis): Kopfzeilen, Positionen mit Spaltenmarken, Gesamtpreis, § 19, Zahlung, Termine', mit(async (fx) => {
const el = renderZusammenfassung(ab())
assert.equal(el.getAttribute('data-art'), 'festpreis')
assert.deepEqual(paare(fx, el).slice(0, 3), [['Nummer', 'AB-2026-0002'], ['Datum', '06.10.2026'], ['Projekt', 'Website Anelia Wolf']])
const zeilen = fx.alle(el, '[data-position]')
assert.equal(zeilen.length, 2)
assert.deepEqual(fx.alle(zeilen[1], 'td').map((td) => td.textContent), ['Texte und Bildbearbeitung', '2', `100,00${NB}€`, `200,00${NB}€`])
assert.deepEqual(fx.alle(zeilen[1], 'td').map((td) => td.getAttribute('data-label')), ['Beschreibung', 'Menge', 'Einzelpreis', 'Summe'])
assert.deepEqual(fx.alle(el, 'th').map((th) => th.textContent), ['Beschreibung', 'Menge', 'Einzelpreis', 'Summe'])
assert.equal(fx.finde(el, '[data-gesamt] strong').textContent, `800,00${NB}€`)
assert.equal(fx.finde(el, '[data-hinweis]').textContent, KLEINUNTERNEHMER)
assert.equal(fx.finde(el, '[data-zahlung]').textContent, ab().zahlungsplanText)
assert.deepEqual(paare(fx, fx.finde(el, '[data-abschnitt="termine"]')), [['Start', '12.10.2026'], ['Voraussichtliche Fertigstellung', '30.11.2026']])
}))
test('Zusammenfassung (Aufwand): Aufwandstext statt Tabelle und Gesamtpreis; ohne Termine kein Abschnitt', mit(async (fx) => {
const a = ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.', startAm: '', fertigBis: '' })
const el = renderZusammenfassung(a)
assert.equal(el.getAttribute('data-art'), 'aufwand')
assert.equal(fx.finde(el, 'table'), null)
assert.equal(fx.finde(el, '[data-gesamt]'), null)
assert.equal(fx.finde(el, '[data-aufwand]').textContent, '100,00 € pro Stunde in Schritten von 15 Minuten.')
assert.equal(fx.finde(el, '[data-hinweis]').textContent, KLEINUNTERNEHMER)
assert.equal(fx.finde(el, '[data-abschnitt="termine"]'), null)
}))
test('Zusammenfassung: der § 19-Satz kommt nur aus der API; fehlt er, gibt es keinen Hinweis', mit(async (fx) => {
assert.equal(fx.finde(renderZusammenfassung(ab({ texte: { bestaetigung: 'x', fruehbeginn: null } })), '[data-hinweis]'), null)
}))
test('Zusammenfassung: Überschriftenebene wählbar (Seite h2, Portalkarte h4); leere Eingabe wirft nicht', mit(async (fx) => {
assert.deepEqual(fx.alle(renderZusammenfassung(ab()), 'h2').map((x) => x.textContent), ['Leistungsumfang', 'Zahlung', 'Termine'])
const karte = renderZusammenfassung(ab(), { ebene: 4 })
assert.equal(fx.alle(karte, 'h2').length, 0)
assert.deepEqual(fx.alle(karte, 'h4').map((x) => x.textContent), ['Leistungsumfang', 'Zahlung', 'Termine'])
assert.doesNotThrow(() => renderZusammenfassung({}))
}))
test('Fremdtext bleibt Text: Titel, Positionen und Zahlungsplan werden nie als HTML gelesen', mit(async (fx) => {
const boese = '<img src=x onerror=alert(1)> & <b>fett</b> "Test"'
const lang = 'x'.repeat(200)
const el = renderZusammenfassung(ab({
titel: boese, zahlungsplanText: boese, summeCents: 200,
positionen: [{ beschreibung: boese, menge: 1, einzelpreisCents: 100, summeCents: 100 }, { beschreibung: lang, menge: 1, einzelpreisCents: 100, summeCents: 100 }],
}))
assert.equal(fx.finde(el, 'img'), null)
assert.equal(fx.finde(el, 'b'), null)
assert.ok(fx.text(el).includes(boese))
assert.ok(fx.text(el).includes(lang))
}))
test('Bestätigt-Block: Zeitpunkt in Berliner Zeit und Zeile zum Beginn; ohne Bestätigung null', mit(async (fx) => {
assert.equal(renderBestaetigtBlock(ab()), null)
const block = renderBestaetigtBlock(bestaetigt(privat()))
assert.equal(block.getAttribute('role'), 'status')
assert.equal(block.getAttribute('tabindex'), '-1')
assert.equal(block.hasAttribute('data-bestaetigt'), true)
assert.deepEqual(fx.alle(block, 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.'])
}))
test('Unterlagen: PDF und AGB als Links in neuem Tab, leere Ziele entfallen', mit(async (fx) => {
const links = fx.alle(renderDokumente({ pdfHref: '/api/auftrag/T/pdf', agbHref: '/rechtliches/agb-2026-09-30.pdf' }), 'a')
assert.deepEqual(links.map((a) => [a.getAttribute('data-link'), a.getAttribute('href'), a.textContent]), [
['pdf', '/api/auftrag/T/pdf', 'Auftragsbestätigung als PDF ansehen'],
['agb', '/rechtliches/agb-2026-09-30.pdf', 'Allgemeine Geschäftsbedingungen (PDF)'],
])
for (const a of links) {
assert.equal(a.getAttribute('target'), '_blank')
assert.equal(a.getAttribute('rel'), 'noopener noreferrer')
}
assert.equal(fx.alle(renderDokumente({ agbHref: '/rechtliches/agb-2026-09-30.pdf' }), 'a').length, 1)
assert.equal(renderDokumente({}), null)
}))
/* ------------------------------------------------------------ Bestätigen-Formular --- */
const OK = () => Promise.resolve({ ok: true, auftrag: bestaetigt(ab()) })
function monte(fx, a, { antwort = OK, gesperrt = '', anrede } = {}) {
const aufrufe = []
const erfolg = []
const ende = []
const form = renderBestaetigung(a, {
senden: (koerper) => { aufrufe.push(koerper); return antwort(koerper) },
nachErfolg: (x) => erfolg.push(x),
nachEnde: (status, body) => ende.push([status, body]),
gesperrt,
anrede,
})
fx.document.body.append(form)
return {
form, aufrufe, erfolg, ende,
knopf: fx.finde(form, '[data-aktion="bestaetigen"]'),
haken1: fx.finde(form, '[data-feld="bestaetigung"]'),
haken2: fx.finde(form, '[data-feld="fruehbeginn"]'),
fehler: fx.finde(form, '[data-fehler]'),
}
}
test('Bestätigen (Unternehmen): ein Häkchen mit dem Text vom Server, Knopf erst nach dem Haken, Antwort geht an nachErfolg', mit(async (fx) => {
const t = monte(fx, ab())
assert.equal(t.haken2, null)
assert.equal(fx.text(fx.finde(t.form, 'label')), 'Ich bestätige den Auftrag zu diesen Bedingungen.')
assert.equal(t.knopf.textContent, 'Auftrag bestätigen')
assert.equal(t.knopf.disabled, true)
await fx.klick(t.haken1)
assert.equal(t.knopf.disabled, false)
await fx.klick(t.haken1)
assert.equal(t.knopf.disabled, true)
await fx.klick(t.haken1)
await fx.klick(t.knopf)
assert.deepEqual(t.aufrufe, [{ bestaetigt: true, fruehbeginn: false }])
assert.equal(t.erfolg.length, 1)
assert.equal(t.erfolg[0].status, 'bestaetigt')
assert.equal(fx.text(fx.finde(t.form, '.ab-notiz')), 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.')
}))
test('Bestätigen: fehlt der Text vom Server, gilt der feste Wortlaut aus der Übersicht (V7)', mit(async (fx) => {
const t = monte(fx, ab({ texte: {} }))
assert.equal(fx.text(fx.finde(t.form, 'label')), 'Ich bestätige den Auftrag zu diesen Bedingungen.')
}))
test('Bestätigen (Privatperson): zweites, freiwilliges Häkchen; ohne Haken fruehbeginn false, mit Haken true', mit(async (fx) => {
const a = privat()
for (const [angehakt, soll] of [[false, false], [true, true]]) {
const t = monte(fx, a)
const zweites = fx.alle(t.form, 'label')[1]
assert.equal(fx.text(zweites).startsWith(a.texte.fruehbeginn), true)
assert.match(fx.text(zweites), /\(freiwillig\)$/)
assert.equal(t.haken2.checked, false)
await fx.klick(t.haken1)
assert.equal(t.knopf.disabled, false, 'das zweite Häkchen ist nicht nötig')
if (angehakt) await fx.klick(t.haken2)
await fx.klick(t.knopf)
assert.deepEqual(t.aufrufe, [{ bestaetigt: true, fruehbeginn: soll }])
t.form.remove()
}
}))
test('Bestätigen: Privatperson ohne Frühbeginn-Text vom Server bekommt kein zweites Häkchen und sendet false', mit(async (fx) => {
const t = monte(fx, privat({ texte: { bestaetigung: 'Ich bestätige den Auftrag zu diesen Bedingungen.', fruehbeginn: null } }))
assert.equal(t.haken2, null)
await fx.klick(t.haken1)
await fx.klick(t.knopf)
assert.deepEqual(t.aufrufe, [{ bestaetigt: true, fruehbeginn: false }])
}))
test('Doppelter Absenden-Versuch sendet genau einmal; Knopf zeigt „Wird gesendet …“ und bleibt nach Erfolg gesperrt', mit(async (fx) => {
let fertig
const t = monte(fx, ab(), { antwort: () => new Promise((resolve) => { fertig = resolve }) })
await fx.klick(t.haken1)
const erster = fx.klick(t.knopf)
assert.equal(t.knopf.textContent, 'Wird gesendet …')
assert.equal(t.knopf.disabled, true)
assert.equal(t.knopf.getAttribute('aria-busy'), 'true')
await fx.sende(t.form, 'submit') // zweiter Versuch (z. B. Enter), während der erste läuft
assert.equal(t.aufrufe.length, 1)
fertig({ ok: true, auftrag: bestaetigt(ab()) })
await erster
assert.equal(t.erfolg.length, 1)
assert.equal(t.knopf.disabled, true)
await fx.sende(t.form, 'submit')
assert.equal(t.aufrufe.length, 1, 'auch nach dem Erfolg geht nichts mehr raus')
}))
test('Netzfehler: Meldung, Häkchen bleiben stehen, erneutes Senden möglich', mit(async (fx) => {
const antworten = [{ ok: false, status: 0, body: {} }, { ok: true, auftrag: bestaetigt(privat()) }]
const t = monte(fx, privat(), { antwort: async () => antworten.shift() })
await fx.klick(t.haken1)
await fx.klick(t.haken2)
await fx.klick(t.knopf)
assert.equal(t.fehler.hidden, false)
assert.equal(t.fehler.textContent, 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.')
assert.equal(t.haken1.checked, true)
assert.equal(t.haken2.checked, true)
assert.equal(t.knopf.disabled, false)
assert.equal(t.knopf.textContent, 'Auftrag bestätigen')
assert.equal(t.erfolg.length, 0)
await fx.klick(t.knopf)
assert.deepEqual(t.aufrufe, [{ bestaetigt: true, fruehbeginn: true }, { bestaetigt: true, fruehbeginn: true }])
assert.equal(t.erfolg.length, 1)
}))
test('Fehlertexte nach Status; 404 und 410 gehen an nachEnde statt in die Fehlerzeile', mit(async (fx) => {
for (const [status, text] of [
[429, 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.'],
[500, 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.'],
[400, 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.'],
]) {
const t = monte(fx, ab(), { antwort: async () => ({ ok: false, status, body: { error: 'Servertext, der nie erscheint' } }) })
await fx.klick(t.haken1)
await fx.klick(t.knopf)
assert.equal(t.fehler.textContent, text, String(status))
t.form.remove()
}
const body = { error: 'Diese Auftragsbestätigung wurde ersetzt.' }
const t = monte(fx, ab(), { antwort: async () => ({ ok: false, status: 410, body }) })
await fx.klick(t.haken1)
await fx.klick(t.knopf)
assert.deepEqual(t.ende, [[410, body]])
assert.equal(t.fehler.hidden, true)
}))
test('Gesperrt (Ansichtsmodus): Häkchen und Knopf gesperrt, Hinweis sichtbar, kein Senden', mit(async (fx) => {
const hinweis = 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.'
const t = monte(fx, privat(), { gesperrt: hinweis })
assert.equal(fx.alle(t.form, 'input').every((i) => i.disabled), true)
assert.equal(t.knopf.disabled, true)
assert.equal(fx.finde(t.form, '[data-gesperrt]').textContent, hinweis)
await fx.sende(t.form, 'submit')
assert.equal(t.aufrufe.length, 0)
}))
test('Anrede: im Portal (Du) stehen Überschrift, Hinweis, Fehlertext und die Zeile zum Wunsch in der Du-Form, auf der öffentlichen Seite (Sie) in der Sie-Form', mit(async (fx) => {
const sie = monte(fx, ab())
assert.equal(fx.text(fx.finde(sie.form, 'legend')), 'Ihre Bestätigung')
assert.equal(fx.text(fx.finde(sie.form, '.ab-notiz')), 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.')
sie.form.remove()
const du = monte(fx, ab(), { anrede: ANREDE_DU, antwort: async () => ({ ok: false, status: 0, body: {} }) })
assert.equal(fx.text(fx.finde(du.form, 'legend')), 'Deine Bestätigung')
assert.equal(fx.text(fx.finde(du.form, '.ab-notiz')), 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.')
await fx.klick(du.haken1)
await fx.klick(du.knopf)
assert.equal(du.fehler.textContent, 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.')
const wunsch = bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null }))
assert.deepEqual(fx.alle(renderBestaetigtBlock(wunsch), 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.'])
assert.deepEqual(fx.alle(renderBestaetigtBlock(wunsch, { anrede: ANREDE_DU }), 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.'])
}))
test('Quelltext: nur Text, nur ./auftrag-ansicht.js, kein fetch, keine Kundentexte im Modul', () => {
pruefeQuelltext('auftrag-dom.js', { importe: ['./auftrag-ansicht.js'] })
})
```
- [ ] **Step 2: Lauf, erwartet FAIL**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKundenportalDom.test.js
```
Erwartet: FAIL (`Cannot find module '../public/auftrag-dom.js'`).
- [ ] **Step 3: `public/auftrag-dom.js` implementieren**
Kopf-Kommentar wie `shop-dom.js` (Kundentext nur über `textContent`, nie `innerHTML`). `h` liest `document` bei jedem Aufruf. Aufbau (Klassen und Marken wie in „Produces“; alle Überschriften tragen die Klasse `ab-h2`):
- `renderZusammenfassung(auftrag, { ebene = 2 })` → `div.ab-zusammenfassung[data-art]` (Daten aus `zusammenfassung(auftrag)`):
1. `dl.ab-liste` mit je `div.ab-zeile > dt + dd` für `kopf` (entfällt, wenn leer);
2. Festpreis: `section.ab-abschnitt[data-abschnitt="leistung"]` mit Überschrift „Leistungsumfang“, `table.ab-tabelle[data-positionen]` (`thead` mit `th scope="col"` je `texte().spalten`; je Position `tr[data-position]` mit vier `td[data-label=<Spaltentitel>]`; entfällt ohne Positionen), `p.ab-gesamt[data-gesamt]` = `span` „Gesamtpreis“ + Leerzeichen + `strong` (nur mit `summe`), `p.ab-hinweis[data-hinweis]` mit `hinweis` (der § 19-Satz der API; entfällt, wenn leer);
Aufwand: `section[data-abschnitt="aufwand"]` mit Überschrift „Abrechnung nach Aufwand“, `p.ab-text[data-aufwand]`, `p.ab-hinweis[data-hinweis]` (nur mit `hinweis`);
3. `section[data-abschnitt="zahlung"]` mit „Zahlung“ und `p.ab-text[data-zahlung]` (nur mit Text);
4. `section[data-abschnitt="termine"]` mit „Termine“ und `dl.ab-liste` (nur mit mindestens einem Termin).
- `renderBestaetigtBlock(auftrag, { anrede })` → `null` ohne Zeilen aus `bestaetigtZeilen(auftrag, anrede)`; sonst `div.ab-bestaetigt[data-bestaetigt][role="status"][tabindex="-1"]` mit je einem `p` je Zeile (erste Zeile in `strong`).
- `renderDokumente({ pdfHref, agbHref })` → `null` ohne Ziele; sonst `ul.ab-links` mit `li > a.ab-link[data-link][target="_blank"][rel="noopener noreferrer"]` (Texte `linkPdf`, `linkAgb`).
- `renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde, gesperrt, anrede })` (alle Texte aus `texte(anrede)`) → `form.ab-bestaetigen[novalidate]` > `fieldset.ab-feldgruppe` mit `legend` (`formular`), erstes `label.ab-haken` (Checkbox `[data-feld="bestaetigung"]` mit `required`, `span` mit `auftrag.texte.bestaetigung`, bei fehlendem Text `BESTAETIGUNG_FALLBACK`), nur bei `zeigtFruehbeginn(auftrag)` zweites `label.ab-haken` (Checkbox `[data-feld="fruehbeginn"]`, `span` mit `texte.fruehbeginn`, Leerzeichen, `small.ab-frei` „(freiwillig)“); danach außerhalb des Fieldsets `p.ab-fehler[data-fehler][role="alert"][hidden]`, `button.ab-knopf[type="submit"][data-aktion="bestaetigen"]` (anfangs `disabled`), `p.ab-notiz` (`speicherHinweis`) und bei `gesperrt` `p.ab-gesperrt[data-gesperrt]`. Verhalten:
- Knopf gesperrt, solange `gesperrt`, `busy` oder das erste Häkchen fehlt; bei `gesperrt` sind auch beide Häkchen `disabled`.
- `submit`: `preventDefault()`; kehrt zurück bei `busy`, `gesperrt` oder fehlendem erstem Häkchen. Sonst `busy = true` (synchron, vor dem `await`), Fehlerzeile weg, Knopf gesperrt mit `aria-busy="true"` und Text `sendet`, dann `await senden({ bestaetigt: true, fruehbeginn: <zweites Häkchen angehakt, sonst false> })` (wirft `senden`, gilt `{ ok: false, status: 0, body: {} }`).
- `ok` → `nachErfolg(ergebnis.auftrag)`; `busy` bleibt `true` (Knopf bleibt gesperrt, Text zurück auf „Auftrag bestätigen“). `!ok` mit Status 404 oder 410 → `nachEnde(status, body)`, Formular bleibt gesperrt. Jeder andere Fehler → `busy = false`, Knopf zurück (Text, `aria-busy` weg, Zustand nach Häkchen), `fehlerMeldung(status, anrede)` in die Fehlerzeile (`hidden = false`); Häkchen bleiben.
- [ ] **Step 4: Lauf, erwartet PASS**
Derselbe Befehl wie in Step 2. Erwartet: `fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add public/auftrag-dom.js test/auftraegeKundenportalDom.test.js && git commit -m "Kundenportal Aufträge: DOM-Bausteine (Zusammenfassung, Bestätigen mit Häkchen, Sperre)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: Seitenlogik der öffentlichen Seite `public/auftrag.js`
**Files:**
- Create: `public/auftrag.js`
- Create: `test/auftraegeKundenportalSeite.test.js`
**Interfaces:**
- Consumes: Task 3 (`texte`, `KONTAKT`, `tokenAusAdresse`, `pdfPfad`, `agbPfad`, `zustandAusAntwort`, `darfBestaetigen`, `statusAnzeige`), Task 4 (`h`, `renderZusammenfassung`, `renderBestaetigtBlock`, `renderDokumente`, `renderBestaetigung`); HTTP laut Übersicht V5: `GET /api/auftrag/:token` → `{ auftrag }` | 404 | 410 | 429; `POST /api/auftrag/:token/bestaetigen` mit `{ bestaetigt: true, fruehbeginn }` → `{ auftrag }` | 400 | 404 | 410 | 429.
- Produces: `initAuftragsseite({ root, ansage, suche = '', hash = '' }) → Promise<void>`. `root` bekommt `data-zustand` ∈ `laedt | offen | bestaetigt | ungueltig | beendet | limit | fehler` und `aria-busy`; `ansage` ist die Live-Region (`#ab-ansage`). Am Dateiende startet die Seite selbst, wenn `document` existiert und `#ab-wurzel` vorhanden ist (`suche = window.location.search`, `hash = window.location.hash`). Marken: `h1[data-titel]` (`tabindex="-1"`), `button[data-aktion="erneut"]`, `p.ab-kontakt`, `p.ab-pille.ab-pille-<ton>`, `p.ab-text`, `p.ab-laden`.
- [ ] **Step 1: Failing Test schreiben**
`test/auftraegeKundenportalSeite.test.js`:
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { installFakeDom } from './support/fakeDom.js'
import { h } from '../public/auftrag-dom.js'
import { initAuftragsseite } from '../public/auftrag.js'
import { TOKEN, KLEINUNTERNEHMER, ab, privat, bestaetigt, pruefeQuelltext } from './auftraegeKundenportalDaten.js'
const ok = (body, status = 200) => ({ status, body })
const mit = (antworten, fn) => async () => {
const fx = installFakeDom({ antworten })
const root = h('div', { id: 'ab-wurzel' })
const ansage = h('div', { id: 'ab-ansage' })
fx.document.body.append(root, ansage)
try {
await fn(fx, root, ansage)
} finally {
fx.restore()
}
}
const start = (root, ansage, adresse = {}) => initAuftragsseite({ root, ansage, suche: '', hash: `#t=${TOKEN}`, ...adresse }) // K31 verlinkt #t=
const bestaetige = async (fx, root, { frueh = false } = {}) => {
await fx.klick(fx.finde(root, '[data-feld="bestaetigung"]'))
if (frueh) await fx.klick(fx.finde(root, '[data-feld="fruehbeginn"]'))
await fx.klick(fx.finde(root, '[data-aktion="bestaetigen"]'))
}
test('Ohne gültigen Token: „ungültig“ und Kontaktweg, ohne Anfrage', mit([], async (fx, root, ansage) => {
for (const adresse of [{ hash: '' }, { hash: '#t=' }, { hash: '#t=zu-kurz' }, { hash: `#t=${TOKEN}x` }, { hash: '', suche: '?t=zu-kurz' }]) {
await start(root, ansage, adresse)
assert.equal(root.dataset.zustand, 'ungueltig', JSON.stringify(adresse))
assert.equal(fx.finde(root, 'h1').textContent, 'Dieser Link ist ungültig oder abgelaufen.')
assert.equal(fx.finde(root, 'a[href="mailto:support@webklar.com"]').textContent, 'support@webklar.com')
assert.equal(fx.finde(root, 'a[href="tel:017623726355"]').textContent, '0176 23726355')
}
assert.equal(fx.fetchLog.length, 0)
}))
test('Laden und Anzeigen: Kopf, Zusammenfassung, Unterlagen, Formular; der Token steht nur in Links', mit([ok({ auftrag: ab() })], async (fx, root, ansage) => {
await start(root, ansage)
assert.deepEqual(fx.fetchLog, [{ method: 'GET', url: `/api/auftrag/${TOKEN}`, body: undefined }])
assert.equal(root.dataset.zustand, 'offen')
assert.equal(fx.finde(root, 'h1').textContent, 'Auftragsbestätigung AB-2026-0002')
assert.equal(fx.finde(root, '.ab-pille').textContent, 'Bitte bestätigen')
assert.equal(fx.alle(root, '[data-position]').length, 2)
const pdf = fx.finde(root, '[data-link="pdf"]')
const agb = fx.finde(root, '[data-link="agb"]')
assert.equal(pdf.getAttribute('href'), `/api/auftrag/${TOKEN}/pdf`)
assert.equal(agb.getAttribute('href'), '/rechtliches/agb-2026-09-30.pdf')
assert.ok(fx.finde(root, 'form [data-feld="bestaetigung"]'))
assert.equal(fx.finde(root, '[data-feld="fruehbeginn"]'), null)
assert.equal(fx.finde(root, '[data-hinweis]').textContent, KLEINUNTERNEHMER, 'der § 19-Satz kommt aus der API')
assert.equal(fx.finde(root, 'legend').textContent, 'Ihre Bestätigung', 'öffentliche Seite: Sie-Form')
assert.equal(fx.text(ansage), 'Ihre Auftragsbestätigung wurde geladen.')
assert.ok(!fx.text(root).includes(TOKEN) && !fx.text(ansage).includes(TOKEN))
}))
test('Der Token steht im Fragment (#t=, so verlinkt K31); als Rückfall genügt ?t=', mit([ok({ auftrag: ab() }), ok({ auftrag: ab() })], async (fx, root, ansage) => {
await start(root, ansage)
assert.equal(root.dataset.zustand, 'offen')
await start(root, ansage, { hash: '', suche: `?t=${TOKEN}` })
assert.equal(root.dataset.zustand, 'offen')
assert.deepEqual(fx.fetchLog.map((a) => a.url), [`/api/auftrag/${TOKEN}`, `/api/auftrag/${TOKEN}`])
}))
test('Aufwand: Aufwandstext statt Tabelle', mit([ok({ auftrag: ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde.' }) })], async (fx, root, ansage) => {
await start(root, ansage)
assert.equal(fx.finde(root, '[data-aufwand]').textContent, '100,00 € pro Stunde.')
assert.equal(fx.finde(root, 'table'), null)
}))
test('Bestätigen (Unternehmen): POST mit Häkchen, danach „bestätigt“ mit Zeitpunkt, Fokus und Ansage', mit([ok({ auftrag: ab() }), ok({ auftrag: bestaetigt(ab()) })], async (fx, root, ansage) => {
await start(root, ansage)
await bestaetige(fx, root)
assert.deepEqual(fx.fetchLog[1], { method: 'POST', url: `/api/auftrag/${TOKEN}/bestaetigen`, body: { bestaetigt: true, fruehbeginn: false } })
assert.equal(root.dataset.zustand, 'bestaetigt')
assert.equal(fx.finde(root, 'h1').textContent, 'Ihr Auftrag ist bestätigt.')
assert.equal(fx.finde(root, '.ab-pille').textContent, 'Bestätigt')
const block = fx.finde(root, '[data-bestaetigt]')
assert.deepEqual(fx.alle(block, 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr'])
assert.equal(fx.finde(root, 'form'), null)
assert.equal(fx.document.activeElement, block)
assert.equal(fx.text(ansage), 'Ihr Auftrag ist bestätigt.')
assert.ok(fx.finde(root, '[data-link="pdf"]'), 'Unterlagen bleiben erreichbar')
}))
test('Privatperson: zweites Häkchen; angezeigt wird, was der Server meldet (Frühbeginn „nein“ trotz Häkchen)', mit([ok({ auftrag: privat() }), ok({ auftrag: bestaetigt(privat({ fruehbeginn: false })) })], async (fx, root, ansage) => {
await start(root, ansage)
assert.ok(fx.finde(root, '[data-feld="fruehbeginn"]'))
await bestaetige(fx, root, { frueh: true })
assert.deepEqual(fx.fetchLog[1].body, { bestaetigt: true, fruehbeginn: true })
assert.deepEqual(fx.alle(fx.finde(root, '[data-bestaetigt]'), 'p').map((p) => p.textContent), [
'Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.',
])
}))
test('Schon bestätigt beim Laden: „bestätigt“ ohne Formular, mit der Zeile zum Beginn', mit([ok({ auftrag: bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })) })], async (fx, root, ansage) => {
await start(root, ansage)
assert.equal(root.dataset.zustand, 'bestaetigt')
assert.equal(fx.finde(root, 'form'), null)
assert.deepEqual(fx.alle(fx.finde(root, '[data-bestaetigt]'), 'p').map((p) => p.textContent), [
'Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.',
])
}))
test('Zustände: ungültig, ersetzt, storniert, widerrufen, zu viele Anfragen, Serverfehler – feste Texte, nie der Token', mit([], async (fx, root, ansage) => {
const faelle = [
[ok({ error: 'Dieser Link ist ungültig oder abgelaufen.' }, 404), 'ungueltig', 'Dieser Link ist ungültig oder abgelaufen.'],
[ok({ error: 'Diese Auftragsbestätigung wurde ersetzt.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde ersetzt.'],
[ok({ error: 'Diese Auftragsbestätigung wurde storniert.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde storniert.'],
[ok({ error: 'Diese Auftragsbestätigung wurde widerrufen.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde widerrufen.'],
[ok({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }, 429), 'limit', 'Zu viele Anfragen'],
[ok({ error: 'Interner Fehler.' }, 500), 'fehler', 'Das hat leider nicht geklappt'],
[ok({}, 502), 'fehler', 'Das hat leider nicht geklappt'],
[ok({}, 200), 'fehler', 'Das hat leider nicht geklappt'], // Antwort ohne auftrag
]
for (const [antwort, zustand, titel] of faelle) {
fx.antworten.push(antwort)
await start(root, ansage)
assert.equal(root.dataset.zustand, zustand, titel)
assert.equal(fx.finde(root, 'h1').textContent, titel)
assert.equal(fx.text(ansage), titel)
assert.equal(fx.finde(root, 'form'), null)
assert.ok(!fx.text(root).includes(TOKEN) && !fx.text(ansage).includes(TOKEN))
assert.equal(Boolean(fx.finde(root, '[data-aktion="erneut"]')), ['limit', 'fehler'].includes(zustand), `Erneut-Knopf bei ${zustand}`)
}
assert.equal(fx.finde(root, 'a[href="mailto:support@webklar.com"]') !== null, true, 'auch beim Fehler steht der Kontaktweg da')
}))
test('Netzfehler beim Laden: Fehlerzustand ohne Fremdtext, „Erneut versuchen“ lädt neu', mit([() => { throw new TypeError('fetch failed') }, ok({ auftrag: ab() })], async (fx, root, ansage) => {
await start(root, ansage)
assert.equal(root.dataset.zustand, 'fehler')
assert.ok(!fx.text(root).includes('fetch failed'))
await fx.klick(fx.finde(root, '[data-aktion="erneut"]'))
assert.equal(root.dataset.zustand, 'offen')
assert.equal(fx.fetchLog.length, 2)
}))
test('Status wechselt zwischen Laden und Bestätigen (410): „storniert“, kein Formular mehr', mit([ok({ auftrag: ab() }), ok({ error: 'Diese Auftragsbestätigung wurde storniert.' }, 410)], async (fx, root, ansage) => {
await start(root, ansage)
await bestaetige(fx, root)
assert.equal(root.dataset.zustand, 'beendet')
assert.equal(fx.finde(root, 'h1').textContent, 'Diese Auftragsbestätigung wurde storniert.')
assert.equal(fx.finde(root, 'form'), null)
}))
test('Fehler beim Bestätigen (429): Meldung in der Fehlerzeile, Seite bleibt offen, Häkchen bleiben', mit([ok({ auftrag: ab() }), ok({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }, 429)], async (fx, root, ansage) => {
await start(root, ansage)
await bestaetige(fx, root)
assert.equal(root.dataset.zustand, 'offen')
assert.equal(fx.finde(root, '[data-fehler]').textContent, 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.')
assert.equal(fx.finde(root, '[data-feld="bestaetigung"]').checked, true)
assert.equal(fx.finde(root, '[data-aktion="bestaetigen"]').disabled, false)
assert.ok(!fx.text(root).includes(TOKEN))
}))
test('Quelltext: genau ein fetch ohne Cookies, keine Protokollausgabe, nur relative Importe', () => {
const quelle = pruefeQuelltext('auftrag.js', { fetchAnzahl: 1, importe: ['./auftrag-ansicht.js', './auftrag-dom.js'] })
assert.match(quelle, /credentials: 'omit'/)
assert.doesNotMatch(quelle, /location\.href|history\.|document\.referrer/)
})
```
- [ ] **Step 2: Lauf, erwartet FAIL**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKundenportalSeite.test.js
```
Erwartet: FAIL (`Cannot find module '../public/auftrag.js'`).
- [ ] **Step 3: `public/auftrag.js` implementieren**
ES-Modul (Kopf-Kommentar wie `termin.js`: Token nur in Adresse und Speicher, keine Cookies, kein Browserspeicher, nichts ins Protokoll). Struktur:
- Intern `anfrage(token, aktion, { method = 'GET', body } = {}) → { status, body }`: **einziger** `fetch`-Aufruf der Datei, URL `/api/auftrag/<encodeURIComponent(token)>[/<aktion>]`, `credentials: 'omit'`, bei Körper `Content-Type: application/json`; die Antwort wird mit `.json().catch(() => ({}))` gelesen (kein Objekt → `{}`); jeder Fehler des `fetch` → `{ status: 0, body: {} }`. Nie die Fehlermeldung des Browsers anzeigen oder weiterreichen.
- `initAuftragsseite({ root, ansage, suche, hash })`: `token = tokenAusAdresse(suche, hash)` (Fragment `#t=` zuerst, Rückfall `?t=`); alle Texte aus `texte()` (Sie-Form, die Seite übergibt keine `anrede`); ohne Token sofort der Zustand `ungueltig` (keine Anfrage). Sonst `lade()`: Zustand `laedt` (`p.ab-laden`), `anfrage(token)`; Status 200 mit `body.auftrag` → `zeigeAuftrag`, Ansage `texte().geladen`; alles andere → `zeigeZustand(zustandAusAntwort(status === 200 ? 0 : status, body))`, Ansage = Titel.
- `zeige(zustand, ...kinder)`: setzt `root.dataset.zustand`, `aria-busy` (`'true'` nur bei `laedt`) und ersetzt den Inhalt mit `root.replaceChildren(...kinder.filter(Boolean))` (`null` nie weiterreichen: der Browser macht daraus den Text „null“; `renderBestaetigtBlock` und `renderDokumente` liefern `null`, wenn es nichts zu zeigen gibt).
- `zeigeZustand({ zustand, titel, text })`: `h1.ab-h1[data-titel][tabindex="-1"]` mit dem Titel, `p.ab-text` mit dem Text, bei `ungueltig`, `beendet` und `fehler` `p.ab-kontakt` (`texte().kontaktVor` + Link `mailto:` + „ oder “ + Link `tel:` mit den Ziffern von `KONTAKT.telefon` + „.“), bei `limit` und `fehler` `button.ab-knopf[data-aktion="erneut"]` („Erneut versuchen“), der `lade()` erneut ruft.
- `zeigeAuftrag(auftrag)`: Kinder in dieser Reihenfolge: `p.ab-pille.ab-pille-<ton>` aus `statusAnzeige({ ...auftrag, kannBestaetigen: darfBestaetigen(auftrag) })`; `h1[data-titel]` mit `Auftragsbestätigung <nummer>` bzw. (bestätigt, `bestaetigtAm` gesetzt) `Ihr Auftrag ist bestätigt.`; nur im offenen Zustand `p.ab-intro`; `renderBestaetigtBlock`; `renderZusammenfassung`; Überschrift „Unterlagen“ mit `renderDokumente({ pdfHref: pdfPfad(token), agbHref: agbPfad(auftrag.agbUrl) })`; bei `darfBestaetigen(auftrag)` das Formular `renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde })`. Zustand `bestaetigt` bei gesetztem `bestaetigtAm`, sonst `offen`.
- `senden(koerper)`: `anfrage(token, 'bestaetigen', { method: 'POST', body: koerper })`; Status 200 mit `body.auftrag` → `{ ok: true, auftrag }`, sonst `{ ok: false, status: <200 → 0>, body }`.
- `nachErfolg(neu)`: `zeigeAuftrag(neu)`, Ansage `texte().titelBestaetigt`, Fokus auf `[data-bestaetigt]` (falls vorhanden). `nachEnde(status, body)`: `zeigeZustand(zustandAusAntwort(status, body))`, Ansage = Titel.
- Selbststart am Dateiende: `if (typeof document !== 'undefined' && document.getElementById('ab-wurzel')) initAuftragsseite({ root, ansage: document.getElementById('ab-ansage'), suche: window.location.search, hash: window.location.hash })` (der Aufruf liefert ein Promise; Fehler werden nicht ausgegeben).
- [ ] **Step 4: Lauf, erwartet PASS**
Derselbe Befehl wie in Step 2. Erwartet: `fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add public/auftrag.js test/auftraegeKundenportalSeite.test.js && git commit -m "Kundenportal Aufträge: Seitenlogik der öffentlichen Bestätigungsseite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 6: Hülle und Gestaltung `public/auftrag.html` und `public/auftrag.css`
**Files:**
- Create: `public/auftrag.html`
- Create: `public/auftrag.css`
- Create: `test/auftraegeKundenportalHuelle.test.js`
**Interfaces:**
- Consumes: `#ab-wurzel`, `#ab-ansage` (Task 5), das Klassen- und Markenvokabular aus Task 4/5, Bilder `/webklar-logo.png`, `/favicon.ico`, `/favicon.svg`.
- Produces: die Seite `/auftrag.html` (Token aus `#t=`, Rückfall `?t=`); `public/auftrag.css` mit den Schlüsseln `--ab-*` (hell/dunkel) für `body.ab-seite`. Task 7 ergänzt den Portal-Teil (`.ab-portal`), Task 8 die Regel `.inv-project`.
- [ ] **Step 1: Failing Test schreiben**
`test/auftraegeKundenportalHuelle.test.js`:
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
const html = lies('public/auftrag.html')
const css = lies('public/auftrag.css')
test('auftrag.html: noindex, kein Referrer, Viewport mit Notch, Titel, ein Modul-Skript, kein Inline-Skript', () => {
assert.match(html, /<meta name="robots" content="noindex, nofollow">/)
assert.match(html, /<meta name="referrer" content="no-referrer">/)
assert.match(html, /<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">/)
assert.match(html, /<meta name="color-scheme" content="light dark">/)
assert.match(html, /<title>Auftragsbestätigung – WEBklar<\/title>/)
assert.match(html, /<link rel="stylesheet" href="\/auftrag\.css">/)
assert.deepEqual(html.match(/<script\b[^>]*>/g), ['<script type="module" src="/auftrag.js">'])
assert.doesNotMatch(html, /<script>/)
assert.doesNotMatch(html, /\son[a-z]+=/i, 'keine Ereignis-Attribute')
assert.doesNotMatch(html, /https?:\/\/(?!webklar\.com\/impressum)/, 'keine Fremdressourcen')
})
test('auftrag.html: Wurzel im Zustand „lädt“, Live-Region, Hinweis ohne JavaScript', () => {
assert.match(html, /<div class="ab-wurzel" id="ab-wurzel" data-zustand="laedt" aria-busy="true">/)
assert.match(html, /<p class="ab-laden">Ihre Auftragsbestätigung wird geladen …<\/p>/)
assert.match(html, /<div class="ab-vh" id="ab-ansage" aria-live="polite"><\/div>/)
assert.match(html, /<noscript><p>Bitte aktivieren Sie JavaScript, um Ihren Auftrag zu bestätigen\.<\/p><\/noscript>/)
assert.match(html, /<noscript><style>\.ab-laden\{display:none\}<\/style><\/noscript>/)
})
test('auftrag.html: Fußzeile mit Datenschutz und Impressum; Zurück-Link nur für die installierte App', () => {
assert.match(html, /<footer class="ab-fuss">[\s\S]*<a href="\/datenschutz\.html" target="_blank" rel="noopener noreferrer">Datenschutz<\/a>[\s\S]*<a href="https:\/\/webklar\.com\/impressum" target="_blank" rel="noopener noreferrer">Impressum<\/a>[\s\S]*<\/footer>/)
assert.match(html, /<a class="ab-app-back" href="\/dashboard\.html">‹ Zurück zum Kundenbereich<\/a>/)
})
test('auftrag.css: Farben der Mailvorlagen hell und dunkel, Standalone-Modus, Handy-Tabelle, Umbruch, Tippflächen', () => {
for (const farbe of ['#f1f0ec', '#efe6fb', '#5a2a91', '#e4f2e6', '#1d5b2c', '#161615', '#1f1f1e', '#f2f1ee', '#a8a6a0']) assert.ok(css.includes(farbe), farbe)
assert.match(css, /@media \(prefers-color-scheme: dark\)/)
assert.match(css, /@media \(display-mode: standalone\)/)
assert.match(css, /env\(safe-area-inset-top\)/)
assert.match(css, /overflow-wrap:\s*anywhere/)
assert.match(css, /min-height:\s*48px/)
assert.match(css, /content:\s*attr\(data-label\)/)
assert.match(css, /\.ab-app-back\s*\{[^}]*display:\s*none/)
assert.match(css, /\.ab-vh\s*\{/)
assert.match(css, /body\.ab-seite[^{]*\.hidden|\.ab-seite \.hidden/)
})
test('Service Worker und Manifest brauchen für die neue Seite keinen Eintrag', () => {
assert.doesNotMatch(lies('public/sw.js'), /auftrag/)
const manifest = JSON.parse(lies('public/manifest.webmanifest'))
assert.equal(manifest.scope, '/', 'der Link aus der Mail kann in der installierten App öffnen: darum der Standalone-Modus oben')
})
```
- [ ] **Step 2: Lauf, erwartet FAIL**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKundenportalHuelle.test.js
```
Erwartet: FAIL (`ENOENT … public/auftrag.html`).
- [ ] **Step 3: Dateien anlegen**
`public/auftrag.html` (wörtlich, der Test prüft diese Zeilen):
```html
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f1f0ec" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#161615" media="(prefers-color-scheme: dark)">
<title>Auftragsbestätigung – WEBklar</title>
<link rel="icon" href="/favicon.ico" sizes="32x32">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/auftrag.css">
<noscript><style>.ab-laden{display:none}</style></noscript>
</head>
<body class="ab-seite">
<!-- Bestätigungsseite für eine Auftragsbestätigung (Link aus der Mail K31: /auftrag.html#t=<Token>, Rückfall ?t=<Token>).
Der Token steht nur in der Adresse und im Speicher der Seite: keine Cookies, kein Browserspeicher, keine Fremdanfragen,
nichts im Protokoll (auftrag.js). Ohne Anmeldung erreichbar, nicht für Suchmaschinen. -->
<a class="ab-app-back" href="/dashboard.html">‹ Zurück zum Kundenbereich</a>
<header class="ab-kopf">
<div class="ab-innen"><img class="ab-logo" src="/webklar-logo.png" width="36" height="36" alt=""><span class="ab-marke">WEBklar</span></div>
</header>
<main class="ab-haupt">
<div class="ab-innen">
<div class="ab-wurzel" id="ab-wurzel" data-zustand="laedt" aria-busy="true">
<noscript><p>Bitte aktivieren Sie JavaScript, um Ihren Auftrag zu bestätigen.</p></noscript>
<p class="ab-laden">Ihre Auftragsbestätigung wird geladen …</p>
</div>
</div>
</main>
<div class="ab-vh" id="ab-ansage" aria-live="polite"></div>
<footer class="ab-fuss">
<div class="ab-innen">
<a href="/datenschutz.html" target="_blank" rel="noopener noreferrer">Datenschutz</a>
<a href="https://webklar.com/impressum" target="_blank" rel="noopener noreferrer">Impressum</a>
</div>
</footer>
<script type="module" src="/auftrag.js"></script>
</body>
</html>
```
`public/auftrag.css`: Kopf-Kommentar („Bestätigungsseite für Auftragsbestätigungen und Abschnitt ‚Aufträge‘ im Portal; Farben wie die Mailvorlagen“). Die Datei wird auch vom Dashboard geladen, deshalb ist jede Regel an `body.ab-seite` bzw. `.ab-portal` gebunden (kein Eingriff in andere Seiten); einzige Ausnahmen sind `.ab-rechnungen-titel` (Task 7) und `.inv-project` (Task 8). Inhalt dieses Tasks (Teil „öffentliche Seite“):
| Bereich | Vorgabe |
|---|---|
| Schlüssel (hell, auf `body.ab-seite`) | `--ab-grund #f1f0ec`, `--ab-karte #ffffff`, `--ab-text #111111`, `--ab-weich #4a4843`, `--ab-linie #e8e6e0`, `--ab-box #f7f6f3`, `--ab-knopf #111111`, `--ab-knopf-text #ffffff`, Pillen `--ab-warn-bg #efe6fb` / `--ab-warn-text #5a2a91`, `--ab-ok-bg #e4f2e6` / `--ab-ok-text #1d5b2c`, `--ab-rot-bg #fbe4e1` / `--ab-rot-text #8c1d18`, `--ab-muted-bg #ecebe6` / `--ab-muted-text #4a4843`, `--ab-fehler #b3261e` |
| Dunkel (`@media (prefers-color-scheme: dark)`) | `--ab-grund #161615`, `--ab-karte #1f1f1e`, `--ab-text #f2f1ee`, `--ab-weich #a8a6a0`, `--ab-linie #3a3a37`, `--ab-box #2a2a28`, `--ab-knopf #f2f1ee`, `--ab-knopf-text #161615`, Pillen und `--ab-fehler` entsprechend abgedunkelt (Kontrast mindestens 4,5 : 1) |
| Grundlayout | `body.ab-seite` ohne Rand, `-webkit-text-size-adjust: 100%`, `min-height: 100dvh`, Spalte (`display: flex; flex-direction: column`), Systemschrift 16 px/1,55; `.ab-innen` höchstens 40 rem, 16 px Rand; `.ab-kopf` mit Logo (36 px, Radius 10) und Wortmarke, oben `max(20px, env(safe-area-inset-top))`; `.ab-wurzel` als Karte (Radius 16, Rand `--ab-linie`, Innenabstand 28/24 px, auf dem Handy 22/18 px) mit `overflow-wrap: anywhere`; `.ab-haupt { flex: 1 0 auto }` |
| Inhalt | `.ab-pille` (Kapsel, 12 px fett) mit `ab-pille-ok/warn/muted/rot`; `.ab-h1` 1,6 rem; `.ab-h2` als Beschriftung (0,8 rem, Großbuchstaben, `--ab-weich`); `.ab-liste` Zeilen als Raster 9 rem + Rest (auf dem Handy untereinander); `.ab-zeile` mit Trennlinie; `.ab-gesamt` Zeile mit Abstand und fetter Summe; `.ab-hinweis` klein, `--ab-weich`; `.ab-text` behält Zeilenumbrüche (`white-space: pre-line`); `.ab-links a` mindestens 44 px hoch; `.ab-bestaetigt` als grüne Box (`--ab-ok-*`), `:focus-visible` mit Rahmen |
| Tabelle | Desktop: normale Tabelle; unter 640 px: `thead` nur für Screenreader (`.ab-vh`-Technik), `tr` als Block mit Trennlinie, `td` als Zeile mit `td::before { content: attr(data-label) }` (erste Zelle ohne Marke, fett) |
| Formular | `.ab-haken` Zeile mit 22-px-Haken (`accent-color`) und Text 15 px; `.ab-frei` klein, `--ab-weich`; `.ab-fehler` in `--ab-fehler`, fett; `.ab-knopf` mit `min-height: 48px`, Radius 10, `--ab-knopf`/`--ab-knopf-text`, auf dem Handy volle Breite, `:disabled` mit `opacity .45`, `:focus-visible` mit 3-px-Rahmen; `.ab-notiz`, `.ab-gesperrt` klein und `--ab-weich` |
| Fuß und Hilfsklassen | `.ab-fuss` klein, Links nebeneinander mit 16 px Abstand, unten `max(24px, env(safe-area-inset-bottom))`; `.ab-vh` (nur für Screenreader); `body.ab-seite .hidden { display: none !important }` |
| Installierte App | `.ab-app-back { display: none }`; `@media (display-mode: standalone)`: `.ab-app-back { display: inline-block; … }` und `.ab-kopf` oben `calc(env(safe-area-inset-top) + 12px)` (Muster `legal-form.css`) |
- [ ] **Step 4: Lauf, erwartet PASS**
Derselbe Befehl wie in Step 2. Erwartet: `fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add public/auftrag.html public/auftrag.css test/auftraegeKundenportalHuelle.test.js && git commit -m "Kundenportal Aufträge: auftrag.html und auftrag.css (noindex, Mail-Stil, Fußzeile, Standalone)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 7: Abschnitt „Aufträge“ im Portal `public/auftrag-portal.js`
**Files:**
- Create: `public/auftrag-portal.js`
- Modify: `public/auftrag.css` (Teil „Portal“, `.ab-portal` und die Karten)
- Create: `test/auftraegeKundenportalPortal.test.js`
**Interfaces:**
- Consumes: Task 3 (`texte`, `ANREDE_DU`, `statusAnzeige`, `kartenMeta`, `sortiereAuftraege`, `darfBestaetigen`, `agbPfad`), Task 4 (`h`, `renderZusammenfassung`, `renderDokumente`, `renderBestaetigtBlock`, `renderBestaetigung`); `request(path, { method, body })` wie `api()` in `app.js` (liefert JSON, wirft `Error` mit `status`; `body` ist bereits ein JSON-Text); HTTP laut V5: `GET /api/auftraege` → `{ aktiv, auftraege: (AuftragOeffentlich & { id, kannBestaetigen })[] }` (`kannBestaetigen` laut Plan von L5: `true` bei Status `versendet`/`abgerechnet` ohne `bestaetigtAm`, in der Kundenansicht immer `false`), `GET /api/auftraege/:id/pdf` (Link), `POST /api/auftraege/:id/bestaetigen` → `{ auftrag }`; Antworten 404 und 410 beim Bestätigen laut L5-Plan.
- Produces: `initAuftraege({ section, body, invoicesTitle, request, viewAs = false }) → Promise<{ sichtbar: boolean }>`; Klassen `ab-portal`, `ab-karte`, `ab-karte-kopf`, `ab-karte-titel`, `ab-karte-meta`, `ab-karte-aktionen`, `ab-details`; Marken `data-auftrag="<id>"`, `data-status`.
- [ ] **Step 1: Failing Test schreiben**
`test/auftraegeKundenportalPortal.test.js`:
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { h } from '../public/auftrag-dom.js'
import { initAuftraege } from '../public/auftrag-portal.js'
import { ab, privat, bestaetigt, eintrag, pruefeQuelltext } from './auftraegeKundenportalDaten.js'
const NB = '\u{a0}'
const mit = (fn) => async () => {
const fx = installFakeDom()
try {
await fn(fx)
} finally {
fx.restore()
}
}
function aufbau(fx) {
const body = h('div', { id: 'orders-body' })
const section = h('section', { id: 'orders-section', class: 'ab-portal hidden' }, body)
const invoicesTitle = h('h2', { id: 'invoices-title', class: 'hidden' }, 'Rechnungen')
fx.document.body.append(section, invoicesTitle)
return { section, body, invoicesTitle }
}
/** request wie api() in app.js: gibt die nächste Antwort zurück oder wirft sie (Error mit status). */
function fakeRequest(antworten) {
const aufrufe = []
const request = async (pfad, optionen = {}) => {
aufrufe.push({ pfad, methode: optionen.method || 'GET', body: optionen.body ? JSON.parse(optionen.body) : undefined })
const antwort = antworten.shift()
if (antwort instanceof Error) throw antwort
return antwort
}
return { request, aufrufe }
}
const fehler = (status, text = `Fehler ${status}`) => Object.assign(new Error(text), { status })
const sichtbar = (el) => !el.classList.contains('hidden')
const liste = (...auftraege) => ({ aktiv: true, auftraege })
const A_OFFEN = eintrag({ id: 'ab1', nummer: 'AB-2026-0002' })
const A_OHNE_RECHT = eintrag({ id: 'ab4', nummer: 'AB-2026-0004', kannBestaetigen: false }) // Status offen, der Server erlaubt das Bestätigen nicht
const A_FERTIG = eintrag({ id: 'ab2', ...bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })), nummer: 'AB-2026-0003', kannBestaetigen: false })
const A_STORNO = eintrag({
id: 'ab0', nummer: 'AB-2026-0001', status: 'storniert', kannBestaetigen: false, art: 'aufwand', positionen: [], summeCents: null,
aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.',
})
const bestaetige = async (fx, wurzel) => {
await fx.klick(fx.finde(wurzel, '[data-feld="bestaetigung"]'))
await fx.klick(fx.finde(wurzel, '[data-aktion="bestaetigen"]'))
}
test('Der Abschnitt bleibt weg: Schalter aus, keine Aufträge, Ladefehler', mit(async (fx) => {
for (const antwort of [{ aktiv: false, auftraege: [] }, { aktiv: false, auftraege: [A_OFFEN] }, liste(), fehler(500), fehler(401)]) {
const els = aufbau(fx)
const { request, aufrufe } = fakeRequest([antwort])
assert.deepEqual(await initAuftraege({ ...els, request, viewAs: false }), { sichtbar: false })
assert.equal(sichtbar(els.section), false)
assert.equal(sichtbar(els.invoicesTitle), false)
assert.deepEqual(aufrufe, [{ pfad: '/api/auftraege', methode: 'GET', body: undefined }])
els.section.remove()
els.invoicesTitle.remove()
}
}))
test('Liste: Bestätigbares zuerst, Status, Zeile, PDF-Link; nur der offene Auftrag ist aufgeklappt und hat das Formular', mit(async (fx) => {
const els = aufbau(fx)
const { request } = fakeRequest([liste(A_STORNO, A_FERTIG, A_OHNE_RECHT, A_OFFEN)])
assert.deepEqual(await initAuftraege({ ...els, request, viewAs: false }), { sichtbar: true })
assert.equal(sichtbar(els.section), true)
assert.equal(sichtbar(els.invoicesTitle), true)
const karten = fx.alle(els.body, '.ab-karte')
assert.deepEqual(karten.map((k) => k.getAttribute('data-auftrag')), ['ab1', 'ab4', 'ab2', 'ab0'])
const [offen, ohneRecht, fertig, storno] = karten
assert.equal(fx.finde(offen, '.ab-pille').textContent, 'Bitte bestätigen')
assert.equal(fx.finde(ohneRecht, '.ab-pille').textContent, 'Versendet')
assert.equal(fx.finde(ohneRecht, 'form'), null, 'ohne kannBestaetigen vom Server gibt es kein Formular')
assert.equal(fx.finde(fertig, '.ab-pille').textContent, 'Bestätigt')
assert.equal(fx.finde(storno, '.ab-pille').textContent, 'Storniert')
assert.equal(fx.finde(offen, '.ab-karte-titel').textContent, 'Website Anelia Wolf')
assert.equal(fx.finde(offen, '.ab-karte-meta').textContent, `vom 06.10.2026 · 800,00${NB}€`)
assert.equal(fx.finde(storno, '.ab-karte-meta').textContent, 'vom 06.10.2026 · nach Aufwand')
assert.equal(fx.finde(offen, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/ab1/pdf')
assert.equal(fx.finde(offen, '[data-link="pdf"]').textContent, 'PDF ansehen')
assert.equal(fx.finde(offen, 'summary').textContent, 'Details ansehen und bestätigen')
assert.equal(fx.finde(fertig, 'summary').textContent, 'Details ansehen')
assert.equal(fx.finde(offen, 'details').hasAttribute('open'), true)
assert.equal(fx.finde(fertig, 'details').hasAttribute('open'), false)
assert.ok(fx.finde(offen, 'form [data-feld="bestaetigung"]'))
assert.equal(fx.finde(fertig, 'form'), null)
assert.equal(fx.finde(storno, 'form'), null)
assert.deepEqual(fx.alle(fx.finde(fertig, '[data-bestaetigt]'), 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.'])
assert.equal(fx.text(fx.finde(offen, 'legend')), 'Deine Bestätigung', 'Portal: Du-Form')
assert.equal(fx.text(fx.finde(offen, '.ab-notiz')), 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.')
assert.deepEqual(fx.alle(offen, 'h4').map((x) => x.textContent), ['Leistungsumfang', 'Zahlung', 'Termine'])
assert.equal(fx.finde(offen, '[data-link="agb"]').getAttribute('href'), '/rechtliches/agb-2026-09-30.pdf')
}))
test('Bestätigen: POST an /api/auftraege/<id>/bestaetigen, die Karte zeigt danach „Bestätigt“ (auch ohne id in der Antwort)', mit(async (fx) => {
const els = aufbau(fx)
const { request, aufrufe } = fakeRequest([liste(A_OFFEN), { auftrag: bestaetigt(ab()) }])
await initAuftraege({ ...els, request, viewAs: false })
await bestaetige(fx, els.body)
assert.deepEqual(aufrufe[1], { pfad: '/api/auftraege/ab1/bestaetigen', methode: 'POST', body: { bestaetigt: true, fruehbeginn: false } })
const karte = fx.finde(els.body, '.ab-karte')
assert.equal(karte.getAttribute('data-auftrag'), 'ab1')
assert.equal(fx.finde(karte, '.ab-pille').textContent, 'Bestätigt')
assert.equal(fx.finde(karte, 'form'), null)
assert.equal(fx.finde(karte, 'details').hasAttribute('open'), true)
assert.equal(fx.document.activeElement, fx.finde(karte, '[data-bestaetigt]'))
}))
test('Ansichtsmodus: Häkchen und Knopf gesperrt, Hinweis sichtbar, kein POST; der Abschnitt bleibt lesbar', mit(async (fx) => {
const els = aufbau(fx)
// wie L5 es liefert: in der Kundenansicht ist kannBestaetigen immer false; die Oberfläche zeigt die offene AB trotzdem mit gesperrtem Formular
const { request, aufrufe } = fakeRequest([liste(eintrag({ ...privat(), id: 'ab1', kannBestaetigen: false }))])
await initAuftraege({ ...els, request, viewAs: true })
assert.equal(fx.finde(els.body, '.ab-pille').textContent, 'Bitte bestätigen')
const form = fx.finde(els.body, 'form')
assert.equal(fx.alle(form, 'input').every((i) => i.disabled), true)
assert.equal(fx.finde(form, '[data-aktion="bestaetigen"]').disabled, true)
assert.equal(fx.finde(form, '[data-gesperrt]').textContent, 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.')
await fx.sende(form, 'submit')
assert.equal(aufrufe.length, 1, 'nur das Laden der Liste')
assert.ok(fx.finde(els.body, '[data-link="pdf"]'))
assert.ok(fx.finde(els.body, '[data-position]'))
}))
test('Fehler beim Bestätigen: feste Meldung statt Servertext; ein 410 lädt die Liste neu', mit(async (fx) => {
const els = aufbau(fx)
const storniert = { ...A_OFFEN, status: 'storniert', kannBestaetigen: false }
const { request, aufrufe } = fakeRequest([liste(A_OFFEN), fehler(500), fehler(410, 'Diese Auftragsbestätigung wurde storniert.'), liste(storniert)])
await initAuftraege({ ...els, request, viewAs: false })
await bestaetige(fx, els.body)
assert.equal(fx.finde(els.body, '[data-fehler]').textContent, 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.')
assert.ok(!fx.text(els.body).includes('Fehler 500'))
await fx.klick(fx.finde(els.body, '[data-aktion="bestaetigen"]'))
assert.equal(aufrufe.length, 4, 'Liste, POST, POST, Liste neu')
assert.equal(fx.finde(els.body, '.ab-pille').textContent, 'Storniert')
assert.equal(fx.finde(els.body, 'form'), null)
}))
test('Fremdtext bleibt Text; die ID im PDF-Link wird kodiert', mit(async (fx) => {
const els = aufbau(fx)
const boese = '<script>alert(1)</script> & "Test"'
const { request } = fakeRequest([liste(eintrag({ id: 'a/b', titel: boese }))])
await initAuftraege({ ...els, request, viewAs: false })
assert.equal(fx.alle(els.body, 'script').length, 0)
assert.equal(fx.finde(els.body, '.ab-karte-titel').textContent, boese)
assert.equal(fx.finde(els.body, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/a%2Fb/pdf')
}))
test('Quelltext und CSS: kein eigenes fetch, Karten und Abschnitt sind gestaltet', () => {
pruefeQuelltext('auftrag-portal.js', { importe: ['./auftrag-ansicht.js', './auftrag-dom.js'] })
const css = readFileSync(new URL('../public/auftrag.css', import.meta.url), 'utf8')
for (const klasse of ['ab-portal', 'ab-karte', 'ab-karte-kopf', 'ab-karte-titel', 'ab-karte-meta', 'ab-karte-aktionen', 'ab-details']) {
assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[>]`), `.${klasse} ist gestaltet`)
}
assert.match(css, /\.ab-portal\s*\{[^}]*--ab-/, 'der Portal-Abschnitt setzt seine eigenen Farbschlüssel (dunkles Portal)')
})
```
- [ ] **Step 2: Lauf, erwartet FAIL**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKundenportalPortal.test.js
```
Erwartet: FAIL (`Cannot find module '../public/auftrag-portal.js'`).
- [ ] **Step 3: `public/auftrag-portal.js` und Portal-CSS**
`initAuftraege({ section, body, invoicesTitle, request, viewAs })` (ES-Modul, Kopf-Kommentar „Abschnitt ‚Aufträge‘ im Reiter Rechnungen, geladen von app.js, loadCustomerOrders; `request` ist `api` aus app.js“). Anrede: **Du** wie das übrige Portal; das Modul benutzt nur `ANREDE_DU` und `texte(ANREDE_DU)`, kein eigener Text:
- Wirksames `kannBestaetigen` je Eintrag, einmal beim Laden gesetzt: `darfBestaetigen(eintrag) && (viewAs || eintrag.kannBestaetigen === true)`. Grund: L5 liefert `kannBestaetigen` in der Kundenansicht immer als `false` (Vertragsfrage 3), die Oberfläche soll dort die offene AB trotzdem als „Bitte bestätigen“ mit gesperrtem Formular zeigen. Status-Pille (`statusAnzeige`), Sortierung, aufgeklappte Karte und Formular benutzen nur dieses wirksame Flag.
- Fehlen `section`, `body` oder `request`: `{ sichtbar: false }`. `lade()`: `await request('/api/auftraege')`; jeder Fehler → `{ sichtbar: false }` (kein Wurf, keine Anzeige). `data.aktiv !== true` oder keine Einträge → `{ sichtbar: false }`, `hidden` bleibt an Abschnitt und `invoicesTitle`. Sonst Einträge mit `typeof id === 'string'` und `id` nicht leer, `sortiereAuftraege`, Karten in `body` (`replaceChildren`), `hidden` von `section` und `invoicesTitle` entfernen, `{ sichtbar: true }`.
- Karte `article.ab-karte[data-auftrag=<id>][data-status=<status>]`: `div.ab-karte-kopf` mit Titelblock (`p.ab-karte-nummer` > `strong` Nummer, `p.ab-karte-titel` Titel (nur wenn vorhanden), `p.ab-karte-meta` aus `kartenMeta`) und `span.ab-pille.ab-pille-<ton>` aus `statusAnzeige`; `div.ab-karte-aktionen` mit `a.ab-link[data-link="pdf"]` (`texte(ANREDE_DU).kartePdf`, Ziel `/api/auftraege/${encodeURIComponent(id)}/pdf`, `target="_blank"`, `rel="noopener noreferrer"`); `details.ab-details` (Attribut `open` bei bestätigbaren Aufträgen und bei der Karte, die gerade bestätigt wurde) mit `summary` (`karteDetailsBestaetigen` bei bestätigbaren, sonst `karteDetails`), `renderZusammenfassung(auftrag, { ebene: 4 })`, `renderDokumente({ agbHref: agbPfad(auftrag.agbUrl) })`, dann `renderBestaetigtBlock(auftrag, { anrede: ANREDE_DU })` bzw. bei wirksamem `kannBestaetigen` das Formular.
- Formular: `renderBestaetigung(auftrag, { senden, nachErfolg, nachEnde, gesperrt: viewAs ? texte(ANREDE_DU).ansichtsmodus : '', anrede: ANREDE_DU })`; `senden(koerper)` ruft `request` mit dem Pfad `/api/auftraege/<id>/bestaetigen` (`id` per `encodeURIComponent`) und `{ method: 'POST', body: JSON.stringify(koerper) }` auf → `{ ok: true, auftrag: antwort.auftrag }`, bei Wurf `{ ok: false, status: Number(err?.status) || 0, body: { error: String(err?.message ?? '') } }` (der Text dient nur der Erkennung, `renderBestaetigung` zeigt ihn nie). `nachErfolg(neu)`: den Eintrag in der Liste durch `{ ...alt, ...neu, id: alt.id, kannBestaetigen: false }` ersetzen und alle Karten per `replaceChildren` neu zeichnen (die eben bestätigte Karte aufgeklappt, Reihenfolge wie beim Laden), dann in ihr `[data-bestaetigt]` fokussieren (der Plan von L5 liefert `id` und `kannBestaetigen` auch in der POST-Antwort; das Zusammenführen bleibt als Vorsicht). `nachEnde()`: Liste neu laden (`GET /api/auftraege`) und neu zeichnen; scheitert das, bleibt die Anzeige stehen.
`public/auftrag.css` (Teil „Portal“, an `.ab-portal` gebunden): `.ab-portal` setzt dunkle Schlüssel `--ab-*` aus den Portalfarben (`--kb-surface`, `--kb-border`, `--kb-muted`, `--kb-purple`, Pillen wie `.pill-ok/-warn/-red/-muted` in `kundenbereich.css`), `margin: 0 0 28px`; `.ab-portal h2` 20 px; `.ab-karte` wie `.kb-panel` (Radius 16, Rand, 16/18 px Innenabstand, 12 px Abstand zwischen Karten); `.ab-karte-kopf` Zeile mit Abstand, Pille rechts, `overflow-wrap: anywhere`; `.ab-karte-nummer` fett, `.ab-karte-meta` klein und gedämpft; `.ab-karte-aktionen` Zeile mit 10 px Abstand, Links als Kapsel mit mindestens 44 px Höhe; `.ab-details > summary` mindestens 44 px, Farbe `#d4b4ff`, fett; `.ab-knopf` im Portal lila (`--kb-purple`, weiße Schrift, Kapsel, `min-height: 48px`); `.ab-haken`, `.ab-fehler` (`#fca5a5`), `.ab-notiz`, `.ab-gesperrt`, Tabelle (unter 640 px gestapelt wie auf der Seite) in den Portalfarben; `.ab-rechnungen-titel` 20 px.
- [ ] **Step 4: Lauf, erwartet PASS**
Derselbe Befehl wie in Step 2. Erwartet: `fail 0`. Danach `test/auftraegeKundenportalHuelle.test.js` erneut laufen lassen (CSS wurde ergänzt): `fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add public/auftrag-portal.js public/auftrag.css test/auftraegeKundenportalPortal.test.js && git commit -m "Kundenportal Aufträge: Abschnitt „Aufträge“ im Portal (Karten, Bestätigen, Ansichtsmodus gesperrt)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 8: Dashboard verdrahten, Projekt unter der Rechnungsnummer
**Files:**
- Modify: `public/dashboard.html` (Ansicht `view-invoices`, Stylesheet)
- Modify: `public/app.js` (`invoiceNumberHtml`, `loadCustomerOrders`, Aufruf in `initDashboardPage`)
- Modify: `public/auftrag.css` (Regel `.inv-project`)
- Create: `test/auftraegeKundenportalDashboard.test.js`
**Interfaces:**
- Consumes: `initAuftraege` (Task 7); `api`, `escapeHtml`, `chatState.viewAs` aus `app.js`; `projekt` aus `/api/invoices` (Task 2).
- Produces: in `dashboard.html` die Elemente `#orders-section`, `#orders-body`, `#invoices-title`; in `app.js` die globalen Funktionen `invoiceNumberHtml(i) → string` (maskiert) und `loadCustomerOrders() → Promise`.
- [ ] **Step 1: Failing Test schreiben**
`test/auftraegeKundenportalDashboard.test.js`:
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
const html = lies('public/dashboard.html')
const app = lies('public/app.js')
const css = lies('public/auftrag.css')
test('dashboard.html: auftrag.css nach zeiten.css; Abschnitt „Aufträge“ vor der Rechnungsliste, zunächst versteckt', () => {
assert.match(html, /<link rel="stylesheet" href="\/zeiten\.css">\s*<link rel="stylesheet" href="\/auftrag\.css">/)
const start = html.indexOf('id="view-invoices"')
const ende = html.indexOf('</section>', html.indexOf('id="invoices-abo-note"'))
const ansicht = html.slice(start, ende)
assert.match(ansicht, /<section class="ab-portal hidden" id="orders-section" aria-labelledby="orders-title">\s*<h2 id="orders-title">Aufträge<\/h2>[\s\S]*<div id="orders-body"><\/div>\s*<\/section>\s*<h2 class="ab-rechnungen-titel hidden" id="invoices-title">Rechnungen<\/h2>\s*<div class="kb-panel" id="invoices-body">/)
assert.match(ansicht, /<p class="kb-note">Deine Auftragsbestätigungen\. Offene Aufträge bestätigst du hier mit einem Klick\.<\/p>/, 'Portal: Du-Form')
assert.ok(ansicht.indexOf('id="orders-section"') < ansicht.indexOf('id="invoices-body"'))
assert.equal([...html.matchAll(/id="orders-section"/g)].length, 1)
assert.match(ansicht, /<h1>Rechnungen<\/h1>/, 'Kopf und Hinweis des Reiters bleiben')
})
test('app.js: loadCustomerOrders lädt auftrag-portal.js nach der Rechnungsliste, mit api und Ansichtsmodus; Fehler bleiben still', () => {
const funktion = app.slice(app.indexOf('function loadCustomerOrders()'), app.indexOf('/* ---', app.indexOf('function loadCustomerOrders()')))
assert.ok(funktion.length > 40, 'Funktion gefunden')
assert.match(funktion, /import\('\/auftrag-portal\.js'\)/)
assert.match(funktion, /m\.initAuftraege\(/)
for (const teil of ["section: document.getElementById('orders-section')", "body: document.getElementById('orders-body')", "invoicesTitle: document.getElementById('invoices-title')", 'request: api', 'viewAs: chatState.viewAs']) {
assert.ok(funktion.includes(teil), teil)
}
assert.match(funktion, /\.catch\(\(\) => \{\}\)/)
const dashboard = app.slice(app.indexOf('async function initDashboardPage'))
const aufruf = (name) => dashboard.search(new RegExp(`^\\s*${name}\\(\\)\\s*$`, 'm'))
assert.ok(aufruf('loadCustomerInvoices') > 0 && aufruf('loadCustomerOrders') > aufruf('loadCustomerInvoices'), 'ein eigener Aufruf, nach der Rechnungsliste')
assert.equal([...app.matchAll(/function loadCustomerOrders\(\)/g)].length, 1)
})
test('invoiceNumberHtml: Projekt unter der Rechnungsnummer, alles maskiert', () => {
const ausschnitt = (muster) => {
const treffer = app.match(muster)
assert.ok(treffer, `${muster} nicht in app.js`)
return treffer[0]
}
const code = [
ausschnitt(/function escapeHtml\(str\) \{[\s\S]*?\n\}/),
ausschnitt(/\/\/ @rechnungsnummer-anfang[\s\S]*?\/\/ @rechnungsnummer-ende/),
].join('\n')
const { invoiceNumberHtml } = vm.runInNewContext(`${code}\n({ invoiceNumberHtml })`)
assert.equal(invoiceNumberHtml({ number: 'WEBKLAR-0009', projekt: 'Website Anelia Wolf' }), 'WEBKLAR-0009<span class="inv-project">Website Anelia Wolf</span>')
assert.equal(invoiceNumberHtml({ number: 'WEBKLAR-0009', projekt: '' }), 'WEBKLAR-0009')
assert.equal(invoiceNumberHtml({ number: 'H96T-0004' }), 'H96T-0004')
assert.equal(
invoiceNumberHtml({ number: 'X', projekt: '<img src=x onerror=alert(1)> & "Bäckerei"' }),
'X<span class="inv-project">&lt;img src=x onerror=alert(1)&gt; &amp; &quot;Bäckerei&quot;</span>',
)
assert.match(app, /<td class="inv-no">\$\{invoiceNumberHtml\(i\)\}<\/td>/)
assert.doesNotMatch(app, /\$\{i\.projekt\}/, 'der Projektname geht nie unmaskiert in eine Vorlage')
})
test('auftrag.css: Projektname unter der Rechnungsnummer (Tabelle und Handy-Karte)', () => {
assert.match(css, /\.inv-project\s*\{[^}]*display:\s*block/)
assert.match(css, /\.inv-project\s*\{[^}]*overflow-wrap:\s*anywhere/)
})
```
- [ ] **Step 2: Lauf, erwartet FAIL**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeKundenportalDashboard.test.js
```
Erwartet: FAIL (kein `auftrag.css`-Link, kein Abschnitt, keine Funktionen).
- [ ] **Step 3: Änderungen**
`public/dashboard.html`:
- Nach `<link rel="stylesheet" href="/zeiten.css">` die Zeile ` <link rel="stylesheet" href="/auftrag.css">`.
- In `#view-invoices` zwischen `</header>` und `<div class="kb-panel" id="invoices-body">`:
```html
<!-- Aufträge: Auftragsbestätigungen ansehen und bestätigen (auftrag-portal.js, von app.js loadCustomerOrders); erscheint nur,
wenn Aufträge freigeschaltet sind und der Kunde welche hat -->
<section class="ab-portal hidden" id="orders-section" aria-labelledby="orders-title">
<h2 id="orders-title">Aufträge</h2>
<p class="kb-note">Deine Auftragsbestätigungen. Offene Aufträge bestätigst du hier mit einem Klick.</p>
<div id="orders-body"></div>
</section>
<h2 class="ab-rechnungen-titel hidden" id="invoices-title">Rechnungen</h2>
```
`public/app.js`:
- Direkt über `async function loadCustomerInvoices()` (unter dem Abschnittskommentar „Rechnungen“):
```js
// @rechnungsnummer-anfang
/** Rechnungsnummer mit dem Projekt darunter (Stripe-Metadaten projektName, Feld `projekt`); beides maskiert. */
function invoiceNumberHtml(i) {
const project = i.projekt ? `<span class="inv-project">${escapeHtml(i.projekt)}</span>` : ''
return `${escapeHtml(i.number)}${project}`
}
// @rechnungsnummer-ende
```
und in der Tabellenzeile `<td class="inv-no">${escapeHtml(i.number)}</td>` ersetzen durch `<td class="inv-no">${invoiceNumberHtml(i)}</td>`.
- Hinter `loadCustomerInvoices` (vor dem Abschnittskommentar „Aktivität“) `function loadCustomerOrders()`: `return import('/auftrag-portal.js').then((m) => m.initAuftraege({ section: document.getElementById('orders-section'), body: document.getElementById('orders-body'), invoicesTitle: document.getElementById('invoices-title'), request: api, viewAs: chatState.viewAs })).catch(() => {})`, mit `//`-Kommentar (nicht `/* --- */`, der Test schneidet die Funktion am nächsten Abschnittskommentar ab): Abschnitt „Aufträge“ im Reiter Rechnungen, `public/auftrag-portal.js`; Fehler lassen den Abschnitt weg und stören die Rechnungen nicht.
- In `initDashboardPage` direkt nach dem Aufruf `loadCustomerInvoices()` die Zeile `loadCustomerOrders()` (`chatState.viewAs` ist dort schon gesetzt).
`public/auftrag.css`: `.inv-project { display: block; margin-top: 2px; font-size: 12.5px; font-weight: 400; color: var(--kb-muted, rgba(255, 255, 255, 0.6)); overflow-wrap: anywhere; }` (gilt in der Desktop-Tabelle und in der Handy-Karte; der Vorsatz „Rechnung “ steht davor als `::before` und bleibt unverändert).
- [ ] **Step 4: Lauf, erwartet PASS**
Derselbe Befehl wie in Step 2, danach die bestehenden Tests, die `app.js` und `dashboard.html` festnageln:
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeKundenportalDashboard.test.js test/auftraegeKundenportalHuelle.test.js test/shopWiring.test.js test/termineView.test.js test/zeitenBestellen.test.js test/analyseView.test.js test/funktionenRoutes.test.js
```
Erwartet: `fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add public/dashboard.html public/app.js public/auftrag.css test/auftraegeKundenportalDashboard.test.js && git commit -m "Kundenportal Aufträge: Abschnitt im Reiter Rechnungen, Projekt unter der Rechnungsnummer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 9: Widerrufsseite: Vertragsart „Projektauftrag“ mit freiwilliger Nummer der AB
**Files:**
- Modify: `public/widerrufen.html`
- Create: `public/auftrag-widerruf.js`
- Modify: `test/publicForms.test.js` (genau eine Zeile im Test `widerrufen_html_radio`, Vertragsfrage 1)
- Create: `test/auftraegeKundenportalWiderruf.test.js`
**Interfaces:**
- Consumes: `abNummerNormalisiert`, `texte().abNummerUngueltig` (Task 3); `initLegalForm({ endpoint, fields })` aus `public/legal-form.js` (unverändert: liest je Feld `form.elements[name].value`, schickt es und zeigt es in der Eingangsbestätigung mit der Beschriftung aus `data-label-for`/`data-label`); `POST /api/abo/public/withdraw` mit `order: 'projekt'` und `abNummer` (Strang L5: optional; ohne oder ungültig nur Mails mit dem Vermerk „ungeprüft“).
- Produces: `initProjektWiderruf(form) → void` in `public/auftrag-widerruf.js` (Selbststart, wenn `document` existiert und `#decl-form` und `#ab-nummer-wrap` vorhanden sind). Markup: Radioknopf `order=projekt`, `#ab-nummer-wrap` mit dem **freiwilligen** Feld `#abNummer` (Hinweis „falls zur Hand“; Entscheidung der Planrunde: nicht Pflicht, das Muster `AB-JJJJ-NNNN` wird nur geprüft, wenn etwas eingegeben ist).
- [ ] **Step 1: Failing Test schreiben**
In `test/publicForms.test.js` im Test `widerrufen_html_radio` die Zeile `assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)` ersetzen durch `assert.match(src, /fields: \['order', 'abNummer', 'name', 'customerNumber', 'email'\]/)`. Sonst nichts an dieser Datei.
`test/auftraegeKundenportalWiderruf.test.js`:
```js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { h } from '../public/auftrag-dom.js'
import { initProjektWiderruf } from '../public/auftrag-widerruf.js'
import { pruefeQuelltext } from './auftraegeKundenportalDaten.js'
const html = readFileSync(new URL('../public/widerrufen.html', import.meta.url), 'utf8')
const MELDUNG = 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfen Sie die Nummer oder lassen Sie das Feld leer.'
test('widerrufen.html: Vertragsart „Projektauftrag“ hinter „Öffnungszeiten live“, freiwilliges Feld abNummer vor dem Namen, Skripte', () => {
assert.match(html, /name="order" value="zeiten"[^>]*> Öffnungszeiten live<\/label>\s*<label class="radio"><input type="radio" name="order" value="projekt" data-text="Projektauftrag \(Auftragsbestätigung\)"> Projektauftrag \(Auftragsbestätigung\)<\/label>\s*<\/fieldset>/)
assert.match(html, /<div id="ab-nummer-wrap">\s*<label for="abNummer" data-label-for="abNummer" data-label="Nummer der Auftragsbestätigung">Nummer der Auftragsbestätigung <small>\(falls zur Hand, zum Beispiel AB-2026-0001\)<\/small><\/label>\s*<input id="abNummer" name="abNummer" maxlength="20" autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="AB-2026-0001">\s*<\/div>/)
assert.doesNotMatch(html, /<input id="abNummer"[^>]*required/, 'die Nummer ist freiwillig')
assert.ok(html.indexOf('id="ab-nummer-wrap"') < html.indexOf('for="name"'), 'das Feld steht vor dem Namen')
assert.doesNotMatch(html, /id="ab-nummer-wrap"[^>]*hidden/, 'ohne das Skript bleibt das Feld sichtbar')
assert.match(html, /initLegalForm\(\{ endpoint: '\/api\/abo\/public\/withdraw', fields: \['order', 'abNummer', 'name', 'customerNumber', 'email'\] \}\)/)
assert.match(html, /<script type="module" src="\/auftrag-widerruf\.js"><\/script>/)
for (const wert of ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten']) assert.match(html, new RegExp(`name="order" value="${wert}"`), `${wert} bleibt`)
})
const mit = (fn) => async () => {
const fx = installFakeDom()
try {
await fn(fx)
} finally {
fx.restore()
}
}
function aufbau(fx, order = 'abschluss') {
const radios = ['abschluss', 'projekt'].map((wert) => h('input', { type: 'radio', name: 'order', value: wert, checked: wert === order }))
const nummer = h('input', { id: 'abNummer', name: 'abNummer' })
const wrap = h('div', { id: 'ab-nummer-wrap' }, h('label', { for: 'abNummer' }, 'Nummer'), nummer)
const fehler = h('p', { id: 'form-error', class: 'error hidden' })
const knopf = h('button', { type: 'submit' }, 'Widerruf bestätigen')
const form = h('form', { id: 'decl-form' }, h('fieldset', {}, ...radios.map((r) => h('label', { class: 'radio' }, r))), wrap, fehler, knopf)
fx.document.body.append(form)
const gesendet = []
form.addEventListener('submit', (ev) => {
ev.preventDefault()
gesendet.push(nummer.value)
})
initProjektWiderruf(form)
return { form, radios, nummer, wrap, fehler, knopf, gesendet }
}
test('Das Feld erscheint nur bei „Projektauftrag“ und wird beim Wegwählen geleert', mit(async (fx) => {
const t = aufbau(fx, 'abschluss')
assert.equal(t.wrap.classList.contains('hidden'), true)
await fx.klick(t.radios[1])
assert.equal(t.wrap.classList.contains('hidden'), false)
await fx.setze(t.nummer, 'AB-2026-0001')
await fx.klick(t.radios[0])
assert.equal(t.wrap.classList.contains('hidden'), true)
assert.equal(t.nummer.value, '')
}))
test('Ist „Projektauftrag“ schon gewählt, ist das Feld sofort da', mit(async (fx) => {
const t = aufbau(fx, 'projekt')
assert.equal(t.wrap.classList.contains('hidden'), false)
}))
test('Projektauftrag: eine eingegebene, aber unpassende Nummer wird gemeldet und stoppt das Absenden; Wechsel der Vertragsart nimmt die Meldung weg', mit(async (fx) => {
const t = aufbau(fx, 'projekt')
for (const eingabe of ['AB-2026-01', 'RE-2026-0001', 'irgendwas']) {
await fx.setze(t.nummer, eingabe)
await fx.klick(t.knopf)
assert.deepEqual(t.gesendet, [], eingabe)
assert.equal(t.fehler.classList.contains('hidden'), false)
assert.equal(t.fehler.textContent, MELDUNG)
assert.equal(t.nummer.value, eingabe)
}
assert.equal(fx.document.activeElement, t.nummer)
await fx.klick(t.radios[0])
assert.equal(t.fehler.classList.contains('hidden'), true)
}))
test('Ohne Nummer geht der Projektauftrag durch (freiwilliges Feld); die eigene Meldung verschwindet, sobald der Widerruf durchgeht', mit(async (fx) => {
const t = aufbau(fx, 'projekt')
await fx.klick(t.knopf)
assert.deepEqual(t.gesendet, [''])
assert.equal(t.fehler.classList.contains('hidden'), true)
await fx.setze(t.nummer, 'kaputt')
await fx.klick(t.knopf)
assert.deepEqual(t.gesendet, [''], 'unpassende Eingabe: nichts gesendet')
assert.equal(t.fehler.classList.contains('hidden'), false)
await fx.setze(t.nummer, ' ')
await fx.klick(t.knopf)
assert.deepEqual(t.gesendet, ['', ''], 'nur Leerzeichen zählen als leer')
assert.equal(t.fehler.classList.contains('hidden'), true)
}))
test('Gültige Nummer in anderer Schreibweise wird vereinheitlicht, dann geht der Widerruf durch', mit(async (fx) => {
const t = aufbau(fx, 'projekt')
await fx.setze(t.nummer, ' ab 2026 0001 ')
await fx.klick(t.knopf)
assert.deepEqual(t.gesendet, ['AB-2026-0001'])
assert.equal(t.fehler.classList.contains('hidden'), true)
}))
test('Andere Vertragsart: kein Zwang, das Feld wird leer gesendet', mit(async (fx) => {
const t = aufbau(fx, 'abschluss')
await fx.klick(t.knopf)
assert.deepEqual(t.gesendet, [''])
}))
test('Quelltext: nur ./auftrag-ansicht.js, kein fetch', () => {
pruefeQuelltext('auftrag-widerruf.js', { importe: ['./auftrag-ansicht.js'] })
})
```
- [ ] **Step 2: Lauf, erwartet FAIL**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeKundenportalWiderruf.test.js test/publicForms.test.js
```
Erwartet: FAIL (Modul fehlt; `publicForms` erwartet das neue `fields`).
- [ ] **Step 3: Implementieren**
`public/widerrufen.html`:
- Hinter dem Radioknopf `zeiten` (vor `</fieldset>`): ` <label class="radio"><input type="radio" name="order" value="projekt" data-text="Projektauftrag (Auftragsbestätigung)"> Projektauftrag (Auftragsbestätigung)</label>`
- Hinter `</fieldset>` und vor dem Namensfeld:
```html
<!-- Nur bei „Projektauftrag“ und freiwillig (auftrag-widerruf.js blendet das Feld sonst aus und leert es); ohne das Skript bleibt es sichtbar -->
<div id="ab-nummer-wrap">
<label for="abNummer" data-label-for="abNummer" data-label="Nummer der Auftragsbestätigung">Nummer der Auftragsbestätigung <small>(falls zur Hand, zum Beispiel AB-2026-0001)</small></label>
<input id="abNummer" name="abNummer" maxlength="20" autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="AB-2026-0001">
</div>
```
- Den Aufruf ändern in `initLegalForm({ endpoint: '/api/abo/public/withdraw', fields: ['order', 'abNummer', 'name', 'customerNumber', 'email'] })` und danach die Zeile `<script type="module" src="/auftrag-widerruf.js"></script>` einfügen (das klassische Skript `legal-form.js` und der Aufruf bleiben unverändert und laufen zuerst; fällt das Modul aus, funktioniert der Widerruf wie bisher).
`public/auftrag-widerruf.js` (ES-Modul, Kopf-Kommentar: Rechtsfunktion, darf nie die anderen Vertragsarten stören): `initProjektWiderruf(form)`:
- Hilfen: `gewaehlt()` = `form.querySelector('[name="order"]:checked')?.value`; `wrap = form.querySelector('#ab-nummer-wrap')`, `feld = form.querySelector('#abNummer')`, `fehler = form.querySelector('#form-error')`; fehlt eines davon, nichts tun.
- `zeige()`: `wrap.classList.toggle('hidden', gewaehlt() !== 'projekt')`; beim Verbergen `feld.value = ''`, und eine von diesem Modul gesetzte Meldung entfernen (`fehler` wieder `hidden`, Merker zurücksetzen). Sofort einmal aufrufen; Listener `change` auf dem Formular (steigt von den Radioknöpfen auf).
- Listener `click` auf dem Formular (steigt vom Knopf auf, läuft vor dem Absenden, unabhängig von der Reihenfolge der `submit`-Listener): trifft `ev.target.closest('button[type="submit"], input[type="submit"]')` und ist `gewaehlt() === 'projekt'`: `eingabe = feld.value.trim()`. Ist sie leer (die Nummer ist freiwillig): `feld.value = ''`, eine eigene Meldung ausblenden, nichts verhindern. Sonst `nummer = abNummerNormalisiert(eingabe)`; ist sie leer (unpassend) → `ev.preventDefault()`, `fehler.textContent = texte().abNummerUngueltig`, `fehler.classList.remove('hidden')`, Merker setzen, `feld.focus()`; ist sie gültig → `feld.value = nummer` (der Eintrag von `initLegalForm` liest danach den vereinheitlichten Wert) und eine eigene Meldung ausblenden.
- Selbststart am Dateiende: `if (typeof document !== 'undefined')` und Formular und Wrap vorhanden → `initProjektWiderruf(document.getElementById('decl-form'))`.
- [ ] **Step 4: Lauf, erwartet PASS**
Derselbe Befehl wie in Step 2. Erwartet: `fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add public/widerrufen.html public/auftrag-widerruf.js test/publicForms.test.js test/auftraegeKundenportalWiderruf.test.js && git commit -m "Widerrufsseite: Vertragsart Projektauftrag mit freiwilliger Nummer der Auftragsbestätigung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 10: Oberflächenprüfung mit Playwright und Bildschirmfotos
Dieser Task prüft die fertige Oberfläche im echten Browser: Modul-Laden, Ereignisse, Layout bei 390 und 1440 px, Tippflächen, hell und dunkel, den Ablauf auf der Widerrufsseite mit dem echten `legal-form.js`. Die Logik haben die fakeDom-Tests der Tasks 3 bis 9 schon festgehalten. Das Skript ist neu; ein Fehlschlag ist ein Fehler aus Task 3 bis 9 und wird dort behoben (mit Test), nicht im Skript versteckt.
**Files:**
- Create: `test/ui/auftraege-kundenportal.ui.mjs` (nicht Teil von `npm test`, Muster `test/ui/kundenansicht.ui.mjs`)
**Interfaces:**
- Consumes: alle Seiten aus Task 5 bis 9, `test/auftraegeKundenportalDaten.js` (`TOKEN`, `ab`, `privat`, `bestaetigt`, `eintrag`); Chromium (`playwright-core` unter `/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core`, Fallback `/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome`).
- Produces: 21 Bildschirmfotos in `$SHOTS` (Standard `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-l6/`) als `<Name>-<handy|desktop>.png` (handy = 390 px, desktop = 1440 px) für die Namen `auftrag-offen-unternehmen`, `auftrag-offen-privat`, `auftrag-aufwand`, `auftrag-bestaetigt-privat`, `auftrag-ungueltig`, `auftrag-beendet`, `auftrag-fehler`, `portal-auftraege`, `portal-auftraege-ansichtsmodus`, `widerrufen-projekt` (20 Bilder) und `auftrag-offen-privat-dunkel-handy.png`; Exit-Code 1, wenn eine Prüfung scheitert.
- [ ] **Step 1: Skript schreiben**
`test/ui/auftraege-kundenportal.ui.mjs`:
```js
// Playwright-Prüfung der Kundenseiten zu Aufträgen (Strang L6): auftrag.html, Abschnitt „Aufträge“ im Dashboard, widerrufen.html.
// Statischer Server für public/ + page.route-Nachbau der /api-Antworten; nichts verlässt den Rechner. Nicht Teil von npm test.
// Aufruf: free -m; flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock nice node test/ui/auftraege-kundenportal.ui.mjs
// Bildschirmfotos: $SHOTS (Standard: Scratchpad auftraege-l6/). Exit-Code 1, wenn eine Prüfung scheitert.
import http from 'node:http'
import { readFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
import { TOKEN, ab, privat, bestaetigt, eintrag } from '../auftraegeKundenportalDaten.js'
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = process.env.SHOTS || '/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-l6'
await mkdir(SHOTS, { recursive: true })
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', '.woff2': 'font/woff2' }
const server = http.createServer(async (req, res) => {
try {
const datei = path.join(pub, new URL(req.url, 'http://x').pathname)
if (!datei.startsWith(pub + path.sep)) throw new Error('außerhalb von public/')
res.writeHead(200, { 'content-type': TYPES[path.extname(datei)] || 'application/octet-stream' })
res.end(await readFile(datei))
} catch {
res.writeHead(404)
res.end('nicht gefunden')
}
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
let failed = 0
const check = (name, ok, detail = '') => {
console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
if (!ok) failed += 1
}
const norm = (text) => String(text).replace(/\s+/g, ' ').trim() // auch das geschützte Leerzeichen vor „€“
const laufe = async (name, fn) => {
try {
await fn()
} catch (err) {
check(name, false, String(err.message).split('\n')[0])
}
}
let aufrufe = [] // alle /api-Anfragen der laufenden Prüfung
async function mocke(page, handler) {
aufrufe = []
await page.unroute('**/api/**')
await page.route('**/api/**', async (route) => {
const req = route.request()
const roh = req.postData()
aufrufe.push({ pfad: new URL(req.url()).pathname, methode: req.method(), body: roh ? JSON.parse(roh) : undefined })
const antwort = await handler(aufrufe.at(-1))
if (antwort === 'abbrechen') return route.abort()
const { status = 200, body = {} } = antwort || {}
return route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
})
}
const GET_AB = `/api/auftrag/${TOKEN}`
const POST_AB = `/api/auftrag/${TOKEN}/bestaetigen`
const KUNDE = { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' }
const RECHNUNGEN = [
{ id: 'in_1', source: 'stripe', number: 'WEBKLAR-0009', date: '2026-10-06', dueDate: '2026-10-20', amount: 500, balance: 500, status: 'Offen', paid: false, cancelled: false, overdue: false, portalUrl: 'https://invoice.stripe.test/i/in_1', hasPdf: true, projekt: 'Website Anelia Wolf' },
{ id: 'in_2', source: 'stripe', number: 'H96T-0004', date: '2026-10-01', dueDate: '', amount: 25, balance: 0, status: 'Bezahlt', paid: true, cancelled: false, overdue: false, portalUrl: '', hasPdf: true, projekt: '' },
]
const AUFTRAEGE = [
eintrag({ id: 'ab3', nummer: 'AB-2026-0003' }),
eintrag({ id: 'ab2', ...bestaetigt(privat()), nummer: 'AB-2026-0002', titel: 'Logo und Visitenkarten', kannBestaetigen: false }),
eintrag({ id: 'ab1', nummer: 'AB-2026-0001', titel: 'Wartung Oktober', status: 'storniert', kannBestaetigen: false, art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.' }),
]
const dashboardApi = (me) => ({ pfad, methode }) => {
if (pfad === '/api/auth/me') return { body: me }
if (pfad === '/api/invoices') return { body: { invoices: RECHNUNGEN, incomplete: false } }
// wie L5: in der Kundenansicht ist kannBestaetigen immer false
if (pfad === '/api/auftraege') return { body: { aktiv: true, auftraege: AUFTRAEGE.map((a) => (me.viewAs ? { ...a, kannBestaetigen: false } : a)) } }
if (methode === 'POST' && pfad === '/api/auftraege/ab3/bestaetigen') return { body: { auftrag: bestaetigt(ab({ nummer: 'AB-2026-0003' })) } }
if (pfad === '/api/projects') return { body: { projects: [] } }
if (pfad === '/api/features') return { body: { features: [] } }
if (pfad === '/api/activity') return { body: { tickets: [] } }
if (pfad === '/api/contact') return { body: { name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' } }
if (pfad === '/api/chat/unread-count') return { body: { unread: 0 } }
if (pfad === '/api/chat/messages') return { body: { messages: [], unread: 0 } }
return { body: {} }
}
const postBody = () => JSON.stringify(aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body)
const browser = await chromium.launch().catch(() => chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome' }))
try {
for (const [label, viewport, mobil] of [['handy', { width: 390, height: 844 }, true], ['desktop', { width: 1440, height: 900 }, false]]) {
const ctx = await browser.newContext({ viewport, deviceScaleFactor: mobil ? 2 : 1, isMobile: mobil, hasTouch: mobil, locale: 'de-DE', timezoneId: 'Europe/Berlin' })
const page = await ctx.newPage()
page.setDefaultTimeout(30000)
const jsFehler = []
page.on('pageerror', (e) => jsFehler.push(e.message))
const foto = (name) => page.screenshot({ path: `${SHOTS}/${name}-${label}.png`, fullPage: true })
const zustand = (z) => page.waitForSelector(`#ab-wurzel[data-zustand="${z}"]`)
const ohneUeberlauf = (selektor) => page.evaluate((s) => { const el = document.querySelector(s); return el.scrollWidth <= el.clientWidth + 1 }, selektor)
const oeffne = async (pfad, handler) => {
await mocke(page, handler)
await page.goto('about:blank') // frisch laden, auch wenn sich nur das Fragment unterscheidet
await page.goto(`${base}${pfad}`)
}
const oeffneAuftrag = (handler, adresse = `#t=${TOKEN}`) => oeffne(`/auftrag.html${adresse}`, handler) // K31 verlinkt #t=
const alsAb = (a, neu) => ({ pfad, methode }) => {
if (methode === 'GET' && pfad === GET_AB) return { body: { auftrag: a } }
if (methode === 'POST' && pfad === POST_AB) return { body: { auftrag: neu } }
}
await laufe(`${label} unternehmen`, async () => {
await oeffneAuftrag(alsAb(ab(), bestaetigt(ab())))
await zustand('offen')
check(`${label} offen_titel`, norm(await page.textContent('h1')) === 'Auftragsbestätigung AB-2026-0002')
check(`${label} formular_sie_form`, norm(await page.textContent('legend')) === 'Ihre Bestätigung')
check(`${label} paragraf_19_aus_der_api`, norm(await page.textContent('[data-hinweis]')) === 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
check(`${label} offen_positionen_und_summe`, (await page.locator('[data-position]').count()) === 2 && norm(await page.innerText('[data-gesamt]')) === 'Gesamtpreis 800,00 €')
check(`${label} offen_links`, (await page.getAttribute('[data-link="pdf"]', 'href')) === `/api/auftrag/${TOKEN}/pdf` && (await page.getAttribute('[data-link="agb"]', 'href')) === '/rechtliches/agb-2026-09-30.pdf')
check(`${label} offen_kein_zweites_haekchen`, (await page.locator('[data-feld="fruehbeginn"]').count()) === 0)
check(`${label} fusszeile`, (await page.locator('footer a').allInnerTexts()).join('|') === 'Datenschutz|Impressum')
check(`${label} kein_seitlicher_ueberlauf`, await ohneUeberlauf('html'))
const knopf = page.locator('[data-aktion="bestaetigen"]')
check(`${label} knopf_gesperrt_ohne_haken_und_gross_genug`, (await knopf.isDisabled()) && (await knopf.boundingBox()).height >= 44)
await foto('auftrag-offen-unternehmen')
await page.check('[data-feld="bestaetigung"]')
check(`${label} knopf_frei_mit_haken`, await knopf.isEnabled())
await knopf.click()
await zustand('bestaetigt')
check(`${label} post_koerper`, postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false }))
check(`${label} bestaetigt_zeit`, norm(await page.innerText('[data-bestaetigt]')) === 'Bestätigt am 07.10.2026, 10:15 Uhr')
check(`${label} token_nicht_im_text`, !(await page.innerText('body')).includes(TOKEN))
})
await laufe(`${label} rueckfall`, async () => {
await oeffneAuftrag(alsAb(ab()), `?t=${TOKEN}`)
await zustand('offen')
check(`${label} rueckfall_t_in_der_suche`, aufrufe.some((a) => a.methode === 'GET' && a.pfad === GET_AB))
})
await laufe(`${label} privat`, async () => {
await oeffneAuftrag(alsAb(privat(), bestaetigt(privat())))
await zustand('offen')
const frueh = page.locator('[data-feld="fruehbeginn"]')
check(`${label} privat_zweites_haekchen_frei_waehlbar`, (await frueh.isVisible()) && !(await frueh.isChecked()))
check(`${label} privat_kein_seitlicher_ueberlauf`, await ohneUeberlauf('html'))
await foto('auftrag-offen-privat')
await page.check('[data-feld="bestaetigung"]')
await page.click('[data-aktion="bestaetigen"]')
await zustand('bestaetigt')
check(`${label} privat_ohne_fruehbeginn`, postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false }))
check(`${label} privat_fruehester_beginn`, norm(await page.innerText('[data-bestaetigt]')) === 'Bestätigt am 07.10.2026, 10:15 Uhr Wir beginnen frühestens am 21.10.2026.')
await foto('auftrag-bestaetigt-privat')
})
await laufe(`${label} aufwand`, async () => {
await oeffneAuftrag(alsAb(ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten. Geschätzter Umfang: etwa 10 Stunden (unverbindlich).', zahlungsplanText: 'Monatlich nach Aufwand.' })))
await zustand('offen')
check(`${label} aufwand_text_statt_tabelle`, (await page.locator('[data-aufwand]').count()) === 1 && (await page.locator('table').count()) === 0)
await foto('auftrag-aufwand')
})
await laufe(`${label} fehlerzustaende`, async () => {
await oeffneAuftrag(() => ({ status: 404, body: { error: 'Dieser Link ist ungültig oder abgelaufen.' } }))
await zustand('ungueltig')
check(`${label} ungueltig_kontaktweg`, (await page.locator('a[href="mailto:support@webklar.com"]').count()) === 1)
await foto('auftrag-ungueltig')
await oeffneAuftrag(() => ({ status: 410, body: { error: 'Diese Auftragsbestätigung wurde ersetzt.' } }))
await zustand('beendet')
check(`${label} beendet_titel`, norm(await page.textContent('h1')) === 'Diese Auftragsbestätigung wurde ersetzt.')
await foto('auftrag-beendet')
await oeffneAuftrag(() => ({ status: 429, body: {} }))
await zustand('limit')
let versuche = 0
await oeffneAuftrag(({ pfad }) => {
if (pfad !== GET_AB) return undefined
versuche += 1
return versuche === 1 ? { status: 500, body: { error: 'Interner Fehler.' } } : { body: { auftrag: ab() } }
})
await zustand('fehler')
await foto('auftrag-fehler')
await page.click('[data-aktion="erneut"]')
await zustand('offen')
await oeffneAuftrag(() => 'abbrechen')
await zustand('fehler')
check(`${label} netzfehler_ohne_browsertext`, !/Failed to fetch|NetworkError|fetch failed/i.test(await page.innerText('body')))
await oeffne('/auftrag.html', () => ({}))
await zustand('ungueltig')
check(`${label} ohne_token_keine_anfrage`, aufrufe.length === 0)
})
await laufe(`${label} portal`, async () => {
await oeffne('/dashboard.html#rechnungen', dashboardApi({ authenticated: true, customer: KUNDE }))
await page.waitForSelector('#orders-section:not(.hidden) .ab-karte')
check(`${label} portal_reihenfolge`, JSON.stringify(await page.$$eval('.ab-karte', (k) => k.map((x) => x.getAttribute('data-auftrag')))) === JSON.stringify(['ab3', 'ab2', 'ab1']))
check(`${label} portal_du_form`, norm(await page.textContent('#orders-section > p')) === 'Deine Auftragsbestätigungen. Offene Aufträge bestätigst du hier mit einem Klick.' && norm(await page.textContent('.ab-karte[data-auftrag="ab3"] legend')) === 'Deine Bestätigung')
check(`${label} portal_projekt_unter_rechnungsnummer`, norm(await page.textContent('.inv-no .inv-project')) === 'Website Anelia Wolf')
check(`${label} portal_offene_ab_aufgeklappt`, (await page.locator('.ab-karte[data-auftrag="ab3"] details').getAttribute('open')) !== null)
check(`${label} portal_kein_seitlicher_ueberlauf`, await ohneUeberlauf('#orders-section'))
await foto('portal-auftraege')
await page.check('.ab-karte[data-auftrag="ab3"] [data-feld="bestaetigung"]')
await page.click('.ab-karte[data-auftrag="ab3"] [data-aktion="bestaetigen"]')
await page.waitForSelector('.ab-karte[data-auftrag="ab3"] [data-bestaetigt]')
check(`${label} portal_post_und_status`, aufrufe.some((a) => a.methode === 'POST' && a.pfad === '/api/auftraege/ab3/bestaetigen') && postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false }) && norm(await page.textContent('.ab-karte[data-auftrag="ab3"] .ab-pille')) === 'Bestätigt')
})
await laufe(`${label} ansichtsmodus`, async () => {
const me = { authenticated: true, role: 'admin', viewAs: true, viewId: 'a'.repeat(32), viewExpiresAt: Date.now() + 3_600_000, admin: { name: 'Andrej', email: 'andrej@webklar.com', isAdmin: false }, customer: KUNDE }
await oeffne('/dashboard.html#rechnungen', dashboardApi(me))
await page.waitForSelector('#orders-section:not(.hidden) .ab-karte')
const karte = '.ab-karte[data-auftrag="ab3"]'
check(`${label} ansicht_gesperrt`, (await page.locator(`${karte} [data-aktion="bestaetigen"]`).isDisabled()) && (await page.locator(`${karte} [data-feld="bestaetigung"]`).isDisabled()))
check(`${label} ansicht_hinweis`, norm(await page.textContent(`${karte} [data-gesperrt]`)) === 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.')
await foto('portal-auftraege-ansichtsmodus')
})
await laufe(`${label} widerruf`, async () => {
const api = ({ pfad, methode }) => {
if (pfad === '/api/auth/me') return { body: { authenticated: false } }
if (methode === 'POST' && pfad === '/api/abo/public/withdraw') return { body: { receivedAt: '2026-10-07T08:15:00.000Z' } }
}
const oeffneWiderruf = async () => {
await oeffne('/widerrufen.html', api)
await page.waitForFunction(() => document.getElementById('ab-nummer-wrap')?.classList.contains('hidden'))
}
const absenden = async (nummer) => {
await page.check('input[name="order"][value="projekt"]')
await page.fill('#name', 'Berta Berg')
await page.fill('#email', 'b@example.test')
if (nummer !== undefined) await page.fill('#abNummer', nummer)
await page.click('button[type="submit"]')
}
await oeffneWiderruf()
check(`${label} widerruf_feld_zuerst_versteckt`, !(await page.locator('#ab-nummer-wrap').isVisible()))
await absenden('AB-2026-01')
check(`${label} widerruf_feld_nur_bei_projektauftrag`, await page.locator('#ab-nummer-wrap').isVisible())
check(`${label} widerruf_unpassende_nummer_gestoppt`, aufrufe.every((a) => a.methode !== 'POST') && norm(await page.textContent('#form-error')).startsWith('Die Nummer sieht anders aus'))
await foto('widerrufen-projekt')
await page.fill('#abNummer', 'ab 2026 0001')
await page.click('button[type="submit"]')
await page.waitForSelector('#done:not(.hidden)')
const mitNummer = aufrufe.find((a) => a.methode === 'POST')?.body
check(`${label} widerruf_nummer_vereinheitlicht`, mitNummer?.order === 'projekt' && mitNummer.abNummer === 'AB-2026-0001', JSON.stringify(mitNummer))
check(`${label} widerruf_bestaetigung_nennt_nummer`, norm(await page.textContent('#done-list')).includes('Nummer der AuftragsbestätigungAB-2026-0001'))
await oeffneWiderruf()
await absenden() // ohne Nummer: freiwillig
await page.waitForSelector('#done:not(.hidden)')
const ohneNummer = aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body
check(`${label} widerruf_ohne_nummer_geht_durch`, ohneNummer?.order === 'projekt' && ohneNummer.abNummer === '', JSON.stringify(ohneNummer))
await oeffneWiderruf()
await page.fill('#name', 'Berta Berg')
await page.fill('#email', 'b@example.test')
await page.click('button[type="submit"]')
await page.waitForSelector('#done:not(.hidden)')
const andere = aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body
check(`${label} widerruf_andere_art_ohne_nummer`, andere?.order === 'abschluss' && andere.abNummer === '', JSON.stringify(andere))
})
check(`${label} keine_js_fehler`, jsFehler.length === 0, jsFehler.join(' | '))
await ctx.close()
}
// Dunkler Modus (Systemeinstellung): die Seite folgt den dunklen Farben der Mailvorlagen
const dunkel = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'de-DE', colorScheme: 'dark' })
const seite = await dunkel.newPage()
await laufe('dunkel', async () => {
await mocke(seite, ({ pfad }) => (pfad === GET_AB ? { body: { auftrag: privat() } } : undefined))
await seite.goto(`${base}/auftrag.html#t=${TOKEN}`)
await seite.waitForSelector('#ab-wurzel[data-zustand="offen"]')
check('dunkel hintergrund', (await seite.evaluate(() => getComputedStyle(document.body).backgroundColor)) === 'rgb(22, 22, 21)')
await seite.screenshot({ path: `${SHOTS}/auftrag-offen-privat-dunkel-handy.png`, fullPage: true })
})
await dunkel.close()
} finally {
await browser.close()
server.close()
}
console.log(failed ? `${failed} Check(s) fehlgeschlagen` : 'alle Checks ok')
process.exit(failed ? 1 : 0)
```
- [ ] **Step 2: Lauf**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && free -m | head -2 && flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock timeout 900 nice -n 10 node --max-old-space-size=1024 test/ui/auftraege-kundenportal.ui.mjs
```
Erwartet: alle Zeilen `OK`, letzte Zeile `alle Checks ok`, Exit-Code 0; danach `ls /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-l6/*.png | wc -l` ergibt 21. Beim ersten Lauf sind Fehlschläge möglich: Ursache im betroffenen Modul (Task 3 bis 9) beheben, den fehlenden Fall dort mit einem Test festhalten, dann Lauf wiederholen. Unter 1500 MB „available“: warten.
- [ ] **Step 3: Bildschirmfotos ansehen**
Alle 21 Bilder mit dem Lese-Werkzeug öffnen und prüfen: nichts abgeschnitten oder übereinander, Kontrast lesbar (hell und dunkel), Knöpfe auf dem Handy volle Breite, Tabelle auf dem Handy gestapelt, Pillen und Kartenkopf ohne Überlauf, Projektname unter der Rechnungsnummer (Desktop-Tabelle und Handy-Karte), Fußzeile sichtbar. Sichtbare Mängel in `public/auftrag.css` beheben (nur Regeln, die Task 6, 7 und 8 beschreiben; kein neues Verhalten), Lauf aus Step 2 wiederholen und dazu `test/auftraegeKundenportalHuelle.test.js`, `test/auftraegeKundenportalPortal.test.js` und `test/auftraegeKundenportalDashboard.test.js` (CSS-Tests).
- [ ] **Step 4: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && git add test/ui/auftraege-kundenportal.ui.mjs public/auftrag.css && git commit -m "Kundenportal Aufträge: Playwright-Prüfung mit Bildschirmfotos (Handy 390 px, Desktop 1440 px, dunkel)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
(`public/auftrag.css` nur mit angeben, wenn Step 3 sie geändert hat; sonst nur das Skript.)
---
### Task 11: Selbstprüfung
**Files:** keine neuen; Korrekturen nur in den Dateien dieses Strangs.
**Interfaces:**
- Consumes: alle Tasks.
- Produces: Bericht an den Auftraggeber (Zeile je Punkt unten) und die Bildschirmfotos in `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-l6/` für Strang Z.
- [ ] **Step 1: Eigene Tests**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/auftraegeKundenportal*.test.js 2>&1 | tail -12
```
Erwartet: `fail 0` (acht Testdateien: Rechnungen, Ansicht, Dom, Seite, Huelle, Portal, Dashboard, Widerruf).
- [ ] **Step 2: Berührte bestehende Tests** (dieselbe Liste wie in Task 1)
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && free -m | head -2 && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/customerInvoices.test.js test/publicForms.test.js test/shopWiring.test.js test/termineView.test.js test/zeitenBestellen.test.js test/analyseView.test.js test/funktionenRoutes.test.js test/termineFragebogenView.test.js 2>&1 | tail -12
```
Erwartet: `fail 0` (oder genau die in Task 1 notierten, schon vorher roten Tests).
- [ ] **Step 3: Oberflächenprüfung und Bildschirmfotos** (Befehl aus Task 10, Step 2)
Erwartet: `alle Checks ok`, 21 PNGs im Scratchpad-Ordner `auftraege-l6/`.
- [ ] **Step 4: Nur eigene Dateien geändert, Arbeitsbaum sauber**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege-l6 && diff <(git diff --name-only feature/auftraege...HEAD | LC_ALL=C sort) <(LC_ALL=C sort <<'EOF'
public/app.js
public/auftrag-ansicht.js
public/auftrag-dom.js
public/auftrag-portal.js
public/auftrag-widerruf.js
public/auftrag.css
public/auftrag.html
public/auftrag.js
public/dashboard.html
public/widerrufen.html
server/services/customerInvoices.js
test/auftraegeKundenportalAnsicht.test.js
test/auftraegeKundenportalDashboard.test.js
test/auftraegeKundenportalDaten.js
test/auftraegeKundenportalDom.test.js
test/auftraegeKundenportalHuelle.test.js
test/auftraegeKundenportalPortal.test.js
test/auftraegeKundenportalRechnungen.test.js
test/auftraegeKundenportalSeite.test.js
test/auftraegeKundenportalWiderruf.test.js
test/customerInvoices.test.js
test/publicForms.test.js
test/ui/auftraege-kundenportal.ui.mjs
EOF
) && echo "nur eigene Dateien" ; git status --short
```
Erwartet: `nur eigene Dateien` und `git status --short` zeigt höchstens `?? node_modules` (der Symlink). Zusätzlich: `git diff feature/auftraege...HEAD -- test/customerInvoices.test.js test/publicForms.test.js` zeigt je genau eine geänderte Zeile.
- [ ] **Step 5: Verträge abhaken** (je Punkt Beleg im Bericht nennen)
- V3 `AuftragOeffentlich` gelesen wie definiert (`nummer, datum, titel, art, positionen[…summeCents], summeCents, aufwandText, zahlungsplanText, startAm, fertigBis, kundenart, status, bestaetigtAm, fruehbeginn, fruehesterBeginn, agbUrl, texte.bestaetigung/fruehbeginn/kleinunternehmer`; Portal zusätzlich `id`, `kannBestaetigen`): Fixture `ab()`/`eintrag()`, Test `Zusammenfassung (Festpreis) …`.
- V5 öffentlich: `GET /api/auftrag/:token`, `POST …/bestaetigen` mit `{ bestaetigt: true, fruehbeginn }`, Links `/api/auftrag/<token>/pdf` und `agbUrl`: Test `Laden und Anzeigen …`, `Bestätigen (Unternehmen) …`; 404/410/429 und Netzfehler: Test `Zustände …`.
- V5 Kunde: `GET /api/auftraege` (`aktiv`, Liste), `GET /api/auftraege/:id/pdf`, `POST /api/auftraege/:id/bestaetigen`; Schalter aus → Abschnitt weg; Kundenansicht gesperrt: Tests in `auftraegeKundenportalPortal.test.js`.
- V5 Widerruf: `order: 'projekt'` und das freiwillige `abNummer` (Muster `AB-JJJJ-NNNN`, nur bei Eingabe geprüft): `auftraegeKundenportalWiderruf.test.js`, UI-Prüfungen `widerruf_nummer_vereinheitlicht`, `widerruf_ohne_nummer_geht_durch`, `widerruf_andere_art_ohne_nummer`.
- V4 L6: `customerStripeInvoice(raw).projekt = raw.metadata?.projektName || ''`: `auftraegeKundenportalRechnungen.test.js`.
- V7 `BESTAETIGUNG_TEXT` als Rückfall, `texte.fruehbeginn` nur bei Privatperson: Test `Bestätigen: fehlt der Text vom Server …`.
- Globale Regeln: Anrede (Sie auf der öffentlichen Seite und der Widerrufsseite, Du im Portal; Tests `Anrede …` in `auftraegeKundenportalAnsicht.test.js` und `auftraegeKundenportalDom.test.js`), Cent/`euroDe`, Berliner Zeit, keine IP/Cookies/Browserspeicher, keine Fremdressourcen, kein verstecktes geschütztes Leerzeichen im Quelltext: `pruefeQuelltext` in allen Modulen, `auftrag.html`-Test `keine Fremdressourcen`.
- [ ] **Step 6: Bericht**
Dem Auftraggeber melden: Ergebnis von Step 1 bis 4 (Anzahl Tests, Ausgabe der Läufe), Pfad der Bildschirmfotos, Liste `git log --oneline feature/auftraege..HEAD`, die Vertragsfragen (alle entschieden, Abschnitt unten) und Hinweise für Z: (a) `test/auftraegeKundenportalDaten.js` wird auch von `test/ui/auftraege-kundenportal.ui.mjs` benutzt, (b) Z1 lässt `test/ui/auftraege-kundenportal.ui.mjs` nach dem Zusammenführen noch einmal laufen, (c) die Seitentexte stehen in `public/auftrag-ansicht.js` (`TEXTE_GEMEINSAM`, `TEXTE_SIE`, `TEXTE_DU`, `zustandAusAntwort`) und werden von L7 ins Freigabe-Dokument übernommen.
Kein weiterer Commit, außer Step 1 bis 4 haben eine Korrektur nötig gemacht (dann in der betroffenen Datei, mit Test, kleiner Commit).
---
## Vertragsfragen
Alle Fragen dieses Strangs sind in der Übersicht entschieden (Abschnitt „Nachträge nach der Planrunde“ sowie Global Constraints, V3, V5, V6). Der Plan setzt die Entscheidungen um; offen ist nichts.
1. **Zusätzliche Dateien und die Zuständigkeitsprüfung in Z** – **entschieden (siehe Übersicht, Nachträge).** `public/auftrag-ansicht.js`, `auftrag-dom.js`, `auftrag-portal.js`, `auftrag-widerruf.js`, `test/auftraegeKundenportalDaten.js`, `test/ui/auftraege-kundenportal.ui.mjs` und je eine Zeile in `test/customerInvoices.test.js` und `test/publicForms.test.js` stehen in der Zuständigkeitstabelle. Die `LANES` in `…-12-integration-kontrolle.md` müssen dasselbe zulassen (`/^public\/auftrag(-[a-z]+)?\.(html|js|css)$/`, `'test/customerInvoices.test.js'`, `'test/publicForms.test.js'`, `/^test\/auftraegeKundenportalDaten\.js$/`, `/^test\/ui\/auftraege-kundenportal\.ui\.mjs$/`).
2. **AB-Nummer auf der Widerrufsseite** – **entschieden (siehe Übersicht, Nachträge):** freiwillig („falls zur Hand“); das Muster `AB-JJJJ-NNNN` wird nur geprüft, wenn etwas eingegeben ist. Umsetzung in Task 9 (`auftrag-widerruf.js`, `TEXTE_SIE.abNummerUngueltig`). L5 nimmt jede Eingabe an (ohne oder ungültig nur der Vermerk „ungeprüft“), der Widerruf bleibt formfrei; die Vertragsart steht für alle da (der Widerruf gilt unabhängig vom Schalter).
3. **`kannBestaetigen`** – **entschieden (siehe V3):** `(status === 'versendet' || (status === 'abgerechnet' && !bestaetigtAm)) && nicht Kundenansicht`; die POST-Antwort der Kundenroute trägt `id` und `kannBestaetigen`. Umsetzung in Task 7: in der Kundenansicht gilt die Bestätigbarkeit nach Status, damit der gesperrte Knopf mit Hinweis erscheint.
4. **Kleinunternehmer-Satz** – **entschieden (siehe V3, Nachträge L0):** `auftrag.texte.kleinunternehmer` von der API, keine eigene Konstante (Task 3, 4).
5. **Token im Link** – **entschieden (siehe V6, Nachträge L5/L6):** K31 verlinkt `auftrag.html#t=<Token>`; die Seite liest `#t=` und als Rückfall `?t=` (Task 3, 5, 6).
6. **Anrede im Portal** – **entschieden (siehe Global Constraints der Übersicht):** Abschnitt „Aufträge“ im angemeldeten Portal in der Du-Form, öffentliche Seite und Widerrufsseite in der Sie-Form (Task 3, 4, 7, 8; `texte('sie' | 'du')`).
7. **Bestätigen per Link durch das Team** – **entschieden (siehe V6):** K31 geht ohne Team-BCC, die Teamkopie trägt den Link nicht.
8. **Rechtlich gefärbte Sätze der Seiten** – **entschieden (siehe Übersicht, Nachträge L6):** L7 sammelt sie im Freigabe-Dokument. Sie stehen in `public/auftrag-ansicht.js` (`TEXTE_SIE`, `TEXTE_DU`, `zustandAusAntwort`): „Wir beginnen frühestens am …“, „Wir beginnen auf Ihren/deinen Wunsch …“, „… wird mit Datum und Uhrzeit gespeichert.“, der Hinweis zum Widerrufsfeld und die Zustands- und Fehlertexte.