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

23 KiB
Raw Blame History

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)

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“

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

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

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)

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!).