# WOMS „Weißes Design“ P2: Tickets – Umsetzungsplan > **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:** Ein Ticket öffnet als eigene Seite (`/tickets/`). Die Ticketliste ist eine Tabelle mit Reitern, Zählern und Spalte „Zeit“, ihr Zustand steht in der URL. Arbeitsblätter sind flache Zeilen, die vier Ticket-Dialoge laufen auf ``. Danach sind `TicketCard`, die Hover-Dropdowns und die Altklassen `.tcard*` gelöscht. **Architecture:** - Logik zuerst und getestet, als reine Module in `src/lib` ohne Appwrite-Import: `ticketRoutes`, `ticketListUrl`, `rowClick`, `ticketView`, `worksheetFlow`, `worksheetView`, `ticketTimes`, `workorderLookup`, `requestGuard`. - Drei neue dünne Hooks (`useWorkorder`, `useTicketCounts`, `useTicketMinutes`). `useWorkorders` bekommt als einzige Hook-Änderung den Anfrage-Zähler. - `TicketPage` (Route `/tickets/:ref/:tab?`) ruft `useWorksheets`, `useCustomerAbo` und `useEmployees` je einmal auf und verdrahtet die Dialoge. `TicketsPage` hält ihren Zustand in der URL. - CSS: ein Block „Tickets (P2)“ in `global.css`, danach Löschen der Altklassen. **Tech Stack:** wie P1. Neue Abfragen sind reine Lesezugriffe (Appwrite 1.8.1: `Query.select`, `total` bei `Query.limit(1)`). **Spec:** `docs/superpowers/specs/2026-10-04-weisses-design-design.md`, Abschnitte „Ticket als eigene Seite“ (komplett), „Bewusste Änderungen am Verhalten“, „Was gleich bleibt“, „Texte“ und Zeile P2. Dateidetails im Anhang `…-umbaukarte.md`, Abschnitt 5 (P2) sowie 3, 4 und 7. Prototyp: `quelle/styles.css` (`.ws*`, `.facts`, `.urgent-toggle`) und `quelle/app.js` (`pageTicket`, `sheetRow`, Dialoge). ## Global Constraints Jeder Run-Block beginnt mit `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh &&` (setzt `WT`, `W`, `TMPDIR` und wechselt nach `$WT`). Tests und Build laufen nur über `"$W/check.sh"` (auch `--nur-tests`, `--nur-build`), Ansichten nur über `"$W/build.sh"` und `"$W/shots.sh"`. Einzelne Testdateien laufen mit `node --test tests/unit/.test.mjs` (eigener Exit-Code, ohne Pipe). `rg` statt `grep`, nie `rg -P`. Es gelten die Regeln der Übersicht. Zusätzlich für P2: - Die getesteten Module (alle neuen Dateien in `src/lib` außer dem dünnen `elapsed.js`) importieren nie `appwrite.js`, damit sie unter Node testbar bleiben. `date-fns` ist erlaubt (der esbuild-Lader bündelt es). `elapsed.js` bleibt trotzdem ein dünner Wrapper ohne eigenen Test, Hooks bleiben dünn. - Neu sind nur Lesezugriffe (Zähler, Spalte „Zeit“, Lookup). Kein neues Schreiben, keine neuen Attribute, Indizes oder Collections. - Freitext-Datumsfelder (`startDate`, `endDate`, `deadline`, `startTime`, `endTime`) werden nur über `lib/ticketView.js` angezeigt (`parseGermanDate`/`formatTime`). Nie `new Date()` oder `format()` auf den gespeicherten Text. - Links auf Tickets entstehen nur mit `ticketPath`. Zähler und Zeitspalte melden Fehler mit `console.warn`, nie mit `console.error`. - Nicht angefasst werden `TicketProjects`, `AssignedProjectCard`, `PreviewBlock` und die Link-Buttons (P5). Bis P5 enthalten sie noch Icons. - Dialoge: Texte wörtlich, keine neuen „Abbrechen“-Knöpfe, Fehler als `.msg.err` mit `role="alert"` oben im Dialogkörper, Ladetexte „Speichert …“ und „Wird angelegt …“. Jeder Knopf in einem `` außer dem Absenden-Knopf trägt `type="button"` (`Button` setzt keinen Standard-Typ). - Abstände zwischen Karten (`section`) kommen aus P1 (`.page section + section`). P2 baut dafür keine eigene Regel. - Klickbare Tabellenzeilen prüfen Klicks mit `isRowActivation(event)` aus `src/lib/rowClick.js` (entsteht in Task 2). - Jede Testdatei beginnt wie `tests/unit/returnPath.test.mjs` mit den Imports `node:test`, `node:assert/strict` und `./load.mjs`, die Snippets zeigen sie nicht. - Testzahl: Stand nach P1 als `N1` notieren. P2 bringt 41 neue Tests (37 für die Logik, 4 für `rowClick`), danach gilt `N1 + 41`. ## Review Focus Aus der Übersicht gehören P2 die Punkte 1, 3, 4 und 5. Jede Zeile hat eine Prüfung in Task 7 (Ansichten) und Task 13 (Gate). - **Punkt 1, Altdaten:** Ansichten `tickets-altdaten` und `ticket-altdaten-` (`datumKurz` `2.5.2026`, `datumUngueltig` `31.02.2026`, `datumIso`, `ohneWoid`, `statusLeer`, `statusUnbekannt`, `ohneKunde`, `langeTexte`). - Erwartet: Seite rendert ohne `console-error`, unlesbare Datumswerte erscheinen als Rohtext, `2.5.2026` als `02.05.2026`. - Ohne woid zeigt der Reiter Arbeitsblätter den Hinweis der Spec, und der Knopf „Arbeitsblatt hinzufügen“ ist gesperrt. Leerer oder unbekannter Status ergibt eine graue Pille, die Auswahl enthält den Wert. Ohne Kunde steht „Unbekannt“. - **Punkt 3, Teilausfall:** Ansichten `fehler-tickets` und `fehler-ticket`. - Zähler und Spalte „Zeit“ zeigen „–“, „Zuständig“ fällt auf `assignedName` zurück, Zeilen bleiben anklickbar. - Auf der Ticketseite zeigt der Reiter Arbeitsblätter den Fehlertext, die Zuständig-Auswahl behält den aktuellen Wert. - **Punkt 4, nur Tastatur:** Ansichten `tasten-tickets` (Tab erreicht den Link der ersten Zelle, Enter öffnet), `tasten-dialog` (Esc, Vorschlagsliste schließt zuerst, Fokus kehrt zurück), `tasten-busy` (× gesperrt, Esc wirkungslos beim Speichern). - **Punkt 5, viele Einträge:** Ansichten `ticket-viel` (300 Blätter) und `tickets-suche` (250 Treffer). Spalte „Zeit“ nur für die ersten 200 Zeilen mit Hinweis im Fuß, das große Ticket zeigt `77 h 30 min`, Zeile 201 zeigt „–“, die Seite bleibt bedienbar. - **Zusätzlich, Zähler = Liste:** `countFilters` und `listFilters` liefern je Reiter denselben Statusfilter (Test in Task 2). Die Liste fragt nicht in einer Schleife ab. **Erreichbarkeit:** Jedes heutige Bedienelement bleibt erreichbar, die Schritte in Task 7 prüfen die Selektoren. | Heute | Danach | |---|---| | Karte aufklappen, Chevron | Zeilenlink und Zeilenklick öffnen `/tickets/` | | Status-Dropdown, Dringend-Schalter, Zuständig-Dropdown | Ticketseite: Auswahl „Status“, `UrgentToggle`, Fakt „Zuständig“ | | Reiter Details / Arbeitsblätter, „Arbeitsblatt hinzufügen“ | Route `/tickets/:ref/:tab?`, Knopf im Kopf (alle Ticketarten, ohne Ticketnummer gesperrt) | | „Zeit nachtragen“, „Zeiten bearbeiten“, „Ich habe unterstützt“, „Meine Unterstützung bearbeiten“, Abo-Umschalter | Aktionen und Umschalter der `.ws`-Zeile | | Filter-Knopf, Mehrfach-Status, „Standard“, „Alle“ | Reiter Aktiv … Alle | | Suchfeld mit „Anwenden“ | Suchfeld, 300 ms Pause oder Enter | | Art-Auswahl, „Nur dringende“, „Mehr laden“, „Neues Ticket“ | unverändert vorhanden | --- ### Task 1: Ausgangslage prüfen (Paketstart) **Files:** keine Änderung. - [ ] **Step 1: Tag, Rebase, Stand** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && git status --short && git fetch origin && git merge-base --is-ancestor design-p1 HEAD && git rebase origin/test && "$W/check.sh"` Expected: `git status` leer, `design-p1` ist Vorfahr von `HEAD` (sonst stoppen und melden), Rebase ohne Konflikt, `# fail 0` und Exit 0. Die Zahl `# pass` ist `N1`. - [ ] **Step 2: Parallele Arbeit an TP2** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && git log --oneline origin/test..feature/terminplaner-tp2 -- src/App.jsx src/hooks/useWorkorders.js src/lib/ticketStatus.js src/styles/global.css src/pages/TicketsPage.jsx src/pages/TicketPage.jsx src/pages/PlanboardPage.jsx src/pages/AssignProjectPage.jsx src/pages/ServicesPage.jsx src/components/customer/CustomerFunktionen.jsx src/components/UrgentToggle.jsx src/components/WorksheetSummary.jsx src/components/WorksheetList.jsx src/components/ProjectContextPanel.jsx src/components/CreateTicketModal.jsx src/components/CreateWorksheetModal.jsx src/components/WorksheetTimesModal.jsx src/components/OwnSupportModal.jsx src/components/SupportersEditor.jsx src/components/TicketCard.jsx src/components/StatusDropdown.jsx src/components/EditorDropdown.jsx` Expected: keine Ausgabe. Gibt es Treffer, stoppt der Agent und fragt den Eigentümer. Er empfiehlt dabei, TP2 zuerst nach `test` zu bringen. - [ ] **Step 3: P1-Schnittstellen vorhanden** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && ls src/components/ui/Dialog.jsx src/components/ui/Stat.jsx && rg -n "export function statusTone" src/lib/ticketStatus.js && rg -n "^\.dialog|^\.ws|^\.facts|^\.stats" src/styles/global.css` Expected: Dateien und `statusTone` vorhanden, `.dialog` und `.facts` definiert, `.ws` noch nicht. Fehlt eine Schnittstelle der Übersicht, wird gestoppt und gemeldet, nicht nachgebaut. - [ ] **Step 4: Stellen mit altem Ticketlink** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "tickets\?woid" src` Expected: die sechs Linkstellen (`PlanboardPage`, `AssignProjectPage` 2×, `ServicesPage`, `ProjectContextPanel`, `CustomerFunktionen`) plus Kommentare in `TicketsPage.jsx` und `useWorkorders.js`. ### Task 2: Routen, Listen-URL und Zeilenklick (`ticketRoutes`, `ticketListUrl`, `rowClick`) **Files:** - Create: `src/lib/ticketRoutes.js`, `src/lib/ticketListUrl.js`, `src/lib/rowClick.js` - Modify: `src/lib/ticketStatus.js` (`TYPE_FILTERS`: Eintrag `all` bekommt das Label „Aufträge und Akquise“, Id unverändert) - Test: `tests/unit/ticketRoutes.test.mjs`, `tests/unit/ticketListUrl.test.mjs`, `tests/unit/rowClick.test.mjs` **Interfaces:** - Consumes: `STATUS_GROUPS`, `DEFAULT_STATUS_GROUPS`, `statusValuesFor`, `TYPE_FILTERS` (`lib/ticketStatus.js`). - Produces: - `ticketPath(ref, tab?)`, `ticketRef(ticket)`, `resolveTicketTab(tab) → 'details' | 'arbeitsblaetter' | null`, `listBackPath(locationState)`, `TICKET_TABS` - `LIST_TABS` mit `[{ id, label, groups: string[] | null }]`, `LIST_DEFAULTS = { tab: 'aktiv', typeId: 'orders', urgentOnly: false, search: '', limit: 10 }` - `parseListParams(input)` (`URLSearchParams` oder String), `serializeListParams(state) → URLSearchParams`, `withFilter(state, patch)`, `nextLimit(state)`, `clampLimit(value)` - `listFilters(state) → { status, type, excludeType, priority, search, limit }`, `countFilters(state, tabId) → { status, type, excludeType, priority }` - `woidRedirectTarget(input) → string | null` - `isRowActivation(event) → boolean` (`src/lib/rowClick.js`): der gemeinsame Helfer für klickbare Tabellenzeilen. Spätere Pakete benutzen ihn und bauen die Regel nicht neu. Entscheidung: URL-Parameter heißen `status` (Reiter-Id), `art` (Id aus `TYPE_FILTERS`), `dringend` (`1`), `q`, `limit`, geschrieben in dieser Reihenfolge. `ticketRef` und `listBackPath` sind Zusatz-Exporte, weil dieselbe Regel an mehreren Stellen gebraucht wird. - [ ] **Step 1: Failing Tests schreiben** `ticketRoutes.test.mjs`: ```js const { ticketPath, ticketRef, resolveTicketTab, listBackPath } = await loadModule('src/lib/ticketRoutes.js') test('ticketPath: woid, Zahl, $id, Reiter', () => { assert.equal(ticketPath('100131'), '/tickets/100131') assert.equal(ticketPath(100131), '/tickets/100131') assert.equal(ticketPath('100131', 'details'), '/tickets/100131') assert.equal(ticketPath('100131', 'arbeitsblaetter'), '/tickets/100131/arbeitsblaetter') assert.equal(ticketPath('100131', 'unbekannt'), '/tickets/100131') assert.equal(ticketPath('66f1c2a9000b1c2d3e4f'), '/tickets/66f1c2a9000b1c2d3e4f') }) test('ticketPath: Sonderzeichen kodiert, leeres ref ergibt /tickets', () => { assert.equal(ticketPath('a/b c?d#e'), '/tickets/a%2Fb%20c%3Fd%23e') for (const empty of ['', ' ', null, undefined]) { assert.equal(ticketPath(empty), '/tickets') assert.equal(ticketPath(empty, 'arbeitsblaetter'), '/tickets') } }) test('ticketRef: woid vor $id, nie die Kurznummer', () => { assert.equal(ticketRef({ woid: '100131', $id: 'abc' }), '100131') assert.equal(ticketRef({ woid: '', $id: 'abcdefghij' }), 'abcdefghij') assert.equal(ticketRef(null), '') }) test('resolveTicketTab: unbekannt ergibt null', () => { assert.equal(resolveTicketTab(undefined), 'details') assert.equal(resolveTicketTab('arbeitsblaetter'), 'arbeitsblaetter') for (const bad of ['Details', 'worksheets', '', 'x']) assert.equal(resolveTicketTab(bad), null) }) test('listBackPath: nur Suchteile mit ?', () => { assert.equal(listBackPath({ listSearch: '?status=done&q=x' }), '/tickets?status=done&q=x') for (const bad of [undefined, null, {}, { listSearch: '' }, { listSearch: '?' }, { listSearch: 'evil' }, { listSearch: 42 }]) assert.equal(listBackPath(bad), '/tickets') }) ``` `ticketListUrl.test.mjs`: ```js const m = await loadModule('src/lib/ticketListUrl.js') const { LIST_TABS, LIST_DEFAULTS, parseListParams: parse, serializeListParams: ser, withFilter, nextLimit, listFilters, countFilters, woidRedirectTarget } = m const { TYPE_FILTERS } = await loadModule('src/lib/ticketStatus.js') test('Reiter und Art-Auswahl nach Spec', () => { assert.deepEqual(LIST_TABS.map((t) => [t.id, t.label]), [['aktiv', 'Aktiv'], ['open', 'Offen'], ['working', 'In Arbeit'], ['waiting', 'Wartet auf Kunde'], ['review', 'Prüfung'], ['done', 'Erledigt'], ['cancelled', 'Storniert'], ['alle', 'Alle']]) assert.deepEqual(TYPE_FILTERS.map((f) => f.label), ['Aufträge', 'Webpage', 'Migration', 'Projekt', 'Akquise', 'Aufträge und Akquise']) }) test('parse: Standard, gültige und ungültige Werte', () => { assert.deepEqual(parse(''), LIST_DEFAULTS) assert.deepEqual(parse('?status=done&art=all&dringend=1&q=%20Müller%20&limit=30'), { tab: 'done', typeId: 'all', urgentOnly: true, search: 'Müller', limit: 30 }) assert.deepEqual(parse('status=foo&art=bar&dringend=ja&limit=abc'), LIST_DEFAULTS) for (const [raw, want] of [['5', 10], ['0', 10], ['25', 25], ['500', 500], ['9999', 500], ['-3', 10], ['1e3', 10], ['', 10]]) assert.equal(parse('limit=' + raw).limit, want, raw) }) test('serialize: Standardwerte werden nicht geschrieben, Rundlauf', () => { assert.equal(ser(LIST_DEFAULTS).toString(), '') assert.equal(ser({ ...LIST_DEFAULTS, tab: 'alle', limit: 20 }).toString(), 'status=alle&limit=20') assert.equal(ser({ ...LIST_DEFAULTS, urgentOnly: true, search: 'a b' }).toString(), 'dringend=1&q=a+b') for (const s of [LIST_DEFAULTS, { tab: 'done', typeId: 'Akquise', urgentOnly: true, search: 'x y', limit: 40 }]) assert.deepEqual(parse(ser(s)), s) }) test('Filteränderung setzt limit zurück, Mehr laden +10 bis 500', () => { for (const patch of [{ tab: 'done' }, { typeId: 'all' }, { urgentOnly: true }, { search: 'x' }]) assert.equal(withFilter({ ...LIST_DEFAULTS, limit: 50 }, patch).limit, 10) assert.deepEqual([10, 495, 500].map((limit) => nextLimit({ limit })), [20, 500, 500]) }) test('listFilters und countFilters', () => { assert.deepEqual(listFilters(LIST_DEFAULTS), { status: ['Open', 'Occupied', 'Assigned', 'Added Info', 'In Kontakt', 'Awaiting', 'Halted', 'In Test'], type: [], excludeType: 'Akquise', priority: [], search: undefined, limit: 10 }) assert.deepEqual(listFilters({ ...LIST_DEFAULTS, tab: 'alle', typeId: 'Akquise', urgentOnly: true, search: 'abc' }), { status: [], type: ['Akquise'], excludeType: undefined, priority: [3, 4], search: 'abc', limit: 10 }) assert.deepEqual(listFilters({ ...LIST_DEFAULTS, tab: 'done' }).status, ['Closed', 'Zugesagt']) const s = { ...LIST_DEFAULTS, typeId: 'Webpage', urgentOnly: true, search: 'zzz', limit: 80 } assert.deepEqual(countFilters(s, 'review'), { status: ['In Test'], type: ['Webpage'], excludeType: undefined, priority: [3, 4] }) for (const t of LIST_TABS) assert.deepEqual(countFilters(s, t.id).status, listFilters({ ...s, tab: t.id }).status, t.id) }) test('woid-Umleitung hat Vorrang', () => { assert.equal(woidRedirectTarget('?woid=100131'), '/tickets/100131') assert.equal(woidRedirectTarget('?woid=%20100131%20&status=done&q=x'), '/tickets/100131') assert.equal(woidRedirectTarget('?woid=a%2Fb'), '/tickets/a%2Fb') for (const none of ['', '?woid=', '?status=done']) assert.equal(woidRedirectTarget(none), null) }) ``` `rowClick.test.mjs` (Ereignisse ohne DOM, `closest` liefert einen Treffer, wenn der Selektor das Tag nennt, `contains` der Zeile entscheidet, ob er in der Zeile liegt): ```js const { isRowActivation } = await loadModule('src/lib/rowClick.js') const TAGS = ['a', 'button', 'select', 'input', 'textarea', 'label'] function click({ target = 'td', outside = false, ...over } = {}) { const row = { contains: (el) => el?.inRow === true } const hit = TAGS.includes(target) ? { inRow: !outside } : null return { target: { closest: (sel) => (hit && sel.split(',').map((s) => s.trim()).includes(target) ? hit : null) }, currentTarget: row, button: 0, defaultPrevented: false, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, ...over, } } test('Linksklick auf Zelle oder Zeile aktiviert die Zeile', () => { assert.equal(isRowActivation(click()), true) assert.equal(isRowActivation(click({ target: 'tr' })), true) }) test('Klick auf a, button, select, input, textarea oder label aktiviert nicht', () => { for (const tag of TAGS) assert.equal(isRowActivation(click({ target: tag })), false, tag) }) test('Mittelklick, Modifikatortasten und behandelte Klicks aktivieren nicht', () => { assert.equal(isRowActivation(click({ button: 1 })), false) for (const key of ['metaKey', 'ctrlKey', 'shiftKey', 'altKey']) assert.equal(isRowActivation(click({ [key]: true })), false, key) assert.equal(isRowActivation(click({ defaultPrevented: true })), false) }) test('ein interaktiver Vorfahre außerhalb der Zeile zählt nicht', () => { assert.equal(isRowActivation(click({ target: 'label', outside: true })), true) }) ``` - [ ] **Step 2: Tests laufen lassen** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/ticketRoutes.test.mjs tests/unit/ticketListUrl.test.mjs tests/unit/rowClick.test.mjs` Expected: FAIL (Module fehlen). - [ ] **Step 3: Implementieren** - `ticketPath`: `ref` wird zu `String(ref ?? '').trim()`. Leer ergibt `'/tickets'`, sonst `'/tickets/' + encodeURIComponent(ref)` plus `'/arbeitsblaetter'` nur für genau diesen Reiter. `ticketRef` ist `String(ticket?.woid || ticket?.$id || '')`. `listBackPath` hängt `listSearch` nur an, wenn es ein String mit `?` und mindestens einem weiteren Zeichen ist. - `LIST_TABS`: erst `aktiv` mit `DEFAULT_STATUS_GROUPS`, dann je Gruppe aus `STATUS_GROUPS` (`id`, `label`, `groups: [id]`), zuletzt `alle` mit `groups: null`. - `parseListParams`: `limit` gilt nur bei `/^\d+$/`, sonst 10, danach auf 10 bis 500 begrenzt. Unbekannte Reiter- oder Art-Ids fallen auf den Standard, `dringend` gilt nur bei `1`, `q` wird getrimmt. - `listFilters`: `status` ist `[]` bei `groups === null`, sonst `statusValuesFor(groups)`. `type` und `excludeType` kommen aus `TYPE_FILTERS`, `priority` ist `[3, 4]` bei `urgentOnly`, `search` ist `state.search || undefined`. `countFilters` ist `listFilters({ ...state, tab: tabId })` ohne `search` und `limit`. - In `ticketStatus.js` das Label ändern: `{ id: 'all', label: 'Aufträge und Akquise' }`. - `isRowActivation(event)` ist falsch bei `event.defaultPrevented`, `event.button !== 0` oder gedrückter Meta-, Strg-, Umschalt- oder Alt-Taste. Sonst ist es falsch, wenn `event.target?.closest?.('a,button,select,input,textarea,label')` ein Element trifft, das in `event.currentTarget` (der Zeile) liegt (`contains`). Ein Treffer außerhalb der Zeile zählt nicht. - [ ] **Step 4: Tests grün** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/ticketRoutes.test.mjs tests/unit/ticketListUrl.test.mjs tests/unit/rowClick.test.mjs` Expected: PASS (15 Tests: 5 + 6 + 4). - [ ] **Step 5: Commit** ```bash git add src/lib/ticketRoutes.js src/lib/ticketListUrl.js src/lib/rowClick.js src/lib/ticketStatus.js tests/unit/ticketRoutes.test.mjs tests/unit/ticketListUrl.test.mjs tests/unit/rowClick.test.mjs git commit -m "Tickets: ticketPath, Listenzustand in der URL (Reiter, Art, Dringend, Suche, limit), woid-Umleitung, Zeilenklick-Helfer" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 3: Anzeige-Helfer (`ticketView`, `statusSelectOptions`) **Files:** - Create: `src/lib/ticketView.js` - Modify: `src/lib/ticketStatus.js` (neuer Export `statusSelectOptions`) - Test: `tests/unit/ticketView.test.mjs` (neu), `tests/unit/ticketStatus.test.mjs` (aus P1: Import-Zeile um `statusSelectOptions` erweitern) **Interfaces:** - Consumes: `parseGermanDate`, `formatTime` (`lib/planboard.js`), `isoToGermanDate`, `germanDateToIso` (`lib/ticketForm.js`), `TICKET_STATUSES`, `statusLabel`. - Produces: `displayDate(v)`, `displayTime(v)`, `appointmentText(ticket)`, `dueText(ticket)`, `ticketNumber(ticket)`, `createdDate(ticket) → Date | null`, `shortName(name)`, `assigneeLabel(ticket, employees) → { text, none }`, `assigneeOptions(ticket, employees) → [{ value, label }]`, `statusSelectOptions(current)`. - [ ] **Step 1: Failing Tests schreiben** `ticketView.test.mjs`: ```js const v = await loadModule('src/lib/ticketView.js') test('displayDate und displayTime: lesbar normalisiert, sonst Rohtext', () => { for (const [raw, want] of [['2.5.2026', '02.05.2026'], ['31.02.2026', '31.02.2026'], ['2026-10-07T09:00:00.000Z', '2026-10-07T09:00:00.000Z'], [' ', ''], [undefined, '']]) assert.equal(v.displayDate(raw), want, String(raw)) for (const [raw, want] of [['0930', '09:30'], ['9:30', '09:30'], ['2560', '2560'], ['ab 9', 'ab 9'], ['', '']]) assert.equal(v.displayTime(raw), want, raw) }) test('appointmentText: kein Rückfall, Rohtext bei Unlesbarem', () => { const t = (o) => v.appointmentText(o) assert.equal(t({ startDate: '2.5.2026', startTime: '0930', endTime: '1030' }), '02.05.2026, 09:30–10:30') assert.equal(t({ startDate: '02.05.2026', startTime: '0930' }), '02.05.2026, 09:30') assert.equal(t({ startDate: '02.05.2026', endTime: '1030' }), '02.05.2026') assert.equal(t({ startDate: '31.02.2026', startTime: '0900' }), '31.02.2026, 09:00') assert.equal(t({ startDate: '', startTime: '0900' }), '') assert.equal(t({}), '') assert.equal(v.dueText({ deadline: '5.10.2026' }), '05.10.2026') assert.equal(v.dueText({}), '') }) test('ticketNumber, createdDate, shortName', () => { assert.equal(v.ticketNumber({ woid: '100131', $id: 'x' }), '100131') assert.equal(v.ticketNumber({ $id: '66f1c2a9abcde' }), 'abcde') assert.equal(v.ticketNumber({}), '') assert.ok(v.createdDate({ $createdAt: '2026-10-01T10:00:00.000Z' }) instanceof Date) assert.ok(v.createdDate({ createdAt: '2026-10-01T10:00:00.000Z' }) instanceof Date) assert.equal(v.createdDate({ $createdAt: 'kaputt' }), null) assert.equal(v.createdDate({}), null) for (const [raw, want] of [['Kenso Grimm', 'Kenso G.'], [' Anna Maria Meier ', 'Anna M.'], ['Andrej', 'Andrej'], ['', '']]) assert.equal(v.shortName(raw), want) }) const emps = [{ userId: 'u1', displayName: 'Kenso Grimm' }, { userId: 'u2', displayName: 'Andrej' }, { displayName: 'Ohne Login' }] test('assigneeLabel: Niemand, Liste, assignedName, Unbekannt', () => { assert.deepEqual(v.assigneeLabel({ assignedTo: '' }, emps), { text: 'Niemand', none: true }) assert.deepEqual(v.assigneeLabel({ assignedTo: 'u1' }, emps), { text: 'Kenso G.', none: false }) assert.equal(v.assigneeLabel({ assignedTo: 'u9', assignedName: 'Justin Nowak' }, emps).text, 'Justin N.') assert.equal(v.assigneeLabel({ assignedTo: 'u9' }, emps).text, 'Unbekannt') assert.equal(v.assigneeLabel({ assignedTo: 'u1', assignedName: 'Kenso Grimm' }, []).text, 'Kenso G.') }) test('assigneeOptions: aktueller Wert bleibt immer wählbar', () => { assert.deepEqual(v.assigneeOptions({ assignedTo: 'u1' }, emps), [{ value: '', label: 'Nicht zugewiesen' }, { value: 'u1', label: 'Kenso Grimm' }, { value: 'u2', label: 'Andrej' }]) assert.deepEqual(v.assigneeOptions({ assignedTo: 'u9', assignedName: 'Justin Nowak' }, emps).at(-1), { value: 'u9', label: 'Justin Nowak' }) assert.deepEqual(v.assigneeOptions({ assignedTo: 'u9' }, emps).at(-1), { value: 'u9', label: 'Unbekannte Person' }) assert.deepEqual(v.assigneeOptions({ assignedTo: '' }, []), [{ value: '', label: 'Nicht zugewiesen' }]) assert.equal(v.assigneeOptions({ assignedTo: 'u1' }, []).at(-1).label, 'Unbekannte Person') }) ``` Anhang in `ticketStatus.test.mjs`: ```js test('statusSelectOptions: sechs Werte, aktueller Wert zusätzlich', () => { const six = ['Open', 'Occupied', 'Awaiting', 'In Test', 'Closed', 'Cancelled'] for (const cur of ['Open', 'Closed', '', undefined, null]) assert.deepEqual(statusSelectOptions(cur).map((o) => o.value), six, String(cur)) assert.deepEqual(statusSelectOptions('In Kontakt').at(-1), { value: 'In Kontakt', label: 'In Kontakt' }) assert.deepEqual(statusSelectOptions('Assigned').at(-1), { value: 'Assigned', label: 'Eingeplant' }) assert.deepEqual(statusSelectOptions('Completed').at(-1), { value: 'Completed', label: 'Completed' }) }) ``` - [ ] **Step 2: Tests laufen lassen** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/ticketView.test.mjs tests/unit/ticketStatus.test.mjs` Expected: FAIL. - [ ] **Step 3: Implementieren** - `displayDate(v) = isoToGermanDate(germanDateToIso(v)) || String(v ?? '').trim()`, `displayTime(v) = formatTime(v) || String(v ?? '').trim()`. - `appointmentText`: leer ohne `startDate`, sonst `displayDate(startDate)`, dann `, ` plus Beginn, dann `–` plus Ende. Das Ende steht nur mit Beginn. - `assigneeLabel`: Mitarbeiter über `userId`, dann `shortName(displayName)`, Rückfall `shortName(assignedName)`, sonst „Unbekannt“. `shortName` ist erstes Wort plus ` ` plus Initial des letzten Wortes plus `.`. - `assigneeOptions`: Mitarbeiter ohne `userId` entfallen, Label ist `displayName || email || userId`. Die Zusatzoption steht am Ende, wenn `assignedTo` gesetzt und nicht in der Liste ist, ihr Label ist `assignedName` oder „Unbekannte Person“. - `statusSelectOptions(cur)`: `TICKET_STATUSES` plus `{ value: cur, label: statusLabel(cur) }`, wenn `cur` gesetzt und nicht enthalten ist. Leer zählt als `Open`. Entscheidung: Lesbare Daten erscheinen normalisiert (`02.05.2026`), unlesbare als Rohtext. „Termin“ ist leer ohne `startDate`, „Fällig“ nutzt `endTime` nicht. - [ ] **Step 4: Tests grün** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/ticketView.test.mjs tests/unit/ticketStatus.test.mjs` Expected: PASS (6 neue Tests). - [ ] **Step 5: Commit** ```bash git add src/lib/ticketView.js src/lib/ticketStatus.js tests/unit/ticketView.test.mjs tests/unit/ticketStatus.test.mjs git commit -m "Tickets: Anzeige-Helfer fuer Termin, Faellig, Zustaendig und Status-Auswahl (Altdaten sicher)" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 4: Arbeitsblatt-Logik (`worksheetFlow`, `worksheetView`) **Files:** - Create: `src/lib/worksheetFlow.js`, `src/lib/worksheetView.js` - Test: `tests/unit/worksheetFlow.test.mjs`, `tests/unit/worksheetView.test.mjs` **Interfaces:** - Consumes: `worksheetMinutes`, `supportMinutes`, `parseSupporters`, `firstName` (`lib/support.js`), `needsTimeEntry`, `worksheetTone`, `isGitWorksheet`, `serviceTypeLabel` (`lib/ticketForm.js`), `statusLabel`, `stepLabel` (`lib/timeSteps.js`), `displayDate`, `displayTime` (Task 3). - Produces: - `acquisitionOutcome(ticket, newStatus) → 'customer' | 'lost' | null` - `createWorksheetFlow({ ticket, worksheetData, user, createWorksheet, updateTicket, updateCustomer, now = () => new Date() }) → Promise` - `sumWorksheetMinutes(worksheets, predicate?)`, `worksheetSums(worksheets) → { totalMinutes, aboMinutes, openCount }` - `showAboToggle(ws, aboHoursAllowed)` (die Prüfung `onTogglePlan` bleibt in der Komponente), `worksheetWhen(ws) → { date, time }`, `worksheetKindLabel(ws)`, `worksheetBadge(ws) → null | { tone, text }`, `statusAfterLabel(ws)`, `supportLines(ws) → string[]`, `timeLabel(ws)` `createWorksheetFlow` ist die Logik aus `TicketCard.handleCreateWorksheet` (Zeilen 55-76): 1. `createWorksheet(worksheetData, user)`. 2. Bei Erfolg und `newStatus !== ticket.status`: `updateTicket(ticket.$id, { status: newStatus })`. 3. Danach bei Erfolg, `ticket.type === 'Akquise'` und `ticket.customerId`: `updateCustomer(customerId, { customerStatus, updatedAt: now().toISOString() })`. `customerStatus` ist `'customer'` bei „Zugesagt“, `'lost'` bei „Abgesagt“, sonst kein Aufruf. Ein Fehler hier wird verschluckt. 4. Rückgabe ist das Ergebnis von `createWorksheet`. - [ ] **Step 1: Failing Tests schreiben** `worksheetFlow.test.mjs`: ```js const { createWorksheetFlow, acquisitionOutcome } = await loadModule('src/lib/worksheetFlow.js') async function run(ticket, newStatus, over = {}) { const calls = { ticket: [], customer: [] } const result = await createWorksheetFlow({ ticket, worksheetData: { newStatus }, user: { $id: 'u' }, createWorksheet: async () => over.result ?? { success: true }, updateTicket: async (id, data) => { calls.ticket.push([id, data]) }, updateCustomer: async (id, data) => { calls.customer.push([id, data]); if (over.customerFails) throw new Error('weg') }, now: () => new Date('2026-10-05T10:00:00Z'), }) return { result, calls } } const akq = { $id: 't1', type: 'Akquise', status: 'In Kontakt', customerId: 'c1' } test('Akquise + Zugesagt: Status zurückschreiben, Kunde fest', async () => { const { calls } = await run(akq, 'Zugesagt') assert.deepEqual(calls.ticket, [['t1', { status: 'Zugesagt' }]]) assert.deepEqual(calls.customer, [['c1', { customerStatus: 'customer', updatedAt: '2026-10-05T10:00:00.000Z' }]]) }) test('Akquise + Abgesagt: Kunde verloren', async () => { assert.deepEqual((await run(akq, 'Abgesagt')).calls.customer, [['c1', { customerStatus: 'lost', updatedAt: '2026-10-05T10:00:00.000Z' }]]) }) test('Akquise + In Kontakt: keine Änderung', async () => { assert.deepEqual((await run(akq, 'In Kontakt')).calls, { ticket: [], customer: [] }) assert.deepEqual((await run({ ...akq, status: 'Zugesagt' }, 'In Kontakt')).calls.customer, []) }) test('Nicht-Akquise ändert nie den Kunden, Status nur bei Abweichung', async () => { for (const status of ['Zugesagt', 'Abgesagt', 'Closed']) assert.deepEqual((await run({ ...akq, type: 'Webpage', status: 'Open' }, status)).calls.customer, []) assert.deepEqual((await run({ ...akq, type: 'Webpage', status: 'Closed' }, 'Closed')).calls.ticket, []) assert.deepEqual((await run({ ...akq, customerId: '' }, 'Zugesagt')).calls.customer, []) }) test('Fehlschlag und Kundenfehler', async () => { const failed = await run(akq, 'Zugesagt', { result: { success: false, error: 'x' } }) assert.deepEqual(failed, { result: { success: false, error: 'x' }, calls: { ticket: [], customer: [] } }) assert.equal((await run(akq, 'Zugesagt', { customerFails: true })).result.success, true) assert.equal(acquisitionOutcome({ type: 'Akquise', customerId: 'c' }, 'Zugesagt'), 'customer') }) ``` `worksheetView.test.mjs`: ```js const w = await loadModule('src/lib/worksheetView.js') const ws = (o = {}) => ({ totalTime: 60, isComment: false, countsToPlan: false, serviceType: 'Remote', ...o }) const sup = (n, name = 'Anna Meier') => JSON.stringify(Array.from({ length: n }, (_, i) => ({ employeeId: 'e' + i, name, task: 'DNS' }))) test('worksheetSums: Kommentare zählen nie, Abo nur countsToPlan, Zeiten offen', () => { const list = [ws(), ws({ supporters: sup(1), countsToPlan: true }), ws({ isComment: true, totalTime: 0 }), ws({ serviceType: 'GIT', isComment: true, totalTime: 0 }), ws({ serviceType: 'GIT', totalTime: 30, startTime: '0900' })] assert.deepEqual(w.worksheetSums(list), { totalMinutes: 180, aboMinutes: 90, openCount: 1 }) assert.deepEqual(w.worksheetSums([]), { totalMinutes: 0, aboMinutes: 0, openCount: 0 }) assert.deepEqual(w.worksheetSums([ws({ isComment: true, countsToPlan: true })]), { totalMinutes: 0, aboMinutes: 0, openCount: 0 }) }) test('showAboToggle: Regel bleibt exakt', () => { assert.equal(w.showAboToggle(ws(), true), true) assert.equal(w.showAboToggle(ws(), false), false) assert.equal(w.showAboToggle(ws({ countsToPlan: true }), false), true) assert.equal(w.showAboToggle(ws({ totalTime: 0 }), true), false) assert.equal(w.showAboToggle(ws({ isComment: true }), true), false) assert.equal(w.showAboToggle(ws({ totalTime: '45' }), true), true) }) test('worksheetWhen: Start-, sonst Endzweig, Altwerte als Rohtext', () => { assert.deepEqual(w.worksheetWhen({ startDate: '2.5.2026', startTime: '0930', endDate: '2.5.2026', endTime: '1100' }), { date: '02.05.2026', time: '09:30' }) assert.deepEqual(w.worksheetWhen({ startDate: '', startTime: '', endDate: '31.02.2026', endTime: '1405' }), { date: '31.02.2026', time: '14:05' }) assert.deepEqual(w.worksheetWhen({ startDate: '03.10.2026', startTime: '' }), { date: '03.10.2026', time: '' }) assert.deepEqual(w.worksheetWhen({}), { date: '', time: '' }) }) test('Art, Badge, Status danach, Zeit', () => { assert.equal(w.worksheetKindLabel(ws({ isComment: true })), 'Kommentar') assert.deepEqual(['Remote', 'On Site', 'Off Site', 'GIT'].map((s) => w.worksheetKindLabel(ws({ serviceType: s, isComment: s === 'GIT' }))), ['Fernwartung', 'Vor Ort', 'Fernwartung', 'GIT']) assert.deepEqual(w.worksheetBadge(ws({ serviceType: 'GIT', isComment: true, totalTime: 0 })), { tone: 'orange', text: 'Zeit offen' }) assert.deepEqual(w.worksheetBadge(ws({ serviceType: 'GIT', startTime: '0900' })), { tone: 'green', text: 'Zeit eingetragen' }) assert.equal(w.worksheetBadge(ws()), null) assert.equal(w.statusAfterLabel({ oldStatus: 'Open', newStatus: 'Occupied' }), 'Status danach: In Arbeit') assert.equal(w.statusAfterLabel({ oldStatus: 'Closed', newStatus: 'Open' }), 'Status danach: Offen') assert.equal(w.statusAfterLabel({ oldStatus: 'Open', newStatus: 'Completed' }), 'Status danach: Completed') for (const same of [{ oldStatus: 'Open', newStatus: 'Open' }, { oldStatus: 'Open', newStatus: '' }, {}]) assert.equal(w.statusAfterLabel(same), '') assert.deepEqual([ws({ isComment: true }), ws({ totalTime: 0 }), ws({ totalTime: 90 }), ws({ totalTime: '45' })].map(w.timeLabel), ['', '–', '1 h 30 min', '45 min']) }) test('supportLines: je Person eine Zeile, Wörter wie bisher', () => { assert.deepEqual(w.supportLines(ws({ supporters: sup(1) })), ['Unterstützung Anna · DNS · 30 min']) assert.deepEqual(w.supportLines(ws({ totalTime: 75, supporters: sup(1, '') })), ['Unterstützung · DNS · 45 min']) assert.deepEqual(w.supportLines(ws({ isComment: true, supporters: sup(1) })), []) assert.deepEqual(w.supportLines(ws({ supporters: '{kaputt' })), []) }) ``` - [ ] **Step 2: Tests laufen lassen** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/worksheetFlow.test.mjs tests/unit/worksheetView.test.mjs` Expected: FAIL. - [ ] **Step 3: Implementieren** - `worksheetFlow.js` nach der Beschreibung oben. Ticket-Update vor Kunden-Update, wie bisher. Die Funktion kennt kein Appwrite, die Seite reicht `updateCustomer` herein (Task 10). - `sumWorksheetMinutes`: Summe von `worksheetMinutes(ws)` über `!ws.isComment && predicate(ws)`. Dieselbe Funktion gilt für die Spalte „Zeit“ (Task 5). `worksheetSums`: gesamt, Abo mit Prädikat `countsToPlan`, offen als Anzahl `needsTimeEntry`. - `showAboToggle`: `!isComment && Number(totalTime) > 0 && (aboHoursAllowed || countsToPlan)`. - `worksheetWhen`: Startzweig, wenn `startDate` gesetzt ist, sonst Endzweig (`displayDate`, `displayTime`). - `worksheetKindLabel`: `isComment && !isGitWorksheet` → „Kommentar“, sonst `serviceTypeLabel(serviceType) || ''`. `worksheetBadge` über `worksheetTone`: `git-open` → orange „Zeit offen“, `git-done` → grün „Zeit eingetragen“. - `statusAfterLabel`: nur bei `newStatus` nicht leer und ungleich `oldStatus`, Text `Status danach: ` plus `statusLabel(newStatus)`. - `supportLines`: leer bei Kommentar, sonst je Person `['Unterstützung', firstName(name)]` ohne Leerwerte, ` · task · ` und `stepLabel(supportMinutes(totalTime))`. - `timeLabel`: Kommentar `''`, `<= 0` `'–'`, sonst `stepLabel(Number(totalTime))`. Entscheidung: Die Zeit rechts in der Zeile bleibt `totalTime` (wie heute), die Kennzahlen rechnen mit Unterstützung. Die Badges „+n Unterstützung“ und „Abo-Stunden“ entfallen, weil Unterstützungszeilen und der Abo-Zustand (Task 8) dasselbe zeigen. „↳“ in der Unterstützungszeile und „✓“ in den Zeithinweisen und vor „Abo“ entfallen, die Wörter bleiben. Das ist eine bewusste Abweichung von der Spec (sie nennt es weder unter „Texte“ noch unter „Bewusste Änderungen“) und steht in der Übergabe (Task 13). - [ ] **Step 4: Tests grün** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/worksheetFlow.test.mjs tests/unit/worksheetView.test.mjs` Expected: PASS (10 Tests). - [ ] **Step 5: Commit** ```bash git add src/lib/worksheetFlow.js src/lib/worksheetView.js tests/unit/worksheetFlow.test.mjs tests/unit/worksheetView.test.mjs git commit -m "Tickets: Arbeitsblatt-Folgen und Zeilen-Logik als getestete Lib (Akquise-Ergebnis, Summen, Abo-Regel)" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 5: Daten-Logik (`ticketTimes`, `workorderLookup`, `requestGuard`) **Files:** - Create: `src/lib/ticketTimes.js`, `src/lib/workorderLookup.js`, `src/lib/requestGuard.js` - Test: `tests/unit/ticketTimes.test.mjs`, `tests/unit/workorderLookup.test.mjs`, `tests/unit/requestGuard.test.mjs` **Interfaces:** - Consumes: `sumWorksheetMinutes` (Task 4), `stepLabel`. - Produces: - `TIME_BLOCK = 100`, `TIME_QUERY_LIMIT = 5000`, `TIME_MAX_ROWS = 200`, `TIME_PARALLEL = 3` - `chunk(list, size)`, `planTimeLoad(tickets, known) → { blocks: string[][], capped }` (`known` hat `.has`) - `blockMinutes(woids, documents, total, limit = 5000) → { [woid]: number } | null` - `mapLimit(items, limit, fn) → Promise>` - `minutesCellText(ticket, index, byWoid: Map) → string` - `lookupWorkorder(ref, { findByWoid, getById }) → Promise<{ status: 'ok', ticket } | { status: 'notfound' }>` - `createRequestGuard() → { next(), isCurrent(id) }` - [ ] **Step 1: Failing Tests schreiben** `ticketTimes.test.mjs`: ```js const t = await loadModule('src/lib/ticketTimes.js') const tickets = (n, from = 100000) => Array.from({ length: n }, (_, i) => ({ $id: 'id' + i, woid: String(from + i) })) const sup = JSON.stringify([{ employeeId: 'e', name: 'N', task: 'x' }]) test('chunk und planTimeLoad: erste 200 Zeilen, Blöcke zu 100', () => { assert.deepEqual(t.chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]) const plan = t.planTimeLoad(tickets(250), new Set()) assert.deepEqual(plan.blocks.map((b) => b.length), [100, 100]) assert.equal(plan.blocks[1].at(-1), '100199') assert.equal(plan.capped, true) assert.equal(t.planTimeLoad(tickets(200), new Set()).capped, false) }) test('planTimeLoad: nur Neues, ohne woid, ohne Doppelte', () => { const known = new Set(tickets(100).map((x) => x.woid)) assert.deepEqual(t.planTimeLoad(tickets(120), known).blocks, [tickets(20, 100100).map((x) => x.woid)]) assert.deepEqual(t.planTimeLoad([{ $id: 'a' }, { woid: '' }, { woid: '5' }, { woid: 5 }, { woid: '5' }], new Set()).blocks, [['5']]) assert.deepEqual(t.planTimeLoad(tickets(3), new Set(tickets(3).map((x) => x.woid))).blocks, []) }) test('blockMinutes: Summe ohne Kommentare, mit Unterstützung, nur woids des Blocks', () => { const docs = [ { woid: '1', totalTime: 60 }, { woid: '1', totalTime: 30, supporters: sup }, { woid: '1', totalTime: 0, isComment: true }, { woid: '2', totalTime: 45, isComment: true }, { woid: 7, totalTime: 15 }, { woid: '99', totalTime: 15 }, ] assert.deepEqual(t.blockMinutes(['1', '2', '3', '7'], docs, 6), { 1: 105, 2: 0, 3: 0, 7: 15 }) }) test('blockMinutes: abgeschnittene Blöcke liefern null, nie eine falsche Zahl', () => { assert.equal(t.blockMinutes(['1'], Array.from({ length: 5000 }, () => ({ woid: '1', totalTime: 15 })), 5000), null) assert.equal(t.blockMinutes(['1'], [{ woid: '1', totalTime: 15 }], 7), null) assert.deepEqual(t.blockMinutes(['1'], [{ woid: '1', totalTime: 15 }], 1), { 1: 15 }) }) test('minutesCellText: Fehler, null, 0, ohne woid und jenseits von 200 zeigen –', () => { const by = new Map([['1', 105], ['2', 0], ['3', null]]) assert.equal(t.minutesCellText({ woid: '1' }, 0, by), '1 h 45 min') for (const x of [{ woid: '2' }, { woid: '3' }, { woid: '4' }, {}, { woid: '' }]) assert.equal(t.minutesCellText(x, 0, by), '–') assert.equal(t.minutesCellText({ woid: '1' }, 199, by), '1 h 45 min') assert.equal(t.minutesCellText({ woid: '1' }, 200, by), '–') }) test('mapLimit: höchstens 3 gleichzeitig, Reihenfolge, Fehler je Element', async () => { let running = 0, peak = 0 const out = await t.mapLimit([1, 2, 3, 4, 5, 6, 7], 3, async (n) => { running += 1; peak = Math.max(peak, running) await new Promise((r) => setTimeout(r, 5)) running -= 1 if (n === 4) throw new Error('x') return n * 2 }) assert.equal(peak, 3) assert.deepEqual(out.map((r) => r.ok), [true, true, true, false, true, true, true]) assert.deepEqual(out.filter((r) => r.ok).map((r) => r.value), [2, 4, 6, 10, 12, 14]) assert.deepEqual(await t.mapLimit([], 3, async () => 1), []) }) ``` `workorderLookup.test.mjs`: ```js const { lookupWorkorder } = await loadModule('src/lib/workorderLookup.js') const fake = ({ byWoid = null, byId, byIdError } = {}) => { const calls = [] return { calls, findByWoid: async (w) => { calls.push(['woid', w]); return byWoid }, getById: async (id) => { calls.push(['id', id]); if (byIdError) throw byIdError; return byId } } } test('erst woid, dann $id', async () => { let d = fake({ byWoid: { $id: 'a', woid: '5' } }) assert.deepEqual(await lookupWorkorder(' 5 ', d), { status: 'ok', ticket: { $id: 'a', woid: '5' } }) assert.deepEqual(d.calls, [['woid', '5']]) d = fake({ byId: { $id: 'abc' } }) assert.deepEqual(await lookupWorkorder('abc', d), { status: 'ok', ticket: { $id: 'abc' } }) assert.deepEqual(d.calls, [['woid', 'abc'], ['id', 'abc']]) }) test('400 und 404 sind nicht gefunden, leeres ref ohne Abfrage', async () => { for (const code of [400, 404]) assert.deepEqual(await lookupWorkorder('x', fake({ byIdError: { code } })), { status: 'notfound' }) const d = fake() for (const empty of ['', ' ', null, undefined]) assert.deepEqual(await lookupWorkorder(empty, d), { status: 'notfound' }) assert.deepEqual(d.calls, []) }) test('andere Fehler werden weitergereicht', async () => { await assert.rejects(lookupWorkorder('x', fake({ byIdError: { code: 500, message: 'boom' } }))) await assert.rejects(lookupWorkorder('x', { findByWoid: async () => { throw new Error('netz') }, getById: async () => ({}) }), /netz/) }) ``` `requestGuard.test.mjs`: ```js const { createRequestGuard } = await loadModule('src/lib/requestGuard.js') test('nur die jüngste Anfrage gilt', () => { const g = createRequestGuard() const a = g.next(); const b = g.next() assert.equal(g.isCurrent(a), false) assert.equal(g.isCurrent(b), true) }) ``` - [ ] **Step 2: Tests laufen lassen** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/ticketTimes.test.mjs tests/unit/workorderLookup.test.mjs tests/unit/requestGuard.test.mjs` Expected: FAIL. - [ ] **Step 3: Implementieren** - `planTimeLoad`: nimmt `tickets.slice(0, TIME_MAX_ROWS)`, bildet woids als `String(woid).trim()`, entfernt leere, doppelte und bekannte, teilt mit `chunk(…, TIME_BLOCK)`. `capped` ist `tickets.length > TIME_MAX_ROWS`. - `blockMinutes`: `null`, wenn `documents.length >= limit` oder (`total` ist eine Zahl und `total > documents.length`). Sonst je woid des Blocks `sumWorksheetMinutes` der Dokumente mit `String(doc.woid)`. - `mapLimit`: Worker-Pool, Ergebnisse in Eingabereihenfolge, verwirft nie. `minutesCellText`: `'–'` bei `index >= TIME_MAX_ROWS`, ohne woid, ohne Eintrag, bei `null` und bei 0, sonst `stepLabel(minutes)`. - `lookupWorkorder`: `ref` wird getrimmt, leer ergibt `notfound`. Erst `findByWoid(ref)`, ohne Treffer `getById(ref)`. Dessen Fehler mit `code` 400 oder 404 ergibt `notfound`, alle anderen werden geworfen. - `createRequestGuard`: Zähler, `next()` erhöht und liefert, `isCurrent(id)` vergleicht. Entscheidung: Eine Zelle ohne Ergebnis (lädt noch, Fehler, 0 min, abgeschnitten, jenseits der 200 Zeilen) zeigt immer „–“. Der Fuß nennt die Grenze (Task 11). - [ ] **Step 4: Tests grün** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/ticketTimes.test.mjs tests/unit/workorderLookup.test.mjs tests/unit/requestGuard.test.mjs && "$W/check.sh" --nur-tests` Expected: PASS, gesamt `# pass` ist `N1 + 41`, `# fail 0`, Exit 0. - [ ] **Step 5: Commit** ```bash git add src/lib/ticketTimes.js src/lib/workorderLookup.js src/lib/requestGuard.js tests/unit/ticketTimes.test.mjs tests/unit/workorderLookup.test.mjs tests/unit/requestGuard.test.mjs git commit -m "Tickets: Zeit-Spalte (Bloecke, 3 parallel, 200 Zeilen), Ticket-Suche per woid oder ID, Anfrage-Zaehler" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 6: Hooks **Files:** - Modify: `src/hooks/useWorkorders.js` (Zeilen 1, 20-22, 35-117) - Create: `src/hooks/useWorkorder.js`, `src/hooks/useTicketCounts.js`, `src/hooks/useTicketMinutes.js` **Interfaces:** - Consumes: `lookupWorkorder`, `createRequestGuard`, `countFilters`, `LIST_TABS`, `planTimeLoad`, `blockMinutes`, `mapLimit`, `TIME_*`, `databases`, `DATABASE_ID`, `COLLECTIONS`, `Query` (`lib/appwrite.js`). - Produces: - `useWorkorder(ref) → { ticket, loading, error, notFound, updateWorkorder, refresh }`. `updateWorkorder(id, data)` liefert wie in `useWorkorders` `{ success, data }` oder `{ success: false, error }` und ersetzt das geladene Ticket. - `useTicketCounts({ typeId, urgentOnly }) → { counts }`. `counts[tabId]` ist eine Zahl, bei Fehler `null`, vor der ersten Antwort fehlt der Schlüssel. - `useTicketMinutes(tickets) → { byWoid: Map, capped, version }`. Ein Fehler setzt alle woids des Blocks auf `null`. - [ ] **Step 1: `useWorkorders` – nur der Anfrage-Zähler** Importiere `useRef` und `createRequestGuard`, dazu `const guard = useRef(createRequestGuard())`. In `fetchWorkorders`: `const requestId = guard.current.next()` direkt vor `setLoading(true)`. Nach `await databases.listDocuments(…)` und am Anfang des `catch` steht `if (!guard.current.isCurrent(requestId)) return`. Im `finally` steht `if (guard.current.isCurrent(requestId)) setLoading(false)`. Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && git diff --numstat src/hooks/useWorkorders.js` Expected: höchstens 12 geänderte Zeilen, alle zum Zähler gehörend. - [ ] **Step 2: `useWorkorder`** Zustand `{ ref, status, ticket, error }`, abgeleitet `const current = state.ref === ref ? state : LOADING`, damit nie das vorige Ticket unter neuer Adresse steht. Ein Effekt auf `[ref]` ruft `lookupWorkorder(String(ref), …)`. `findByWoid` nutzt `Query.equal('woid', [String(ref)])`, `Query.orderDesc('$createdAt')`, `Query.limit(1)` auf `COLLECTIONS.WORKORDERS`, `getById` nutzt `databases.getDocument`. Antworten veralteter Anfragen verwirft `createRequestGuard`. - [ ] **Step 3: `useTicketCounts`** Effekt auf `[typeId, urgentOnly]` (nie auf Reiter oder Suche). Je Eintrag aus `LIST_TABS` ein `listDocuments(…WORKORDERS, [Query.limit(1), …])` mit `res.total`. Die Zusatzfilter entsprechen Zeile für Zeile den Bedingungen in `useWorkorders`: `equal('status', f.status)`, `equal('type', f.type)`, `notEqual('type', f.excludeType)`, `equal('priority', f.priority)`, jeweils nur bei gesetztem Wert. `Promise.allSettled`, danach ein `setCounts` mit Zahl oder `null`, Fehler per `console.warn`. Alte Zähler bleiben bis zur neuen Antwort stehen. - [ ] **Step 4: `useTicketMinutes`** Zwischenspeicher `useRef(new Map())` je Hook-Instanz plus ein `Set` laufender woids. Effekt auf `[tickets]`: `planTimeLoad(tickets, bekannt)`, dann `mapLimit(plan.blocks, TIME_PARALLEL, …)`. Je Block `listDocuments(…WORKSHEETS, [Query.equal('woid', block), Query.select(['woid', 'totalTime', 'supporters', 'isComment']), Query.limit(TIME_QUERY_LIMIT)])` und `blockMinutes(block, res.documents, res.total)`. Ein `null`-Ergebnis oder ein Fehler setzt alle woids des Blocks auf `null` (`console.warn`). Danach `setVersion((v) => v + 1)`. Entscheidung: Beide Zwischenspeicher gelten nur für die Lebensdauer der Seite. Wer von einer Ticketseite zurückkehrt, sieht frische Zahlen. - [ ] **Step 5: Bauen** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/check.sh"` Expected: Exit 0, `# fail 0`, Build ohne Fehler. - [ ] **Step 6: Commit** ```bash git add src/hooks/useWorkorders.js src/hooks/useWorkorder.js src/hooks/useTicketCounts.js src/hooks/useTicketMinutes.js git commit -m "Tickets: Hooks useWorkorder, Zaehler und Zeitsummen; useWorkorders verwirft veraltete Antworten" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 7: Werkzeug – Ansichten, Testdaten, Gate `p2` **Files** (unter `$W/`, außerhalb des Repos, kein Commit): - Modify: `views.mjs`, `gates.mjs`, `shots.mjs`, `backend.mjs`, `fixtures/standard.mjs`, `fixtures/altdaten.mjs`, `fixtures/viel.mjs` **Interfaces:** - Consumes: `VIEWS`, `GATES`, `createBackend`, Testdaten aus P0. - Produces: die Ansichten der Tabelle und das Gate `p2`. Vereinbarung: Eine Ansicht ist ein Eintrag `{ id, path, fixtures, scenario, viewports?, steps?: async (page, ctx) => … }` wie in der Übersicht. `ctx.backend.calls` liefert die beantworteten Requests (`{ method, url, body }`). Ein Schritt, der wirft, gilt als Verstoß `step-failed`, gate-relevant in jedem Gate. Fehlt das in `shots.mjs`, wird es hier ergänzt. - [ ] **Step 1: Testdaten ergänzen** - `altdaten.mjs` exportiert `ALT_CASES = [{ key, ref, tab? }]` mit den Schlüsseln `datumKurz` (`startDate: '2.5.2026'`), `datumUngueltig` (`deadline: '31.02.2026'`), `datumIso`, `ohneWoid` (`ref` ist die `$id`, `tab: 'arbeitsblaetter'`), `statusLeer`, `statusUnbekannt` (`'Completed'`), `ohneKunde`, `langeTexte` (Betreff 120, Firma 60 Zeichen). - `viel.mjs` exportiert `EXPECT = { bigWoid, bigLabel: '77 h 30 min', beyondWoid }`. Das große Ticket hat „Test“ im Betreff und liegt unter den 5 neuesten der 250 Treffer. Seine 300 Blätter haben je 15 min ohne Kommentar, 10 davon mit einer Unterstützung (290 × 15 + 10 × 30 = 4650 min). Das 201.-neueste Treffer-Ticket (`beyondWoid`) hat zwei Blätter. - `standard.mjs`: Ticket 100131 hat `priority: 2` (nicht dringend). Fehlt an ihm ein Git-Blatt ohne Zeit mit `employeeName: 'knso'` (Kürzel des angemeldeten Kenso Grimm) oder ein Blatt eines anderen Mitarbeiters mit Zeit, werden beide ergänzt. Dazu kommt ein Blatt, das kein Kommentar ist, mit `totalTime: 0` und `countsToPlan: true` (Abo-Zustand ohne Umschalter). - `backend.mjs`, Szenario `fehler` zusätzlich: GET auf `workorders` mit `limit(1)` und ohne `equal("woid"` antwortet 500. Das trifft nur die Zähler, denn `useWorkorder` filtert immer auf `woid`. - [ ] **Step 2: Ansichten anlegen** Die Ansichten `tickets`, `tickets-suche`, `ticket`, `ticket-arbeitsblaetter`, `ticket-altdaten`, `fehler-tickets` und alle `dlg-*` gibt es seit P0. P2 ändert ihren Pfad und ihre Schritte und legt nur die übrigen neu an. Die Listenansicht `ticket-altdaten` aus P0 heißt jetzt `tickets-altdaten`, `ticket-altdaten` zeigt danach die Ticketseite. Es gibt nie zwei Ansichten für dieselbe Adresse und dieselben Schritte. Die neue `dlg-arbeitsblatt-unterstuetzung` hat wie alle `dlg-*` vier Viewports und ruft am Ende `submitReachable(page)` auf. | ID | Pfad und Schritte | Testdaten | |---|---|---| | `tickets` | `/tickets` | standard | | `tickets-suche` | `/tickets`, Suchfeld mit „Test“ füllen, warten bis URL `q=Test` | viel | | `tickets-altdaten` | `/tickets?status=alle&art=all` | altdaten | | `ticket`, `ticket-arbeitsblaetter` | `/tickets/100131`, `/tickets/100131/arbeitsblaetter` | standard | | `ticket-viel` | `/tickets//arbeitsblaetter` | viel | | `ticket-altdaten`, `ticket-altdaten-` | `/tickets/` je `ALT_CASES` (`ticket-altdaten` ist `datumKurz`) | altdaten | | `ticket-unbekannt` | zuerst `/tickets/100131/xyz`, danach `/tickets/999999` | standard | | `alt-link` | `/tickets?woid=100131&status=done` | standard | | `fehler-tickets`, `fehler-ticket` | `/tickets`, `/tickets/100131/arbeitsblaetter`, Szenario `fehler` | standard | | `dlg-ticket-neu` | `/tickets`, Klick „Neues Ticket“ | standard | | `dlg-arbeitsblatt`, `dlg-arbeitsblatt-unterstuetzung` | `/tickets/100131`, Klick „Arbeitsblatt hinzufügen“, danach Kästchen „Unterstützung“ | standard | | `dlg-zeiten`, `dlg-unterstuetzung` | Reiter Arbeitsblätter, Klick „Zeit nachtragen“ bzw. „Ich habe unterstützt“ | standard | | `tasten-tickets`, `tasten-dialog`, `tasten-busy` | siehe Step 3 | standard | - [ ] **Step 3: Prüfungen als Schritte einbauen** - **`ticket`:** - `.crumb` enthält „Tickets / 100131“. Der Menüpunkt „Tickets“ hat `.active`. `.facts` hat 6 `.fact`. `select[aria-label="Status"]` hat die 6 Werte von `TICKET_STATUSES`. Im Seitenkopf steht genau ein `.btn-primary`. - Zuletzt zwei Klicks auf `.urgent-toggle`, nach jedem wartet der Schritt auf den Wechsel von `aria-pressed` (`false` → `true` → `false`). `ctx.backend.calls` enthält danach genau zwei PATCH auf `workorders`, deren Body `priority` 4 und dann 2 setzt. Das schützt vor einem doppelten `priorityFor` (Task 10). - **`ticket-arbeitsblaetter`:** - `.stats` hat 3 `.stat`, die Zahl der `.ws` entspricht den Testdaten, `.ws-acts .link` hat transparenten Hintergrund und Randbreite 0. - Das 90-Minuten-Blatt hat `button.abo-toggle.on` mit „Abo“. Das Blatt mit Zeit 0 und `countsToPlan` zeigt „Abo“ als Text ohne `button.abo-toggle`. - **`ticket-viel`:** 300 `.ws`, das `.stat` „Arbeitszeit gesamt“ zeigt `EXPECT.bigLabel`. - **`ticket-altdaten-`:** - `datumKurz`: „Termin“ enthält `02.05.2026`. `datumUngueltig`: „Fällig“ zeigt `31.02.2026`. - `ohneWoid`: Der Hinweis „Für dieses Ticket gibt es keine Ticketnummer, Arbeitsblätter sind nicht möglich.“ steht da, es gibt keine `.msg.err`. Der Knopf „Arbeitsblatt hinzufügen“ ist `disabled` und hat den `title` „Für dieses Ticket gibt es keine Ticketnummer“. - `ohneKunde`: „Unbekannt“ steht in `.sub`. `statusUnbekannt`: Die Status-Auswahl enthält und wählt `Completed`. - **`ticket-unbekannt`:** `/tickets/100131/xyz` wird zu `/tickets/100131`, im Verlauf ohne eigenen Eintrag (`replace`). `/tickets/999999` zeigt „Ticket nicht gefunden“. - **`alt-link`:** Die URL endet auf `/tickets/100131`, ohne `status=done`. - **`tickets-suche`:** - 250 Zeilen, der Fuß enthält „250 Treffer“ und „ersten 200“, an den Reitern stehen keine Zahlen. - Das große Ticket zeigt `EXPECT.bigLabel`, die Zeile von `beyondWoid` zeigt „–“. Ein Klick auf `.cell-sub` der ersten Zeile (kein Link, also Zeilenklick über `isRowActivation`) öffnet in unter 2 s ihre Ticketseite. - Tippt der Schritt „Tes“, wartet 100 ms und tippt „t“, steht nach 500 ms `q=Test` in der URL, `history.length` ist unverändert. - **`dlg-arbeitsblatt-unterstuetzung`:** Ein Klick auf „+ weitere Person“ und danach auf „Entfernen“ sendet nichts: `ctx.backend.calls` enthält keinen POST auf `worksheets` (beide Knöpfe tragen `type="button"`). - **`fehler-tickets`:** Reiterzähler und jede Zelle der Spalte „Zeit“ zeigen „–“, „Zuständig“ zeigt den gekürzten `assignedName`, ein Klick auf `.cell-sub` einer Zeile öffnet das Ticket. - **`fehler-ticket`:** Der Reiter Arbeitsblätter zeigt eine `.msg.err`, `select[aria-label="Zuständig"]` zeigt den aktuellen Wert. - **`tasten-tickets`:** Ab `body` erreicht Tab nach höchstens 40 Anschlägen einen Link `tr.row a`, Enter ändert die URL auf `/tickets/`. - **`tasten-dialog`:** - Klick „Neues Ticket“, Esc schließt den Dialog, der Fokus liegt wieder auf dem Knopf. - Wieder öffnen, „Angefragt von“ fokussieren, „K“ tippen: Die Liste `[role=listbox]` liegt nach `scrollIntoView({ block: 'nearest' })` mit der Unterkante im `.dialog-body`. - Das erste Esc schließt nur die Liste, das zweite den Dialog. - **`tasten-busy`:** - Ein Seiten-Handler verzögert den POST: `await page.route(/collections\/worksheets\/documents/, async (route) => { if (route.request().method() === 'POST') await new Promise((r) => setTimeout(r, 1500)); await route.fallback() })`. - Der Schritt füllt Uhrzeit Beginn 09:00, Uhrzeit Ende 10:00 und „Was wurde gemacht?“, klickt „Speichern“ und wartet 300 ms. - Dann ist `.dialog-close` `disabled`, Esc lässt den Dialog offen, und der Knopf zeigt „Speichert …“. Nach der Antwort ist der Dialog zu und die URL endet auf `/arbeitsblaetter`. - `ctx.backend.calls` enthält genau einen POST auf `worksheets` (Doppelklick-Sperre). - [ ] **Step 4: Gate `p2` anlegen** Nach dem Schema der Übersicht ist `p2` das Gate `p1` plus `icon-svg` für die Ansichten dieses und der früheren Pakete (P1: Login, Menü, Basis, Dialog-Probe): ```js p2: { ...GATES.p1, scoped: { ...GATES.p1.scoped, 'icon-svg': { views: [ 'login', 'login-code', 'login-code-senden', 'basis-weiss', 'menue-mobil', 'dlg-probe', 'tickets', 'tickets-*', 'ticket', 'ticket-*', 'alt-link', 'fehler-tickets', 'fehler-ticket', 'dlg-ticket-neu', 'dlg-arbeitsblatt*', 'dlg-zeiten', 'dlg-unterstuetzung', 'tasten-*', ], ignore: ['.ticket-projects'], }, }, }, ``` `step-failed`, `external-request` und `console-error` sind in jedem Gate aktiv, `checks` und `views: '*'` erbt `p2` von `p1`. `shots.mjs` wertet `scoped` aus (falls P0 es noch nicht tut): `icon-svg` ist nur für die genannten Ansichten gate-relevant, ein `*` am Ende eines Eintrags ist ein Präfix-Platzhalter, Treffer unterhalb von `ignore`-Selektoren zählen nicht. Entscheidung: `.ticket-projects` ist bis P5 von `icon-svg` ausgenommen, weil `TicketProjects` laut Spec bis dahin unverändert bleibt. P5 streicht die Ausnahme. - [ ] **Step 5: Ausgangslauf** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p2 && "$W/shots.sh" p2 "$W/shots/p2-t7" --only ticket,ticket-arbeitsblaetter,alt-link --gate p2; echo "exit $?"` Expected: `exit 1`. `ticket` meldet `step-failed` (Route fehlt), `alt-link` landet nicht auf `/tickets/100131`. ### Task 8: CSS-Block und kleine Komponenten **Files:** - Modify: `src/styles/global.css` (neuer Block „Tickets (P2)“ direkt vor dem KOMPAT-Block) - Modify: `src/components/UrgentToggle.jsx`, `src/components/WorksheetSummary.jsx`, `src/components/ProjectContextPanel.jsx` - Rewrite: `src/components/WorksheetList.jsx` (341 Zeilen) - Create: `src/lib/elapsed.js` **Interfaces:** - Consumes: `Stat`, `Card`, `StatusPill`, `EmptyState` (P1), `worksheetSums`, `worksheetWhen`, `worksheetKindLabel`, `worksheetBadge`, `statusAfterLabel`, `supportLines`, `showAboToggle`, `timeLabel` (Task 4), `ticketPath`, `ticketRef` (Task 2), `createdDate` (Task 3), `needsTimeEntry`, `isGitWorksheet`. - Produces: - `UrgentToggle({ value, onChange, disabled })`, `WorksheetSummary({ worksheets })`, `ProjectContextPanel({ ticket })` - `WorksheetList({ worksheets, loading, onTogglePlan, aboHoursAllowed = true, canEditTimes, onEditTimes, canAddSupport, onOwnSupport, currentUserId })`. Das Prop `totalTime` entfällt, es war unbenutzt. - `elapsedLabel(ticket)`: `formatDistanceToNow(createdDate(ticket), { locale: de, addSuffix: true })`, bei `null` „–“. Dünner Wrapper ohne eigenen Test. - [ ] **Step 1: CSS-Block schreiben** Der Block steht in `global.css`. Die Bausteinregeln aus P1 überstimmt er über Reihenfolge oder höhere Spezifität, nie über `!important`. Werte aus `quelle/styles.css`, Abschnitt „Arbeitsblätter“, mit diesen Abweichungen: - `.ws { grid-template-columns: 148px minmax(0, 1fr) auto }`. `.ws-text, .ws-support, .ws-head .who { overflow-wrap: anywhere }`. `.ws-text { white-space: pre-wrap }`. `.ws-acts { flex-wrap: wrap }`. - `.abo-toggle { color: var(--text-2) }` und `.abo-toggle.on { color: var(--blue-fg) }`, denn der Umschalter ist ein Bedienelement, kein Nebentext. `button.link, .abo-toggle { background: none; border: 0; padding: 0; font: inherit; cursor: pointer }`. - Unter 860 px: `.ws { grid-template-columns: minmax(0, 1fr) auto }`, `.ws-when { grid-column: 1 / -1; display: flex; gap: 8px }`. Dazu: - `.row-link { display: inline-flex; align-items: baseline; gap: 8px; min-width: 0; color: inherit; overflow-wrap: anywhere }` (die Klasse verhindert den Unterstrich von `a:not([class])`), `.table td .cell-sub { overflow-wrap: anywhere }`, `.list-more { display: flex; justify-content: center; padding: 8px 0 24px }`. - `@media (min-width: 861px) { .toolbar .search { width: 360px } }`, damit der Platzhalter passt. - `.supporter-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px 12px; align-items: center; margin-top: 8px }`, `.supporter-row .sup-meta { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; font-size: 13px; color: var(--text-2) }`, unter 860 px `.supporter-row { grid-template-columns: minmax(0, 1fr) }`. - [ ] **Step 2: `UrgentToggle`, `WorksheetSummary`** - `UrgentToggle`: ``. `title` und Logik bleiben wörtlich: `onClick` ruft weiter `onChange(priorityFor(!urgent))` auf, `onChange` bekommt also schon die Prioritätszahl (4 oder 2). Wer die Komponente benutzt, wendet kein zweites `priorityFor` an. - `WorksheetSummary`: `null` bei leerer Liste (wie heute), sonst `
` mit drei `Stat`: „Arbeitszeit gesamt“ (`stepLabel(totalMinutes)`), „Davon Abo-Stunden“ (`stepLabel(aboMinutes)`), „Zeiten offen“ (`openCount`, `tone="warn"` bei mehr als 0). - [ ] **Step 3: `WorksheetList` neu** Container `
` mit `.ws`-Zeilen (Klasse `comment` bei `isComment && !isGit`), neueste zuerst wie geliefert. Keine Icons, kein Aufklappen, keine Inline-Styles. - **Laden:** `
Wird geladen …
`. **Leer:** `EmptyState` mit Titel „Noch keine Arbeitsblätter.“ und Hinweis „Leg das erste mit „Arbeitsblatt hinzufügen“ an.“ - **Links** (`.ws-when`): `.d` ist `worksheetWhen(ws).date` (leer → „–“), darunter die Zeit in `.num`. - **Mitte:** - `.ws-head`: `.who` mit `employeeName`, `employeeShort` als ``, `.muted` mit `worksheetKindLabel`, `.faint.xs.num` mit „WSID {wsid}“, das Badge aus `worksheetBadge` (``) und der Abo-Zustand (Umschalter oder Text, siehe unten). - Darunter `.ws-text` (`details`), je Zeile aus `supportLines` ein `.ws-support`, bei `statusAfterLabel` ein `.ws-support`, zuletzt `.ws-acts`. - **`.ws-acts`:** ``. - Danach ``. Der Hinweis „Mitarbeiterliste konnte nicht geladen werden.“ erscheint wie heute bei `!loading && (error || keine Auswahl)` als `.hint`. - [ ] **Step 5: Bauen** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && ! rg -n "react-icons|style=\{\{|form-control|overlay-content|bg-red" src/components/CreateTicketModal.jsx src/components/CreateWorksheetModal.jsx src/components/WorksheetTimesModal.jsx src/components/OwnSupportModal.jsx src/components/SupportersEditor.jsx && "$W/check.sh" --nur-build` Expected: `rg` ohne Treffer, Build grün (Exit 0). Die Bilder folgen in Task 10 und 11. Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n -A4 "<(Button|button)\b" src/components/CreateTicketModal.jsx src/components/CreateWorksheetModal.jsx src/components/WorksheetTimesModal.jsx src/components/OwnSupportModal.jsx src/components/SupportersEditor.jsx` Expected: Jeder Knopf trägt `type="button"`, außer dem Absenden-Knopf mit `type="submit"` (bei mehrzeiligen Knöpfen stehen die Props in den Folgezeilen). - [ ] **Step 6: Commit** ```bash git add src/components/CreateTicketModal.jsx src/components/CreateWorksheetModal.jsx src/components/WorksheetTimesModal.jsx src/components/OwnSupportModal.jsx src/components/SupportersEditor.jsx git commit -m "Tickets: vier Ticket-Dialoge auf Dialog (Esc, Sperre beim Speichern), Unterstuetzung als Feldblock" -m "Co-Authored-By: Claude Opus 5.5 " ``` ### Task 10: `TicketPage` und Route **Files:** - Create: `src/pages/TicketPage.jsx` - Modify: `src/App.jsx` (Import und Route direkt nach `/tickets`, vor dem Fallback `*`) **Interfaces:** - Consumes: Task 2 bis 9, `PageHeader`, `Tabs`, `Card`, `EmptyState`, `Button` (P1), `useAuth`, `useWorksheets`, `useCustomerAbo`, `useEmployees`, `TicketProjects`, `canEditWorksheetTimes`, `canAddOwnSupport`. - Produces: `} />`. - [ ] **Step 1: Ausgangslauf** Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p2 && "$W/shots.sh" p2 "$W/shots/p2-t10" --only ticket-unbekannt --gate p2; echo "exit $?"` Expected: `exit 1` (Route fehlt). - [ ] **Step 2: Route und äußere Komponente** Alle Hooks von `TicketPage` stehen vor jeder Weiterleitung, sonst bricht der Wechsel `/tickets/5/xyz` → `/tickets/5` die Hook-Reihenfolge. - `{ ref, tab }` aus `useParams()`, `useWorkorder(ref)`. `resolveTicketTab(tab) === null` ergibt ``. - Laden: `
Wird geladen …
`. Nicht gefunden: `EmptyState` „Ticket nicht gefunden“ mit Aktion `Button as={Link} to={listBackPath(location.state)}` „Zurück“. Fehler: `.msg.err` „Fehler: {error}“ plus derselbe Knopf. - Sonst ``. - [ ] **Step 3: Kopf und Fakten in `TicketView`** `TicketView` ruft `useAuth`, `useEmployees` (einmal), `useWorksheets(ticket.woid || null)` und `useCustomerAbo(ticket.customerId, tab === 'arbeitsblaetter' || showCreateWorksheet)` auf. - **`PageHeader`:** - `crumb=[{ label: 'Tickets', to: listBackPath(location.state) }, { label: ticketNumber(ticket) }]`, `eyebrow` ist `{ticketNumber} · {ticketTypeLabel(type)}` (ohne Art nur die Nummer), `title` ist `topic || title || 'Kein Betreff'`. - `subtitle` ist der Kunde, bei `customerId` als ``, sonst Text, ohne Namen „Unbekannt“, dazu ` · {customerLocation}`. - `actions` sind `UrgentToggle`, `` mit `assigneeOptions`, das `