Plan: Admin-Umzug Etappe 3 (Gutschriften, Abo & Angebote, E-Mail-Speicher, Funktionen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 02:28:18 +00:00
1 parent a6ac4510cd
commit a06c3bc8ef
1 file changed
+225
@@ -0,0 +1,225 @@
# 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!).