Files
tickte-system/docs/superpowers/plans/2026-10-05-weisses-design-p2-tickets.md
T

93 KiB
Raw Blame History

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:

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: 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
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:

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 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
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:

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.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
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:

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: 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
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
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):

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
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
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

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

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

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