Files
tickte-system/docs/superpowers/plans/2026-10-05-weisses-design-p5-projekte.md
T
ClaudeandClaude Opus 5.5 4af78f0bad Plaene fuer das weisse Design (Uebersicht, P0-P10)
Umsetzungsplaene je Paket mit gemeinsamen Regeln (env.sh/check.sh,
Gate-Schema, CSS-Reihenfolge, TP2-Pruefung vor jedem Paket) und den
Schnittstellen aus P1. Gegen Spec und untereinander geprueft (zwei
Pruefer, Korrekturrunde, Nachpruefung).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:02:54 +00:00

900 lines
69 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WOMS „Weißes Design“ P5: Projekte – 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:** Nach P5 steht der Projektbereich im neuen Design. Die Projektliste ist eine Tabelle mit Reitern, beide Projekt-Dialoge laufen über `Dialog`, Zuordnen-Assistent und Services bestehen aus ruhigen Abschnitten. Alle Projekt-Bausteine (am Ticket und beim Kunden) kommen ohne Karten, Icons und Farbliterale aus. Jede heutige Funktion bleibt erreichbar.
**Architecture:**
- Reine Logik, die heute in `ProjectsPage.jsx` steckt (Projektart, Hosting-Zustand, Aktionen), wandert mit Tests nach `src/lib/projectRow.js`. `src/lib/projectList.js` bekommt den neuen Export `projectTabs`.
- Die Töne der Status-Vokabulare (Preview, Analyse, Projektstatus) werden in `src/components/projectTones.js` auf Badge-Töne abgebildet. `previewEnv.js`, `previewView.js` und `analyseView.js` bleiben unverändert.
- `global.css` bekommt am Ende den Block „Projekte (P5)“. Die Komponenten benutzen nur Bausteine aus P1.
**Tech Stack:** React 18, react-router-dom 6, date-fns 2 (`formatDistance`, Locale `de`, wie schon in `TicketCard`), reines CSS. Tests mit `node --test`, Screenshot-Werkzeug aus P0.
**Spec:** `docs/superpowers/specs/2026-10-04-weisses-design-design.md`, Abschnitte „Bausteine“ (Tabelle, Liste, Dialog, Badge, Reiter, Meldungen, Laden), „Ticket als eigene Seite / Route und Links“ (Ticket-Spalte), „Texte“, „Was gleich bleibt“ und Paket P5. Dazu der Anhang `docs/superpowers/specs/2026-10-04-weisses-design-umbaukarte.md` (Abschnitte 3, 4, 5.P5, 7). Globale Regeln und gemeinsame Schnittstellen: `2026-10-05-weisses-design-00-uebersicht.md`.
## Global Constraints
Jeder Run-Block beginnt mit `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh`. Danach gelten `$WT` (Worktree, Arbeitsverzeichnis), `$W` (Screenshot-Werkzeug) und `$TMPDIR`. Zeilenangaben beziehen sich auf den Stand `603d08c` und sind nach P1 bis P4 verschoben. Maßgeblich ist der Inhalt.
Es gelten die Regeln der Übersicht. Für P5 kommt hinzu:
- **Vorbedingung:** Die Tags `design-p1` bis `design-p4` existieren. Task 0 prüft das und rebased auf `origin/test`.
- **Dateien dieses Pakets:** `ProjectsPage`, `AssignProjectPage`, `ServicesPage`, `TicketProjects`, `AssignedProjectCard`, `AnalyseRow`, `PreviewBlock`, `PreviewLinkButton`, `GiteaLinkButton`, `CopyableCredential`, dazu `global.css` und `AdminPage.css` (nur `.credential*`).
- **Unverändert bleiben:** alle Hooks, `lib/previewEnv.js`, `lib/previewView.js`, `lib/analyseView.js`, `lib/projectAssign.js`, `lib/assignFlow.js`, `lib/servicesView.js`, `lib/projectAdminApi.js` und die Signaturen von `AssignedProjectCard`, `TicketProjects`, `AnalyseRow`, `PreviewBlock`, `CopyableCredential`, `copyText`, `getCustomerPortalPassword`. `lib/projectList.js` bekommt nur einen neuen Export.
- **Texte:** wörtlich wie heute (auch die ae/ue-Schreibung wie „Oeffentliche“ oder „angestossen“). Neu sind nur die Texte der Spec-Liste „Texte“ und die mit „Entscheidung:“ markierten Zeilen dieses Plans.
- **Status-Vokabulare** (`previewStatus().color`, `analyseStatus().color`, `previewState`, `status`, `provisioningStatus`) werden nicht geändert. Die Tonabbildung liegt in `projectTones.js`.
- **Jeder `Button` und jeder Textlink-Knopf (`button.link`) bekommt ein explizites `type`** (`button` oder `submit`), weil P1 keinen Standard setzt. In einem `<Dialog as="form">` trägt jeder Knopf außer dem Absenden-Knopf `type="button"`.
- **Zeilenklick:** Klickbare Tabellenzeilen prüfen Klicks mit `isRowActivation(event)` aus `src/lib/rowClick.js` (P2, Task 2). P5 baut keinen eigenen Helfer.
- **Abschnittsabstände:** Zwischen aufeinanderfolgenden Abschnitten (`Card`) sorgt `.page section + section` (P1). P5 hat dafür keine eigene Regel.
- **Keine `style={{…}}`:** P5 hat keine Laufzeitwerte. Kein Farbliteral, kein `btn-sm`, keine Icon-Importe in den Dateien dieses Pakets.
- **`window.confirm` und `window.alert`** bleiben an ihren Stellen.
- **Ticketlinks** über `ticketPath` aus `src/lib/ticketRoutes.js` (P2). Die drei Stellen in `AssignProjectPage` (2×) und `ServicesPage` sind seit P2 umgestellt und bleiben es.
- **Testzahl:** Stand `design-p4` plus 13 neue Tests (7 in `projectRow`, 2 in `projectList`, 4 in `projectTones`).
## Review Focus
Aus der Übersicht gehören vier Punkte zu P5. Die Prüfungen laufen als Ansichten im Gate `p5` (Task 4).
- **Punkt 2, lange Inhalte am Handy:** Ansicht `projekte-lang` (Projektname 120 Zeichen, Repo-Pfad 90 Zeichen, Kundenname 60 Zeichen). Dazu `projekte-861` und `projekte-1101` (Menü fest, aber wenig Platz). Erwartet: kein `overflow-x`, die erste Spalte bleibt (gerundet) mindestens 140 px breit.
- **Punkt 3, Teilausfall:** Ansicht `fehler-projekte` (Mitarbeiterliste antwortet mit 500). Erwartet: Tabelle und Reiter bedienbar, Mitarbeiter-Auswahl zeigt nur „Alle Mitarbeiter“.
- **Punkt 4, nur Tastatur:**
- Das `<button class="link">` der ersten Zelle ist per Tab erreichbar, Enter öffnet „Projekt bearbeiten“, Esc schließt.
- Während Speichern ist „ד gesperrt und Esc wirkungslos.
- Enter im Namensfeld speichert, Enter in den Feldern des `PreviewBlock` speichert nie.
- Klicks auf Link, Auswahl und Knopf in einer Zeile öffnen den Dialog nicht.
- **Punkt 5, viele Einträge:** Ansicht `projekte-viel` (300 Projekte, 40 Kunden) rendert ohne Verstoß, der Bearbeiten-Dialog öffnet.
- **Altdaten:** Projekt ohne Namen („(ohne Name)“), Ticket-`$id` ohne Treffer in der Ticketliste (Rohwert als Link), `$updatedAt` unlesbar oder leer („-“), Status leer („-“). Sie stehen in `projekte-lang`.
- **Kein Flackern:** Nach Hosten, Aktualisieren, Kundenwechsel und Speichern bleibt die Tabelle stehen. Der Text „Wird geladen …“ erscheint nur beim ersten Laden.
- **Jede heutige Steuerung ist erreichbar:** Die Zuordnungen stehen in den Tabellen der Tasks 7 bis 13. Task 14 prüft sie anhand der Ansichten.
---
### Task 0: Paketstart
- [ ] **Step 1: Stand und Rebase**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && git fetch origin && git merge-base --is-ancestor design-p4 HEAD && git rebase origin/test && "$W/check.sh"`
Expected: Exit 0 und `# fail 0`. Die Zahl `# pass` wird notiert (Basis für Task 14). Ist `design-p4` kein Vorfahre von `HEAD`, stoppt der Agent und meldet es dem Eigentümer.
- [ ] **Step 2: Parallele Arbeit an TP2**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && git log --oneline origin/test..origin/feature/terminplaner-tp2 -- src/pages/ProjectsPage.jsx src/pages/AssignProjectPage.jsx src/pages/ServicesPage.jsx src/components/TicketProjects.jsx src/components/AssignedProjectCard.jsx src/components/AnalyseRow.jsx src/components/PreviewBlock.jsx src/components/PreviewLinkButton.jsx src/components/GiteaLinkButton.jsx src/components/CopyableCredential.jsx src/pages/AdminPage.css src/styles/global.css src/lib/projectList.js`
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: Bausteine aus P2**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && ls src/lib/rowClick.js src/lib/ticketRoutes.js && rg -n "isRowActivation" src/lib/rowClick.js`
Expected: Beide Dateien sind da, `isRowActivation` steht in `rowClick.js` (P2, Task 2). Fehlt etwas, stoppt der Agent.
### Task 1: Projekt-Helfer `projectRow.js`
**Files:**
- Create: `src/lib/projectRow.js`
- Test: `tests/unit/projectRow.test.mjs` (neu)
**Interfaces:**
- Consumes: `fmtDateTime(value)` aus `src/lib/format.js` (liefert `05.10.26, 09:00`), `formatDistance` aus `date-fns`, `de` aus `date-fns/locale`.
- Produces (1:1 aus `ProjectsPage.jsx` L35-85 herausgezogen, Töne statt `badge-*`-Klassen):
- `PROJECT_TYPES`: `{ preview, website, template, project }`, je `{ label, short, tone, hint, adminOnly? }`. Die Reihenfolge der Schlüssel bestimmt die Optionen im Dialog.
- `HOSTED_TYPES`: `Set(['preview','website','template'])`.
- `typeOf(project) → 'template' | 'website' | 'preview' | 'project'`.
- `isProjectReady(project) → boolean`.
- `projectActions(project) → { type, hosted, ready, host, preview, refresh, gitea, hostedBadge }`.
- `projectTicketRef(project, ticketMap) → string`.
- `updatedLabel(iso, now = new Date()) → { text, title }`.
- Den Zeilenklick-Helfer `isRowActivation(event)` liefert `src/lib/rowClick.js` (P2, Task 2). `projectRow.js` baut keinen eigenen.
- [ ] **Step 1: Failing Test schreiben**
```js
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { PROJECT_TYPES, typeOf, isProjectReady, projectActions, projectTicketRef, updatedLabel } = await loadModule('src/lib/projectRow.js')
test('typeOf: Vorlage vor Website vor Preview vor Projekt', () => {
assert.equal(typeOf({ isTemplate: true, isPublic: true, subdomain: 'x' }), 'template')
assert.equal(typeOf({ isPublic: true, subdomain: 'x' }), 'website')
assert.equal(typeOf({ subdomain: 'x' }), 'preview')
assert.equal(typeOf({}), 'project')
})
test('isProjectReady: nur ready/deployed, provisioningStatus hat Vorrang', () => {
assert.equal(isProjectReady({ status: 'ready' }), true)
assert.equal(isProjectReady({ status: 'DEPLOYED' }), true)
assert.equal(isProjectReady({ provisioningStatus: 'pending', status: 'ready' }), false)
assert.equal(isProjectReady({ status: 'building' }), false)
assert.equal(isProjectReady({}), false)
})
test('PROJECT_TYPES: Reihenfolge, Texte und Töne unverändert', () => {
assert.deepEqual(Object.keys(PROJECT_TYPES), ['preview', 'website', 'template', 'project'])
assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.label), ['Preview (mit Login)', 'Oeffentliche Website', 'Vorlage', 'Internes Projekt (kein Hosting)'])
assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.short), ['Preview', 'Website', 'Vorlage', 'Projekt'])
assert.deepEqual(Object.values(PROJECT_TYPES).map((t) => t.tone), ['info', 'ok', 'warn', 'muted'])
assert.equal(PROJECT_TYPES.template.adminOnly, true)
assert.equal(PROJECT_TYPES.project.hint, 'Nur Gitea-Repository, keine Website/Subdomain')
})
test('projectActions: gehostet und nicht deployt', () => {
const ready = projectActions({ subdomain: 'a', status: 'ready', previewUrl: 'https://a.project.webklar.com', giteaRepoUrl: 'https://git/x' })
assert.deepEqual(ready, { type: 'preview', hosted: true, ready: true, host: false, preview: true, refresh: true, gitea: true, hostedBadge: { text: 'gehostet', tone: 'ok' } })
const open = projectActions({ subdomain: 'a', status: 'provisioning', previewUrl: 'https://a.project.webklar.com' })
assert.deepEqual(open, { type: 'preview', hosted: true, ready: false, host: true, preview: false, refresh: false, gitea: false, hostedBadge: { text: 'nicht deployt', tone: 'warn' } })
})
test('projectActions: archiviert, ohne Website, Vorlage', () => {
const arch = projectActions({ subdomain: 'a', status: 'ready', archived: true, previewUrl: 'u' })
assert.deepEqual([arch.host, arch.preview, arch.refresh, arch.hostedBadge], [false, true, false, null])
const archOpen = projectActions({ subdomain: 'a', status: 'error', archived: true })
assert.deepEqual([archOpen.host, archOpen.refresh, archOpen.hostedBadge], [false, false, null])
const intern = projectActions({ giteaRepoUrl: 'https://git/x' })
assert.deepEqual([intern.type, intern.host, intern.preview, intern.refresh, intern.hostedBadge, intern.gitea], ['project', false, false, false, null, true])
const tpl = projectActions({ isTemplate: true, subdomain: 't', status: 'deployed' })
assert.deepEqual([tpl.type, tpl.refresh, tpl.host], ['template', true, false])
})
test('projectTicketRef: woid aus der Liste, sonst die $id, ohne Ticket leer', () => {
assert.equal(projectTicketRef({ ticketId: 'abc' }, { abc: '100131' }), '100131')
assert.equal(projectTicketRef({ ticketId: 'abc' }, { abc: 100131 }), '100131')
assert.equal(projectTicketRef({ ticketId: 'zzz' }, {}), 'zzz')
assert.equal(projectTicketRef({ ticketId: '' }, { abc: '1' }), '')
assert.equal(projectTicketRef({}, {}), '')
})
test('updatedLabel: deutsch relativ, Titel in Berliner Zeit, ungültig = "-"', () => {
const now = new Date('2026-10-05T10:00:00.000Z')
assert.deepEqual(updatedLabel('2026-10-05T07:00:00.000Z', now), { text: 'vor etwa 3 Stunden', title: '05.10.26, 09:00' })
assert.equal(updatedLabel('2026-10-04T10:00:00.000Z', now).text, 'vor 1 Tag')
for (const bad of ['', 'kaputt', undefined]) assert.deepEqual(updatedLabel(bad, now), { text: '-', title: '' })
})
```
- [ ] **Step 2: Test laufen lassen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/projectRow.test.mjs`
Expected: FAIL, Modul `src/lib/projectRow.js` fehlt.
- [ ] **Step 3: `projectRow.js` implementieren**
- `PROJECT_TYPES`, `typeOf`, `isProjectReady` stehen wörtlich in `ProjectsPage.jsx` L38-85. Übernommen werden `label`, `short`, `hint`, `adminOnly`. `icon` entfällt. `badge: 'badge-info'` wird `tone: 'info'` (`badge-ok` → `ok`, `badge-warn` → `warn`, `badge-muted` → `muted`).
- `projectActions`:
- `host = hosted && !ready && !archived`
- `preview = !host && Boolean(project.previewUrl)`
- `refresh = hosted && ready && !archived`
- `gitea = Boolean(project.giteaRepoUrl)`
- `hostedBadge = null`, wenn `!hosted || archived`, sonst `{ text: 'gehostet', tone: 'ok' }` bzw. `{ text: 'nicht deployt', tone: 'warn' }`
- `projectTicketRef`: `String(ticketMap?.[project.ticketId] ?? project.ticketId)`, bei leerer `ticketId` `''`.
- `updatedLabel`: Text mit `formatDistance(new Date(iso), now, { addSuffix: true, locale: de })`, Titel mit `fmtDateTime(iso)`. Bei fehlendem oder unlesbarem Wert `{ text: '-', title: '' }`.
- [ ] **Step 4: Test grün**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/projectRow.test.mjs`
Expected: PASS, 7 Tests.
- [ ] **Step 5: Commit**
```bash
git add src/lib/projectRow.js tests/unit/projectRow.test.mjs
git commit -m "Projekte: Typ-, Hosting- und Aktionslogik als getestete Lib-Funktionen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 2: `projectTabs`
**Files:**
- Modify: `src/lib/projectList.js` (neuer Export nach `VIEW_LABELS`)
- Test: `tests/unit/projectList.test.mjs` (Importzeile um `projectTabs` ergänzen, zwei Tests anhängen)
**Interfaces:**
- Consumes: `projectCounts(projects, { isAdmin })` und `VIEW_LABELS` aus derselben Datei.
- Produces: `projectTabs(counts, { isAdmin = false }) → [{ id, label, count }]` für `Tabs` aus P1. Die IDs sind die Filterwerte der Seite (`all`, `unassigned`, `assigned`, `templates`, `archived`, `everything`).
- [ ] **Step 1: Failing Tests schreiben**
```js
const counts = { all: 5, unassigned: 2, assigned: 3, archived: 4, templates: 1, everything: 10 }
test('projectTabs: sechs Reiter für Admins, Wortlaut und Zähler', () => {
assert.deepEqual(projectTabs(counts, { isAdmin: true }), [
{ id: 'all', label: 'Aktive Projekte', count: 5 },
{ id: 'unassigned', label: 'Unzugeordnet', count: 2 },
{ id: 'assigned', label: 'Zugeordnet', count: 3 },
{ id: 'templates', label: 'Vorlagen', count: 1 },
{ id: 'archived', label: 'Archiviert', count: 4 },
{ id: 'everything', label: 'Alle inkl. Archiv', count: 10 },
])
})
test('projectTabs: ohne Admin keine Vorlagen, Zähler passen zur Ansicht', () => {
assert.deepEqual(projectTabs(counts).map((t) => t.id), ['all', 'unassigned', 'assigned', 'archived', 'everything'])
for (const t of projectTabs(projectCounts(P, { isAdmin: true }), { isAdmin: true })) {
assert.equal(t.count, projectsInView(P, t.id, { isAdmin: true }).length)
}
})
```
(`P` ist die Testliste am Dateianfang.)
- [ ] **Step 2: Test laufen lassen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/projectList.test.mjs`
Expected: FAIL, `projectTabs is not a function`.
- [ ] **Step 3: Implementieren**
Reihenfolge wie `FILTER_OPTIONS` in `ProjectsPage.jsx` L358-366. Labels aus `VIEW_LABELS` bzw. fest „Unzugeordnet“ und „Zugeordnet“. `templates` nur bei `isAdmin`. `VIEW_LABELS` bleibt unverändert.
- [ ] **Step 4: Tests grün**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/projectList.test.mjs`
Expected: PASS, alle bisherigen plus 2 neue.
- [ ] **Step 5: Commit**
```bash
git add src/lib/projectList.js tests/unit/projectList.test.mjs
git commit -m "Projekte: Reiter-Liste der sechs Ansichten als getestete Funktion" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 3: Tonabbildung `projectTones.js`
**Files:**
- Create: `src/components/projectTones.js`
- Test: `tests/unit/projectTones.test.mjs` (neu)
**Interfaces:**
- Consumes: nichts. Die Töne sind die Namen der Logik aus `Badge.jsx` (P1): `ok`, `warn`, `info`, `danger`, `muted`, `plain`.
- Produces:
- `previewColorTone(color)` für `previewStatus().color`.
- `previewStateTone(state)` für `previewState` der Services-Antwort.
- `analyseColorTone(color)` für `analyseStatus().color`.
- `statusValueTone(value)` für `status` und `provisioningStatus` eines Projekts.
- [ ] **Step 1: Failing Test schreiben**
```js
const { previewColorTone, previewStateTone, analyseColorTone, statusValueTone } = await loadModule('src/components/projectTones.js')
test('previewColorTone', () => {
assert.deepEqual(['green', 'yellow', 'red', 'grey', undefined].map(previewColorTone), ['ok', 'warn', 'danger', 'plain', 'plain'])
})
test('previewStateTone', () => {
assert.deepEqual(['online', 'building', 'starting', 'sleeping', 'failed', 'off', undefined, 'constructor'].map(previewStateTone), ['ok', 'warn', 'warn', 'warn', 'danger', 'plain', 'plain', 'plain'])
})
test('analyseColorTone', () => {
assert.deepEqual(['green', 'red', 'grey', undefined].map(analyseColorTone), ['ok', 'danger', 'muted', 'muted'])
})
test('statusValueTone: wie die Farben in AssignedProjectCard heute', () => {
assert.deepEqual(['ready', 'LIVE', 'online', 'provisioning', 'pending', 'building', 'error', 'failed'].map(statusValueTone), ['ok', 'ok', 'ok', 'warn', 'warn', 'info', 'danger', 'danger'])
assert.deepEqual(['deployed', '', undefined, 'irgendwas'].map(statusValueTone), ['muted', 'muted', 'muted', 'muted'])
})
```
- [ ] **Step 2: Test laufen lassen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/projectTones.test.mjs`
Expected: FAIL, Modul fehlt.
- [ ] **Step 3: Implementieren**
Je Funktion eine `Map` (kein Nachschlagen im Objekt-Prototyp, sonst liefert `'constructor'` eine Funktion). Die Zuordnung steht im Test. `deployed` bleibt grau, wie die Farbtabelle `STATUS_COLORS` in `AssignedProjectCard` heute (Standardfarbe `#a0aec0`).
- [ ] **Step 4: Test grün**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node --test tests/unit/projectTones.test.mjs && "$W/check.sh" --nur-tests`
Expected: PASS und `# fail 0` in der Ausgabe von `check.sh`.
- [ ] **Step 5: Commit**
```bash
git add src/components/projectTones.js tests/unit/projectTones.test.mjs
git commit -m "Projekte: Tonabbildung fuer Preview-, Analyse- und Projektstatus" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 4: Werkzeug – Ansichten, Testdaten, Gate `p5`
**Files** (alle unter `$W/`, kein Commit, das Werkzeug liegt außerhalb des Repos):
- Create: `fixtures/projekte-lang.mjs`, `fixtures/projekte-viel.mjs`
- Modify: `views.mjs`, `gates.mjs`, bei Bedarf `fixtures/standard.mjs`
**Interfaces:**
- Consumes: Aufbau von `fixtures/altdaten.mjs` (Standarddaten plus überschriebene Listen), `VIEWS` und `GATES` aus P0, Schritt-Funktionen `steps: async (page, ctx) => void` mit `node:assert/strict`. `ctx.backend.calls` listet alle beantworteten Requests.
- Produces: Ansichten und Gate `p5` für die Tasks 6 bis 14.
- [ ] **Step 1: Testdaten anlegen**
- `projekte-lang`: Standarddaten plus
- Projekt A: `projectName` mit 120 Zeichen, `repoFullName` `WEBklar/` plus 82 Zeichen, Kunde mit 60 Zeichen im Namen, `ticketId` eines Tickets der Liste
- Projekt B: ohne `projectName` und `giteaRepoName`, `ticketId` ohne Treffer in der Ticketliste, `$updatedAt: 'kaputt'`, `status: ''`, `subdomain` und `previewUrl` gesetzt, nicht deployt
- Projekt C: archiviert, `subdomain`, `status: 'ready'`
- `projekte-viel`: Standarddaten plus 300 Projekte und 40 Kunden.
- `fixtures/standard.mjs` braucht ein Projekt an Ticket 100131 mit `subdomain`, `status: 'ready'`, `previewUrl` und `giteaRepoUrl`, mindestens ein Projekt je Typ (Preview, Website, Vorlage, intern) und eines ohne `kind`. Der Kunde, den `kunde-projects` öffnet, hat dieses Projekt zugeordnet. Fehlt etwas, wird es ergänzt.
- [ ] **Step 2: Ansichten in `views.mjs` anlegen oder anpassen**
`projekte`, `dlg-projekt-neu`, `dlg-projekt-bearbeiten`, `dlg-projekt-art`, `zuordnen`, `services` und `analyse-zustimmung` gibt es seit P0, `kunde-projects` und `kunde-overview` aus den Reiter-IDs der Übersicht seit P4. Dort werden nur Schritte angepasst. Alle `dlg-*` behalten `viewports` und `submitReachable` aus P0.
| ID | Pfad / Schritt | Testdaten |
|---|---|---|
| `projekte` | `/projects` | `standard` |
| `projekte-archiv` | `/projects`, Klick auf Reiter „Archiviert“ | `standard` |
| `projekte-treffer` | `/projects`, Suche „vorlage“ (erzeugt die Zeile „Weitere Treffer“) | `standard` |
| `projekte-lang` | `/projects` | `projekte-lang` |
| `projekte-viel` | `/projects` | `projekte-viel` |
| `projekte-861`, `projekte-1101` | `/projects`, Schritt `page.setViewportSize({ width: 861 \| 1101, height: 900 })`, dann Prüfung, dass die erste Zelle der ersten Zeile (gerundet) mindestens 140 px breit ist | `projekte-lang` |
| `projekte-aktion` | `/projects`. Ein `MutationObserver` merkt sich, ob je `.loading` im DOM erscheint. Klick auf „Aktualisieren“, 500 ms warten. Erwartung: `window.__sawLoading === false`, Tabelle sichtbar. | `standard` |
| `projekte-tastatur` | Fokus auf das `button.link` der ersten Zeile, Enter, Dialog sichtbar, Esc, Dialog weg. Erneut öffnen, `page.route` verzögert die Speicher-Anfrage um 1200 ms (`route.fallback()`), Klick auf „Speichern & synchronisieren“. Erwartung: „ד ist `disabled`, Esc lässt den Dialog offen. Danach schließt er. | `standard` |
| `projekte-zeilenklick` | Klick auf die Kunde-Auswahl, auf „Vorschau öffnen“ und auf ein Badge. Erwartung: nur das Badge (Zellfläche) öffnet den Dialog. | `standard` |
| `fehler-projekte` | `/projects`, Szenario `fehler` | `standard` |
| `dlg-projekt-neu` | Klick auf „Neues Projekt“ | `standard` |
| `dlg-projekt-bearbeiten` | Klick auf das `button.link` der ersten Zeile | `standard` |
| `dlg-projekt-archiv` | Reiter „Archiviert“, Klick auf das erste `button.link` | `standard` |
| `dlg-projekt-art` | wie in P0 (Art-Frage in `TicketProjects`) | `standard` |
| `zuordnen`, `zuordnen-kunde`, `zuordnen-ticket`, `zuordnen-fertig` | `/projects/zuordnen/<id>` eines Projekts ohne `kind`: Schritt 1 (Art), nach Art-Wahl und „Weiter“ Kunde ( „Ja, für einen Kunden“ ), nach Kundenwahl Ticket, am Ende Zusammenfassung | `standard` |
| `services` | `/services` | `standard` |
| `analyse-zustimmung` | wie in P0 (Ticket 100131, „Einschalten“ in der Analyse-Zeile) | `standard` |
| `ticket-projekte` | `/tickets/100131`, Klick auf „Preview ▾“ | `standard` |
| `kunde-projects` | `/customers/<id>?tab=projects` (Ansicht aus P4, unverändert, kommt nur ins Gate) | `standard` |
Der Zugang (`CopyableCredential`, Abschnitt „Portal-Zugang“) steht auf der Übersicht. Dafür entsteht keine zweite Ansicht für dieselbe URL. Die `steps` von `kunde-overview` (P4) werden am Ende ergänzt: Klick auf „Anzeigen“ beim Passwort in `.credential`. Erwartung: „Verbergen“ und „Kopieren“ sind sichtbar, kein `svg` in `.credential`. Danach Klick auf „Verbergen“, damit das Bild unverändert bleibt.
- [ ] **Step 3: Gate eintragen**
In `gates.mjs`, nach dem Schema der Übersicht:
```js
GATES.p5 = {
...GATES.p4,
scoped: {
...GATES.p4.scoped,
'icon-svg': {
views: [...GATES.p4.scoped['icon-svg'].views, 'projekte', 'projekte-*', 'fehler-projekte', 'dlg-projekt-*', 'zuordnen', 'zuordnen-*', 'services', 'analyse-zustimmung', 'ticket-projekte', 'kunde-projects'],
},
},
}
```
`checks` und `views: '*'` kommen von `p4`. `step-failed`, `external-request` und `console-error` sind in jedem Gate aktiv. Der neue `icon-svg`-Eintrag hat kein `ignore`: Die Ausnahmen `.ticket-projects` (P2) und `.credential` (P4) entfallen, weil `TicketProjects` und `CopyableCredential` in diesem Paket umgebaut werden.
- [ ] **Step 4: Ausgangslauf**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p5 && "$W/shots.sh" p5 "$W/shots/p5-t4" --gate p5; echo "exit $?"`
Expected: `exit 1`, `icon-svg` auf allen Ansichten mit Projektbausteinen. Einige Schritte finden ihre Knöpfe („Neues Projekt“, „Aktualisieren“, „Anzeigen“) noch nicht. Das ist der Ausgangszustand.
### Task 5: CSS-Block „Projekte (P5)“
**Files:**
- Modify: `src/styles/global.css` (neuer Block „Projekte (P5)“ am Ende, vor dem Bereichs-CSS der Finanzen, falls dieses dort steht)
- Modify: `src/styles/global.css` (alte Regeln `.ticket-projects`, `.ticket-projects-assign`, `.ticket-projects-assign .form-control` löschen, L596-599)
**Interfaces:**
- Consumes: Tokens und Klassen aus P1 (`.table`, `.link`, `.badge`, `.list-row`, `.seg`, `.check`, `.cell-title`, `.cell-sub`, `.hide-md`, `.hide-sm`).
- Produces: die Klassen der Tabellen unten. Die Komponenten-Tasks benutzen sie wörtlich.
- [ ] **Step 1: Vorhandenes prüfen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "button\.link|^\.link" src/styles/global.css`
Expected: Das Zurücksetzen für `button.link` fehlt oder steht schon. Es wird nur ergänzt, was fehlt.
- [ ] **Step 2: Block anlegen**
Die Werte der Projekttabelle sind in einer statischen Probe bei 390, 861, 900, 1101, 1180, 1280 und 1440 px gemessen. Es gibt dabei keinen waagerechten Überlauf. Der Block steht am Ende von `global.css`. Farben kommen nur über Tokens, Abstände zwischen Abschnitten regelt `.page section + section` (P1).
Gemessene und von Ansichten geprüfte Regeln:
| Selektor | Regel |
|---|---|
| `button.link` (falls fehlend) | `background: none; border: 0; padding: 0; font: inherit; text-align: left; cursor: pointer` |
| `.projects-table td:first-child` | `min-width: 140px` (geprüft von `projekte-861` und `projekte-1101`) |
| `.projects-table th, .projects-table td` | `padding-right: 8px`, in der letzten Zelle `0` |
| `.projects-table .cell-title .link, .projects-table .cell-sub` | `overflow-wrap: anywhere` |
| `.projects-table .select` | `width: clamp(104px, 10vw, 180px); max-width: 100%` |
| `.projects-table td.cell-address code` | `display: block; max-width: 84px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px` |
| `.projects-table td.cell-updated` | `min-width: 70px` |
| `@media (max-width: 860px)` für `.projects-table` | Die Zeile wird ein Grid mit zwei Spalten (`1fr 1fr`, Abstand `8px 12px`, `padding: 14px 0`, Trennlinie `1px solid var(--line)`). `thead` ist nur für Screenreader sichtbar. `table`, `tbody` und `td` sind `block`, `td` ohne Innenabstand und Rahmen. Erste Zelle und `td.cell-actions` gehen über beide Spalten. `td[data-label]::before` zeigt `attr(data-label)` (12px, `--text-2`). `.select` ist 100 % breit, `.row-actions` linksbündig, kein Zeilen-Hover. `.projects-table td.hide-sm, .projects-table td.hide-md { display: none }` (0,2,1) schlägt `.hide-sm` (0,1,0) und `.projects-table td { display: block }` (0,1,1) über die Spezifität, ohne `!important`. |
| `.badge.wrap` | `white-space: normal; height: auto; min-height: 20px; padding-top: 2px; padding-bottom: 2px; overflow-wrap: anywhere` |
| `.pv-log` | helle Fläche (`background: var(--bg-subtle)`), `max-height: 260px; overflow: auto; white-space: pre-wrap; word-break: break-all`, Monospace 11px/16px |
| `.pv-var code`, `.pv-var .input`, `.pv-add .input` | `flex: 1 1 140px; min-width: 0`, bei `code` zusätzlich `overflow-wrap: anywhere` |
Übrige Klassen (nur Aufbau, kein Messwert):
| Klassen | Aufbau |
|---|---|
| `.projects-table tr.is-archived td` | `opacity: .72` (wie die Karten heute) |
| `.row-actions` | Flex-Reihe mit Umbruch (Abstand `4px 14px`, 13px), in der Tabelle rechtsbündig, `.link` ohne Zeilenumbruch |
| `.dialog-form` | Flex-Spalte, Abstand 16px |
| `.pv-block` | 13px, Flex-Spalte, Abstand 10px |
| `.pv-row`, `.pv-add`, `.pv-var` | Flex-Reihe mit Umbruch, Abstand 8px, mittig ausgerichtet. `.pv-var` hat unten `1px solid var(--line)`. |
| `.pv-mode`, `.pv-title` | `width: 200px; max-width: 100%` bzw. 13px, `font-weight: 600` |
| `.proj-item`, `.proj-head`, `.proj-main`, `.proj-meta` | Zeile mit `padding: 16px 0` und Trennlinie. Kopf als Flex mit Umbruch und `justify-content: space-between`, `.proj-main { min-width: 0; overflow-wrap: anywhere }`. Meta-Zeile 12px, `--text-2`, mit Umbruch. |
| `.ticket-projects-assign` | Flex-Reihe mit Umbruch, Abstand 8px, `margin-top: 12px`. `.select` mit `flex: 1 1 220px`, `.input` mit `flex: 1 1 160px`, beide `min-width: 0`. |
| `button.list-row` | Knopf-Reset (`width: 100%; text-align: left; background: none; border: 0; border-bottom: 1px solid var(--line); font: inherit; color: inherit; cursor: pointer`). `:hover` und `[aria-pressed="true"]` mit `background: var(--bg-hover)`, `.cell-sub` als `block`. |
| `.pick-list`, `.pick-empty` | `max-height: 360px; overflow-y: auto` bzw. `padding: 12px 0` |
| `.seg.wrap`, `.row-buttons`, `.check.top` | `flex-wrap: wrap`, Flex mit Umbruch und Abstand 8px, `align-items: flex-start` |
| `.summary` | `list-style: none`, Flex-Spalte, Abstand 6px |
- [ ] **Step 3: Bauen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/check.sh" --nur-build`
Expected: Exit 0.
- [ ] **Step 4: Commit**
```bash
git add src/styles/global.css
git commit -m "Projekte: CSS-Block fuer Tabelle, Dialog-Inhalte, Preview-Block und Zuordnen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 6: Link-Bausteine (`PreviewLinkButton`, `GiteaLinkButton`, `CopyableCredential`)
**Files:**
- Modify: `src/components/PreviewLinkButton.jsx` (alles ab L7), `src/components/GiteaLinkButton.jsx`, `src/components/CopyableCredential.jsx` (L93-152)
- Modify: `src/pages/AdminPage.css` (L330-345: `.credential-action`, `.credential-action:hover`, `.credential-check` löschen, `.credential` und `.credential .link` anpassen)
**Interfaces:**
- Consumes: `.link` aus P1, `createPreviewLink` aus `lib/teamApi`, `openInNewTab` aus `lib/openInNewTab`.
- Produces:
- `PreviewLinkButton({ href, subdomain, label = 'Vorschau öffnen' })` rendert `<a className="link" href target="_blank" rel="noopener noreferrer" onClick>{label}</a>`. Ohne `href` kommt `null`. Die Logik (Einmal-Link, `window.alert` bei Fehler) bleibt.
- `GiteaLinkButton({ href, label = 'In Gitea öffnen' })` rendert `<a className="link" href target rel>{label}</a>`.
- `CopyableCredential({ value, secret = false, label = 'Wert' })` (Signatur unverändert) mit Textknöpfen.
- Für P8: Von `.credential*` bleiben `.credential`, `.credential-value`, `.credential-value.is-masked`, `.credential-empty` und `.credential .link`. P8 zieht sie nach `global.css`.
- [ ] **Step 1: Prüfen, dass `title` und `size` nirgends übergeben werden**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "<(PreviewLinkButton|GiteaLinkButton)[^>]*(title|size)=" src`
Expected: keine Treffer. Dann entfallen die Props `title` und `size`.
- [ ] **Step 2: Umbau**
- Die Link-Bausteine verlieren Inline-Stil und Icon. Der Text ist `label`.
- `CopyableCredential`:
- Der Umschalter ist `<button type="button" className="link">` mit „Anzeigen“ bzw. „Verbergen“.
- Der Kopierknopf ist `<button type="button" className="link">` mit „Kopieren“ bzw. nach dem Klick „Kopiert“.
- `aria-label` und `title` führen die sichtbaren Wörter mit: `${label} anzeigen`, `${label} verbergen`, `${label} kopieren`, `${label} kopiert`.
- Maskierung, Timer, `stopPropagation` und `copyText` bleiben.
- Entscheidung: `aria-label` und `title` sagen „verbergen“ statt „ausblenden“, damit der zugängliche Name das sichtbare Wort enthält.
- `.credential`: `gap: 8px`, `.credential .link { flex: 0 0 auto; font-size: 12px; white-space: nowrap }`. `AdminPage.css` lädt vor `global.css`. `.credential .link` (0,2,0) schlägt `.link` und `button.link` (0,1,1) über die Spezifität und braucht kein `!important`.
- [ ] **Step 3: Prüfen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p5 && "$W/shots.sh" p5 "$W/shots/p5-t6" --only kunde-overview,ticket-projekte --gate p5; echo "exit $?"`
Expected: `kunde-overview` besteht (Login und Passwort mit Textknöpfen, kein `svg` in `.credential`, auch nach „Anzeigen“). `ticket-projekte` darf noch Verstöße der späteren Tasks zeigen, aber „Vorschau öffnen“ und „In Gitea öffnen“ stehen als Text da.
- [ ] **Step 4: Commit**
```bash
git add src/components/PreviewLinkButton.jsx src/components/GiteaLinkButton.jsx src/components/CopyableCredential.jsx src/pages/AdminPage.css
git commit -m "Projekte: Vorschau-, Gitea- und Zugangsknoepfe als Textlinks" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 7: `PreviewBlock`
**Files:**
- Modify: `src/components/PreviewBlock.jsx` (Import L1-12 behalten, `dotColor`-Import und `STATE_OF_COLOR` L14 löschen, Markup L174-297 neu)
**Interfaces:**
- Consumes: `Button`, `Badge` aus `components/ui`, `previewColorTone` aus `./projectTones`, `previewStatus`, `modeOptionsFor`, `modeHint`, `isDevUnavailableError`, `NO_DEV_HINT` aus `lib/previewView`, Klassen `.pv-*`, `.input`, `.select`, `.msg`, `.link`, `.badge.wrap`.
- Produces: `PreviewBlock({ projectId })` (Signatur unverändert).
- [ ] **Step 1: Umbau**
Alle Zustände, Handler, Intervalle und Texte bleiben. Nur das Markup ändert sich.
| Heute | Danach |
|---|---|
| „Lädt …“ | `<p className="muted xs">Lädt …</p>` |
| Label „Modus“ und Auswahl | `.pv-row`: `<label className="muted xs" htmlFor>`, `<select className="select pv-mode">` |
| `NO_DEV_HINT`, `modeHint` | `<p className="muted xs">` |
| Punkt und Statustext | `<Badge tone={previewColorTone(status.color)} className="wrap">{status.text}</Badge>` |
| Fehlertext des Builds, `error`, `envError` | `<p className="msg err">`. `error` zusätzlich mit `role="alert"`. |
| „Preview-Runner ist nicht aktiv.“ | `<p className="muted xs">` |
| Neu bauen, Protokoll ansehen, Jetzt starten, Stoppen | `Button` (Standard), `type="button"`, in `.pv-row`, `disabled`-Bedingungen unverändert |
| Lauf-Auswahl und `<pre>` | `<select className="select">` (`aria-label="Lauf wählen"`) und `<pre className="pv-log">` |
| `<strong>Variablen</strong>` | `<h4 className="pv-title">Variablen</h4>` |
| Variablenzeile | `.pv-var`: `<code>`, „•••“, bei Bearbeitung `input.input` und `<button type="button" className="link">Übernehmen</button>`, sonst `<button type="button" className="link">Ändern</button>`, dazu `<button type="button" className="link">Entfernen</button>` |
| Neue Variable | `.pv-add`: zwei `input.input` (Enter-Handler bleiben) und `Button type="button"` „Hinzufügen“ |
| „Variablen speichern“ | `Button` (Standard), `type="button"` |
| Wurzel | `<div className="pv-block">` |
- [ ] **Step 2: Prüfen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p5 && "$W/shots.sh" p5 "$W/shots/p5-t7" --only ticket-projekte --gate p5; echo "exit $?"`
Expected: Im Bild steht der geöffnete Preview-Block ohne Knopfrahmen-Reihe, ohne dunklen Log-Kasten. `icon-svg` an der Projektzeile und am Abschnitt (Tasks 9 und 10) darf noch melden.
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "btn-sm|#[0-9a-f]{3,6}|style=\{" src/components/PreviewBlock.jsx`
Expected: keine Treffer.
- [ ] **Step 3: Commit**
```bash
git add src/components/PreviewBlock.jsx
git commit -m "Projekte: Preview-Block ohne Kleinknoepfe, Statuspunkt als Badge, Log auf hellem Grund" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 8: `AnalyseRow` (Zustimmung als Dialog)
**Files:**
- Modify: `src/components/AnalyseRow.jsx` (`COLORS` L5 löschen, Markup L58-132 neu)
**Interfaces:**
- Consumes: `Dialog`, `Button`, `Badge`, `Field` aus `components/ui` (P1), `analyseColorTone` aus `./projectTones`, alle Funktionen aus `lib/analyseView` unverändert (`analyseStatus`, `textformLabel`, `canSubmitTextform`, `channelLabel`, `legalLinks`, `chatTemplate`, `privacyHint`).
- Produces: `AnalyseRow({ projectId })` (Signatur unverändert).
- [ ] **Step 1: Umbau**
Zustände, `apply`, `cancel`, `copyTemplate`, `canSubmit` und alle Wortlaute bleiben. Neu ist das Markup:
| Heute | Danach |
|---|---|
| Zeile „Website-Analyse:“ | `<div className="pv-row">`: `<span className="muted">Website-Analyse:</span>`, dann Status |
| Status | `data`: `<Badge tone={analyseColorTone(status.color)} className="wrap">{status.text}</Badge>`; `loadError`: `<span className="msg err" role="alert">`; sonst `<span className="muted">Lädt …</span>`; `status.hint` als `<span className="muted">({status.hint})</span>` |
| „Einschalten“ (oder `status.enableLabel`) und „Ausschalten“ | `<button type="button" className="link">` mit dem Text von heute |
| Zustimmungsablauf (`mode === 'on'`) | `<Dialog title="Website-Analyse" onClose={cancel} busy={busy} footer={…}>` |
| Kästchen mit `textformLabel` | `<label className="check top">`. Die beiden Links sind `<a className="link" target="_blank" rel="noopener noreferrer">`, Wortlaut aus `label.before/avv/between/lb/after`. |
| „Datum der Zustimmung:“ | `<Field label="Datum der Zustimmung:" htmlFor>` mit `input.input` (`type="date"`, `required`, `min`, `max` wie heute) |
| „Weg:“ und die zwei Radios | `<div className="field" role="radiogroup" aria-label="Weg der Zustimmung">`, `<span className="strong">Weg:</span>`, Radios in `<label className="check">` mit `channelLabel(c)` |
| `privacyHint` | `<p className="msg err">` |
| `<details>` „Textvorlage für den Chat“ | bleibt `<details>`. Die Vorlage steht in `<p className="prose">`, „Kopieren“/„Kopiert“ ist `Button` (Standard) mit `type="button"`. |
| „Einschalten“ und „Abbrechen“ | Fuß: `<span className="grow" />`, `Button type="button"` „Abbrechen“ (`disabled={busy}`), `Button type="button" variant="primary"` „Einschalten“ (`disabled={!canSubmit \|\| busy}`) |
| Ausschalten-Rückfrage (`mode === 'off'`) | bleibt eine Zeile ohne Dialog: Text „Website-Analyse wirklich ausschalten?“, `<button type="button" className="link-danger">Ja, ausschalten</button>`, `<button type="button" className="link">Abbrechen</button>` |
| `error` | im Dialog als `<p className="msg err" role="alert">` ganz oben im Dialog-Inhalt, sonst unter der Zeile |
- Entscheidung: Der Dialogtitel ist „Website-Analyse“ (das Wort der Zeile, ohne Doppelpunkt). Ein Titel muss sein, ein neuer Wortlaut ist nicht nötig.
- Entscheidung: Die Ausschalten-Rückfrage bleibt inline, nur die Zustimmung ist ein Dialog (wie in der Spec).
- Entscheidung: Die Reihenfolge im Fuß folgt dem Prototyp (Abbrechen links vom Hauptknopf).
- [ ] **Step 2: Prüfen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p5 && "$W/shots.sh" p5 "$W/shots/p5-t8" --only analyse-zustimmung --gate p5; echo "exit $?"`
Expected: Der Dialog hat keinen Verstoß. `icon-svg` darf noch an `AssignedProjectCard` und `TicketProjects` melden (Tasks 9 und 10). Bei 390 px liegt der Dialog unten als Sheet, „Einschalten“ ist erreichbar. Esc schließt, bei `busy` nicht. Der Text im Kästchen ist unverändert (Stichprobe: Diff gegen `textformLabel`).
- [ ] **Step 3: Commit**
```bash
git add src/components/AnalyseRow.jsx
git commit -m "Projekte: Website-Analyse-Zeile ohne Farbliterale, Zustimmung als Dialog (Wortlaut unveraendert)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 9: `AssignedProjectCard`
**Files:**
- Modify: `src/components/AssignedProjectCard.jsx` (Icon-Imports und `STATUS_COLORS`/`StatusBadge` L2-35 ersetzen, Markup L81-176 neu)
**Interfaces:**
- Consumes: `Button`, `Badge` aus `components/ui`, `statusValueTone` aus `./projectTones`, `PreviewLinkButton`, `GiteaLinkButton`, `PreviewBlock`, `AnalyseRow`, Klassen `.proj-*`, `.row-actions`, `.link`, `.msg`.
- Produces: `AssignedProjectCard({ project, onUnassign, showActions = false, customerName = '', onChanged = () => {} })` (Signatur unverändert). Aufrufer sind `TicketProjects` und der Projekte-Reiter der Kundenseite (P4).
- [ ] **Step 1: Umbau**
Handler (`openPreviewInput`, `handleEnablePreview`, `handleBackfill`) und Zustände bleiben.
| Heute | Danach |
|---|---|
| Karte mit blauem Rahmen | `<div className="proj-item">` |
| Name, `(subdomain)`, Repo mit `FaCodeBranch` | `.proj-head > .proj-main`: `<strong>`, `<span className="muted small">`, `<div className="cell-sub">{repoFullName}</div>` |
| Vorschau-, Gitea-Knopf, Lösen-Knopf | `.row-actions` mit `PreviewLinkButton`, `GiteaLinkButton` und `<Button variant="danger" type="button" title="Vom Ticket lösen" aria-label="Vom Ticket lösen">Lösen</Button>`. Er erscheint unter derselben Bedingung (`showActions && onUnassign`). |
| `StatusBadge` mit Punkt | `.proj-meta`: `<span>Status: <Badge tone={statusValueTone(value)}>{value}</Badge></span>`, ebenso „Provisioning:“. Ohne Wert nichts. |
| `AnalyseRow` | unverändert eingebunden (`project.subdomain`) |
| „Preview ▴/▾“ (`btn btn-sm`) | `<button type="button" className="link" aria-expanded>` mit demselben Text. Die Zeichen ▴ und ▾ bleiben, sie sind Text. Der `PreviewBlock` steht darunter. |
| „Preview einschalten“, „Einschalten“, „Pushes nachtragen“ | `.row-actions` (linksbündig): zwei `<button type="button" className="link">`, `input.input` mit `Button type="button"` „Einschalten“ (`disabled={!subdomain \|\| previewBusy}`), `<button type="button" className="link" disabled={pushBusy}>Pushes nachtragen</button>` |
| Meldungen | `<p className="msg muted">` je `previewMessage`, `pushMessage` |
- [ ] **Step 2: Prüfen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p5 && "$W/shots.sh" p5 "$W/shots/p5-t9" --only ticket-projekte,kunde-projects --gate p5; echo "exit $?"`
Expected: `kunde-projects` besteht. `ticket-projekte` darf noch `icon-svg` am Abschnitt melden (Task 10). Die Projektzeile am Ticket zeigt Name, Repo, drei Textlinks, Status-Badges und die Analyse-Zeile ohne Kasten.
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "react-icons|btn-sm|#[0-9a-f]{3,6}|style=\{" src/components/AssignedProjectCard.jsx`
Expected: keine Treffer.
- [ ] **Step 3: Commit**
```bash
git add src/components/AssignedProjectCard.jsx
git commit -m "Projekte: Projektzeile am Ticket und beim Kunden ohne Kasten, Aktionen als Textlinks" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 10: `TicketProjects`
**Files:**
- Modify: `src/components/TicketProjects.jsx` (Import L2 löschen, Markup L158-228 neu)
**Interfaces:**
- Consumes: `Card`, `Button`, `Dialog` aus `components/ui`, `AssignedProjectCard`, Klassen `.ticket-projects-assign`, `.row-buttons`, `.msg`.
- Produces: `TicketProjects({ ticket })` (Signatur unverändert, P2 bindet sie auf der Ticketseite ein). `data-testid="project-kind"` bleibt.
- [ ] **Step 1: Umbau**
Alle Zustände und Handler (`run`, `assign`, `assignPlain`, `assignWithKind`, `unassign`, `create`) bleiben.
| Heute | Danach |
|---|---|
| `ui-card pad` mit `<h5>` | `<Card title={\`Projekte (${assigned.length})\`}>` |
| „Suche neue Repos …“ | `<p className="muted">` |
| `importHint`, `error` | `<p className="msg err" role="alert">` |
| „Noch kein Projekt an diesem Ticket.“ | `<p className="muted">` |
| Art-Zeile und Karte je Projekt | `<span className="muted xs" data-testid="project-kind">` und `AssignedProjectCard` (Props wie heute) |
| Auswahl „Projekt zuweisen“ | `.ticket-projects-assign`: `<select className="select" aria-label="Projekt zuweisen">`, Optionstexte wie heute |
| „Zuweisen“ (`FaLink`) | `Button type="button"` (Standard), `disabled={!selected \|\| busy}` |
| „Neues Projekt“ (`FaPlus`) | `Button variant="quiet" type="button"`, schaltet das Formular um |
| `assignResult` | `<p className="msg">` mit den Warnungen als `<span className="muted">` |
| Anlegen-Formular | bleibt inline: `<form className="ticket-projects-assign" onSubmit>` mit zwei `input.input` (`required`) und `Button type="submit" variant="primary"` („Anlegen & zuweisen“, beim Laden „Wird angelegt …“) |
| „Was ist das?“-Overlay | `<Dialog title="Was ist das?" onClose={() => setKindDialog(false)}>`. Darin „Bitte wähle die Art des Projekts, bevor es zugewiesen wird.“ als `<p className="muted">`, der Fehler „Arten konnten nicht geladen werden – bitte neu laden.“ als `msg err`, die Arten als `.row-buttons` mit `Button type="button"` je `k.label` (`onClick={() => assignWithKind(k.key)}`). |
Danach die toten Klassen prüfen:
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "ticket-projects[^-]|ui-btn|ui-card" src/components/TicketProjects.jsx`
Expected: keine Treffer.
- [ ] **Step 2: Prüfen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p5 && "$W/shots.sh" p5 "$W/shots/p5-t10" --only ticket-projekte,dlg-projekt-art --gate p5`
Expected: PASS. Bei 390 px ist der Dialog ein Sheet. Esc schließt ihn. Die Auswahl „Projekt zuweisen“ läuft nicht über den Rand.
- [ ] **Step 3: Commit**
```bash
git add src/components/TicketProjects.jsx
git commit -m "Projekte: Projektabschnitt am Ticket als Abschnitt, Art-Frage als Dialog" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 11: `ProjectsPage`
**Files:**
- Modify: `src/pages/ProjectsPage.jsx`
- L1-85 (Importe, `TYPES`, `typeOf`, `HOSTED_TYPES`, `isProjectReady`) durch Importe aus `lib/projectRow` ersetzen
- L358-369 (`FILTER_OPTIONS`, `typeOptions`) ersetzen
- L371-782 (Markup) neu
- `Row` (L784-791) und die tote `customerMap` (L131-137) löschen
**Interfaces:**
- Consumes:
- aus Task 1 bis 3: `PROJECT_TYPES`, `projectActions`, `projectTicketRef`, `updatedLabel`, `typeOf`, `projectTabs`
- aus P1: `PageHeader`, `Tabs`, `Button`, `Badge`, `EmptyState`, `Field`, `Dialog`
- aus P2: `ticketPath` und `isRowActivation(event)` aus `src/lib/rowClick.js`
- Hooks unverändert (`useWebsiteProjects`, `useCustomers`, `useWorkorders`, `useEmployees`)
- Klassen `.projects-table`, `.row-actions`, `.dialog-form`, `.pv-title`
- Produces: Seite `/projects`. Die Route `/projects/zuordnen/:id` wird nach „Projekt anlegen“ wie heute aufgerufen.
- [ ] **Step 1: Zustand und Laden**
- Neu: `const [loadedOnce, setLoadedOnce] = useState(false)`. Im Effekt: `fetchAllProjects().then(() => { if (!cancelled) setLoadedOnce(true) })`. Der Effekt behält sonst Reihenfolge und Import-Aufruf.
- Neu: Der Text „Wird geladen …“ (`<div className="loading">`) erscheint nur, solange `!loadedOnce`. Danach bleibt die Tabelle bei jedem Nachladen stehen, auch wenn `useWebsiteProjects` `loading` setzt. Der Hook bleibt unverändert.
- Entscheidung: Die Wurzel ist `<div className="page">` (seit P1), der Titel „Website-Projekte“ ohne Untertitel (ein neuer Untertitel wäre neuer Text).
- [ ] **Step 2: Kopf, Reiter, Filterzeile, Meldungen**
| Heute | Danach |
|---|---|
| `<h2>Website-Projekte</h2>` | `PageHeader title="Website-Projekte"` |
| Knopf „+“ (Titel „Neues Projekt“) | `Button variant="primary" type="button"` „Neues Projekt“ in `actions`, öffnet den Dialog |
| Knopf Sync (Titel „Gitea-Repos synchronisieren“, Spinner) | `Button type="button"` „Gitea-Repos synchronisieren“ in `actions`, `disabled={syncLoading}`, beim Laden Text „Wird geladen …“ |
| Ansicht-Select mit sechs Optionen | `Tabs tabs={projectTabs(counts, { isAdmin })} active={filter} onChange={setFilter}`. „Vorlagen“ nur für Admins. |
| Suchfeld | `input.input.search type="search"`, Platzhalter „Nach Titel suchen…“, in `.toolbar`, `aria-label="Nach Titel suchen"` |
| Kunde-Filter, Mitarbeiter-Filter | zwei `select.select.select-quiet` in `.toolbar`, Titel und Optionen wie heute |
| `notice` | `<p className="msg muted" role="status">` |
| „Weitere Treffer“ | `<p className="msg muted" data-hidden-hits>`: „Weitere Treffer: “, je Ansicht `<button type="button" className="link">{h.count} unter „{VIEW_LABELS[h.view]}“</button>`, getrennt durch „ · “. Klick setzt `setFilter(h.view)`. |
| `error` | `<p className="msg err" role="alert">` |
| Leere Liste | `EmptyState title` mit „Keine archivierten Projekte.“ bzw. „Keine Projekte gefunden.“ |
- [ ] **Step 3: Tabelle**
`<table className="table projects-table" role="table">`, Zeilen `role="row"`, Zellen `role="cell"` (Tabellensemantik bleibt beim Stapeln unter 860 px erhalten). `<tr className={\`row${archived ? ' is-archived' : ''}\`}>` mit `onClick={(e) => { if (isRowActivation(e)) openEdit(project) }}`. `isRowActivation` ignoriert Klicks auf `a, button, select, input, label`, Mehrfach-Modifikatoren und andere Maustasten.
| Spalte (Kopf) | Klassen | Inhalt |
|---|---|---|
| Projekt | – | `.cell-title` mit `<button type="button" className="link" onClick={() => openEdit(project)}>{projectName \|\| giteaRepoName \|\| '(ohne Name)'}</button>`, darunter `.cell-sub` `repoFullName` |
| Kunde | `data-label="Kunde"` | `select.select` mit Optionen „Kein Kunde“ und `({code}) {name}`, `aria-label={\`Kunde von ${name}\`}`, `disabled={assignBusyId === id \|\| archived}`, `onChange` ruft `handleAssignCustomer` (mit `window.confirm` beim Entfernen, unverändert) |
| Art | `hide-md` | `<Badge tone={PROJECT_TYPES[type].tone}>{short}</Badge>` |
| Adresse | `hide-md cell-address` | `<code title={subdomain}>{subdomain}</code>`, sonst `<span className="faint">ohne Website</span>` |
| Ticket | `hide-sm num` | `<Link className="link" to={ticketPath(projectTicketRef(project, ticketMap))}>`, Text derselbe Wert. Ohne Ticket `<span className="faint">-</span>`. |
| Status | `data-label="Status"` | `hostedBadge` als `Badge` (nur wenn nicht `null`), darunter `.cell-sub` mit `archived ? 'archiviert' : (status \|\| '-')` |
| Zuletzt geändert | `hide-md right cell-updated` | `updatedLabel(project.$updatedAt).text`, `title` aus `.title` |
| Aktionen | `right cell-actions` | `.row-actions` mit den vier Links unten |
Aktionen, jeweils nur wenn zutreffend (`projectActions(project)`):
| Bedingung | Link |
|---|---|
| `host` | `<button type="button" className="link" title="Projekt jetzt deployen/hosten" disabled={hostBusyId === id}>` „Hosten“, beim Laden „Wird geladen …“ |
| `preview` | `PreviewLinkButton` „Vorschau öffnen“ |
| `refresh` | `<button type="button" className="link" title="Aktualisieren – Website neu deployen" disabled={refreshBusyId === id}>` „Aktualisieren“, beim Laden „Wird geladen …“ |
| `gitea` | `GiteaLinkButton` „In Gitea öffnen“ |
- Entscheidung: Kein Tabellenfuß (die Zähler stehen in den Reitern).
- Entscheidung: Leere Werte bleiben „-“ wie heute, nicht „–“.
- Entscheidung: „Adresse“ zeigt wie die Karte heute die Subdomain.
- Entscheidung: Das Wort „Hosten“ erhält als Ladezustand den Text „Wird geladen …“ aus der Spec-Liste.
- Entscheidung: Das alte „Bearbeiten“ (Stift) entfällt als eigener Knopf. Sein Platz ist das `button.link` der ersten Zelle und der Zeilenklick.
- Entscheidung: Unter 860 px stapelt die Zeile (Projekt, dann Kunde und Status nebeneinander, dann Aktionen), weil Auswahl und Aktionen sonst keinen Platz finden. Die Kernspalten laut Spec bleiben dabei Projekt, Kunde, Status und Aktionen.
- [ ] **Step 4: Dialog „Neues Projekt“**
`Dialog as="form" onSubmit={handleCreate} title="Neues Website-Projekt" busy={createLoading} onClose={() => setShowCreateModal(false)}`. Fuß: `<span className="grow" />` und `Button type="submit" variant="primary"` („Projekt anlegen“, beim Laden „Wird angelegt...“).
Inhalt (Texte unverändert, `Field` mit `htmlFor`):
- `createError` als `<p className="msg err" role="alert">` ganz oben
- „Anzeigename“ (`required`)
- „Subdomain (optional)“ mit Platzhalter und dem Hinweis „Mit Subdomain wird die Website … als normales Projekt angelegt.“ als `hint`
- „Repo-Name (optional)“ mit Platzhalter
- „Kunde (optional)“
Der Kommentar zum Repo-Namen (nicht aus der Subdomain mitfüllen) bleibt.
- [ ] **Step 5: Dialog „Projekt bearbeiten“**
`Dialog wide title="Projekt bearbeiten" busy={editLoading \|\| archiveBusy} onClose={() => setEditProject(null)}`. Die Box ist ein `div`, damit der `PreviewBlock` außerhalb des Formulars steht.
- Inhalt in `.dialog-body`:
- `<p className="muted small">{editProject.repoFullName}</p>`
- `editError` als `msg err`
- `<form id="projekt-bearbeiten" className="dialog-form" onSubmit={handleEditSave}>` mit den Feldern „Name“ (`required`), „Typ / Template“ (Optionen aus `PROJECT_TYPES`, `adminOnly` nur für Admins, `hint` ohne Icon) und, außer bei Typ `project`, „Subdomain“ (Hinweis „Ergebnis: https://…project.webklar.com - Aenderung wird neu deployt (alte Subdomain wird abgebaut).“)
- bei archiviertem Projekt `<p className="muted small">Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.</p>`
- unter denselben Bedingungen wie heute (`projectType !== 'project' && subdomain && !archived`) `<section><h4 className="pv-title">Preview</h4><PreviewBlock key projectId /></section>`
- Fuß:
- links `Button variant="danger" type="button"` „Repository archivieren“ (`window.confirm` bleibt) bzw. bei archivierten Projekten `<button type="button" className="link">Reaktivieren</button>`, `disabled={archiveBusy \|\| editLoading}`
- dann `<span className="grow" />`
- rechts `Button type="submit" form="projekt-bearbeiten" variant="primary"` („Speichern & synchronisieren“, beim Laden „Speichert...“, `disabled` bei `editLoading \|\| archiveBusy \|\| archived`, `title` wie heute)
- Entscheidung: Der Speichern-Knopf steht im Fuß außerhalb des `<form>` und hängt über das Attribut `form` daran. So löst Enter im Namensfeld Speichern aus, im `PreviewBlock` nie.
- [ ] **Step 6: Alles Übrige löschen**
Entfallen: Icon-Importe, `TYPES`, `typeOf`, `HOSTED_TYPES`, `isProjectReady` (jetzt in `lib/projectRow`), `Row`, `customerMap`, `FILTER_OPTIONS`. Die Handler `handleCreate`, `handleSyncRepos`, `handleAssignCustomer`, `handleHostNow`, `handleRefresh`, `openEdit`, `handleEditSave`, `handleArchiveToggle` bleiben bis auf den Ersatz von `typeOf` durch den Import unverändert.
- [ ] **Step 7: Prüfen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p5 && "$W/shots.sh" p5 "$W/shots/p5-t11" --only projekte,projekte-archiv,projekte-treffer,projekte-lang,projekte-viel,projekte-861,projekte-1101,projekte-aktion,projekte-tastatur,projekte-zeilenklick,fehler-projekte,dlg-projekt-neu,dlg-projekt-bearbeiten,dlg-projekt-archiv --gate p5`
Expected: PASS (Exit 0).
- Kein `overflow-x`, auch bei 861 und 1101 px, die erste Zelle ist dort (gerundet) mindestens 140 px breit.
- Bei 390 px stehen Projekt, Kunde, Status und Aktionen gestapelt in der Zeile.
- `projekte-aktion` und `projekte-tastatur` bestehen.
- Im Bild `projekte` steht höchstens ein schwarzer Knopf im Kopf („Neues Projekt“).
- [ ] **Step 8: Commit**
```bash
git add src/pages/ProjectsPage.jsx
git commit -m "Projekte: Liste als Tabelle mit Reitern, Neu- und Bearbeiten-Dialog, Laden nur beim ersten Mal" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 12: `AssignProjectPage`
**Files:**
- Modify: `src/pages/AssignProjectPage.jsx` (Import L3 löschen, Rückgaben ab L178 neu)
**Interfaces:**
- Consumes: `PageHeader`, `Card`, `Button`, `Field`, `Badge` aus `components/ui`, `CreateCustomerForm` (Dialog aus P4, Props `onCancel`, `onCreate`), `ticketPath` (P2), Klassen `.seg.wrap`, `.pick-list`, `button.list-row`, `.row-buttons`, `.summary`, `.check`, `.field-row-3`.
- Produces: Seite `/projects/zuordnen/:id`. Zustände, Effekte, `finish`, `go`, `patch`, `startNewTicket`, `setNT`, `stepReady`, die Logik aus `lib/assignFlow` und der 409-Fall bleiben unverändert.
- [ ] **Step 1: Vorher prüfen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "ticketPath" src/pages/AssignProjectPage.jsx`
Expected: 2 Treffer (P2). Sie bleiben bis zum Schluss erhalten.
- [ ] **Step 2: Kopf und Zustände**
- Alle `Button` dieser Seite tragen `type="button"`, die Seite hat kein `<form>`.
- Ein gemeinsamer Kopf `PageHeader crumb={[{ label: 'Projekte', to: '/projects' }, { label: repoName \|\| project.projectName }]} title="Projekt zuordnen" subtitle={…}` für alle Zustände. `subtitle` ist die Zeile der bisherigen Kopf-Karte: „Repository in Gitea öffnen“ (`<a className="link" target="_blank" rel="noreferrer">`) und „ · angelegt am {Datum}“ mit „ von {createdByGitea}“, Wortlaut wie heute.
- Entscheidung: Der bisherige Untertitel mit dem Schrittnamen entfällt. Dieselbe Zeile steht direkt darunter als Abschnittstitel `h2.section` (kommt von `Card title`). `STEP_TITLES` bleibt unverändert.
- Laden: `<div className="page"><div className="loading">Wird geladen …</div></div>`. Ladefehler: `PageHeader` mit der Brotkrume nur „Projekte“ (es gibt noch kein `project`) und `<p className="msg err" role="alert">{loadError}</p>`.
- Zustand „Zugeordnet, mit Hinweisen“: `<Card title="Zugeordnet, mit Hinweisen">` mit den Warnungen als `<ul className="summary">` und `Button variant="primary"` „Weiter“.
- Zustand „Art bereits gesetzt“: Hinweis (`notice` als `msg err`), „Bereits erledigt von **…** am …“ und `TicketLink` (`Link className="link"`, Text „Zum Ticket #{woid}“) ohne Kasten.
- [ ] **Step 3: Schritte**
Je Schritt `<Card title={STEP_TITLES[state.step]}>`. „Zurück“ (`Button variant="ghost"`) und „Weiter“ (`Button variant="primary"`) bleiben mit denselben Bedingungen, ohne Pfeil-Icons. Der Fehler (`error`) ist `msg err`.
| Schritt | Danach |
|---|---|
| Art | `<div className="seg wrap" role="group" aria-label={STEP_TITLES.kind}>` mit je einem `<button type="button" aria-pressed={state.kind === k.key}>`. Das Verhalten (`patch({ kind, newTicket: null, ticketId: '' })`) bleibt. |
| „Ist das für einen Kunden?“ | `.row-buttons`: `Button variant="primary"` „Ja, für einen Kunden“, `Button` „Nein, internes Projekt“ |
| Kunde | Zeile aus `input.input.search` (Platzhalter „Kunde suchen …“) und für Admins `Button` „Neuer Kunde“ (ohne `FaPlus`). `showNewCustomer && isAdmin` rendert `CreateCustomerForm` wie P4 es vorsieht, ohne umgebendes `div`. Liste in `.pick-list`, je Kunde `<button type="button" className="list-row" aria-pressed>` mit `.cell-title` (Name, `· code` als `.faint`) und `.cell-sub` (`ort · mail` oder `-`). „Kein Kunde gefunden.“ als `<p className="muted pick-empty">`. |
| Ticket | Laden: `<p className="muted">Wird geladen …</p>`. Fehler: `msg err` mit „Die Tickets des Kunden konnten nicht geladen werden.“ und `Button` „Erneut versuchen“. Liste wie beim Kunden mit `#{woid} {topic}` und `type · status`. „Dieser Kunde hat keine offenen Tickets.“ als `pick-empty`. „Neues Ticket anlegen“ als `Button` (primary, solange `newTicket` gesetzt ist, wie heute). Das Formular: `.field-row-3` mit „Thema“, „Angefragt von“, „Start“ (`type="date"`), darunter „Details“ als `textarea.textarea`, dann „Ticket-Art: … (ergibt sich aus der Art)“ als `<p className="muted small">`. |
| Zusammenfassung | `<ul className="summary">` mit den Zeilen von heute (Art, Kunde bzw. „Internes Projekt (ohne Kunde)“, Ticket bzw. „Neues Ticket“). Preview-Hinweise als `<p className="muted">`. „Preview einschalten“ als `<label className="check"><input type="checkbox" …>`, „Subdomain“ als `Field` mit dem `hint`. „Abschließen“ als `Button variant="primary"`, beim Laden „Speichert …“. |
- Entscheidung: Die Auswahl ist nicht nur an Hintergrund und Häkchen erkennbar. Die gewählte Zeile trägt `aria-pressed="true"`, den Hintergrund `--bg-hover` und das Badge „Ausgewählt“ (`<Badge>Ausgewählt</Badge>` am Zeilenende). Das Wort ist neu und von der Spec verlangt („Auswahl auch als Text markiert“).
- Entscheidung: Die Zusammenfassung bleibt eine Liste mit dem Wortlaut von heute (kein `.kv`, damit die Doppelpunkte und Sätze unverändert bleiben).
- Entscheidung: Der Brotkrumen-Link führt immer auf `/projects`.
- [ ] **Step 4: Prüfen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p5 && "$W/shots.sh" p5 "$W/shots/p5-t12" --only zuordnen,zuordnen-kunde,zuordnen-ticket,zuordnen-fertig --gate p5`
Expected: PASS. Bei 390 px läuft nichts über den Rand, die gewählte Zeile zeigt „Ausgewählt“.
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "ticketPath" src/pages/AssignProjectPage.jsx`
Expected: weiter 2 Treffer.
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "react-icons|style=\{|surface-2|text-red" src/pages/AssignProjectPage.jsx`
Expected: keine Treffer.
- [ ] **Step 5: Commit**
```bash
git add src/pages/AssignProjectPage.jsx
git commit -m "Projekte: Zuordnen-Assistent mit Abschnitten, Brotkrumen und im Text markierter Auswahl" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 13: `ServicesPage`
**Files:**
- Modify: `src/pages/ServicesPage.jsx` (Importe L2-4 anpassen, Markup L30-84 neu)
**Interfaces:**
- Consumes: `PageHeader`, `Card`, `Button`, `Badge` aus `components/ui`, `previewStateTone` aus `components/projectTones`, `servicesCards` unverändert, `ticketPath` (P2, bleibt wie umgestellt), Klassen `.list-row`, `.cell-title`, `.cell-sub`, `.link`, `.msg`.
- Produces: Seite `/services`.
- [ ] **Step 1: Umbau**
- Laden: `<div className="loading">Wird geladen …</div>`.
- Fehler: `<p className="msg err" role="alert">` und `Button type="button"` „Erneut versuchen“, ohne Kasten.
- Das Kachelraster entfällt. Jede Art ist ein untereinander stehender Abschnitt `<Card title={\`${c.label} (${c.count})\`}>`.
- Beschreibung als `<p className="muted">`. Funktions-Badges bleiben `<Badge>` mit dem Text `label · statusLabel` in einer Zeile (`.pv-row`).
- Projektzeilen sind `.list-row` (kein Link, `div`): links `.cell-title` mit Projektname, dem Badge „Analyse an“ (nur bei `features.analyse === 'active'`) und davor dem Preview-Badge, darunter `.cell-sub` (`Kunde · Preview` oder „Intern“, wie heute). Rechts `Link className="link"` „Ticket #{woid}“ und in der Karte „Art fehlt“ `Link className="link" to={\`/projects/zuordnen/${p.id}\`}` „Zuordnen“.
- „Noch keine Projekte.“ als `<p className="muted pick-empty">`.
- Preview-Badge: `<Badge tone={previewStateTone(p.previewState)}>Preview: {p.previewState}</Badge>`.
- Der Import von `dotColor`, `FaSpinner`, `useNavigate` und der Aufruf `navigate` entfallen.
- Entscheidung: Das Badge trägt den Text `Preview: {Zustand}`. Das ist der Wortlaut, den der Punkt heute als Titel und für Screenreader hat. Die Wörter des Anhangs („Online“, „Wird gebaut“, „Fehler“, „Aus“) stehen nicht in der Spec-Liste „Texte“ und kommen deshalb nicht hinein.
- Entscheidung: „Zuordnen“ ist ein echter Link statt eines schwarzen Knopfes. Es ist eine Zeilenaktion, und das Ziel ist dasselbe.
- [ ] **Step 2: Prüfen**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p5 && "$W/shots.sh" p5 "$W/shots/p5-t13" --only services --gate p5`
Expected: PASS. Bei 390 px laufen Zeilen mit langem Namen nicht über den Rand.
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "ticketPath" src/pages/ServicesPage.jsx`
Expected: weiter 1 Treffer.
- [ ] **Step 3: Commit**
```bash
git add src/pages/ServicesPage.jsx
git commit -m "Projekte: Services als untereinander stehende Abschnitte mit Zeilen, Preview-Zustand als Badge" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 14: Gate P5, Review, Tag
- [ ] **Step 1: Rebase**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && git fetch origin && git rebase origin/test && "$W/check.sh"`
Expected: Exit 0 und `# fail 0`. `# pass` ist die Zahl aus Task 0 plus 13 (liegen neue Commits auf `test`, kann sie höher sein). Die Bundle-Größen aus der Ausgabe werden notiert.
- [ ] **Step 2: Vollständiger Gate-Lauf**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/build.sh" "$WT" p5 && "$W/shots.sh" p5 "$W/shots/p5" --gate p5`
Expected:
- Exit 0. Das Gate `p5` gilt für alle Ansichten (`views: '*'`), also auch für die aus P0 bis P4. So zeigt der Lauf, dass P5 sonst nichts verschlechtert hat.
- Keine Verstöße gegen `external-request`, `console-error`, `step-failed`, `dark-surface`, `low-contrast` und `overflow-x`.
- `icon-svg` ist für alle Ansichten im Umfang des Gates (P2 bis P5, ohne `ignore`) sauber.
- [ ] **Step 3: Code-Hygiene und Klassenabgleich**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "react-icons|@tabler|btn-sm|style=\{|#[0-9a-fA-F]{3,6}\b|text-grey|text-red|bg-red|ui-btn|ui-card|form-control|badge-(ok|info|warn|muted|danger)|isInteractiveTarget" src/pages/ProjectsPage.jsx src/pages/AssignProjectPage.jsx src/pages/ServicesPage.jsx src/components/TicketProjects.jsx src/components/AssignedProjectCard.jsx src/components/AnalyseRow.jsx src/components/PreviewBlock.jsx src/components/PreviewLinkButton.jsx src/components/GiteaLinkButton.jsx src/components/CopyableCredential.jsx`
Expected: keine Treffer.
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "btn-sm" src`
Expected: keine Treffer.
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && rg -n "dotColor" src`
Expected: nur noch `src/lib/previewEnv.js` (P10 löscht es samt Test).
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && node "$W/css-klassen.mjs" "$WT" > "$TMPDIR/klassen.json" && node -e "const j=JSON.parse(require('fs').readFileSync(process.argv[1]));const mine=/^(projects-table|row-actions|pv-|proj-|dialog-form|pick-|summary|ticket-projects-assign)/;for (const k of ['undefinedUsed','dead']) console.log(k, j[k].filter((c)=>mine.test(c)).join(' '))" "$TMPDIR/klassen.json"`
Expected: Hinter `undefinedUsed` und hinter `dead` steht kein Name. Keine Klasse des P5-Blocks ist unbenutzt oder ohne Regel (`projects-table`, `row-actions`, `pv-*`, `proj-*`, `dialog-form`, `pick-*`, `summary`, `ticket-projects-assign`).
- [ ] **Step 4: Bundle-Größe**
Die Werte aus der `check.sh`-Ausgabe von Step 1 werden mit `design-p4` verglichen. JS ist kleiner, weil `ProjectsPage`, `AssignedProjectCard`, `GiteaLinkButton` und weitere keine Icon-Importe mehr haben.
- [ ] **Step 5: Code-Review (sonnet)**
Geprüft wird der Diff `design-p4..HEAD` gegen die Spec-Abschnitte „Bausteine“, „Ticket als eigene Seite / Route und Links“, „Texte“, „Was gleich bleibt“ und Paket P5, dazu der Review Focus oben. Besonders:
- Jede Steuerung aus den Tabellen der Tasks 7 bis 13 ist erreichbar.
- Es gibt keinen Textunterschied außer den Spec-Texten und den „Entscheidung“-Zeilen (Stichprobe: `git diff design-p4..HEAD -- src/pages src/components` auf geänderte Texte lesen).
- Alle `window.confirm`, `busyRef`-artigen Sperren (`assignBusyId`, `hostBusyId`, `refreshBusyId`, `archiveBusy`) und `data-testid`/`data-hidden-hits` sind erhalten.
Wichtige Befunde werden behoben, Kleinigkeiten notiert. Danach kommt der Rebase aus Step 1 noch einmal und Step 2 als Kurzlauf mit `--only projekte,services,ticket-projekte`.
- [ ] **Step 6: Tag und Übergabe**
Run: `source /root/reports/ticketsystem-design-2026-10-04/werkzeug/env.sh && "$W/check.sh" && git tag design-p5 && git log --oneline origin/test..design-p5 && git rev-parse design-p5`
Der Eigentümer bekommt:
- die Screenshots aus `$W/shots/p5/1440x900` und `$W/shots/p5/390x844` (Auswahl: `projekte`, `projekte-lang`, `projekte-861`, `dlg-projekt-neu`, `dlg-projekt-bearbeiten`, `zuordnen-kunde`, `zuordnen-fertig`, `services`, `analyse-zustimmung`, `ticket-projekte`)
- die Liste der „Entscheidung“-Zeilen dieses Plans, vor allem: neues Wort „Ausgewählt“, Preview-Badge `Preview: {Zustand}` in den Services, gestapelte Zeilen der Projekttabelle unter 860 px, Dialogtitel „Website-Analyse“
- den Hinweis, dass „Zuordnen“ in den Services jetzt ein Textlink ist
- für P8: Von `.credential*` bleiben `.credential`, `.credential-value`, `.credential-value.is-masked`, `.credential-empty`, `.credential .link`
- die Push-Zeile `git -C /home/webklar/apps/ticket.webklar.com/dev-design push origin 'design-p5^{commit}:refs/heads/test'`
- die Rücknahme `git revert --no-commit design-p4..design-p5`, Commit auf `test` und Push durch den Eigentümer (nur solange P5 das jüngste Paket ist)
- [ ] **Step 7: 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/ | grep -o 'assets/index-[^"]*\.js'` entspricht `dist/index.html` des Tags.
- Ein lesender Sichttest im Chrome des Eigentümers: `/projects` (Reiter, eine Zeile mit „Aktualisieren“ oder „Hosten“) und die Projekte eines Tickets. Nichts anklicken, was speichert.