1139 lines
93 KiB
Markdown
1139 lines
93 KiB
Markdown
# 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/<Nummer>`). 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 `<Dialog>`. 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/<name>.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 `<Dialog as="form">` 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-<fall>` (`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/<Nummer>` |
|
||
| 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
### 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<result>`
|
||
- `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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
### 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<Array<{ ok: true, value } | { ok: false, error }>>`
|
||
- `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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
### 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/<EXPECT.bigWoid>/arbeitsblaetter` | viel |
|
||
| `ticket-altdaten`, `ticket-altdaten-<key>` | `/tickets/<ref>` 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-<key>`:**
|
||
- `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/<woid>`.
|
||
- **`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`: `<button type="button" className="urgent-toggle{ on}" aria-pressed title onClick disabled><span className="sw" aria-hidden="true" />Dringend</button>`. `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 `<div className="stats">` 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 `<div className="ws-list">` mit `.ws`-Zeilen (Klasse `comment` bei `isComment && !isGit`), neueste zuerst wie geliefert. Keine Icons, kein Aufklappen, keine Inline-Styles.
|
||
- **Laden:** `<div className="loading">Wird geladen …</div>`. **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 `<span className="faint xs">`, `.muted` mit `worksheetKindLabel`, `.faint.xs.num` mit „WSID {wsid}“, das Badge aus `worksheetBadge` (`<span className="badge {tone}">`) 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`:** `<button type="button" className="link">` mit „Zeit nachtragen“ (bei `needsTimeEntry`) oder „Zeiten bearbeiten“, nur bei `onEditTimes && canEditTimes?.(ws)`. Dazu „Meine Unterstützung bearbeiten“ (der Nutzer steht in `parseSupporters`) oder „Ich habe unterstützt“, nur bei `onOwnSupport && canAddSupport?.(ws)`. Die `title`-Texte der alten Knöpfe bleiben.
|
||
- **Abo-Zustand:**
|
||
- Umschalter: `<button type="button" className="abo-toggle{ on}">`, sichtbar bei `onTogglePlan && showAboToggle(ws, aboHoursAllowed)`. Bei `countsToPlan` ist er an (`on`) und heißt „Abo“, sonst „+ Abo“. `title` bleibt: „Nicht mehr vom Abo-Stundenkonto abziehen“ bzw. „Vom Abo-Stundenkonto des Kunden abziehen“.
|
||
- Ohne Umschalter (Zeit 0 oder kein `onTogglePlan`) steht bei `countsToPlan && !isComment` stattdessen `<span className="muted xs">Abo</span>`. So bleibt der Abo-Zustand sichtbar, den das alte Badge „Abo-Stunden“ zeigte.
|
||
- **Rechts** (`.ws-time`): `.t` ist `timeLabel(ws)`, bei „–“ mit Klasse `faint`.
|
||
|
||
Entscheidung: `employeeShort` bleibt als kleiner Nebentext sichtbar (wie in der aufgeklappten Karte heute). Links stehen Datum und Beginn wie heute (Start-, sonst Endzweig).
|
||
|
||
- [ ] **Step 4: `ProjectContextPanel` als „Weitere Tickets von …“**
|
||
|
||
Abfrage (`Query.equal('customerId'…)`, `limit(50)`) und Sichtbarkeitsregel (nichts ohne zugewiesenes Projekt) bleiben. Neu:
|
||
- `Card` mit Titel `Weitere Tickets von {ticket.customerName}`, ohne Kundenname „Weitere Tickets“.
|
||
- Zeilen: `<Link className="list-row" to={ticketPath(ticketRef(t))} state={location.state}>` mit `.cell-title` (`.id` plus Betreff) und `StatusPill`. `location` kommt aus `useLocation()`.
|
||
- Die Texte „Kein Kunde zugeordnet — andere Tickets koennen nicht automatisch verknuepft werden.“ und „Keine weiteren Tickets zu diesem Kunden/Projekt.“ bleiben wörtlich als `<p className="muted">`. `IconFolders` und alle Inline-Styles entfallen.
|
||
|
||
Entscheidung: Der Projektname steht nicht mehr in der Überschrift. `TicketProjects` zeigt das Projekt ohnehin.
|
||
|
||
- [ ] **Step 5: Bauen und prüfen**
|
||
|
||
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && ! rg -n "react-icons|@tabler|style=\{\{" src/components/UrgentToggle.jsx src/components/WorksheetSummary.jsx src/components/WorksheetList.jsx src/components/ProjectContextPanel.jsx && ! rg -n "!important" src/styles/global.css && "$W/check.sh" --nur-build`
|
||
Expected: beide `rg` ohne Treffer, Build grün (Exit 0). Die Bilder folgen in Task 10.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/styles/global.css src/components/UrgentToggle.jsx src/components/WorksheetSummary.jsx src/components/WorksheetList.jsx src/components/ProjectContextPanel.jsx src/lib/elapsed.js
|
||
git commit -m "Tickets: Arbeitsblatt-Zeilen (.ws), Kennzahlen, Dringend-Schalter und Weitere Tickets im neuen Look" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
### Task 9: Dialoge auf `<Dialog>`
|
||
|
||
**Files:**
|
||
- Rewrite: `src/components/CreateTicketModal.jsx` (266 Zeilen), `CreateWorksheetModal.jsx` (306), `WorksheetTimesModal.jsx` (166), `OwnSupportModal.jsx` (80), `SupportersEditor.jsx` (76)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Dialog`, `Field`, `Button`, `DateTimeInput`, `DurationSelect`, `AutocompleteInput` (P1).
|
||
- Produces:
|
||
- `CreateTicketModal({ isOpen, onClose, onCreate, customers = [], employees = [] })`. Neu ist `employees`, der Dialog ruft `useEmployees` nicht mehr selbst auf.
|
||
- `CreateWorksheetModal({ isOpen, onClose, workorder, onCreate, aboHoursAllowed = true, employeesState })`
|
||
- `WorksheetTimesModal({ worksheet, currentUserId, onClose, onSave, employeesState })`
|
||
- `OwnSupportModal({ worksheet, currentUserId, onClose, onSave })` (unverändert)
|
||
- `SupportersEditor({ value, onChange, excludeUserId, minutes, employeesState })`
|
||
- `employeesState = { employees, loading, error }` (Werte von `useEmployees()`), Vorgabe `{ employees: [], loading: false, error: null }`.
|
||
|
||
Gemeinsam: `<Dialog as="form" onSubmit title busy={loading|saving} footer={…}>`, Fehler als `<div className="msg err" role="alert">` als erstes Kind, Felder als `Field`, native `required` bleibt (kein `noValidate`), Kästchen als `<label className="check">`. Zustände, Handler, Pflichtfelder, Doppelklick-Sperren und alle Texte bleiben. Alle `FaTimes`-Importe und Inline-Styles entfallen. Der Fuß enthält nur den Speichern-Knopf (`Button variant="primary" type="submit"`, rechts hinter `<span className="grow" />`). Jeder andere Knopf im Dialog (`Button` oder `<button>`) trägt `type="button"`, sonst sendet ein Klick das Formular ab.
|
||
|
||
Entscheidung: `CreateTicketModal` und `CreateWorksheetModal` sind `wide` (zwei Spalten wie heute, die Art „Migration (Übernahme von anderem Anbieter)“ ist lang). `WorksheetTimesModal` und `OwnSupportModal` haben Standardbreite. Es kommen keine „Abbrechen“-Knöpfe dazu. Der Ein-Abruf-pro-Seite-Wunsch der Spec („`useEmployees` läuft einmal pro Seite“) ist der Grund für die Prop `employees` bzw. `employeesState`.
|
||
|
||
- [ ] **Step 1: `CreateTicketModal`**
|
||
|
||
- Rückgabe `null` ohne `isOpen` wie heute, `wide`, Titel „Neues Ticket“.
|
||
- Felder: Kunde und Art als `.field-row`, Betreff, „Angefragt von“ (`AutocompleteInput`, `getSuggestions`, `required`), Beginn und Deadline (je `DateTimeInput` in einem `Field`), Beschreibung (`.textarea`). „Dringend“ ist `.check`.
|
||
- Die Zuweisung ist `<Field label="Zuweisung" hint={…}>` mit den heutigen zwei Sätzen, daher entfällt `.automatic-assignment-note`.
|
||
- Knopf „Ticket anlegen“ bzw. „Wird angelegt …“. Kein Einsatzfeld, Zuweisung automatisch wie in `useWorkorders.createWorkorder`.
|
||
|
||
- [ ] **Step 2: `CreateWorksheetModal`**
|
||
|
||
- Titel `Neues Arbeitsblatt – Ticket {workorder.woid}`.
|
||
- Art (`.select`, `required`) und Neuer Status bzw. Akquise-Ergebnis (`required`) als `.field-row`. Der Hinweis „"Zugesagt" macht den Lead automatisch zum festen Kunden.“ ist `.hint`.
|
||
- Danach die Kästchen „Nur Kommentar (keine Arbeitszeit)“ und (bei `aboHoursAllowed`) „Vom Abo-Stundenkonto abziehen“ mit dem bisherigen `title`.
|
||
- Beginn und Ende als `DateTimeInput`, Arbeitszeit als `DurationSelect` mit `.hint`: „Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten“, „Beginn und Ende eintragen oder Zeit wählen“ oder „Von Hand gewählt“.
|
||
- Danach `SupportersEditor` (nicht bei Kommentar), Textarea „Was wurde gemacht?“ und der Knopf „Speichern“ bzw. „Speichert …“. Rücksetzen beim Öffnen, Zeitberechnung, `validateSupporters` und Fehlertexte bleiben.
|
||
|
||
Entscheidung: Das „✓“ vor dem Berechnungshinweis entfällt (Umbaukarte 4.B), die Wörter bleiben.
|
||
|
||
- [ ] **Step 3: `WorksheetTimesModal` und `OwnSupportModal`**
|
||
|
||
- **`WorksheetTimesModal`:**
|
||
- Titel „Zeit nachtragen“ oder „Zeiten bearbeiten“ (`needsTimeEntry`). Darunter `<p className="muted">` mit `WSID {wsid}` und bei Git-Pushes „Das Ende ist der Zeitpunkt des Git-Pushs. Trag ein, wann die Arbeit begonnen hat.“
|
||
- Felder untereinander: Beginn, Ende, Arbeitszeit (`.hint` wie oben), dann `SupportersEditor`. Knopf „Speichern“ bzw. „Speichert …“ (statt „Speichert...“, laut Spec).
|
||
- **`OwnSupportModal`:**
|
||
- Titel „Unterstützung eintragen“. Darunter `<p className="muted">`: „WSID {wsid} · {employeeName}“ und „Dir werden {stepLabel(supportMinutes(totalTime))} angerechnet.“
|
||
- Feld „Wobei?“ (`maxLength={200}`, `required`). Fuß: links `Button variant="danger" type="button"` „Eintrag entfernen“ (nur bei vorhandenem Eintrag, `disabled={saving}`), rechts „Speichern“ bzw. „Speichert …“. Die Fehlermeldung „Bitte angeben, wobei du unterstützt hast.“ bleibt.
|
||
- Nach dem Öffnen liegt der Fokus im Feld „Wobei?“. Ein Effekt in `OwnSupportModal` setzt ihn per `ref`, er läuft nach dem Fokus-Effekt des `Dialog`.
|
||
|
||
- [ ] **Step 4: `SupportersEditor` als Feldblock**
|
||
|
||
- `<div className="field supporters-editor">` mit `<label className="check">` „Unterstützung“. Die Mitarbeiter kommen aus `employeesState`, kein eigener `useEmployees`-Aufruf.
|
||
- Je Zeile `.supporter-row` mit Select „Person wählen …“ (`aria-label="Mitarbeiter"`) und Input (`aria-label="Wobei?"`, `placeholder="Wobei?"`, `maxLength={200}`). Darunter `.sup-meta` mit „= {stepLabel(supportMinutes(minutes))}“ und `<button type="button" className="link" aria-label="Zeile entfernen">Entfernen</button>`.
|
||
- Danach `<button type="button" className="link">+ weitere Person</button>`. 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
### 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: `<Route path="/tickets/:ref/:tab?" element={<ProtectedRoute><TicketPage /></ProtectedRoute>} />`.
|
||
|
||
- [ ] **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 `<Navigate replace to={ticketPath(ref)} state={location.state} />`.
|
||
- Laden: `<div className="page"><div className="loading">Wird geladen …</div></div>`. 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 `<TicketView key={ticket.$id} ticket updateWorkorder tab />`.
|
||
|
||
- [ ] **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 `<Link className="link" to={'/customers/' + customerId}>`, sonst Text, ohne Namen „Unbekannt“, dazu ` · {customerLocation}`.
|
||
- `actions` sind `UrgentToggle`, `<select className="select" aria-label="Status">` und der einzige schwarze Knopf „Arbeitsblatt hinzufügen“ (alle Ticketarten). Ohne `woid` ist er `disabled` mit `title="Für dieses Ticket gibt es keine Ticketnummer"`, denn Arbeitsblätter brauchen die Ticketnummer (der Hinweis im Reiter Arbeitsblätter sagt dasselbe).
|
||
- **`.facts`:**
|
||
- „Zuständig“: `<select className="select select-quiet" id="ticket-assignee" disabled={employeesState.loading}>` mit `assigneeOptions`, das `<label className="k" htmlFor>` gehört dazu.
|
||
- „Termin“: `appointmentText` (leer → `<span className="faint">Kein Termin</span>`). „Fällig“: `dueText` (leer → „–“). „Einsatz“: `serviceTypeLabel(serviceType) || '–'`. „Angefragt von“: `requestedBy || '–'`. „Erstellt“: `elapsedLabel(ticket)`.
|
||
- **Schreiben:** Status `{ status: value }` (Optionen aus `statusSelectOptions(ticket.status)`, Wert `ticket.status || 'Open'`), Zuständig `{ assignedTo: value }` (`''` für „Nicht zugewiesen“), Dringend `<UrgentToggle value={ticket.priority} onChange={(priority) => update({ priority })} />`. `UrgentToggle` liefert schon die Zahl (Task 8), `TicketView` wendet kein zweites `priorityFor` an. Sonst ergäbe `priorityFor(2)` immer 4 und der Schalter käme nicht mehr auf normal zurück. Während eines Speicherns zeigt das Feld den neuen Wert und ist deaktiviert, bei Fehler springt es zurück.
|
||
|
||
Entscheidung: Fehler beim Schreiben von Status, Zuständig und Dringend bleiben wie heute still, es kommt kein neuer Text dazu.
|
||
|
||
- [ ] **Step 4: Reiter und Inhalte**
|
||
|
||
`Tabs` mit `[{ id: 'details', label: 'Details' }, { id: 'arbeitsblaetter', label: 'Arbeitsblätter', count }]`. `onChange` ruft `navigate(ticketPath(ticketRef(ticket), id), { state: location.state })`. `count` ist die Anzahl der Blätter, bei Fehler „–“, beim Laden und ohne woid fehlt es.
|
||
- **Details**, in dieser Reihenfolge: `Card` „Ticket-Beschreibung“ mit `<p className="prose">{details || 'Keine Details vorhanden.'}</p>`, dann `TicketProjects`, dann `ProjectContextPanel`.
|
||
- **Arbeitsblätter:**
|
||
- Ohne woid steht dort `<p className="muted">Für dieses Ticket gibt es keine Ticketnummer, Arbeitsblätter sind nicht möglich.</p>`.
|
||
- Bei `useWorksheets`-Fehler steht `.msg.err` mit dem Hook-Text statt der Liste.
|
||
- Sonst `WorksheetSummary` und `WorksheetList` mit `canEditTimes={(ws) => canEditWorksheetTimes(ws, user, employee)}`, `canAddSupport={(ws) => canAddOwnSupport(ws, user, employee)}`, `onTogglePlan={(ws) => updateWorksheet(ws.$id, { countsToPlan: !ws.countsToPlan })}`, `aboHoursAllowed`, `currentUserId={user?.$id}`.
|
||
|
||
Entscheidung: Bei einem Ladefehler der Arbeitsblätter erscheint der bestehende Hook-Text als `.msg.err` statt des irreführenden Leerzustands (Review-Focus-Punkt 3).
|
||
|
||
Entscheidung: Ein Reiterwechsel ändert den Pfad und springt deshalb nach oben (P1-Regel). Die Reiter liegen im oberen Seitenteil, ein Sonderfall ist nicht nötig.
|
||
|
||
- [ ] **Step 5: Dialoge und Folgen verdrahten**
|
||
|
||
- `CreateWorksheetModal` bekommt `workorder={ticket}` (also immer `ticket.woid` des geladenen Tickets), `aboHoursAllowed` und `employeesState`.
|
||
- `onCreate` ist `(data, currentUser) => createWorksheetFlow({ ticket, worksheetData: data, user: currentUser, createWorksheet, updateTicket: updateWorkorder, updateCustomer: (id, d) => databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id, d) })`. Nach Erfolg wechselt die Seite auf den Reiter Arbeitsblätter, falls er nicht aktiv ist.
|
||
- `OwnSupportModal` und `WorksheetTimesModal` werden wie in `TicketCard` bedingt gerendert, mit `saveOwnSupport(ws, task, user, employee)` und `updateWorksheetTimes(ws, times, user, employee)`.
|
||
|
||
Entscheidung: Weil „Arbeitsblatt hinzufügen“ im Kopf beider Reiter steht, wechselt die Seite nach dem Speichern auf Arbeitsblätter (wie im Prototyp), damit das neue Blatt sichtbar ist.
|
||
|
||
- [ ] **Step 6: Bauen und Ansichten prüfen**
|
||
|
||
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,ticket-arbeitsblaetter,ticket-viel,ticket-altdaten,ticket-altdaten-datumUngueltig,ticket-altdaten-datumIso,ticket-altdaten-ohneWoid,ticket-altdaten-statusLeer,ticket-altdaten-statusUnbekannt,ticket-altdaten-ohneKunde,ticket-altdaten-langeTexte,ticket-unbekannt,fehler-ticket,dlg-arbeitsblatt,dlg-arbeitsblatt-unterstuetzung,dlg-zeiten,dlg-unterstuetzung,tasten-busy --gate p2`
|
||
Expected:
|
||
- Exit 0, alle Schritte aus Task 7 bestanden.
|
||
- `ticket` zeigt Kopf mit Brotkrumen, 6 Fakten, Reiter und Beschreibung, bei 390 px stehen die Fakten zu zweit ohne waagerechtes Scrollen.
|
||
- Die vier Dialoge sind weiß, bei 390 px liegt der Speichern-Knopf nach dem Scrollen im Bild. In `dlg-arbeitsblatt-unterstuetzung` stehen die Zeilen zweizeilig.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/pages/TicketPage.jsx src/App.jsx
|
||
git commit -m "Tickets: Ticket als eigene Seite mit Route /tickets/:ref/:tab? (Facts, Reiter, Dialoge verdrahtet)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
### Task 11: `TicketsPage` neu
|
||
|
||
**Files:**
|
||
- Rewrite: `src/pages/TicketsPage.jsx` (165 Zeilen)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `parseListParams`, `serializeListParams`, `withFilter`, `nextLimit`, `listFilters`, `LIST_TABS`, `woidRedirectTarget`, `isRowActivation` (Task 2), `assigneeLabel`, `appointmentText`, `ticketNumber` (Task 3), `minutesCellText` (Task 5), `useWorkorders`, `useTicketCounts`, `useTicketMinutes`, `useCustomers`, `useEmployees`, `ticketPath`, `ticketRef`, `elapsedLabel`, `PageHeader`, `Tabs`, `StatusPill`, `UrgentPill`, `EmptyState`, `Button`, `TYPE_FILTERS`.
|
||
- Produces: `TicketsPage` (Standard-Export).
|
||
|
||
- [ ] **Step 1: Äußere Komponente mit Umleitung**
|
||
|
||
`TicketsPage` liest `useSearchParams()`. Ergibt `woidRedirectTarget(searchParams)` einen Pfad, rendert sie `<Navigate replace to={…} />`, sonst die innere Komponente `TicketList`. So läuft bei einem alten Link keine Abfrage.
|
||
|
||
- [ ] **Step 2: Zustand in `TicketList`**
|
||
|
||
- `state = useMemo(() => parseListParams(searchParams))`. Jede Änderung schreibt mit `setSearchParams((prev) => serializeListParams(withFilter(parseListParams(prev), patch)), { replace: true })`. „Mehr laden“ schreibt `limit: nextLimit(state)` ohne Zurücksetzen.
|
||
- Suchfeld: lokaler Text, Übernahme nach 300 ms Pause oder Enter (`<form className="toolbar" onSubmit>`), der Knopf „Anwenden“ entfällt. Ändert sich `state.search` von außen (Zurück-Knopf), folgt der Text, eigene Übernahmen werden nicht zurückgespiegelt (Merker `appliedRef`).
|
||
- Hooks: `useWorkorders(listFilters(state))`, `useTicketCounts({ typeId, urgentOnly })`, `useCustomers()`, `useEmployees()` (einmal, die Mitarbeiter gehen auch an `CreateTicketModal`), `useTicketMinutes(workorders)`.
|
||
|
||
- [ ] **Step 3: Kopf, Reiter, Werkzeugleiste**
|
||
|
||
- **`PageHeader`:** Titel „Tickets“, Untertitel „Alle Arbeitsaufträge auf einen Blick“, Aktion `Button variant="primary"` „Neues Ticket“.
|
||
- **`Tabs`:** `LIST_TABS` mit `count`: Zahl, bei `null` „–“, vor der Antwort fehlt es, bei aktiver Suche sind alle Zähler ausgeblendet.
|
||
- **`.toolbar`:**
|
||
- `<input type="search" className="input search" aria-label="Tickets durchsuchen" placeholder="Suchen: Kunde, Betreff, Angefragt von oder WOID">`.
|
||
- `<select className="select select-quiet" aria-label="Art">` mit allen `TYPE_FILTERS`, `<span className="grow" />`, `<label className="check"><input type="checkbox">Nur dringende</label>`.
|
||
|
||
Entscheidung: Der Zählerausfall zeigt „–“ statt der Zahl (Übersicht, Review-Focus-Punkt 3), bis zur ersten Antwort steht nichts.
|
||
|
||
- [ ] **Step 4: Tabelle, Fuß, Zustände**
|
||
|
||
- **Spalten:** `.table` mit „Ticket“, „Status“, „Zuständig“ (`hide-sm`), „Termin“ (`hide-md`), „Zeit“ (`hide-md right num`), „Erstellt“ (`hide-sm right`).
|
||
- **Erste Zelle:**
|
||
- `.cell-title` mit `<Link className="row-link" to={ticketPath(ticketRef(t))} state={{ listSearch: location.search }}>`, darin `.id` mit `ticketNumber(t)` und der Betreff (Rückfall „Kein Betreff“), daneben `UrgentPill`.
|
||
- Darunter `.cell-sub` „{Kunde oder Unbekannt} · {Ort}“.
|
||
- **Weitere Zellen:** `StatusPill`, `assigneeLabel` (bei `none` mit `faint`), `appointmentText || '–'`, `minutesCellText(t, index, byWoid)`, `elapsedLabel(t)`.
|
||
- **Zeilenklick:** `tr.row` navigiert mit demselben State, wenn `isRowActivation(e)` (`src/lib/rowClick.js`, Task 2) wahr ist. Klicks auf `a`, `button`, `select`, `input`, `textarea` und `label` in der Zeile sowie Mittelklick und Klicks mit Modifikatortaste ignoriert der Helfer. Eine eigene `closest`-Prüfung gibt es in `TicketsPage` nicht.
|
||
- **Fuß `.table-foot`:**
|
||
- Links „{n} Tickets“ bzw. mit Suche „{n} Treffer“ (Singular „1 Ticket“, „1 Treffer“).
|
||
- Dahinter dezent „Wird geladen …“ beim Nachladen und „Zeit nur für die ersten 200 Zeilen berechnet“ bei mehr als 200 Zeilen. Rechts „Neueste zuerst“.
|
||
- **Zustände:**
|
||
- Der leere Ladezustand `<div className="loading">Wird geladen …</div>` erscheint nur beim allerersten Laden, danach bleiben beim Nachladen die alten Zeilen stehen.
|
||
- Fehler: `.msg.err` „Fehler: {error}“. Leer: `EmptyState` „Keine Tickets“ mit dem heutigen Hinweistext und einem Standardknopf „Neues Ticket“.
|
||
- **„Mehr laden“:** `<div className="list-more"><Button disabled={loading}>Mehr laden</Button></div>`, nur ohne Suche, bei `workorders.length >= state.limit` und `state.limit < 500`.
|
||
|
||
Entscheidung: „Mehr laden“ endet bei 500 und ist beim Laden deaktiviert. Der Knopf im leeren Zustand ist ein Standardknopf, damit pro Seite nur der Kopfknopf schwarz ist.
|
||
|
||
- [ ] **Step 5: „Neues Ticket“**
|
||
|
||
`CreateTicketModal` bekommt `customers` und `employees`. `handleCreate` ruft `createWorkorder(data)`. Bei Erfolg schließt es den Dialog und navigiert mit `navigate(ticketPath(ticketRef(result.data)), { state: { listSearch: location.search } })`. Die Zähler laden beim Wiederöffnen der Liste neu.
|
||
|
||
- [ ] **Step 6: Bauen und Ansichten prüfen**
|
||
|
||
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p2 && "$W/shots.sh" p2 "$W/shots/p2-t11" --only tickets,tickets-suche,tickets-altdaten,alt-link,fehler-tickets,dlg-ticket-neu,tasten-tickets,tasten-dialog --gate p2`
|
||
Expected:
|
||
- Exit 0 mit allen Schritten aus Task 7.
|
||
- `tickets` zeigt Reiter mit Zählern, Werkzeugleiste und Tabelle. Bei 390 px zeigt die Tabelle nur Ticket und Status, ohne waagerechtes Scrollen.
|
||
- `fehler-tickets` zeigt „–“ statt Zahlen, die Liste bleibt bedienbar. `tickets-altdaten` rendert alle Altwert-Zeilen.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/pages/TicketsPage.jsx
|
||
git commit -m "Tickets: Liste als Tabelle mit Reitern, Zaehlern, Zeit-Spalte und URL-Zustand; Suche per Tipp-Pause" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
### Task 12: Linkstellen auf `ticketPath`, Altes löschen
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/PlanboardPage.jsx` (Zeile 80), `src/pages/AssignProjectPage.jsx` (Zeilen 156 und 405), `src/pages/ServicesPage.jsx` (Zeile 72), `src/components/customer/CustomerFunktionen.jsx` (Zeile 104). `ProjectContextPanel` ist schon in Task 8 umgestellt.
|
||
- Delete: `src/components/TicketCard.jsx`, `StatusDropdown.jsx`, `EditorDropdown.jsx`
|
||
- Modify: `src/lib/ticketStatus.js` (`GROUP_CLASS` und `statusClass` entfernen), `src/styles/global.css`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ticketPath`, `ticketRef` (Task 2).
|
||
- Produces: keine Verweise mehr auf die gelöschten Dateien und Klassen.
|
||
|
||
- [ ] **Step 1: Links ersetzen**
|
||
|
||
| Stelle | Neu |
|
||
|---|---|
|
||
| `PlanboardPage` | `navigate(ticketPath(entry.woid \|\| entry.id))` (`entry.id` ist die `$id`) |
|
||
| `AssignProjectPage` Zeile 156 | `path = ticketPath(ticketRef(t))` |
|
||
| `AssignProjectPage` `TicketLink` | `to={ticketPath(woid)}` |
|
||
| `ServicesPage` | `to={ticketPath(p.woid)}` |
|
||
| `CustomerFunktionen` | `to={ticketPath(feature.ticketWoid)}` |
|
||
|
||
Jeweils mit Import aus `lib/ticketRoutes`. Texte und Verhalten bleiben.
|
||
|
||
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && ! rg -n "tickets\?woid" src`
|
||
Expected: keine Treffer.
|
||
|
||
- [ ] **Step 2: Bauen, Ansichten prüfen, Links committen**
|
||
|
||
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/check.sh" --nur-build && "$W/build.sh" "$WT" p2 && "$W/shots.sh" p2 "$W/shots/p2-t12" --only planboard,zuordnen,services,kunde-funktionen --gate p1`
|
||
Expected: Build grün, Gate `p1` besteht (Exit 0).
|
||
|
||
```bash
|
||
git add src/pages/PlanboardPage.jsx src/pages/AssignProjectPage.jsx src/pages/ServicesPage.jsx src/components/customer/CustomerFunktionen.jsx
|
||
git commit -m "Tickets: Links auf Tickets nutzen ticketPath (Planboard, Zuordnen, Services, Funktionen)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
- [ ] **Step 3: Nichts verweist mehr auf die alten Dateien**
|
||
|
||
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "TicketCard|StatusDropdown|EditorDropdown|statusClass" src tests`
|
||
Expected: Treffer nur in den drei Dateien selbst und in `ticketStatus.js`.
|
||
|
||
- [ ] **Step 4: Löschen**
|
||
|
||
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && git rm -q src/components/TicketCard.jsx src/components/StatusDropdown.jsx src/components/EditorDropdown.jsx`
|
||
Dazu in `ticketStatus.js` `GROUP_CLASS` (Zeilen 55-68) und `statusClass` löschen. In `global.css` (auch im KOMPAT-Block) entfallen `.tickets-list`, `.tcard*`, `.dropdown*`, `.ticket-tab*` (mit `.ticket-tab-content`), `.worksheet-summary*`, `.chevron*`, `.automatic-assignment-note*` und die Altregeln von `.urgent-toggle`, falls P1 sie noch trägt.
|
||
|
||
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && ! rg -n "tcard|dropdown|ticket-tab|worksheet-summary|chevron|tickets-list|automatic-assignment|statusClass" src`
|
||
Expected: keine Treffer. Die KOMPAT-Klassen `.status-*` und `.priority-*` bleiben bis P10.
|
||
|
||
- [ ] **Step 5: Tests, Build, Commit**
|
||
|
||
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/check.sh"`
|
||
Expected: Exit 0, `# fail 0`, Build grün.
|
||
|
||
```bash
|
||
git add src/lib/ticketStatus.js src/styles/global.css
|
||
git commit -m "Tickets: TicketCard, Dropdowns, statusClass und Altklassen (.tcard, .dropdown, .ticket-tab, .chevron) geloescht" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||
```
|
||
|
||
### Task 13: Rebase, Gate P2, Review, Tag
|
||
|
||
- [ ] **Step 1: Rebase und Tests**
|
||
|
||
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && git fetch origin && git rebase origin/test && git merge-base --is-ancestor design-p1 HEAD && "$W/check.sh"`
|
||
Expected:
|
||
- Rebase ohne Konflikt, `design-p1` bleibt Vorfahr von `HEAD` (sonst stoppen und melden: der Rebase hat P1 umgeschrieben).
|
||
- Exit 0, `# fail 0`, `# pass` ist `N1 + 41`. Liegen neue Commits auf `test`, kann die Zahl höher sein, sie wird notiert.
|
||
- Die Bundle-Größen (JS, CSS) aus der Ausgabe werden notiert und mit P1 verglichen (CSS kleiner durch die gelöschten Altklassen).
|
||
|
||
- [ ] **Step 2: Vollständiger Gate-Lauf**
|
||
|
||
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p2 && "$W/shots.sh" p2 "$W/shots/p2" --gate p2`
|
||
Expected: Exit 0 für alle Ansichten. Die Bilder zeigen den Stand, der getaggt wird. Keine Verstöße gegen `dark-surface`, `low-contrast`, `overflow-x`, `console-error`, `external-request` und `step-failed`. `icon-svg` ist für die P2-Ansichten sauber, außer unterhalb von `.ticket-projects`.
|
||
|
||
- [ ] **Step 3: Code-Hygiene im umgebauten Bereich**
|
||
|
||
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && ! rg -n "react-icons|@tabler|style=\{\{" src/pages/TicketsPage.jsx src/pages/TicketPage.jsx src/components/WorksheetList.jsx src/components/WorksheetSummary.jsx src/components/UrgentToggle.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 && ! rg -n "new Date\(.*(startDate|endDate|deadline)|format\(.*(startDate|endDate|deadline)" src && git diff design-p1..HEAD --stat -- src/hooks`
|
||
Expected: beide `rg` ohne Treffer. Die Statistik nennt als geänderte bestehende Datei nur `useWorkorders.js`, und dort nur Zeilen des Anfrage-Zählers.
|
||
|
||
- [ ] **Step 4: Code-Review (sonnet)**
|
||
|
||
Geprüft wird der Diff `design-p1..HEAD` gegen die Spec-Abschnitte „Ticket als eigene Seite“, „Texte“, „Was gleich bleibt“ und die Review-Focus-Punkte 1, 3, 4 und 5. Besondere Prüfpunkte: Zähler und Liste nutzen dieselben Filter, `TicketPage` hat keine Hook-Reihenfolge-Brüche, alle Dialoge übergeben `busy`, die Abo-Sichtbarkeitsregel ist unverändert, jede Ticket-Verlinkung läuft über `ticketPath`, jede klickbare Zeile über `isRowActivation`, jeder Nicht-Absenden-Knopf in einem Dialog hat `type="button"`. Wichtige Befunde werden behoben, Kleinigkeiten notiert. Nach Korrekturen läuft Step 1 noch einmal und Step 2 als Kurzlauf mit `--only tickets,ticket`.
|
||
|
||
- [ ] **Step 5: Tag und Übergabe**
|
||
|
||
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/check.sh" && git tag design-p2 && git log --oneline origin/test..design-p2 && git rev-parse design-p2`
|
||
|
||
Der Eigentümer bekommt:
|
||
- die Screenshots aus `shots/p2/1440x900` und `shots/p2/390x844` (Auswahl: `tickets`, `tickets-suche`, `ticket`, `ticket-arbeitsblaetter`, `ticket-altdaten`, `fehler-tickets`, alle vier `dlg-*`)
|
||
- den Hinweis auf die neuen Verhalten: Reiter statt Mehrfachfilter, Suche nach Tipp-Pause, Ticket als eigene Seite mit Link, Status und Zuständigkeit nur noch auf der Ticketseite, „Arbeitsblatt hinzufügen“ ohne Ticketnummer gesperrt
|
||
- die Abweichung von der Spec: Die Badges „+n Unterstützung“ und „Abo-Stunden“ sowie „↳“ und „✓“ in den Arbeitsblatt-Zeilen entfallen. Der Abo-Zustand bleibt sichtbar („Abo“ bzw. „+ Abo“ als Umschalter, ohne Umschalter als Text), Unterstützung steht als eigene Zeile.
|
||
- die Push-Zeile `git -C /home/webklar/apps/ticket.webklar.com/dev-design push origin 'design-p2^{commit}:refs/heads/test'`
|
||
- die Rücknahme `git revert --no-commit design-p1..design-p2`, Commit auf `test` und Push durch den Eigentümer (nur solange P2 das jüngste Paket ist)
|
||
|
||
- [ ] **Step 6: Nach dem Push (erst, wenn der Eigentümer gepusht hat)**
|
||
|
||
- Im Log von `webklar-ticket-build` steht „Deployment completed successfully“.
|
||
- `curl -s https://ticket.webklar.com/ | rg -o 'assets/index-[^"]*\.js'` entspricht `dist/index.html` des Tags.
|
||
- `curl -s -o /dev/null -w '%{http_code}' https://ticket.webklar.com/tickets/100131` liefert 200 (Seiten-Fallback für die neue Adresse).
|
||
- Ein lesender Sichttest im Chrome des Eigentümers: Ticketliste mit Zählern, ein Ticket mit beiden Reitern, ein alter Link `/tickets?woid=<N>` landet auf `/tickets/<N>`. Keine Schreibaktionen.
|