93 KiB
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/libohne Appwrite-Import:ticketRoutes,ticketListUrl,rowClick,ticketView,worksheetFlow,worksheetView,ticketTimes,workorderLookup,requestGuard. - Drei neue dünne Hooks (
useWorkorder,useTicketCounts,useTicketMinutes).useWorkordersbekommt als einzige Hook-Änderung den Anfrage-Zähler. TicketPage(Route/tickets/:ref/:tab?) ruftuseWorksheets,useCustomerAbounduseEmployeesje einmal auf und verdrahtet die Dialoge.TicketsPagehä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/libaußer dem dünnenelapsed.js) importieren nieappwrite.js, damit sie unter Node testbar bleiben.date-fnsist erlaubt (der esbuild-Lader bündelt es).elapsed.jsbleibt 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 überlib/ticketView.jsangezeigt (parseGermanDate/formatTime). Nienew Date()oderformat()auf den gespeicherten Text. - Links auf Tickets entstehen nur mit
ticketPath. Zähler und Zeitspalte melden Fehler mitconsole.warn, nie mitconsole.error. - Nicht angefasst werden
TicketProjects,AssignedProjectCard,PreviewBlockund die Link-Buttons (P5). Bis P5 enthalten sie noch Icons. - Dialoge: Texte wörtlich, keine neuen „Abbrechen“-Knöpfe, Fehler als
.msg.errmitrole="alert"oben im Dialogkörper, Ladetexte „Speichert …“ und „Wird angelegt …“. Jeder Knopf in einem<Dialog as="form">außer dem Absenden-Knopf trägttype="button"(Buttonsetzt 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)aussrc/lib/rowClick.js(entsteht in Task 2). - Jede Testdatei beginnt wie
tests/unit/returnPath.test.mjsmit den Importsnode:test,node:assert/strictund./load.mjs, die Snippets zeigen sie nicht. - Testzahl: Stand nach P1 als
N1notieren. P2 bringt 41 neue Tests (37 für die Logik, 4 fürrowClick), danach giltN1 + 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-altdatenundticket-altdaten-<fall>(datumKurz2.5.2026,datumUngueltig31.02.2026,datumIso,ohneWoid,statusLeer,statusUnbekannt,ohneKunde,langeTexte).- Erwartet: Seite rendert ohne
console-error, unlesbare Datumswerte erscheinen als Rohtext,2.5.2026als02.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“.
- Erwartet: Seite rendert ohne
- Punkt 3, Teilausfall: Ansichten
fehler-ticketsundfehler-ticket.- Zähler und Spalte „Zeit“ zeigen „–“, „Zuständig“ fällt auf
assignedNamezurück, Zeilen bleiben anklickbar. - Auf der Ticketseite zeigt der Reiter Arbeitsblätter den Fehlertext, die Zuständig-Auswahl behält den aktuellen Wert.
- Zähler und Spalte „Zeit“ zeigen „–“, „Zuständig“ fällt auf
- 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) undtickets-suche(250 Treffer). Spalte „Zeit“ nur für die ersten 200 Zeilen mit Hinweis im Fuß, das große Ticket zeigt77 h 30 min, Zeile 201 zeigt „–“, die Seite bleibt bedienbar. - Zusätzlich, Zähler = Liste:
countFiltersundlistFiltersliefern 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: Eintragallbekommt 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_TABSLIST_TABSmit[{ id, label, groups: string[] | null }],LIST_DEFAULTS = { tab: 'aktiv', typeId: 'orders', urgentOnly: false, search: '', limit: 10 }parseListParams(input)(URLSearchParamsoder 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 | nullisRowActivation(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:
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:
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):
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:refwird zuString(ref ?? '').trim(). Leer ergibt'/tickets', sonst'/tickets/' + encodeURIComponent(ref)plus'/arbeitsblaetter'nur für genau diesen Reiter.ticketRefistString(ticket?.woid || ticket?.$id || '').listBackPathhängtlistSearchnur an, wenn es ein String mit?und mindestens einem weiteren Zeichen ist. -
LIST_TABS: erstaktivmitDEFAULT_STATUS_GROUPS, dann je Gruppe ausSTATUS_GROUPS(id,label,groups: [id]), zuletztallemitgroups: null. -
parseListParams:limitgilt nur bei/^\d+$/, sonst 10, danach auf 10 bis 500 begrenzt. Unbekannte Reiter- oder Art-Ids fallen auf den Standard,dringendgilt nur bei1,qwird getrimmt. -
listFilters:statusist[]beigroups === null, sonststatusValuesFor(groups).typeundexcludeTypekommen ausTYPE_FILTERS,priorityist[3, 4]beiurgentOnly,searchiststate.search || undefined.countFiltersistlistFilters({ ...state, tab: tabId })ohnesearchundlimit. -
In
ticketStatus.jsdas Label ändern:{ id: 'all', label: 'Aufträge und Akquise' }. -
isRowActivation(event)ist falsch beievent.defaultPrevented,event.button !== 0oder gedrückter Meta-, Strg-, Umschalt- oder Alt-Taste. Sonst ist es falsch, wennevent.target?.closest?.('a,button,select,input,textarea,label')ein Element trifft, das inevent.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
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 ExportstatusSelectOptions) - Test:
tests/unit/ticketView.test.mjs(neu),tests/unit/ticketStatus.test.mjs(aus P1: Import-Zeile umstatusSelectOptionserweitern)
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:
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:
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 ohnestartDate, sonstdisplayDate(startDate), dann,plus Beginn, dann–plus Ende. Das Ende steht nur mit Beginn. -
assigneeLabel: Mitarbeiter überuserId, dannshortName(displayName), RückfallshortName(assignedName), sonst „Unbekannt“.shortNameist erstes Wort plusplus Initial des letzten Wortes plus.. -
assigneeOptions: Mitarbeiter ohneuserIdentfallen, Label istdisplayName || email || userId. Die Zusatzoption steht am Ende, wennassignedTogesetzt und nicht in der Liste ist, ihr Label istassignedNameoder „Unbekannte Person“. -
statusSelectOptions(cur):TICKET_STATUSESplus{ value: cur, label: statusLabel(cur) }, wenncurgesetzt und nicht enthalten ist. Leer zählt alsOpen.
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
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' | nullcreateWorksheetFlow({ ticket, worksheetData, user, createWorksheet, updateTicket, updateCustomer, now = () => new Date() }) → Promise<result>sumWorksheetMinutes(worksheets, predicate?),worksheetSums(worksheets) → { totalMinutes, aboMinutes, openCount }showAboToggle(ws, aboHoursAllowed)(die PrüfungonTogglePlanbleibt 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):
createWorksheet(worksheetData, user).- Bei Erfolg und
newStatus !== ticket.status:updateTicket(ticket.$id, { status: newStatus }). - Danach bei Erfolg,
ticket.type === 'Akquise'undticket.customerId:updateCustomer(customerId, { customerStatus, updatedAt: now().toISOString() }).customerStatusist'customer'bei „Zugesagt“,'lost'bei „Abgesagt“, sonst kein Aufruf. Ein Fehler hier wird verschluckt. - Rückgabe ist das Ergebnis von
createWorksheet.
- Step 1: Failing Tests schreiben
worksheetFlow.test.mjs:
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:
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.jsnach der Beschreibung oben. Ticket-Update vor Kunden-Update, wie bisher. Die Funktion kennt kein Appwrite, die Seite reichtupdateCustomerherein (Task 10). -
sumWorksheetMinutes: Summe vonworksheetMinutes(ws)über!ws.isComment && predicate(ws). Dieselbe Funktion gilt für die Spalte „Zeit“ (Task 5).worksheetSums: gesamt, Abo mit PrädikatcountsToPlan, offen als AnzahlneedsTimeEntry. -
showAboToggle:!isComment && Number(totalTime) > 0 && (aboHoursAllowed || countsToPlan). -
worksheetWhen: Startzweig, wennstartDategesetzt ist, sonst Endzweig (displayDate,displayTime). -
worksheetKindLabel:isComment && !isGitWorksheet→ „Kommentar“, sonstserviceTypeLabel(serviceType) || ''.worksheetBadgeüberworksheetTone:git-open→ orange „Zeit offen“,git-done→ grün „Zeit eingetragen“. -
statusAfterLabel: nur beinewStatusnicht leer und ungleicholdStatus, TextStatus danach:plusstatusLabel(newStatus). -
supportLines: leer bei Kommentar, sonst je Person['Unterstützung', firstName(name)]ohne Leerwerte,· task ·undstepLabel(supportMinutes(totalTime)). -
timeLabel: Kommentar'',<= 0'–', sonststepLabel(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
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 = 3chunk(list, size),planTimeLoad(tickets, known) → { blocks: string[][], capped }(knownhat.has)blockMinutes(woids, documents, total, limit = 5000) → { [woid]: number } | nullmapLimit(items, limit, fn) → Promise<Array<{ ok: true, value } | { ok: false, error }>>minutesCellText(ticket, index, byWoid: Map) → stringlookupWorkorder(ref, { findByWoid, getById }) → Promise<{ status: 'ok', ticket } | { status: 'notfound' }>createRequestGuard() → { next(), isCurrent(id) }
-
Step 1: Failing Tests schreiben
ticketTimes.test.mjs:
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:
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:
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: nimmttickets.slice(0, TIME_MAX_ROWS), bildet woids alsString(woid).trim(), entfernt leere, doppelte und bekannte, teilt mitchunk(…, TIME_BLOCK).cappedisttickets.length > TIME_MAX_ROWS. -
blockMinutes:null, wenndocuments.length >= limitoder (totalist eine Zahl undtotal > documents.length). Sonst je woid des BlockssumWorksheetMinutesder Dokumente mitString(doc.woid). -
mapLimit: Worker-Pool, Ergebnisse in Eingabereihenfolge, verwirft nie.minutesCellText:'–'beiindex >= TIME_MAX_ROWS, ohne woid, ohne Eintrag, beinullund bei 0, sonststepLabel(minutes). -
lookupWorkorder:refwird getrimmt, leer ergibtnotfound. ErstfindByWoid(ref), ohne TreffergetById(ref). Dessen Fehler mitcode400 oder 404 ergibtnotfound, 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
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 inuseWorkorders{ success, data }oder{ success: false, error }und ersetzt das geladene Ticket.useTicketCounts({ typeId, urgentOnly }) → { counts }.counts[tabId]ist eine Zahl, bei Fehlernull, vor der ersten Antwort fehlt der Schlüssel.useTicketMinutes(tickets) → { byWoid: Map, capped, version }. Ein Fehler setzt alle woids des Blocks aufnull.
-
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
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.mjsexportiertALT_CASES = [{ key, ref, tab? }]mit den SchlüsselndatumKurz(startDate: '2.5.2026'),datumUngueltig(deadline: '31.02.2026'),datumIso,ohneWoid(refist die$id,tab: 'arbeitsblaetter'),statusLeer,statusUnbekannt('Completed'),ohneKunde,langeTexte(Betreff 120, Firma 60 Zeichen). -
viel.mjsexportiertEXPECT = { 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 hatpriority: 2(nicht dringend). Fehlt an ihm ein Git-Blatt ohne Zeit mitemployeeName: '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, mittotalTime: 0undcountsToPlan: true(Abo-Zustand ohne Umschalter). -
backend.mjs, Szenariofehlerzusätzlich: GET aufworkordersmitlimit(1)und ohneequal("woid"antwortet 500. Das trifft nur die Zähler, dennuseWorkorderfiltert immer aufwoid. -
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:.crumbenthält „Tickets / 100131“. Der Menüpunkt „Tickets“ hat.active..factshat 6.fact.select[aria-label="Status"]hat die 6 Werte vonTICKET_STATUSES. Im Seitenkopf steht genau ein.btn-primary.- Zuletzt zwei Klicks auf
.urgent-toggle, nach jedem wartet der Schritt auf den Wechsel vonaria-pressed(false→true→false).ctx.backend.callsenthält danach genau zwei PATCH aufworkorders, deren Bodypriority4 und dann 2 setzt. Das schützt vor einem doppeltenpriorityFor(Task 10).
-
ticket-arbeitsblaetter:.statshat 3.stat, die Zahl der.wsentspricht den Testdaten,.ws-acts .linkhat transparenten Hintergrund und Randbreite 0.- Das 90-Minuten-Blatt hat
button.abo-toggle.onmit „Abo“. Das Blatt mit Zeit 0 undcountsToPlanzeigt „Abo“ als Text ohnebutton.abo-toggle.
-
ticket-viel: 300.ws, das.stat„Arbeitszeit gesamt“ zeigtEXPECT.bigLabel. -
ticket-altdaten-<key>:datumKurz: „Termin“ enthält02.05.2026.datumUngueltig: „Fällig“ zeigt31.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“ istdisabledund hat dentitle„Für dieses Ticket gibt es keine Ticketnummer“.ohneKunde: „Unbekannt“ steht in.sub.statusUnbekannt: Die Status-Auswahl enthält und wähltCompleted.
-
ticket-unbekannt:/tickets/100131/xyzwird zu/tickets/100131, im Verlauf ohne eigenen Eintrag (replace)./tickets/999999zeigt „Ticket nicht gefunden“. -
alt-link: Die URL endet auf/tickets/100131, ohnestatus=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 vonbeyondWoidzeigt „–“. Ein Klick auf.cell-subder ersten Zeile (kein Link, also Zeilenklick überisRowActivation) öffnet in unter 2 s ihre Ticketseite. - Tippt der Schritt „Tes“, wartet 100 ms und tippt „t“, steht nach 500 ms
q=Testin der URL,history.lengthist unverändert.
-
dlg-arbeitsblatt-unterstuetzung: Ein Klick auf „+ weitere Person“ und danach auf „Entfernen“ sendet nichts:ctx.backend.callsenthält keinen POST aufworksheets(beide Knöpfe tragentype="button"). -
fehler-tickets: Reiterzähler und jede Zelle der Spalte „Zeit“ zeigen „–“, „Zuständig“ zeigt den gekürztenassignedName, ein Klick auf.cell-subeiner 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: Abbodyerreicht Tab nach höchstens 40 Anschlägen einen Linktr.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 nachscrollIntoView({ 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-closedisabled, 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.callsenthält genau einen POST aufworksheets(Doppelklick-Sperre).
- Ein Seiten-Handler verzögert den POST:
-
Step 4: Gate
p2anlegen
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):
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 ProptotalTimeentfällt, es war unbenutzt.elapsedLabel(ticket):formatDistanceToNow(createdDate(ticket), { locale: de, addSuffix: true }), beinull„–“. 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 vona: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>.titleund Logik bleiben wörtlich:onClickruft weiteronChange(priorityFor(!urgent))auf,onChangebekommt also schon die Prioritätszahl (4 oder 2). Wer die Komponente benutzt, wendet kein zweitespriorityForan. -
WorksheetSummary:nullbei leerer Liste (wie heute), sonst<div className="stats">mit dreiStat: „Arbeitszeit gesamt“ (stepLabel(totalMinutes)), „Davon Abo-Stunden“ (stepLabel(aboMinutes)), „Zeiten offen“ (openCount,tone="warn"bei mehr als 0). -
Step 3:
WorksheetListneu
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:EmptyStatemit Titel „Noch keine Arbeitsblätter.“ und Hinweis „Leg das erste mit „Arbeitsblatt hinzufügen“ an.“ - Links (
.ws-when):.distworksheetWhen(ws).date(leer → „–“), darunter die Zeit in.num. - Mitte:
.ws-head:.whomitemployeeName,employeeShortals<span className="faint xs">,.mutedmitworksheetKindLabel,.faint.xs.nummit „WSID {wsid}“, das Badge ausworksheetBadge(<span className="badge {tone}">) und der Abo-Zustand (Umschalter oder Text, siehe unten).- Darunter
.ws-text(details), je Zeile aussupportLinesein.ws-support, beistatusAfterLabelein.ws-support, zuletzt.ws-acts.
.ws-acts:<button type="button" className="link">mit „Zeit nachtragen“ (beineedsTimeEntry) oder „Zeiten bearbeiten“, nur beionEditTimes && canEditTimes?.(ws). Dazu „Meine Unterstützung bearbeiten“ (der Nutzer steht inparseSupporters) oder „Ich habe unterstützt“, nur beionOwnSupport && canAddSupport?.(ws). Dietitle-Texte der alten Knöpfe bleiben.- Abo-Zustand:
- Umschalter:
<button type="button" className="abo-toggle{ on}">, sichtbar beionTogglePlan && showAboToggle(ws, aboHoursAllowed). BeicountsToPlanist er an (on) und heißt „Abo“, sonst „+ Abo“.titlebleibt: „Nicht mehr vom Abo-Stundenkonto abziehen“ bzw. „Vom Abo-Stundenkonto des Kunden abziehen“. - Ohne Umschalter (Zeit 0 oder kein
onTogglePlan) steht beicountsToPlan && !isCommentstattdessen<span className="muted xs">Abo</span>. So bleibt der Abo-Zustand sichtbar, den das alte Badge „Abo-Stunden“ zeigte.
- Umschalter:
- Rechts (
.ws-time):.tisttimeLabel(ws), bei „–“ mit Klassefaint.
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:
ProjectContextPanelals „Weitere Tickets von …“
Abfrage (Query.equal('customerId'…), limit(50)) und Sichtbarkeitsregel (nichts ohne zugewiesenes Projekt) bleiben. Neu:
Cardmit TitelWeitere Tickets von {ticket.customerName}, ohne Kundenname „Weitere Tickets“.- Zeilen:
<Link className="list-row" to={ticketPath(ticketRef(t))} state={location.state}>mit.cell-title(.idplus Betreff) undStatusPill.locationkommt aususeLocation(). - 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">.IconFoldersund 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
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 istemployees, der Dialog ruftuseEmployeesnicht 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 vonuseEmployees()), 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
nullohneisOpenwie heute,wide, Titel „Neues Ticket“. -
Felder: Kunde und Art als
.field-row, Betreff, „Angefragt von“ (AutocompleteInput,getSuggestions,required), Beginn und Deadline (jeDateTimeInputin einemField), 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 bisherigentitle. -
Beginn und Ende als
DateTimeInput, Arbeitszeit alsDurationSelectmit.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,validateSupportersund Fehlertexte bleiben.
Entscheidung: Das „✓“ vor dem Berechnungshinweis entfällt (Umbaukarte 4.B), die Wörter bleiben.
-
Step 3:
WorksheetTimesModalundOwnSupportModal -
WorksheetTimesModal:- Titel „Zeit nachtragen“ oder „Zeiten bearbeiten“ (
needsTimeEntry). Darunter<p className="muted">mitWSID {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 (
.hintwie oben), dannSupportersEditor. Knopf „Speichern“ bzw. „Speichert …“ (statt „Speichert...“, laut Spec).
- Titel „Zeit nachtragen“ oder „Zeiten bearbeiten“ (
-
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ß: linksButton 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
OwnSupportModalsetzt ihn perref, er läuft nach dem Fokus-Effekt desDialog.
- Titel „Unterstützung eintragen“. Darunter
-
Step 4:
SupportersEditorals Feldblock -
<div className="field supporters-editor">mit<label className="check">„Unterstützung“. Die Mitarbeiter kommen ausemployeesState, kein eigeneruseEmployees-Aufruf. -
Je Zeile
.supporter-rowmit Select „Person wählen …“ (aria-label="Mitarbeiter") und Input (aria-label="Wobei?",placeholder="Wobei?",maxLength={200}). Darunter.sup-metamit „= {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
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 }aususeParams(),useWorkorder(ref).resolveTicketTab(tab) === nullergibt<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 AktionButton 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) }],eyebrowist{ticketNumber} · {ticketTypeLabel(type)}(ohne Art nur die Nummer),titleisttopic || title || 'Kein Betreff'.subtitleist der Kunde, beicustomerIdals<Link className="link" to={'/customers/' + customerId}>, sonst Text, ohne Namen „Unbekannt“, dazu· {customerLocation}.actionssindUrgentToggle,<select className="select" aria-label="Status">und der einzige schwarze Knopf „Arbeitsblatt hinzufügen“ (alle Ticketarten). Ohnewoidist erdisabledmittitle="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}>mitassigneeOptions, 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).
- „Zuständig“:
- Schreiben: Status
{ status: value }(Optionen ausstatusSelectOptions(ticket.status), Wertticket.status || 'Open'), Zuständig{ assignedTo: value }(''für „Nicht zugewiesen“), Dringend<UrgentToggle value={ticket.priority} onChange={(priority) => update({ priority })} />.UrgentToggleliefert schon die Zahl (Task 8),TicketViewwendet kein zweitespriorityForan. Sonst ergäbepriorityFor(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>, dannTicketProjects, dannProjectContextPanel. - 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.errmit dem Hook-Text statt der Liste. - Sonst
WorksheetSummaryundWorksheetListmitcanEditTimes={(ws) => canEditWorksheetTimes(ws, user, employee)},canAddSupport={(ws) => canAddOwnSupport(ws, user, employee)},onTogglePlan={(ws) => updateWorksheet(ws.$id, { countsToPlan: !ws.countsToPlan })},aboHoursAllowed,currentUserId={user?.$id}.
- Ohne woid steht dort
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
-
CreateWorksheetModalbekommtworkorder={ticket}(also immerticket.woiddes geladenen Tickets),aboHoursAllowedundemployeesState. -
onCreateist(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. -
OwnSupportModalundWorksheetTimesModalwerden wie inTicketCardbedingt gerendert, mitsaveOwnSupport(ws, task, user, employee)undupdateWorksheetTimes(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.
-
ticketzeigt 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-unterstuetzungstehen die Zeilen zweizeilig. -
Step 7: Commit
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 mitsetSearchParams((prev) => serializeListParams(withFilter(parseListParams(prev), patch)), { replace: true }). „Mehr laden“ schreibtlimit: 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 sichstate.searchvon außen (Zurück-Knopf), folgt der Text, eigene Übernahmen werden nicht zurückgespiegelt (MerkerappliedRef). -
Hooks:
useWorkorders(listFilters(state)),useTicketCounts({ typeId, urgentOnly }),useCustomers(),useEmployees()(einmal, die Mitarbeiter gehen auch anCreateTicketModal),useTicketMinutes(workorders). -
Step 3: Kopf, Reiter, Werkzeugleiste
-
PageHeader: Titel „Tickets“, Untertitel „Alle Arbeitsaufträge auf einen Blick“, AktionButton variant="primary"„Neues Ticket“. -
Tabs:LIST_TABSmitcount: Zahl, beinull„–“, 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 allenTYPE_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:
.tablemit „Ticket“, „Status“, „Zuständig“ (hide-sm), „Termin“ (hide-md), „Zeit“ (hide-md right num), „Erstellt“ (hide-sm right). -
Erste Zelle:
.cell-titlemit<Link className="row-link" to={ticketPath(ticketRef(t))} state={{ listSearch: location.search }}>, darin.idmitticketNumber(t)und der Betreff (Rückfall „Kein Betreff“), danebenUrgentPill.- Darunter
.cell-sub„{Kunde oder Unbekannt} · {Ort}“.
-
Weitere Zellen:
StatusPill,assigneeLabel(beinonemitfaint),appointmentText || '–',minutesCellText(t, index, byWoid),elapsedLabel(t). -
Zeilenklick:
tr.rownavigiert mit demselben State, wennisRowActivation(e)(src/lib/rowClick.js, Task 2) wahr ist. Klicks aufa,button,select,input,textareaundlabelin der Zeile sowie Mittelklick und Klicks mit Modifikatortaste ignoriert der Helfer. Eine eigeneclosest-Prüfung gibt es inTicketsPagenicht. -
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“.
- Der leere Ladezustand
-
„Mehr laden“:
<div className="list-more"><Button disabled={loading}>Mehr laden</Button></div>, nur ohne Suche, beiworkorders.length >= state.limitundstate.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.
-
ticketszeigt Reiter mit Zählern, Werkzeugleiste und Tabelle. Bei 390 px zeigt die Tabelle nur Ticket und Status, ohne waagerechtes Scrollen. -
fehler-ticketszeigt „–“ statt Zahlen, die Liste bleibt bedienbar.tickets-altdatenrendert alle Altwert-Zeilen. -
Step 7: Commit
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).ProjectContextPanelist schon in Task 8 umgestellt. - Delete:
src/components/TicketCard.jsx,StatusDropdown.jsx,EditorDropdown.jsx - Modify:
src/lib/ticketStatus.js(GROUP_CLASSundstatusClassentfernen),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).
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.
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-p1bleibt Vorfahr vonHEAD(sonst stoppen und melden: der Rebase hat P1 umgeschrieben). -
Exit 0,
# fail 0,# passistN1 + 41. Liegen neue Commits auftest, 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/1440x900undshots/p2/390x844(Auswahl:tickets,tickets-suche,ticket,ticket-arbeitsblaetter,ticket-altdaten,fehler-tickets, alle vierdlg-*) -
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 auftestund 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-buildsteht „Deployment completed successfully“. -
curl -s https://ticket.webklar.com/ | rg -o 'assets/index-[^"]*\.js'entsprichtdist/index.htmldes Tags. -
curl -s -o /dev/null -w '%{http_code}' https://ticket.webklar.com/tickets/100131liefert 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.