Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-02-admin-umzug-etappe-3.md
T

226 lines
23 KiB
Markdown
Raw 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.
# Admin-Panel ins Ticketsystem – Etappe 3 (Gutschriften, Abo & Angebote, E-Mail-Speicher, Funktionen) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die restlichen Admin-Panel-Bereiche laufen im WOMS: Sammellisten Gutschriften, Abo-Angebote, E-Mail-Speicher unter Kunden; Kundenreiter „Abo & Angebote“, „Gutschriften“, „Funktionen“; Abo-Status für alle Mitarbeiter. Das Admin-Panel bleibt bis Etappe 4 nutzbar.
**Architecture:** Die vier bestehenden Admin-Router des Portals (`takeoverAdminRouter`, `offersAdminRouter`, `storageTasksAdminRouter`, `funktionenAdminRouter`) werden zusätzlich unter `/api/team` eingehängt (Logik unverändert, Sitzung aus dem JWT), ergänzt um Kundenfilter, Zähl-Routen ohne Stripe, Abo-Status und signierte Nachweis-Links. WOMS baut die Oberflächen in React nach; reine Logik (Texte, Rechnungen, Prüfungen) in testbaren `src/lib/*`-Modulen, portiert aus `public/admin.js`.
**Tech Stack:** Portal Express 4 (ESM, Node 20), node:test + `test/support/*` (Appwrite-Nachbau, Stripe-Attrappe, Mail-Senke). WOMS React 18, react-router 6, node:test + `tests/unit/load.mjs`, Playwright-Harness im Scratchpad.
**Spec:** `docs/superpowers/specs/2026-10-01-admin-panel-ins-ticketsystem-design.md` (Abschnitt 1 Sidebar/Kundenseite, Abschnitt 2 Gutschriften/Abo & Angebote/E-Mail-Speicher/Funktionen, Abschnitt 4 Tabelle `/api/team`, Etappe 3 in Abschnitt 5). Abo-Status-Route aus Etappe 1 (dort bewusst verschoben) gehört hierher.
## Global Constraints
- Arbeitsorte: Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, Stand `a6ac451` = origin/main), WOMS `/home/webklar/apps/ticket.webklar.com/dev-admin-umzug` (Branch `feature/admin-umzug`, Stand `813c6a7` = origin/test). Vor Beginn je `git fetch` + `git merge --ff-only`.
- Nie `git add -A`, nichts pushen; Livegang Portal zuerst, dann WOMS (Nutzer pusht).
- `command grep -a`. UI-Texte deutsch mit Umlauten. **Funktionsgleich zum Admin-Panel:** Texte, Bestätigungsfragen, Prüfungen und Abläufe wortgleich aus `public/admin.js` (Zeilenangaben je Task); keine neuen Funktionen.
- Server-Logik der vier Router bleibt unverändert; nur Einhängen, Filter, Zähl-Routen, Abo-Status, Signatur-Links kommen dazu.
- Reiterfolge Kundenseite: `Übersicht · Tickets · Rechnungen · Projekte · Chat · Abo & Angebote · Gutschriften · Funktionen · Protokoll` (ids `overview, tickets, invoices, projects, chat, abo, gutschriften, funktionen, protokoll`; Rechnungen nur Admins).
- Sidebar-Unterpunkte unter Kunden: `Chat` (Zahl ungelesen), `Gutschriften` (Zahl offen), `Abo-Angebote`, `E-Mail-Speicher` (Zahl offen) – Abfrage alle 60 s bei sichtbarem Tab.
- Routen WOMS: `/customers/gutschriften` (`?antrag=<id>`), `/customers/angebote`, `/customers/email-speicher`; Kundenseite `?tab=gutschriften&antrag=<id>`.
- Signierte Nachweis-Links: `signFileLink({ kind: 'takeover', customerId: <requestId>, id: <fileId> })`, 15 min.
- Speicherbremse für Builds/Playwright (`systemd-run --scope … MemoryMax`).
## Review Focus
1. **Doppelklick oder zwei Mitarbeiter gleichzeitig auf „Genehmigen & gutschreiben“:** Erwartung: genau eine Gutschrift in Stripe, die zweite Aktion bekommt die Server-Meldung „Über diesen Antrag wurde bereits entschieden.“ (Task 6: Knopf während der Anfrage gesperrt, 409 sichtbar).
2. **Kundenwechsel im Angebotsformular, während der Kontext noch lädt:** Erwartung: Vorschau und „Angebot anlegen“ zeigen nie Daten des vorigen Kunden; Speichern erst, wenn der Kontext zum aktuellen Kunden da ist (Task 7, Playwright).
3. **Mitarbeiter ohne Admin-Label:** Erwartung: Gutschriften, Angebote, E-Mail-Speicher, Funktionen und Abo-Status funktionieren vollständig (Task 1/3 Server-Tests mit `jwt-staff`).
4. **Nachweis länger als 15 min geöffnet:** Erwartung: erneutes Öffnen der Antragsdetails liefert frische Links (Task 2: Links werden bei jedem `GET /:id` neu signiert).
5. **Handy (390 px):** Listen und Detail ohne waagerechtes Scrollen bedienbar (Task 6–8, Playwright).
---
## Portal
### Task 1: Admin-Router im Team-Eingang, Kundenfilter, Zähl-Route E-Mail-Speicher
**Files:**
- Modify: `server/routes/team/index.js` (nach `requireStaffJwt`: `router.use('/takeover-credits', takeoverAdminRouter)`, `router.use('/offers', offersAdminRouter)`, `router.use('/storage-tasks', storageTasksAdminRouter)`, `router.use('/funktionen', funktionenAdminRouter)`)
- Modify: `server/routes/takeoverCredits.js:119-153` (`?customerId=` filtert `requests`; `openCount` unverändert über alle)
- Modify: `server/routes/offers.js:23-39` (`?customerId=` filtert `offers`)
- Modify: `server/routes/storageTasks.js` (neu `GET /open-count` vor `/:id`-Routen: `{ open }` = offene Speicher-Aufgaben, **ohne** `reconcileOpenSwitches`)
- Test: `test/teamAdminRoutes.test.js`
**Interfaces:**
- Produces (HTTP, gleiche Form wie unter `/api/portal-admin`): `/api/team/takeover-credits[?status=all][&customerId=]`, `/calc`, `/open-count`, `/:id`, `/:id/decision`, `/:id/apply`; `/api/team/offers[?customerId=]`, `/context/:customerId`, `POST /`, `/:id/withdraw`, `/:id/notify`; `/api/team/storage-tasks`, `/open-count`, `/:id/done`; `/api/team/funktionen/:customerId`, `POST /:customerId/news`.
- [ ] **Step 1: Failing tests** (Aufbau wie `test/teamChat.test.js`; `seedFixtures`, Mitarbeiter `e_staff2`; Gutschrift-Antraege `takeoverCredits` mit Schema aus `schemaKeys('takeoverCredits')`; Speicher-Aufgaben als `contractEvents` `type: 'speicher', status: 'offen'` wie in `test/storageTasks.test.js`)
```js
test('alle vier Bereiche mit jwt-staff erreichbar (kein Admin-Label noetig)', async () => {
for (const p of ['/api/team/takeover-credits', '/api/team/offers', '/api/team/storage-tasks', '/api/team/funktionen/c1']) {
assert.equal((await rawGet(p, staff)).status, 200, p)
}
})
test('Kundenfilter: Gutschriften und Angebote nur des Kunden, openCount weiter ueber alle', async () => {
const credits = (await rawGet('/api/team/takeover-credits?status=all&customerId=c1', staff)).body
assert.ok(credits.requests.length > 0 && credits.requests.every((r) => r.customerId === 'c1'))
assert.equal(credits.openCount, /* offene Antraege aller Kunden */ 2)
const offers = (await rawGet('/api/team/offers?customerId=c2', staff)).body.offers
assert.ok(offers.every((o) => o.customerId === 'c2'))
})
test('E-Mail-Speicher open-count ohne Stripe-Abgleich', async () => {
const before = st.calls.length
assert.deepEqual((await rawGet('/api/team/storage-tasks/open-count', staff)).body, { open: 1 })
assert.equal(st.calls.length, before)
})
test('Mitarbeiter aus dem JWT: Rueckfrage traegt "Andrej <andrej@webklar.com>", Erledigt traegt die Mail, Funktionen-Notiz den Namen', async () => {
// POST /api/team/takeover-credits/<id>/decision { action: 'question', note: 'Bitte Rechnung schicken' } -> 200, Verlauf by 'Andrej <andrej@webklar.com>'
// POST /api/team/storage-tasks/<id>/done -> task.doneBy 'andrej@webklar.com'
// POST /api/team/funktionen/c1/news { projectId, action: 'activate', note: 'Pilot' } -> adminNote enthaelt 'Andrej: activate'
})
test('ohne JWT 401 fuer alle neuen Pfade', async () => {})
```
- [ ] **Step 2:** Run `node --test test/teamAdminRoutes.test.js` → FAIL (404 unter `/api/team/...`)
- [ ] **Step 3:** Implementieren
- [ ] **Step 4:** Run `npm test` → PASS (bestehende `storageTasks.test.js`, `funktionenRoutes.test.js` bleiben grün)
- [ ] **Step 5: Commit** — `git add server/routes/team/index.js server/routes/takeoverCredits.js server/routes/offers.js server/routes/storageTasks.js test/teamAdminRoutes.test.js` · Nachricht „Team-Eingang: Gutschriften, Angebote, E-Mail-Speicher und Funktionen fuer alle Mitarbeiter, Kundenfilter, Zaehlroute“
### Task 2: Signierte Links für Gutschrift-Nachweise
**Files:**
- Modify: `server/routes/takeoverCredits.js:176-182` (Antwort von `GET /:id`: jedes `request.files[i]` bekommt `url` = `${config.publicBaseUrl}/api/team/takeover-credits/<id>/files/<fileId>?exp=…&sig=…`)
- Modify: `server/routes/team/files.js` (`GET /takeover-credits/:id/files/:fileId` mit `sig` → wie `takeoverAdminRouter.get('/:id/files/:fileId')` über `getTakeoverRequest` + `readUpload`; ungültig → 403 `{ error: 'Link abgelaufen. Bitte den Antrag neu öffnen.' }`; ohne `sig` → `next()`)
- Test: `test/teamAdminRoutes.test.js` (ergänzen)
**Interfaces:**
- Consumes: `signFileLink`/`verifyFileLink` (`server/services/signedLinks.js`).
- Produces: `request.files[].url` in `GET /api/team/takeover-credits/:id` (und unter `/api/portal-admin`, dort unbenutzt).
- [ ] **Step 1: Failing tests**
```js
test('Nachweis: signierter Link ohne JWT 200 mit Inhalt, fremder Antrag/abgelaufen 403, ohne sig 401', async (t) => {
// Antrag mit gespeicherter Datei (wie test/takeoverCredits.test.js bzw. services/takeoverCredits.js storeUpload) seeden
// GET /api/team/takeover-credits/<id> (staff) -> request.files[0].url passt auf /\/api\/team\/takeover-credits\/<id>\/files\/<fid>\?exp=\d+&sig=[0-9a-f]{64}$/
// fetch(url ohne Origin) -> 200, Content-Type application/pdf
})
```
- [ ] **Step 2:** Run → FAIL · **Step 3:** Implementieren · **Step 4:** `npm test` → PASS
- [ ] **Step 5: Commit** — „Gutschriften: signierte Links fuer Nachweise im Ticketsystem“
### Task 3: Abo-Status für alle Mitarbeiter
**Files:**
- Modify: `server/routes/team/customers.js` (`GET /:id/abo`)
- Test: `test/teamRoutes.test.js` (ergänzen)
**Interfaces:**
- Consumes: `getCurrentSubscription(customerId)` (`server/services/stripeBilling.js:178`, nur Appwrite), `PRODUCT_KEYS`/`ACTIVE_STATUSES` wie in `server/routes/admin/customers.js:49-61`.
- Produces: `GET /api/team/customers/:id/abo` → `{ productKey: 'wartung'|'hosting'|null, active: boolean, status: string|null, storageUnits: number, startedAt: string|null, serviceStart: string|null, currentPeriodEnd: string|null, cancelAt: string|null }` (`productKey`/`active` exakt wie die Admin-Route, damit `useCustomerAbo` umsteigen kann).
- [ ] **Step 1: Failing test**
```js
test('abo: Mitarbeiter ohne Admin-Label sieht den Abo-Status', async () => {
aw.seed('subscriptions', { $id: 'sub_t', customerId: 'c3', status: 'active', productKey: 'wartung', storageUnits: 1, startedAt: '2026-09-01T10:00:00.000Z', serviceStart: '2026-09-15T00:00:00.000Z', currentPeriodEnd: '2026-11-01T00:00:00.000Z' })
const res = await rawGet('/api/team/customers/c3/abo', auth)
assert.deepEqual(res.body, { productKey: 'wartung', active: true, status: 'active', storageUnits: 1, startedAt: '2026-09-01T10:00:00.000Z', serviceStart: '2026-09-15T00:00:00.000Z', currentPeriodEnd: '2026-11-01T00:00:00.000Z', cancelAt: null })
assert.deepEqual((await rawGet('/api/team/customers/c4/abo', auth)).body, { productKey: null, active: false, status: null, storageUnits: 0, startedAt: null, serviceStart: null, currentPeriodEnd: null, cancelAt: null })
})
```
- [ ] **Step 2:** Run → FAIL · **Step 3:** Implementieren · **Step 4:** `npm test` → PASS
- [ ] **Step 5: Commit** — „Abo-Status eines Kunden fuer alle Mitarbeiter im Team-Eingang“
---
## WOMS
### Task 4: Team-API, Zähler und Format-Hilfen
**Files:**
- Modify: `src/lib/teamApi.js`, `src/lib/navLinks.js` (+ Test), `src/components/Navbar.jsx`, `src/hooks/useCustomerAbo.js` (auf `getCustomerAboStatus` umstellen, Rückgabe unverändert)
- Create: `src/hooks/useSidebarCounts.js` (ersetzt `useChatUnread.js`, Datei entfernen), `src/lib/money.js`, `src/lib/creditFormat.js`, `src/lib/offerFormat.js`, `src/lib/storageFormat.js`
- Test: `tests/unit/navLinks.test.mjs` (anpassen), `tests/unit/creditFormat.test.mjs`, `tests/unit/offerFormat.test.mjs`, `tests/unit/storageFormat.test.mjs`
**Interfaces:**
- Produces in `teamApi.js`: `listCredits({ status, customerId })`, `getCreditOpenCount()`, `getCredit(id)`, `calcCredit(body)`, `decideCredit(id, body)`, `applyCredit(id)`; `listOffers({ customerId })`, `getOfferContext(customerId)`, `createOffer(body)`, `withdrawOffer(id)`, `notifyOffer(id)`; `listStorageTasks()`, `getStorageOpenCount()`, `markStorageDone(id)`; `getFunktionen(customerId)`, `setNewsFeature(customerId, { projectId, action, note })`; `getCustomerAboStatus(customerId)`.
- Produces: `useSidebarCounts() -> { chat, credits, storage }` (60 s, sichtbarer Tab, Fehler behalten Wert); `buildNavLinks({ isAdmin, pulseLevel, counts })` mit Kunden-Kindern `chat` (count `counts.chat`), `gutschriften` (`/customers/gutschriften`, `counts.credits`), `angebote` (`/customers/angebote`, kein count), `speicher` (`/customers/email-speicher`, Label „E-Mail-Speicher“, `counts.storage`).
- Produces in `money.js`: `formatCents(cents) -> '12,34 €'`, `parseEuroToCents(text) -> number|null`.
- Produces in `creditFormat.js` (aus `public/admin.js:535-906`): `CREDIT_STATUS` (Pill-Text und Ton je Status, admin.js `creditStatusPill`), `historyLabel(type)`, `calcLine(calc)` (`Rechnungszeitraum … → Vorschlag …`), `decisionError(action, note) -> string|null` (Notiz ≥ 5 Zeichen bei question/reject, Texte wie admin.js:845-857), `decisionConfirm(action, amountCents, customerName) -> string` (admin.js:858-863), `decisionMessage(action, result) -> string` (admin.js:873-881), `applyMessage(result)`, `BILLING_MONTHS = [1, 3, 6, 12, 24, 36]`.
- Produces in `offerFormat.js` (aus `public/admin.js:984-1317`): `addMonthsYmd`, `offerDiscountEstimate`, `discountLine(offer)` (admin.js:1034-1057), `offerPreviewText`, `evaluateOfferForm(context, form) -> { errors: string[], running: boolean, saveLabel: string, previewLines: string[] }` (Regeln admin.js `renderOfferPreview` 1155-1205), `offerConfirm(context, form, preview)` (admin.js:1229), `createdMessage(result)`.
- Produces in `storageFormat.js` (aus `public/admin.js:908-982`): `storageLabel(units)`, `summaryText({ allocatedGb, packageGb, warnGb, packageName })`, `taskText(task)`, `orderedText(task)`, `failedLine(event)`.
- [ ] **Step 1: Failing tests** (Erwartungswerte wortgleich aus admin.js; je Modul mindestens: alle Status-/Verlaufs-Texte, eine vollständige Rechenzeile, beide Validierungsfehler, alle drei Bestätigungsfragen, Erfolgsmeldung mit gebucht/nicht gebucht und Mail gesendet/fehlgeschlagen; Angebots-Vorschau mit/ohne laufendem Abo, offenes Angebot blockiert, Preis ≥ regulär blockiert, Rabattende geschätzt und bekannt; Speicher-Texte einrichten/periodenende/teilweise/gescheitert; `formatCents(123456) === '1.234,56 €'`, `parseEuroToCents('12,5') === 1250`, `parseEuroToCents('') === null`)
```js
test('buildNavLinks: vier Unterpunkte unter Kunden mit Zahlen', () => {
const kunden = buildNavLinks({ isAdmin: false, pulseLevel: 'ok', counts: { chat: 3, credits: 1, storage: 2 } }).find((l) => l.key === 'customers')
assert.deepEqual(kunden.children.map((c) => [c.key, c.label, c.href, c.count]), [
['chat', 'Chat', '/customers/chat', 3],
['gutschriften', 'Gutschriften', '/customers/gutschriften', 1],
['angebote', 'Abo-Angebote', '/customers/angebote', undefined],
['speicher', 'E-Mail-Speicher', '/customers/email-speicher', 2],
])
})
```
- [ ] **Step 2:** Run `node --test tests/unit/*.test.mjs` → FAIL
- [ ] **Step 3:** Implementieren (Icons in `Navbar.jsx`: Gutschriften `IconReceiptRefund`, Angebote `IconDiscount`, E-Mail-Speicher `IconMailCog` – vorher in `node_modules/@tabler/icons-react/dist/esm/icons/` prüfen, sonst nächstliegendes vorhandenes Icon)
- [ ] **Step 4:** Run `npm test` → PASS
- [ ] **Step 5: Commit** — „Team-API, Sidebar-Zaehler und Format-Hilfen fuer Gutschriften, Angebote und E-Mail-Speicher“
### Task 5: Abo-Status, Funktionen-Reiter und E-Mail-Speicher
**Files:**
- Create: `src/components/customer/AboStatusCard.jsx`, `src/components/customer/CustomerFunktionen.jsx`, `src/pages/StorageTasksPage.jsx`
- Modify: `src/App.jsx` (Route `/customers/email-speicher`), `src/lib/customerTabs.js` (+ Test: `abo`, `gutschriften`, `funktionen` an ihren Plätzen; `abo` rendert vorerst nur `AboStatusCard`, Angebote folgen in Task 7), `src/pages/CustomerDetailPage.jsx`
**Interfaces:**
- Consumes: Task 4.
- Produces: `AboStatusCard({ customerId })`: Zeilen „Abo“ (Wartungs-Abo/Hosting-Abo/kein Abo, Status-Pille), „Leistungsbeginn“, „Nächste Abbuchung“ (nur aktiv), „Gekündigt zum“ (nur `cancelAt`), „Zusatzspeicher“ (`storageLabel`).
- Produces: `CustomerFunktionen({ customerId })` wie admin.js:188-296 (Tabelle Projekt | News | Ticket | Aktionen, Pillen Aus/Bestellt/Einrichtung/Aktiv/Abgeschaltet, Notiz gekürzt auf 60 Zeichen mit Titel, „Ticket fehlt“, Knöpfe „Aktivieren (ohne Zahlung)“/„Abschalten“ mit Inline-Bestätigung samt Notizfeld (max. 120) und „Ja“/„Abbrechen“, Status „Gespeichert.“).
- Produces: `StorageTasksPage` wie admin.js:908-982 (Zusammenfassung mit Warnfarbe ab `warnGb`, Tabelle Fällig | Kunde | Aufgabe in konsoleH | Bestellt | „Erledigt“, Rückfrage „Stripe-Menge von Hand korrigiert?“ nur bei `partial`, Liste „Gescheiterte Änderungen (letzte 30 Tage)“, Erklärtext aus `public/admin.html:299`); Kundenname verlinkt auf `/customers/<id>`.
- [ ] **Step 1:** `tests/unit/customerTabs.test.mjs` auf die volle Reiterfolge anpassen → Run → FAIL
- [ ] **Step 2: Playwright-Checks schreiben** (Harness wie Etappe 2): E-Mail-Speicher-Zusammenfassung mit Warnfarbe ab Schwelle, Aufgabentexte einrichten/periodenende, „Erledigt“ ruft `POST …/done` (bei `partial` erst nach Rückfrage), gescheiterte Liste; Funktionen-Reiter: „Aktivieren (ohne Zahlung)“ → Inline-Bestätigung mit Notiz → Body `{ projectId, action: 'activate', note }` → Zeile aktualisiert, „Gespeichert.“; Abo-Status-Karte für aktives Abo und „kein Abo“ → Run → FAIL
- [ ] **Step 3:** Implementieren; Run Harness → ok; `npm test` → PASS
- [ ] **Step 4: Commit** — „Kundenseite: Abo-Status und Funktionen; Seite E-Mail-Speicher“
### Task 6: Gutschriften (Sammelliste, Detail, Kunden-Reiter)
**Files:**
- Create: `src/components/credits/CreditList.jsx`, `src/components/credits/CreditDetail.jsx`, `src/pages/CreditsPage.jsx`
- Modify: `src/App.jsx` (Route `/customers/gutschriften`), `src/pages/CustomerDetailPage.jsx` (Reiter `gutschriften` mit `customerId`-Filter, `&antrag=` öffnet Detail)
**Interfaces:**
- Consumes: Task 4 (`listCredits`, `getCredit`, `calcCredit`, `decideCredit`, `applyCredit`, `creditFormat`), `KundenansichtButton` (statt „Als Kunde ansehen“).
- Produces: `CreditList({ customerId?, selectedId, onSelect })` (Filter „Offen“/„Alle“, Spalten und Leertexte wie admin.js; Knopf „Prüfen“/„Ansehen“); `CreditDetail({ id, onClose, onChanged })` mit allen Abschnitten aus admin.js:684-796 (Nachweise als Links auf `file.url`, neuer Tab), Formular mit 4 Prüfhaken, Rechner (`calcCredit` 300 ms entprellt, Gutschrift-Betrag folgt dem Vorschlag bis zur ersten eigenen Eingabe), Notiz (max. 4000), Knöpfe „Genehmigen & gutschreiben“ (erst bei 4 Haken und Betrag > 0) / „Rückfrage senden“ / „Ablehnen“ mit `decisionError` und `window.confirm(decisionConfirm(…))`; während der Anfrage alle drei Knöpfe gesperrt; Ergebnis über `decisionMessage`; entschiedene Anträge zeigen die Zusammenfassung und ggf. „Jetzt in Stripe gutschreiben“.
- Produces: `CreditsPage` unter `/customers/gutschriften`: Liste links/oben, Detail daneben/darunter; `?antrag=<id>` öffnet direkt, Schließen entfernt den Parameter.
- [ ] **Step 1: Playwright-Checks schreiben** (Harness wie Etappe 2 im Scratchpad, Stripe-/Mail-Ergebnisse per gemockter Team-API): Liste „Offen“ zeigt Pillen „Zu prüfen“/„Rückfrage beim Kunden“; Detail per `?antrag=`; Genehmigen gesperrt bis 4 Haken; Rechner-Zeile nach Eingabe; Rückfrage ohne Notiz → Fehlertext ohne Anfrage; Genehmigen → confirm-Text exakt, Body `{ action: 'approve', amountCents, checks, … }`, Meldung „Genehmigt. Das Guthaben ist in Stripe gebucht. Die E-Mail an den Kunden ist raus.“; Doppelklick → genau eine POST-Anfrage; 409 → Servertext sichtbar, Knöpfe wieder frei; Nachweis-Link zeigt auf `file.url`; Kunden-Reiter zeigt nur Anträge dieses Kunden; 390 px ohne waagerechtes Scrollen.
- [ ] **Step 2:** Run Harness → FAIL (Seiten fehlen)
- [ ] **Step 3:** Implementieren
- [ ] **Step 4:** Harness → alle ok; `npm test` → PASS
- [ ] **Step 5: Commit** — „Gutschriften im Ticketsystem: Sammelliste, Pruefung und Entscheidung, Kunden-Reiter“
### Task 7: Abo & Angebote (Sammelliste, Formular, Kunden-Reiter)
**Files:**
- Create: `src/components/offers/OfferList.jsx`, `src/components/offers/OfferForm.jsx`, `src/pages/OffersPage.jsx`
- Modify: `src/App.jsx` (Route `/customers/angebote`), `src/pages/CustomerDetailPage.jsx` (Reiter `abo`: `AboStatusCard`, darunter `OfferList customerId` und „Neues Angebot“ mit fest gewähltem Kunden)
**Interfaces:**
- Consumes: Task 4 (`listOffers`, `getOfferContext`, `createOffer`, `withdrawOffer`, `notifyOffer`, `offerFormat`, `customerPickLabel`/`findCustomerByLabel` aus `chatFormat.js`), `useCustomers`.
- Produces: `OfferList({ customerId?, reloadKey })` (Filter „Offen & aktiv“/„Alle“, Spalten, Statuszelle mit Pillen, Stripe-Links, Mailstatus, Knöpfe „E-Mail senden“/„Erneut senden“ und „Zurückziehen“ mit Bestätigungen/Meldungen wie admin.js); `OfferForm({ fixedCustomerId?, onCreated, onCancel })`: Felder und Vorgaben wie admin.js:1256-1311; Kontext genau einmal je aufgelöster Kunden-ID laden, veraltete Antworten verwerfen, beim Wechsel sofort leeren; Vorschau/Speichern über `evaluateOfferForm`, Speichern erst mit Kontext des aktuellen Kunden; `window.confirm(offerConfirm(…))`; nach dem Anlegen Meldung `createdMessage`.
- Produces: `OffersPage` unter `/customers/angebote` mit Knopf „Neues Angebot“.
- [ ] **Step 1: Playwright-Checks schreiben**: Liste „Offen & aktiv“ filtert, Rabattzeile; „Neues Angebot“: Kunde per eindeutiger Beschriftung, Kontext genau einmal geladen; Kundenwechsel mit verzögertem Kontext von A → Vorschau zeigt nie A, „Angebot anlegen“ gesperrt bis Kontext B da; offenes Angebot blockiert mit rotem Hinweis; laufendes Abo → Knopftext „Angebot anlegen und aufs laufende Abo anwenden“; confirm-Text exakt; Body `{ customerId, productKey, monthlyPriceCents, months, validUntil, note, internalNote, notifyCustomer }`; Zurückziehen/Erneut senden mit Bestätigung; Kunden-Reiter `abo` zeigt Abo-Status und nur eigene Angebote; 390 px ohne waagerechtes Scrollen.
- [ ] **Step 2:** Run → FAIL · **Step 3:** Implementieren · **Step 4:** Harness → ok; `npm test` → PASS
- [ ] **Step 5: Commit** — „Abo-Angebote im Ticketsystem: Sammelliste, neues Angebot, Kunden-Reiter Abo & Angebote“
---
## Abschluss
### Task 8: Gesamtprüfung und Übergabe
- [ ] Portal `npm test` grün; neue Tests unter Node 20 grün. WOMS `npm test` grün; Vite-Build erfolgreich; alle Harness-Checks (Etappe 2 und 3) grün.
- [ ] Abschlussreview beider Branches (frischer Reviewer, sonnet).
- [ ] Übergabe mit Livegang: 1. Portal `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-admin-umzug push origin feature/admin-umzug:main`; 2. Live-Check (ich): neue Team-Pfade ohne JWT 401, Admin-Panel unverändert; 3. WOMS `git -C /home/webklar/apps/ticket.webklar.com/dev-admin-umzug push origin feature/admin-umzug:test`; 4. Live-Check: Bundle enthält „Genehmigen & gutschreiben“, „Neues Angebot“, „Gescheiterte Änderungen“; 5. Nutzer-Test mit einem echten offenen Antrag/Angebot erst nach Rücksprache (echte Stripe-Buchungen!).