Compare commits

...
8 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 24e4f054c4 Unterstützung: Anzeige in Arbeitsblatt-Liste und Gesamtzeit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:00:07 +00:00
ClaudeandClaude Opus 5.5 bc9d673ff7 Unterstützung: Mitarbeiter tragen sich selbst als Unterstützung ein
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:58:58 +00:00
ClaudeandClaude Opus 5.5 d83bbfb48d Unterstützung: Ersteller trägt helfende Personen im Arbeitsblatt ein
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:57:46 +00:00
ClaudeandClaude Opus 5.5 3535f18a2b Arbeitszeit nur in 15-Minuten-Schritten, berechnete Zeit wird aufgerundet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:55:06 +00:00
ClaudeandClaude Opus 5.5 32bc3d7c3d Unterstützung: Setup-Skript für das Attribut supporters
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:48:16 +00:00
ClaudeandClaude Opus 5.5 1497f7a4dc Unterstützung: Rechenregeln für 15-Minuten-Raster und Unterstützungszeit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:47:36 +00:00
ClaudeandClaude Opus 5.5 564fecdde4 Plan: Unterstützung bei Arbeitsblättern + 15-Minuten-Zeiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:43:59 +00:00
ClaudeandClaude Opus 5.5 a336dc07fc Konzept: Unterstützung bei Arbeitsblättern + 15-Minuten-Zeiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:36:03 +00:00
17 changed files with 1131 additions and 46 deletions

No files matched your search

@@ -0,0 +1,256 @@
# Unterstützung bei Arbeitsblättern + 15-Minuten-Zeiten – Implementation Plan
> **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:** Mitarbeiter können auf Arbeitsblättern als Unterstützung (Person + Tätigkeit) eingetragen werden und bekommen 50 % der Arbeitszeit (aufgerundet auf 15 min) angerechnet; Zeiten gibt es überall nur noch in 15-Minuten-Schritten; Kundenportal, Stundenkonto und Verträge ziehen mit.
**Architecture:** Neues JSON-Attribut `supporters` auf `worksheets`; Minuten werden nie gespeichert, sondern mit derselben Rechenregel in WOMS (`src/lib/support.js`) und Portal (`server/services/worksheetMinutes.js`) aus `totalTime` berechnet. WOMS bekommt eine gemeinsame Zeitauswahl (`DurationSelect`) und zwei Wege, Unterstützung einzutragen (Ersteller im Zeiten-Formular, Helfer über „Ich habe unterstützt“). Das Portal zeigt Unterstützung als eigene Zeilen, eine Gesamtzeit oben und zieht sie im Abo-Stundenkonto ab. Abo-Bedingungen Fassung 5 und AGB § 9 Abs. 2 halten die Regeln fest.
**Tech Stack:** WOMS React/Vite + Appwrite Web SDK 13, Tests `node --test` (+ esbuild `tests/unit/load.mjs`), Playwright-Harness; Portal Node/Express ESM, `node --test test/*.test.js`, Vanilla-JS `public/app.js`; webklar.com React/TS + vitest.
**Spec:** `docs/superpowers/specs/2026-09-30-unterstuetzung-design.md` (WOMS-Repo, Commit a336dc0)
## Repos und Arbeitsverzeichnisse
| Teil | Repo / Zweig | Worktree | Livegang |
|---|---|---|---|
| WOMS (Tasks 1–6) | JUSN/tickte-system, `feature/unterstuetzung` von `origin/test` | `/home/webklar/apps/ticket.webklar.com/dev-unterstuetzung` (existiert) | `git push origin feature/unterstuetzung:test` |
| Portal (Tasks 7–10) | Portal-Repo, `feature/unterstuetzung` von `origin/main` | `/home/webklar/apps/kundenbereich.webklar.com/dev-unterstuetzung` (in Task 7 anlegen) | `git push origin feature/unterstuetzung:main` |
| webklar.com (Task 11) | JUSN/Webklar, `fix/agb-haftung` (f40e889) | `/home/webklar/apps/webklar.com/dev-agb-haftung` (existiert, erst auf `origin/main` rebasen) | `git push origin fix/agb-haftung:main` |
Worktree-Regeln (alle drei Repos): `node_modules` ist ein Symlink auf `../build/node_modules`; nach Rebase/Checkout `rm -rf node_modules && ln -s ../build/node_modules node_modules`. In WOMS ist `node_modules` getrackt → nur `git add <dateien>`, nie `-A`. Nichts pushen ohne OK des Nutzers; der WOMS-Webhook deployt JEDEN gepushten Zweig.
## Global Constraints
- Zeit-Raster: `TIME_STEP = 15` Minuten; Auswahl 15 min … 12 h (`MAX_STEP_MINUTES = 720`, 48 Werte; 720 = bestehendes `MAX_MINUTES` der Push-Zeitmail). *Abweichung von der Spec (dort „bis 10 h“) zugunsten eines Maximums in beiden Systemen – Spec-Zeile wird in Task 1 angepasst.*
- Berechnete Dauer wird **aufgerundet** auf das nächste Vielfache von 15; Dauer 0/fehlend → 15 vorausgewählt.
- `supportMinutes(t) = t > 0 ? ceil(t / 2 / 15) * 15 : 0`; Testvektoren überall: 15→15, 30→15, 45→30, 60→30, 75→45, 70→45, 0→0, -5→0, '60'→30, null→0.
- `worksheetMinutes(ws) = base + supporters.length * supportMinutes(base)`, `base = Number(ws.totalTime) || 0`; Kommentar-Filter bleibt beim Aufrufer.
- `supporters`-Eintrag: `{ employeeId: string, name: string, task: string }`; `task` getrimmt 1–200 Zeichen; `employeeId` eindeutig; gespeichert als JSON-String, Attribut `supporters` String 4000, nicht Pflicht.
- Alte Arbeitsblätter werden nicht umgerechnet.
- Anzeige-Texte (exakt): Haken „Unterstützung“, Feld „Wobei?“, Knopf „+ weitere Person“, Knopf „Ich habe unterstützt“ / „Meine Unterstützung bearbeiten“, Listenzeile WOMS „↳ Unterstützung {Vorname} · {task} · {Zeit}“, Portal „↳ Unterstützung {Vorname}: {task}“ + Zeit, Portal-Kopf „Arbeitszeit gesamt: {Zeit}“, Stundenkonto-Zusatz „inkl. {Zeit} Unterstützung“.
- Vorname = erstes Wort von `name`.
- Zeitformat WOMS wie bisher `formatTime` („1h 30min“); Portal wie bisher `fmtMinutes`/`fmtHours`.
- Berechtigungen nur in der Oberfläche; Appwrite-Rechte bleiben unverändert.
- Claude darf keinen Appwrite-API-Key benutzen: Skripte mit Key führt der Nutzer aus.
- Commits enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. Ersteller speichert das Zeiten-Formular, während ein Kollege sich gerade selbst als Unterstützung eingetragen hat → dessen Eintrag darf nicht still verloren gehen (Task 4: Konflikt-Prüfung beim Speichern).
2. Altes Arbeitsblatt mit 70 min wird zum Bearbeiten geöffnet → 75 ist vorausgewählt, und Unterstützung rechnet dann mit 75 (Task 3 + Task 1-Vektoren).
3. Git-Push: der zugeordnete Pusher sieht „Zeiten bearbeiten“ statt „Ich habe unterstützt“; ein Push ohne nachgetragene Zeit bietet keinem „Ich habe unterstützt“ an (Task 1 `canAddOwnSupport`, Task 5).
4. `supporters` kaputt (kein JSON, kein Array, Eintrag ohne `task`, doppelte Person) → WOMS-Liste, Portal-Projektansicht und Stundenkonto laufen weiter und ignorieren nur den kaputten Teil (Task 1, Task 7).
5. Mitarbeiterliste lädt nicht oder ein Mitarbeiter hat kein `userId` → Auswahl zeigt Hinweis statt leerer Zeilen, „Ich habe unterstützt“ funktioniert trotzdem mit `user.$id`/`user.name` (Task 4, Task 5).
---
## WOMS
### Task 1: Rechenregeln (Zeit-Raster + Unterstützung)
**Files:**
- Create: `src/lib/timeSteps.js`, `src/lib/support.js`
- Test: `tests/unit/support.test.mjs` (lädt Module über `tests/unit/load.mjs`)
- Modify: `docs/superpowers/specs/2026-09-30-unterstuetzung-design.md` („bis 10 h (40 Werte)“ → „bis 12 h (48 Werte)“)
**Interfaces:**
- Produces (`timeSteps.js`): `TIME_STEP = 15`, `MAX_STEP_MINUTES = 720`, `roundUpToStep(minutes: number|null): number` (≤0/NaN/null → 15, sonst `ceil(m/15)*15`), `isValidStep(minutes: any): boolean` (ganze Zahl, >0, %15===0, ≤720), `stepOptions(current?: number): {value:number,label:string}[]` (15…720; liegt `current` gültig über 720, wird er angehängt), `stepLabel(minutes): string` („15 min“, „1 h“, „1 h 15 min“).
- Produces (`support.js`): `supportMinutes(total): number`, `parseSupporters(raw: string|array|null): Supporter[]` (nie Exception; verwirft Einträge ohne `employeeId`/`task`, trimmt, kürzt `task` auf 200, erster Eintrag je `employeeId` gewinnt), `serializeSupporters(list): string`, `worksheetMinutes(ws): number`, `firstName(name): string`, `canAddOwnSupport(ws, user, employee): boolean` (`user.$id` vorhanden, `!ws.isComment`, `Number(ws.totalTime) > 0`, `!isWorksheetCreator(ws, user, employee)`), `upsertSupporter(list, entry): Supporter[]`, `removeSupporter(list, employeeId): Supporter[]`, `validateSupporters(list, creatorUserId): string|null` (Fehlertext: „Bitte bei jeder Person angeben, wobei sie unterstützt hat.“, „Jede Person nur einmal eintragen.“, „Du kannst dich nicht selbst als Unterstützung eintragen.“).
- Consumes: `isWorksheetCreator` aus `src/lib/ticketForm.js`.
- [ ] **Step 1: Failing tests** in `tests/unit/support.test.mjs`: Vektoren aus Global Constraints für `supportMinutes`; `roundUpToStep`: 0→15, 50→60, 60→60, 61→75, null→15; `isValidStep`: 15/720 true, 70/0/735/'x'/7.5 false; `stepOptions()` Länge 48, erstes `{15,'15 min'}`, `[3]` = `{60,'1 h'}`, `[4]` = `{75,'1 h 15 min'}`; `stepOptions(900)` Länge 49; `parseSupporters`: `'kaputt'`→[], `'{}'`→[], `'[{"employeeId":"a","name":"Andrej S","task":" Farbe "},{"employeeId":"a","name":"x","task":"y"},{"employeeId":"b","name":"Nico"}]'` → `[{employeeId:'a',name:'Andrej S',task:'Farbe'}]`; `worksheetMinutes({totalTime:60, supporters:<2 Einträge>})` = 120, `{totalTime:70, supporters:<1>}` = 115, ohne `supporters` = `totalTime`; `canAddOwnSupport`: fremdes normales Blatt mit Zeit → true, eigenes → false, Kommentar → false, Git ohne Zeit (`isComment:true,totalTime:0`) → false, Git mit Zeit gepusht von `knso` für `employee.shortcode 'KNSO'` → false, für anderen Mitarbeiter → true; `validateSupporters` für die drei Fehler und `null` für gültige Liste; `firstName('Andrej Stevanovski')` = 'Andrej'.
- [ ] **Step 2:** `npm test` → neue Tests FAIL (Modul fehlt).
- [ ] **Step 3:** `timeSteps.js` und `support.js` mit den Signaturen oben implementieren (reine Funktionen, keine Imports außer `ticketForm.js`).
- [ ] **Step 4:** `npm test` → alle grün.
- [ ] **Step 5:** Spec-Zeile anpassen; commit `git add src/lib/timeSteps.js src/lib/support.js tests/unit/support.test.mjs docs/superpowers/specs/2026-09-30-unterstuetzung-design.md && git commit -m "Unterstützung: Rechenregeln für 15-Minuten-Raster und Unterstützungszeit"`.
### Task 2: Appwrite-Attribut `supporters` (Skript für den Nutzer)
**Files:**
- Create: `scripts/setup-supporters.mjs`
- Test: `tests/setup-supporters.test.mjs`
**Interfaces:**
- Produces: `export const ATTRIBUTE = { key: 'supporters', type: 'string', size: 4000, required: false }`; `export function planAttribute(existing: {key,type,size}[]): 'create'|'exists'|'conflict'` (`conflict` = Key vorhanden mit anderem Typ oder Größe < 4000). CLI wie `scripts/setup-legal.mjs`: `--dry-run`, `APPWRITE_API_KEY`, Endpoint/Projekt/DB-Defaults identisch, Collection `worksheets`; bei `create` POST `/databases/woms-database/collections/worksheets/attributes/string`, danach Status pollen bis `available` (max. 30 s); bei `conflict` Exit 1 mit Meldung. Hauptteil nur, wenn als Skript gestartet (`import.meta.url === pathToFileURL(process.argv[1]).href`).
- [ ] **Step 1: Failing test:** `planAttribute([])` = 'create'; `[{key:'supporters',type:'string',size:4000}]` = 'exists'; `[{key:'supporters',type:'string',size:255}]` = 'conflict'; `[{key:'supporters',type:'integer'}]` = 'conflict'.
- [ ] **Step 2:** `npm test` → FAIL.
- [ ] **Step 3:** Skript implementieren.
- [ ] **Step 4:** `npm test` grün; `node scripts/setup-supporters.mjs --dry-run` ohne Key → gibt geplanten Schritt aus, Exit 0, kein Netzwerkzugriff.
- [ ] **Step 5:** Commit „Unterstützung: Setup-Skript für das Attribut supporters“.
### Task 3: Zeiten nur in 15-Minuten-Schritten
**Files:**
- Create: `src/components/ui/DurationSelect.jsx` (+ Export in `src/components/ui/index.js`, falls dort gebündelt)
- Modify: `src/components/CreateWorksheetModal.jsx` (Feld „Arbeitszeit (Minuten)“ Z. ~238–254, Auto-Berechnung Z. ~60–67, Submit Z. ~105), `src/components/WorksheetTimesModal.jsx` (Z. 21–31, 117–134), `src/hooks/useWorksheets.js` (`createWorksheet` Z. ~146–153, `updateWorksheetTimes` Z. ~222–231)
- Test: `.superpowers/sdd/2026-09-30-unterstuetzung/ui.mjs` (Playwright, siehe Test-Rezept unten), `tests/unit/support.test.mjs` erweitern falls neue reine Hilfen
**Interfaces:**
- Consumes: `stepOptions`, `roundUpToStep`, `isValidStep`, `MAX_STEP_MINUTES` (Task 1), `minutesBetween` (ticketForm).
- Produces: `<DurationSelect id value onChange disabled />` – `<select>` über `stepOptions(value)`, `onChange(minutes:number)`; Label „Arbeitszeit“.
Verhalten: Beide Modals rechnen automatisch `roundUpToStep(minutesBetween(start,end))`, solange nicht manuell gewählt; Hinweis darunter „✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten“ bzw. „Von Hand gewählt“. `WorksheetTimesModal` startet mit `isValidStep(stored) ? stored : roundUpToStep(stored || berechnet)`. „Nur Kommentar“ deaktiviert die Auswahl und speichert 0. `createWorksheet` rundet eine fehlende/ungültige Zeit mit `roundUpToStep`; `createWorksheet` (nicht Kommentar) und `updateWorksheetTimes` lehnen `!isValidStep(totalTime)` ab mit „Die Arbeitszeit muss in 15-Minuten-Schritten angegeben werden.“
- [ ] **Step 1: Failing UI-Checks** in `ui.mjs`: (a) neues Arbeitsblatt 09:00–09:50 → Auswahl zeigt „1 h“, gespeichert `totalTime: 60`; (b) 23:30–00:15 → 45; (c) Auswahl hat 48 Optionen, keine freie Zahleneingabe (`input[type=number]` im Formular fehlt); (d) Zeiten bearbeiten bei `totalTime: 70` → „1 h 15 min“ vorausgewählt, Speichern schickt 75; (e) Git „Zeit nachtragen“ Beginn 14:00, Ende 14:37 → 45; (f) „Nur Kommentar“ → Auswahl disabled, gespeichert 0.
- [ ] **Step 2:** Harness bauen und laufen lassen → Checks FAIL.
- [ ] **Step 3:** `DurationSelect` bauen, in beiden Modals einsetzen, `useWorksheets` anpassen.
- [ ] **Step 4:** `npm test` grün; `ui.mjs` (a)–(f) grün.
- [ ] **Step 5:** Commit „Arbeitszeit nur in 15-Minuten-Schritten, berechnete Zeit wird aufgerundet“.
### Task 4: Unterstützung im Formular des Erstellers
**Files:**
- Create: `src/components/SupportersEditor.jsx`
- Modify: `src/components/WorksheetTimesModal.jsx`, `src/components/CreateWorksheetModal.jsx`, `src/hooks/useWorksheets.js`, `src/components/TicketCard.jsx` (Mitarbeiterliste an die Modals geben)
- Test: `ui.mjs` erweitern
**Interfaces:**
- Consumes: `parseSupporters`, `serializeSupporters`, `validateSupporters`, `supportMinutes`, `stepLabel` (Task 1); `useEmployees()` → `employees` mit `userId`, `displayName`.
- Produces: `<SupportersEditor value={Supporter[]} onChange={(list)=>void} employees={[]} excludeUserId={string} minutes={number} />` – Haken „Unterstützung“ (an, wenn `value.length`), Zeilen *Mitarbeiter* (`<select>`, ohne `excludeUserId`, ohne in anderen Zeilen gewählte, Mitarbeiter ohne `userId` weggelassen) + *Wobei?* (`maxLength 200`, required) + Entfernen; „+ weitere Person“; je Zeile Vorschau „= {stepLabel(supportMinutes(minutes))}“; Haken aus → `onChange([])`; keine Mitarbeiter → Hinweis „Mitarbeiterliste konnte nicht geladen werden.“
- Produces (`useWorksheets`): `updateWorksheetTimes(worksheet, times, currentUser, employee)` akzeptiert zusätzlich `times.supporters: Supporter[]` und `times.supportersBefore: string` (Rohwert beim Öffnen). Vor dem Schreiben: `databases.getDocument` frisch; ist `fresh.supporters ?? ''` ≠ `supportersBefore ?? ''`, Abbruch mit `{ success:false, error:'Inzwischen hat jemand die Unterstützung geändert. Bitte das Formular schließen und neu öffnen.' }`. Sonst patcht es `supporters: serializeSupporters(list)` mit. `createWorksheet(data, currentUser)` übernimmt `data.supporters` (Array) serialisiert.
- [ ] **Step 1: Failing UI-Checks:** (g) Ersteller öffnet „Zeiten bearbeiten“ (60 min), setzt Haken, wählt Andrej „Farbkonzept“ und Nico „Texte“ → PATCH enthält `supporters` mit beiden, Vorschau je „= 30 min“; (h) eigene Person fehlt in der Auswahl, schon gewählte Person fehlt in der zweiten Zeile; (i) leeres „Wobei?“ → Speichern blockiert mit Fehlertext aus Task 1; (j) Mock ändert `supporters` zwischen Öffnen und Speichern → Konfliktmeldung, kein PATCH; (k) neues Arbeitsblatt mit Unterstützung → `createDocument` enthält `supporters`; (l) Employees-Mock liefert 500 → Hinweistext sichtbar.
- [ ] **Step 2:** → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** `npm test` + `ui.mjs` grün (a)–(l).
- [ ] **Step 5:** Commit „Unterstützung: Ersteller trägt helfende Personen im Arbeitsblatt ein“.
### Task 5: „Ich habe unterstützt“
**Files:**
- Create: `src/components/OwnSupportModal.jsx`
- Modify: `src/components/WorksheetList.jsx` (Kopf-Knöpfe Z. ~164–200), `src/components/TicketCard.jsx`, `src/hooks/useWorksheets.js`
- Test: `ui.mjs` erweitern
**Interfaces:**
- Consumes: `canAddOwnSupport`, `parseSupporters`, `upsertSupporter`, `removeSupporter`, `serializeSupporters` (Task 1).
- Produces (`useWorksheets`): `saveOwnSupport(worksheet, task: string|null, currentUser, employee): Promise<{success, error?}>` – prüft `canAddOwnSupport`, liest das Dokument frisch (`getDocument`), setzt/ersetzt den Eintrag `{ employeeId: user.$id, name: employee?.displayName || user.name, task }` bzw. entfernt ihn bei `task === null`, patcht nur `{ supporters }`, aktualisiert den State.
- Produces (`WorksheetList` Props): `canAddSupport(ws): boolean`, `onOwnSupport(ws): void`; Knopf-Text „Ich habe unterstützt“ bzw. „Meine Unterstützung bearbeiten“, wenn `parseSupporters(ws.supporters)` die eigene `user.$id` enthält (dafür neue Prop `currentUserId`).
- `OwnSupportModal`: Titel „Unterstützung eintragen“, Feld „Wobei?“ (required, 200), Anzeige „Dir werden {stepLabel(supportMinutes(ws.totalTime))} angerechnet.“, Knöpfe „Speichern“ und – wenn schon eingetragen – „Eintrag entfernen“.
- [ ] **Step 1: Failing UI-Checks:** (m) Nicht-Ersteller sieht bei fremdem 60-min-Blatt „Ich habe unterstützt“, Ersteller nicht; (n) Speichern mit „Design geprüft“ → vor dem PATCH ein GET des Dokuments; PATCH-Body enthält nur `supporters` mit dem bestehenden Eintrag (vom Mock) **und** dem neuen; (o) danach heißt der Knopf „Meine Unterstützung bearbeiten“, „Eintrag entfernen“ entfernt nur den eigenen; (p) Git-Push ohne Zeit und Kommentar: kein Knopf; Git-Push mit Zeit von `knso`: Kenso sieht „Zeiten bearbeiten“, Andrej sieht „Ich habe unterstützt“.
- [ ] **Step 2:** → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** `npm test` + `ui.mjs` grün.
- [ ] **Step 5:** Commit „Unterstützung: Mitarbeiter tragen sich selbst als Unterstützung ein“.
### Task 6: Anzeige in WOMS
**Files:**
- Modify: `src/components/WorksheetList.jsx` (eingeklappter Kopf Z. ~146–155 und aufgeklappter Körper Z. ~214–240), `src/components/WorksheetSummary.jsx`, `src/hooks/useWorksheets.js` (`getTotalTime` Z. ~255–259)
- Test: `ui.mjs` erweitern
**Interfaces:**
- Consumes: `parseSupporters`, `supportMinutes`, `worksheetMinutes`, `firstName` (Task 1).
Verhalten: Aufgeklappt unter Mitarbeiter/Zeit je Eintrag eine Zeile „↳ Unterstützung Andrej · Farbkonzept · 30min“ (Zeit mit dem vorhandenen `formatTime`). Eingeklappt bei vorhandener Unterstützung zusätzlich kleines Badge „+{n} Unterstützung“. `WorksheetSummary` und `getTotalTime` summieren `worksheetMinutes(ws)` über `!ws.isComment`.
- [ ] **Step 1: Failing UI-Checks:** (q) Blatt 60 min mit 2 Unterstützern → zwei Zeilen „↳ Unterstützung Andrej · Farbkonzept · 30min“ / „↳ Unterstützung Nico · Texte · 30min“, Kopf-Badge „+2 Unterstützung“; (r) Summe „Arbeitszeit gesamt 2h 0min“ bei diesem einen Blatt; (s) `supporters: 'kaputt'` → Blatt rendert normal ohne Zeilen, keine Konsolen-Exception.
- [ ] **Step 2:** → FAIL.
- [ ] **Step 3:** Implementieren.
- [ ] **Step 4:** `npm test` + `ui.mjs` grün; Deploy-naher Build: `git archive HEAD | …` in `docker run --rm node:20-alpine` (`npm install && npm run build`) Exit 0.
- [ ] **Step 5:** Commit „Unterstützung: Anzeige in Arbeitsblatt-Liste und Gesamtzeit“.
**WOMS-Test-Rezept** (aus Memory, `ui.mjs`): esbuild-Harness mit JS-API (`require('<worktree>/node_modules/esbuild').build`), `define` für `import.meta.env.*`, Loader `.js=jsx`, `.ttf`/`.woff2`, Stub-Plugin für ProjectContextPanel/TicketProjects/InvoicePanel; Bau unter `systemd-run --scope -p MemoryMax=450M -p MemorySwapMax=0`, Playwright headless unter MemoryMax 700M, Bundle über `page.route` von `https://ticket.test`, Appwrite-REST (`/v1/account`, `/v1/databases/woms-database/collections/{worksheets,employees}/documents[/:id]`) gemockt, PATCH/POST-Bodies in `window.__calls` protokolliert. `set -o pipefail`, Exit-Codes prüfen.
---
## Kundenportal
### Task 7: Rechenregel + Stundenkonto
**Files:**
- Setup: `git -C /home/webklar/apps/kundenbereich.webklar.com/build fetch origin && git -C … worktree add -b feature/unterstuetzung ../dev-unterstuetzung origin/main`, dann node_modules-Symlink.
- Create: `server/services/worksheetMinutes.js`
- Modify: `server/services/hourLedger.js` (`listPlanUsage` Z. 54–70), `public/app.js` (`renderHoursView` Z. ~1218–1221)
- Test: `test/worksheetMinutes.test.js`, `test/hourLedger.test.js` (neu, falls nicht vorhanden; sonst erweitern)
**Interfaces:**
- Produces: `supportMinutes(total)`, `parseSupporters(raw)`, `worksheetMinutes(ws)`, `firstName(name)` – gleiche Semantik und Testvektoren wie WOMS Task 1; `parseSupporters` loggt bei kaputtem Wert einmal `console.warn('[unterstuetzung] supporters unlesbar', ws?.$id)` über optionalen zweiten Parameter `context`.
- Produces (`listPlanUsage` Eintrag): zusätzlich `supportMinutes: number` (Summe der Unterstützung); `rawMinutes = worksheetMinutes(ws)`, `minutes = roundUsage(rawMinutes)`; Filter `worksheetMinutes(ws) > 0`.
- [ ] **Step 1: Failing tests:** Vektoren; `parseSupporters` wie WOMS; `listPlanUsage` mit gemocktem `listDocuments` (Muster aus `test/support/appwriteMock`) – Blatt 60 min + 1 Unterstützer → `minutes 90, supportMinutes 30`; Blatt mit `supporters:'kaputt'` → `minutes 60`, kein Throw; `computeLedger` mit 60 min Guthaben und diesem 90-min-Eintrag → `overageMinutes 30`.
- [ ] **Step 2:** `npm test` → FAIL.
- [ ] **Step 3:** Implementieren; in `renderHoursView` hinter `−{fmtHours(u.minutes)}` bei `u.supportMinutes` ein `<span class="muted">inkl. {fmtHours(u.supportMinutes)} Unterstützung</span>`.
- [ ] **Step 4:** `npm test` grün (bekannt flaky/unabhängig: `repoMove.test.js`).
- [ ] **Step 5:** Commit „Unterstützung: Rechenregel und Abzug im Abo-Stundenkonto“.
### Task 8: Projektansicht – Unterstützungs-Zeilen und Gesamtzeit
**Files:**
- Modify: `server/services/customerProjectView.js` (`customerWorkEntry` Z. ~197–216, `customerWorkEntries` Z. ~219–233), `server/routes/projects.js` (`GET /:id/work` Z. ~78–95), `public/app.js` (`renderWorkEntry` Z. ~319–336, `renderOrder` Z. ~252–273, Projektdetails Z. ~386–396), `public/kundenbereich.css`, `server/services/customerActivity.js` (Z. ~36–47), `public/admin.js` (Z. ~167)
- Test: `test/customerProjectView.test.js` erweitern
**Interfaces:**
- Consumes: `parseSupporters`, `supportMinutes`, `worksheetMinutes`, `firstName` (Task 7).
- Produces: Work-Eintrag bekommt `supports: { name: string /*Vorname*/, task: string, minutes: number }[]` (leer = `[]`); zusammengefasste Git-Tage hängen `supports` aneinander; `minutes` des Eintrags bleibt die eigene Arbeitszeit (ohne Unterstützung). Neue Export-Funktion `workTotalMinutes(entries): number` = Σ (`minutes` + Σ `supports.minutes`). `GET /:id/work` antwortet zusätzlich `totalMinutes`. `customerActivity` liefert je Blatt zusätzlich `supportMinutes` (Summe); `admin.js` zeigt dann „, +{n} min Unterstützung“.
- Anzeige: `renderWorkEntry` hängt je Unterstützung `<li class="pd-support">↳ Unterstützung {name}: {task} <span class="pd-time">{fmtMinutes}</span></li>` in einer `<ul class="pd-supports">` unter den Eintrag. Kopf: bei `order` in `pd-order-head` ein `<span class="pd-total">Arbeitszeit gesamt: {fmtMinutes(totalMinutes)}</span>`; ohne `order` als erste Zeile des Abschnitts „Arbeiten am Projekt“; bei 0 nichts.
- [ ] **Step 1: Failing tests:** normales Blatt 60 min + Andrej „Farbkonzept“ → `supports: [{name:'Andrej', task:'Farbkonzept', minutes:30}]`, `minutes 60`; zwei Git-Pushs am selben Tag je mit einem Unterstützer → ein Eintrag mit zwei `supports`; `workTotalMinutes` über diese Einträge; Kommentar mit `supporters` → kein Eintrag; `supporters:'kaputt'` → `supports: []`.
- [ ] **Step 2:** → FAIL.
- [ ] **Step 3:** Implementieren (Server + `app.js` + CSS: `.pd-supports` eingerückt, gleiche Schriftgröße wie `.pd-entry-meta`).
- [ ] **Step 4:** `npm test` grün; UI-Blick mit dem statischen Fixture-Server aus [[kundenportal-testing]] (Projektdetails mit `work`-Fixture inkl. `totalMinutes` und `supports`, Desktop + 390 px Breite, Screenshot in den SDD-Ordner).
- [ ] **Step 5:** Commit „Unterstützung: eigene Zeilen und Gesamtzeit in den Projektdetails“.
### Task 9: Push-Zeitmail nur in 15-Minuten-Schritten
**Files:**
- Modify: `server/services/pushTimeEntry.js` (`parseMinutes` Z. ~40–47), `server/routes/pushTime.js` (Formular Z. ~153–156)
- Test: `test/pushTimeEntry.test.js` bzw. `test/pushTimeRoute.test.js` erweitern
**Interfaces:** `parseMinutes(raw)` akzeptiert nur ganze Vielfache von 15 in 15–720, sonst `null`. Das Formular wird ein `<select name="minutes">` mit 15 … 720 in 15er-Schritten (Label wie `formatMinutes`), vorausgewählt `minutes` aus der Query bzw. 60. `QUICK_MINUTES` bleibt.
- [ ] **Step 1: Failing tests:** `parseMinutes('45')`=45, `'720'`=720, `'70'`=null, `'0'`=null, `'735'`=null; Route-Test: GET `/zeit/<token>` enthält `<select name="minutes"` mit 48 `<option`; POST `minutes=70` → Fehlerseite, kein Schreiben.
- [ ] **Step 2:** → FAIL.
- [ ] **Step 3:** Implementieren; Fehlertext „Bitte eine Zeit in 15-Minuten-Schritten wählen.“
- [ ] **Step 4:** `npm test` grün.
- [ ] **Step 5:** Commit „Push-Zeitmail: Zeit nur in 15-Minuten-Schritten“.
### Task 10: Abo-Bedingungen Fassung 5
**Gate:** Vor Step 3 den endgültigen § 4-Text (Spec, Abschnitt Verträge) dem Nutzer im Chat vorlegen und auf sein OK warten.
**Files:**
- Create: `public/vertragsunterlagen/v4/abo-bedingungen.html` + `public/vertragsunterlagen/v4/WEBklar-Abo-Vertragsunterlagen.pdf` (bytegleiche Kopien der heutigen Fassung 4)
- Modify: `public/abo-bedingungen.html` (§ 4 neuer Satz nach der Rundungsregel; Kopf „Version 5 · Stand {Datum}“ an beiden Stellen), `server/config.js` (`termsVersion: '{yyyy-mm-dd}-v5'` Z. ~158), `server/services/products.js` (`isTermsV4`/`termsDocFor` Z. ~28–55), `scripts/build-legal-pdfs.sh`, `public/WEBklar-Abo-Vertragsunterlagen.pdf` (neu erzeugt), `test/legalTexts.test.js`, `test/products.test.js`, alle Fundstellen von `isTermsV4`/`-v4` (`server/routes/abo.js`, `test/mailTemplates.test.js`, `server/mail-templates/K01 Vertragsbestaetigung.html`)
- `{Datum}` = Tag der Umsetzung; verschiebt sich der Livegang, im Livegang-Schritt anpassen.
**Interfaces:**
- Produces (`products.js`): `isCurrentTerms(v): boolean` (endet auf `-v5`), `isTermsV4OrNewer(v): boolean` (endet auf `-v4` oder `-v5`), `termsDocFor(v)`: `-v5` → `{ url:'/abo-bedingungen.html', pdfFile:'WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph:'§ 11' }`; `-v4` → `{ url:'/vertragsunterlagen/v4/abo-bedingungen.html', pdfFile:'vertragsunterlagen/v4/WEBklar-Abo-Vertragsunterlagen.pdf', endParagraph:'§ 11' }`; sonst v3 wie bisher. `contractAttachmentFor` liefert als `filename` den Basisnamen. Alle bisherigen Aufrufer von `isTermsV4`, die „aktuelle Fassung“ meinen, nutzen `isCurrentTerms`; wo „Fassung 4 oder neuer“ gemeint ist (Hosting/Zusatzspeicher/Wechsel), `isTermsV4OrNewer`. `isTermsV4` entfällt.
- [ ] **Step 1: Failing tests** (`legalTexts.test.js`): Fassung 4 bytegleich archiviert (sha256 HTML = `git show origin/main:public/abo-bedingungen.html`, fest eingetragen wie V3_SHA256); Fassung 5 enthält „Version 5“, nicht „Version 4“, § 4 enthält „die Hälfte der Arbeitszeit dieses Arbeitsschritts“, „aufgerundet auf volle 15 Minuten“, „jede Unterstützung mit Person, Tätigkeit und angerechneter Zeit“; Widerrufsbelehrung + Formular wortgleich mit Fassung 4. `products.test.js`: `termsDocFor` für `-v5`, `-v4`, `-v3`, `undefined`.
- [ ] **Step 2:** `npm test` → FAIL.
- [ ] **Step 3:** Nach OK des Nutzers: Archiv anlegen, Text einfügen, Version hochziehen, Code anpassen, PDFs mit `scripts/build-legal-pdfs.sh` neu erzeugen. Weitere Fundstellen der Rundungs-/Stundenregel suchen (`grep -rn "15 Minuten" public server/mail-templates`), bei Abweichung angleichen.
- [ ] **Step 4:** `npm test` grün; PDF öffnen und § 4 prüfen (Seitenumbruch, Kopfzeile „Version 5“).
- [ ] **Step 5:** Commit „Abo-Bedingungen Fassung 5: Unterstützung in § 4, Fassung 4 archiviert“.
## webklar.com
### Task 11: AGB § 9 Absatz 2
**Gate:** Endgültigen Text dem Nutzer vorlegen (zusammen mit Task 10 möglich), OK abwarten.
**Files:**
- Vorher: `git -C /home/webklar/apps/webklar.com/dev-agb-haftung fetch origin && git … rebase origin/main` (f40e889 liegt noch auf 1637a4a; main ist 3c9a879), node_modules-Symlink prüfen.
- Modify: `src/pages/AGB.tsx` (§ 9, Z. ~188–198: neuer zweiter Listenpunkt, bisherige 2 und 3 werden 3 und 4)
- Test: `src/test/agb-verguetung.test.tsx`
Text (vollständig ausgeschrieben, kein Verweis): „Wird nach Aufwand abgerechnet, erfasst WEBklar die Arbeitszeit je Arbeitsschritt in Schritten von 15 Minuten; angefangene 15 Minuten werden aufgerundet. Wirkt eine weitere Person unterstützend mit, rechnet WEBklar für sie die Hälfte der Arbeitszeit des Arbeitsschritts an, aufgerundet auf volle 15 Minuten; bei mehreren unterstützenden Personen gilt das für jede. Jeder Arbeitsschritt und jede Unterstützung ist im Kundenportal einsehbar.“ (AGB sprechen von „WEBklar“ in der dritten Person, daher angepasst.)
- [ ] **Step 1: Failing test:** § 9 hat 4 Absätze; Absatz 1 unverändert „Die Vergütung ergibt sich aus dem Angebot.“; Absatz 2 = Text oben; Absatz 4 „Für die Abos gelten die Vertragsbedingungen für die WEBklar-Abos.“
- [ ] **Step 2:** `npx vitest run src/test/agb-verguetung.test.tsx` → FAIL.
- [ ] **Step 3:** Absatz einfügen; prüfen, ob irgendwo auf „§ 9 Abs. 2/3“ der AGB verwiesen wird (`grep -rn "9 Abs" src` in webklar.com und `public/` des Portals) und anpassen.
- [ ] **Step 4:** `npx vitest run` grün (inkl. `agb-haftung.test.tsx`); `npm run build` Exit 0.
- [ ] **Step 5:** Commit „AGB § 9: Zeiterfassung in 15-Minuten-Schritten und Unterstützung“.
## Livegang (nur mit OK des Nutzers, in dieser Reihenfolge)
- [ ] 1. Nutzer: `cd /home/webklar/apps/ticket.webklar.com/dev-unterstuetzung && APPWRITE_API_KEY=… node scripts/setup-supporters.mjs` → „available“.
- [ ] 2. Portal: `git -C …/kundenbereich.webklar.com/dev-unterstuetzung push origin feature/unterstuetzung:main`; prüfen: Deploy-Marker/Commit im Container, `/api/projects/:id/work` liefert `totalMinutes`, Stundenkonto lädt, `/abo-bedingungen.html` zeigt „Version 5“, `/vertragsunterlagen/v4/abo-bedingungen.html` erreichbar.
- [ ] 3. WOMS: `git -C …/ticket.webklar.com/dev-unterstuetzung push origin feature/unterstuetzung:test`; prüfen: Live-Bundle enthält „Ich habe unterstützt“, Arbeitszeit-Auswahl vorhanden.
- [ ] 4. webklar.com: `git -C …/webklar.com/dev-agb-haftung push origin fix/agb-haftung:main`; `/agb` zeigt § 9 Abs. 2 und § 11 neu.
- [ ] 5. Nutzer lädt in WOMS „Rechtliches“ die neuen Versionen hoch (Abo-Bedingungen v5 PDF, AGB neue Version) mit Änderungsnotiz.
- [ ] 6. Lesender Check mit echten Daten im Chrome des Nutzers: ein Arbeitsblatt mit Unterstützung anlegen (Test-Ticket), Portal-Projektansicht zeigt die Zeile und Gesamtzeit.
- [ ] 7. Memory aktualisieren (woms-ticketsystem, webklar-kundenportal, webklar-website).
@@ -0,0 +1,178 @@
# Unterstützung bei Arbeitsblättern + 15-Minuten-Zeiten – Konzept
Stand 2026-09-30, freigegeben im Gespräch (Abschnitte 1–4).
## Ziel
Wer bei einem Arbeitsblatt eines Kollegen mitgeholfen hat, wird als **Unterstützung** eingetragen:
Person und Tätigkeit. Die Person, die das Arbeitsblatt angelegt hat, behält 100 % der Zeit; jede
unterstützende Person bekommt zusätzlich **50 % der Arbeitszeit, aufgerundet auf volle 15 Minuten**.
Die Unterstützungszeit wird dem Kunden berechnet und im Kundenportal einzeln ausgewiesen.
Gleichzeitig werden Arbeitszeiten **überall nur noch in 15-Minuten-Schritten** erfasst. Das hebt die
Entscheidung vom 2026-09-26 („ganze Minuten, keine 15er-Schritte“) auf.
Die Verträge (Abo-Bedingungen, AGB) halten Rundung und Unterstützung fest.
Nicht im Umfang: Auswertung der Stunden je Mitarbeiter, Umrechnung alter Arbeitsblätter,
Rechnungserstellung.
## Rechenregel
```
supportMinutes(totalTime) = ceil(totalTime / 2 / 15) * 15 // 0 bei totalTime <= 0
worksheetMinutes(ws) = totalTime + Anzahl Unterstützer * supportMinutes(totalTime)
```
Beispiele: 15 → 15, 30 → 15, 45 → 30, 60 → 30, 75 → 45, 70 (alt) → 45.
Kommentare und Git-Pushs ohne nachgetragene Zeit haben `totalTime` 0, also auch 0 Unterstützung.
Die Regel steht in beiden Systemen (WOMS und Portal) mit denselben Testfällen. Die Minuten der
Unterstützung werden **nie gespeichert**, sondern immer aus `totalTime` berechnet. Ändert sich die
Arbeitszeit, stimmt die Unterstützung automatisch.
## Datenmodell
Appwrite `woms-database`, Collection `worksheets`, neues Attribut:
- `supporters` – String, nicht Pflicht, Größe 4000, JSON-Array:
`[{ "employeeId": "<Appwrite-User-ID>", "name": "Andrej Stevanovski", "task": "Farbkonzept" }]`
Regeln: `task` ist Pflicht (1–200 Zeichen, getrimmt). Jede `employeeId` höchstens einmal. Die Person,
die das Arbeitsblatt angelegt hat, darf nicht als Unterstützung stehen (bei Git-Pushs: der Mitarbeiter,
dem der Push gehört, siehe `isWorksheetCreator`). `name` wird beim Eintragen gespeichert, damit die
Zeile auch nach dem Löschen eines Mitarbeiters lesbar bleibt.
Das Attribut legt ein Skript `scripts/setup-supporters.mjs` an (idempotent). Der Nutzer führt es aus,
weil Claude den Appwrite-Schlüssel nicht verwenden darf.
## WOMS (Ticketsystem)
### 15-Minuten-Schritte
Betroffen: `CreateWorksheetModal` (neues Arbeitsblatt), `WorksheetTimesModal` („Zeiten bearbeiten“ und
„Zeit nachtragen“ bei Git).
- Das Feld „Arbeitszeit“ wird eine Auswahl: 15 min, 30 min, 45 min, 1 h, 1 h 15 … bis 12 h (48 Werte).
Kein freies Eintippen mehr.
- Beginn und Ende bleiben freie Uhrzeiten (bei Git ist das Ende die Uhrzeit des Pushs). Die
automatisch berechnete Zeit (`minutesBetween`) wird auf den nächsten 15-Minuten-Schritt
**aufgerundet**; danach kann ein anderer Wert gewählt werden. Dauer 0 → 15 min vorausgewählt.
- Hat ein altes Arbeitsblatt eine Zeit außerhalb des Rasters (z. B. 70), ist beim Bearbeiten der
nächsthöhere Schritt (75) vorausgewählt.
- Beim Speichern wird geprüft, dass `totalTime` ein positives Vielfaches von 15 ist (außer bei
„Nur Kommentar“, dort 0).
- `useWorksheets.createWorksheet` rechnet eine fehlende Zeit ebenfalls aufgerundet.
### Unterstützung eintragen
**Person, die das Arbeitsblatt angelegt hat** (`canEditWorksheetTimes`): im Formular „Zeiten
bearbeiten“ und beim neuen Arbeitsblatt der Haken **„Unterstützung“**. Darunter Zeilen mit
*Mitarbeiter* (Auswahl aus `employees`, ohne sich selbst und ohne schon gewählte) und *Wobei?*
(Pflichttext), „+ weitere Person“, Entfernen je Zeile. Haken aus = Liste leeren.
**Alle anderen Mitarbeiter:** bei fremden Arbeitsblättern mit Arbeitszeit der Knopf
**„Ich habe unterstützt“**. Kleines Formular nur mit *Wobei?*. Ist man schon eingetragen, heißt der
Knopf „Meine Unterstützung bearbeiten“ und man kann die Tätigkeit ändern oder den Eintrag entfernen.
Andere Einträge und die Zeiten sind nicht änderbar.
Neue Hilfen in `lib/ticketForm.js` (oder `lib/support.js`): `supportMinutes`, `worksheetMinutes`,
`parseSupporters` (fehlertolerant, kaputtes JSON → leere Liste), `canAddOwnSupport(ws, user, employee)`
(nicht Ersteller, Arbeitsblatt hat Arbeitszeit), `upsertOwnSupport`, `removeOwnSupport`.
Unterstützung ist nur möglich bei Arbeitsblättern mit Arbeitszeit: nicht bei reinen Kommentaren, bei
Git-Pushs erst nach „Zeit nachtragen“.
Speichern: `updateWorksheet` patcht `{ supporters }`. „Ich habe unterstützt“ liest das Dokument
vorher frisch und schreibt nur den eigenen Eintrag um, damit gleichzeitige Einträge anderer nicht
überschrieben werden.
Berechtigungen: wie bisher nur in der Oberfläche durchgesetzt (die Collection erlaubt Update für
Benutzer). Keine Änderung an den Appwrite-Rechten.
### Anzeige
- `WorksheetList`: unter dem Arbeitsblatt je Unterstützung
„↳ Unterstützung Andrej · Farbkonzept · 30 min“.
- `WorksheetSummary` „Arbeitszeit gesamt“ und `useWorksheets` Summen verwenden `worksheetMinutes`.
- Der Abo-Haken („+ Abo“, `countsToPlan`) gilt für das ganze Arbeitsblatt einschließlich Unterstützung.
## Kundenportal
- Gleiche Rechenregel als `server/services/worksheetMinutes.js` (oder vorhandenes Modul) mit
`supportMinutes`, `worksheetMinutes`, `parseSupporters`. Kaputtes JSON → Eintrag überspringen und
loggen, nie Absturz.
- **Stundenkonto** (`hourLedger.js`): Verbrauch eines Abo-Arbeitsblatts = `worksheetMinutes(ws)`
statt `totalTime`. Die Zeile im Verlauf bekommt den Zusatz „inkl. 30 min Unterstützung“.
- **„Arbeiten am Projekt“** (`customerProjectView.js`, `public/app.js`): jeder Eintrag bekommt
`supports: [{ name, task, minutes }]`, dargestellt als eigene Zeilen unter dem Schritt:
„↳ Unterstützung Andrej: Farbkonzept · 30 min“. Werden mehrere Git-Pushs eines Tages zu
„Website aktualisiert“ zusammengefasst, hängen alle Unterstützungen unter diesem Eintrag.
- **Gesamtzeit oben:** im Kopf der Projektdetails (neben Auftragsnummer und Status) die Zeile
„Arbeitszeit gesamt: 4 h 30 min“ = Summe aller Schritte inkl. Unterstützung. Ohne Auftrag steht sie
oben im Abschnitt „Arbeiten am Projekt“. Bei 0 wird sie nicht gezeigt.
- `customerActivity.js` gibt `supporters` mit weiter, wo `totalTime` weitergegeben wird.
- **Push-Zeitmail** (Spec af7ecce, noch nicht gebaut): „andere Zeit“ nur in 15-Minuten-Schritten;
wird in jener Spec nachgetragen.
## Verträge
Stand heute: Abo-Bedingungen Version 4 (live seit 2026-09-30) § 4 enthält schon „aufgerundet auf volle
15 Minuten“ / „in Schritten von 15 Minuten“, aber nichts zur Unterstützung. AGB § 9 sagt nur
„Die Vergütung ergibt sich aus dem Angebot“.
- **Abo-Bedingungen Version 5**, § 4 neuer Satz nach der Rundungsregel (Entwurf):
„Wirkt an einem Arbeitsschritt eine weitere Person unterstützend mit, etwa bei Gestaltung,
Abstimmung oder Prüfung, rechnen wir für sie die Hälfte der Arbeitszeit dieses Arbeitsschritts an,
aufgerundet auf volle 15 Minuten; bei mehreren unterstützenden Personen gilt das für jede von ihnen.
Im Kundenportal weisen wir jeden Arbeitsschritt mit seiner Arbeitszeit und jede Unterstützung mit
Person, Tätigkeit und angerechneter Zeit einzeln aus.“
- **AGB § 9** neuer Absatz 2, nur für Abrechnung nach Aufwand (Festpreis-Angebote unberührt), Regel
vollständig ausgeschrieben (kein Verweis auf die Abo-Bedingungen):
„Wird nach Aufwand abgerechnet, erfassen wir die Arbeitszeit je Arbeitsschritt in Schritten von
15 Minuten; angefangene 15 Minuten runden wir auf. Wirkt eine weitere Person unterstützend mit,
rechnen wir für sie die Hälfte der Arbeitszeit des Arbeitsschritts an, aufgerundet auf volle
15 Minuten; bei mehreren unterstützenden Personen gilt das für jede. Jeder Arbeitsschritt und jede
Unterstützung ist im Kundenportal einsehbar.“
Wird mit dem offenen § 11-Haftungsentwurf (webklar.com f40e889, fix/agb-haftung) zu **einer** neuen
AGB-Version gebündelt.
- Alle Stellen, die die Texte wiederholen (PDF-Anhang der Vertragsmails, `vertragsunterlagen/`,
Kalkulation, Portal-Texte), werden abgeglichen. Beide Texte kommen in WOMS „Rechtliches“ als neue
Version.
- Laufende Abos bleiben auf ihrer Version. Einschätzung (keine Rechtsberatung): Version 4 rechnet die
„tatsächlich aufgewendete Zeit“ ab; bei zwei Personen fällt mehr Zeit an, als mit 50 % berechnet
wird, die Unterstützung ist also gedeckt.
- Die endgültigen Texte legt Claude dem Nutzer vor dem Livegang einzeln zur Freigabe vor.
## Alte Daten und Fehlerfälle
- Alte Arbeitsblätter bleiben unverändert (keine Massen-Umrechnung); 70 Minuten bleiben 70 Minuten,
bis jemand das Blatt bearbeitet.
- Ohne `supporters` = keine Unterstützung.
- Gelöschter Mitarbeiter: gespeicherter Name bleibt, Zeile wird weiter gezeigt und berechnet.
- Doppelte Person / Ersteller als Unterstützung: Oberfläche verhindert es; beim Lesen werden Duplikate
ignoriert.
- Arbeitszeit 0 oder „Nur Kommentar“: Unterstützung zählt 0 (Einträge bleiben gespeichert).
## Livegang (Reihenfolge)
1. Nutzer führt `scripts/setup-supporters.mjs` aus (Attribut `supporters`).
2. Kundenportal live (Push `main`), damit keine Unterstützung eingetragen werden kann, die der Kunde
nicht sieht.
3. WOMS live (Push `feature/unterstuetzung:test`).
4. Verträge: Abo-Bedingungen v5 (Portal) und AGB (webklar.com) nach Freigabe der Texte; Versionen in
„Rechtliches“ eintragen.
Nichts wird ohne das OK des Nutzers gepusht.
## Tests
- Rechenregel in beiden Systemen mit denselben Fällen (15, 30, 45, 60, 75, 70, 0, Kommentar).
- WOMS: Unit-Tests (`npm test`) für Rechnung, `parseSupporters`, Berechtigungen
(`canAddOwnSupport`, Ersteller/Nicht-Ersteller, Git-Push), eigener Eintrag ändern/entfernen.
Playwright mit nachgebautem Appwrite: 15-Minuten-Auswahl und Aufrunden, Haken „Unterstützung“ mit
zwei Personen, „Ich habe unterstützt“, Anzeige in Liste und Summe.
- Portal: Tests für `customerWorkEntries` (Unterstützungs-Zeilen, zusammengefasste Git-Tage),
Gesamtzeit, Stundenkonto mit Unterstützung, kaputtes JSON.
- Vor dem Livegang: Build wie beim Deploy (`docker run node:20-alpine`), Blick auf echte Daten nur
lesend im Chrome des Nutzers.
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
/**
* WOMS Unterstuetzung – Appwrite-Setup (idempotent).
* Legt in der Sammlung worksheets das Attribut "supporters" an (JSON-Liste der
* unterstuetzenden Mitarbeiter, siehe src/lib/support.js).
*
* Usage:
* node scripts/setup-supporters.mjs --dry-run
* APPWRITE_API_KEY=... node scripts/setup-supporters.mjs
*/
import { pathToFileURL } from "node:url";
const dryRun = process.argv.includes("--dry-run");
const endpoint = process.env.APPWRITE_ENDPOINT || "https://appwrite.webklar.com/v1";
const projectId = process.env.APPWRITE_PROJECT_ID || "6a1058610003c5a13a05";
const databaseId = process.env.APPWRITE_DATABASE_ID || "woms-database";
const apiKey = process.env.APPWRITE_API_KEY;
const collectionId = "worksheets";
export const ATTRIBUTE = { key: "supporters", type: "string", size: 4000, required: false };
/** 'create' = fehlt, 'exists' = passt, 'conflict' = gleicher Name mit anderem Typ oder zu klein */
export function planAttribute(existing) {
const found = (existing || []).find((a) => a.key === ATTRIBUTE.key);
if (!found) return "create";
if (found.type !== ATTRIBUTE.type || !(Number(found.size) >= ATTRIBUTE.size)) return "conflict";
return "exists";
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function run() {
if (dryRun) {
console.log(`Sammlung ${collectionId}: Attribut ${ATTRIBUTE.key} (String ${ATTRIBUTE.size}, nicht Pflicht) wird angelegt, falls es fehlt.`);
return;
}
if (!apiKey) {
console.error("APPWRITE_API_KEY required (oder --dry-run)");
process.exit(1);
}
const { Client, Databases } = await import("node-appwrite");
const client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey);
const db = new Databases(client);
const { attributes } = await db.listAttributes(databaseId, collectionId);
const plan = planAttribute(attributes);
if (plan === "conflict") {
console.error(`Attribut ${ATTRIBUTE.key} existiert schon mit anderem Typ oder kleinerer Größe – bitte in der Appwrite-Konsole prüfen.`);
process.exit(1);
}
if (plan === "exists") {
console.log(`Attribut ${ATTRIBUTE.key} ist schon vorhanden.`);
return;
}
await db.createStringAttribute(databaseId, collectionId, ATTRIBUTE.key, ATTRIBUTE.size, ATTRIBUTE.required);
console.log(`Attribut ${ATTRIBUTE.key} angelegt, warte auf Appwrite …`);
const until = Date.now() + 30000;
for (;;) {
const attr = await db.getAttribute(databaseId, collectionId, ATTRIBUTE.key);
if (attr.status === "available") break;
if (attr.status === "failed" || attr.status === "stuck") throw new Error(`Attribut fehlgeschlagen: ${attr.error || attr.status}`);
if (Date.now() > until) throw new Error("Attribut nach 30 s nicht verfügbar – Status in der Appwrite-Konsole prüfen.");
await sleep(1000);
}
console.log(`Attribut ${ATTRIBUTE.key}: available`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => {
console.error(e.message || e);
process.exit(1);
});
}
+39 -18
View File
@@ -3,7 +3,10 @@ import { FaTimes } from 'react-icons/fa'
import { useAuth } from '../context/AuthContext'
import { SERVICE_TYPES, todayIso, isoToGermanDate, timeToWoms, minutesBetween } from '../lib/ticketForm'
import { TICKET_STATUSES, statusLabel } from '../lib/ticketStatus'
import { DateTimeInput } from './ui'
import { DateTimeInput, DurationSelect } from './ui'
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
import { validateSupporters } from '../lib/support'
import SupportersEditor from './SupportersEditor'
// Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status)
const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt']
@@ -28,7 +31,8 @@ const initialForm = (workorder, newStatus) => ({
endTime: '',
details: '',
isComment: false,
countsToPlan: false
countsToPlan: false,
supporters: []
})
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate, aboHoursAllowed = true }) {
@@ -57,12 +61,12 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
}
}, [isOpen, workorder, defaultStatus])
// Automatische Zeitberechnung
// Automatische Zeitberechnung, aufgerundet auf 15 Minuten
useEffect(() => {
if (!autoCalculate || formData.isComment) return
const minutes = minutesBetween(formData.startTime, formData.endTime)
if (minutes !== null) {
setFormData(prev => ({ ...prev, totalTime: minutes }))
setFormData(prev => ({ ...prev, totalTime: roundUpToStep(minutes) }))
}
}, [formData.startTime, formData.endTime, formData.isComment, autoCalculate])
@@ -95,6 +99,18 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
setLoading(false)
return
}
if (!formData.isComment && !isValidStep(formData.totalTime)) {
setError('Bitte die Arbeitszeit auswählen.')
setLoading(false)
return
}
const supporters = formData.isComment ? [] : formData.supporters
const supportError = validateSupporters(supporters, user?.$id)
if (supportError) {
setError(supportError)
setLoading(false)
return
}
const worksheetData = {
woid: workorder.woid,
@@ -102,7 +118,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
serviceType: formData.serviceType,
oldStatus: workorder.status,
newStatus: formData.newStatus,
totalTime: formData.isComment ? 0 : parseInt(formData.totalTime) || 0,
totalTime: formData.isComment ? 0 : Number(formData.totalTime),
startDate: isoToGermanDate(formData.startDate),
startTime: timeToWoms(formData.startTime),
endDate: isoToGermanDate(formData.endDate),
@@ -110,7 +126,8 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
details: formData.details,
isComment: formData.isComment,
countsToPlan: aboHoursAllowed && !formData.isComment && formData.countsToPlan,
employeeShort: user?.prefs?.shortCode || '' // Aus User-Preferences
employeeShort: user?.prefs?.shortCode || '', // Aus User-Preferences
supporters
}
const result = await onCreate(worksheetData, user)
@@ -235,27 +252,31 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
</div>
<div className="form-group">
<label className="form-label">Arbeitszeit (Minuten)</label>
{/* Ganze Minuten: mit step 15 blockierte der Browser berechnete Zeiten wie 70 */}
<input
type="number"
className="form-control"
min="0"
step="1"
value={formData.totalTime}
onChange={(e) => handleChange('totalTime', e.target.value)}
<label className="form-label" htmlFor="worksheet-total">Arbeitszeit</label>
<DurationSelect
id="worksheet-total"
value={formData.isComment ? 0 : formData.totalTime}
onChange={(value) => handleChange('totalTime', value)}
disabled={formData.isComment}
placeholder="0"
/>
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
{autoCalculate && formData.startTime && formData.endTime
? '✓ Automatisch berechnet'
: 'Manuell eingeben'}
? '✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: autoCalculate ? 'Beginn und Ende eintragen oder Zeit wählen' : 'Von Hand gewählt'}
</small>
</div>
</div>
</div>
{!formData.isComment && (
<SupportersEditor
value={formData.supporters}
onChange={(list) => handleChange('supporters', list)}
excludeUserId={user?.$id}
minutes={formData.totalTime}
/>
)}
<div className="form-group">
<label className="form-label">Was wurde gemacht?</label>
<textarea
+80
View File
@@ -0,0 +1,80 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { parseSupporters, supportMinutes } from '../lib/support'
import { stepLabel } from '../lib/timeSteps'
/* "Ich habe unterstuetzt": die eigene Unterstuetzung bei einem fremden Arbeitsblatt
eintragen, aendern oder entfernen. Andere Eintraege und die Zeiten bleiben unberuehrt. */
export default function OwnSupportModal({ worksheet, currentUserId, onClose, onSave }) {
const existing = parseSupporters(worksheet.supporters).find((s) => s.employeeId === currentUserId)
const [task, setTask] = useState(existing?.task || '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const save = async (value) => {
setSaving(true)
setError('')
const result = await onSave(worksheet, value)
if (result?.success) {
onClose()
return
}
setError(result?.error || 'Speichern fehlgeschlagen')
setSaving(false)
}
const handleSubmit = (e) => {
e.preventDefault()
if (!task.trim()) {
setError('Bitte angeben, wobei du unterstützt hast.')
return
}
save(task.trim())
}
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content" style={{ maxWidth: 480 }}>
<h2 className="mb-2">Unterstützung eintragen</h2>
<p className="text-grey" style={{ fontSize: '14px', marginBottom: '16px' }}>
WSID {worksheet.wsid} · {worksheet.employeeName}
<br />Dir werden {stepLabel(supportMinutes(worksheet.totalTime))} angerechnet.
</p>
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label" htmlFor="own-support-task">Wobei?</label>
<input
id="own-support-task"
className="form-control"
maxLength={200}
required
value={task}
onChange={(e) => setTask(e.target.value)}
autoFocus
/>
</div>
<div className="text-center mt-2 flex gap-2" style={{ justifyContent: 'center' }}>
<button type="submit" className="btn btn-dark" disabled={saving}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
{existing && (
<button type="button" className="btn" disabled={saving} onClick={() => save(null)}>
Eintrag entfernen
</button>
)}
</div>
</form>
</div>
</div>
)
}
+76
View File
@@ -0,0 +1,76 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useEmployees } from '../hooks/useEmployees'
import { supportMinutes } from '../lib/support'
import { stepLabel } from '../lib/timeSteps'
/* Haken "Unterstuetzung" mit Zeilen Mitarbeiter + "Wobei?". Nur fuer die Person, die das
Arbeitsblatt angelegt hat (sie selbst steht nicht in der Auswahl). Die Mitarbeiterliste
laedt erst hier, damit nicht jede Ticket-Karte sie abruft. */
export default function SupportersEditor({ value, onChange, excludeUserId, minutes }) {
const { employees, loading, error } = useEmployees()
const [enabled, setEnabled] = useState(value.length > 0)
const perPerson = stepLabel(supportMinutes(minutes))
const choices = employees.filter((e) => e.userId && e.userId !== excludeUserId)
const toggle = (checked) => {
setEnabled(checked)
onChange(checked ? [{ employeeId: '', name: '', task: '' }] : [])
}
const update = (index, patch) => onChange(value.map((row, i) => (i === index ? { ...row, ...patch } : row)))
const pick = (index, userId) => {
const employee = choices.find((e) => e.userId === userId)
update(index, { employeeId: userId, name: employee?.displayName || '' })
}
const remove = (index) => {
const next = value.filter((_, i) => i !== index)
onChange(next)
if (!next.length) setEnabled(false)
}
return (
<div className="form-group supporters-editor">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input type="checkbox" checked={enabled} onChange={(e) => toggle(e.target.checked)} />
Unterstützung
</label>
{enabled && (
<div style={{ marginTop: 8 }}>
{!loading && (error || !choices.length) && (
<p className="text-grey" style={{ fontSize: 13 }}>Mitarbeiterliste konnte nicht geladen werden.</p>
)}
{value.map((row, index) => (
<div key={index} className="flex gap-2" style={{ marginBottom: 8, alignItems: 'center' }}>
<select
className="form-control"
aria-label="Mitarbeiter"
value={row.employeeId}
onChange={(e) => pick(index, e.target.value)}
>
<option value="">Person wählen …</option>
{choices
.filter((e) => e.userId === row.employeeId || !value.some((r) => r.employeeId === e.userId))
.map((e) => <option key={e.userId} value={e.userId}>{e.displayName}</option>)}
</select>
<input
className="form-control"
aria-label="Wobei?"
placeholder="Wobei?"
maxLength={200}
value={row.task}
onChange={(e) => update(index, { task: e.target.value })}
/>
<span className="text-grey" style={{ whiteSpace: 'nowrap', fontSize: 13 }}>= {perPerson}</span>
<button type="button" className="btn btn-sm" aria-label="Zeile entfernen" onClick={() => remove(index)}>
<FaTimes />
</button>
</div>
))}
<button type="button" className="btn btn-sm" onClick={() => onChange([...value, { employeeId: '', name: '', task: '' }])}>
+ weitere Person
</button>
</div>
)}
</div>
)
}
+16 -1
View File
@@ -8,6 +8,7 @@ import EditorDropdown from './EditorDropdown'
import CreateWorksheetModal from './CreateWorksheetModal'
import WorksheetList from './WorksheetList'
import WorksheetTimesModal from './WorksheetTimesModal'
import OwnSupportModal from './OwnSupportModal'
import WorksheetSummary from './WorksheetSummary'
import TicketProjects from './TicketProjects'
import ProjectContextPanel from './ProjectContextPanel'
@@ -20,6 +21,7 @@ import { useCustomerAbo } from '../hooks/useCustomerAbo'
import { useAuth } from '../context/AuthContext'
import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
import { canEditWorksheetTimes } from '../lib/ticketForm'
import { canAddOwnSupport } from '../lib/support'
import { ticketTypeLabel } from '../lib/ticketStatus'
// Projekt / Preview steht mit im Details-Reiter
@@ -35,13 +37,14 @@ export default function TicketCard({ ticket, onUpdate }) {
const [activeTab, setActiveTab] = useState('details')
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [editTimesWorksheet, setEditTimesWorksheet] = useState(null)
const [ownSupportWorksheet, setOwnSupportWorksheet] = useState(null)
// Abo-Stundenkonto gibt es nur bei Wartungs-Abo; erst laden, wenn Arbeitsblaetter gebraucht werden
const { hoursAllowed: aboHoursAllowed } = useCustomerAbo(
ticket.customerId,
(expanded && activeTab === 'worksheets') || showCreateWorksheet
)
const { worksheets, loading: worksheetsLoading, createWorksheet, updateWorksheet, updateWorksheetTimes, getTotalTime } = useWorksheets(
const { worksheets, loading: worksheetsLoading, createWorksheet, updateWorksheet, updateWorksheetTimes, saveOwnSupport, getTotalTime } = useWorksheets(
expanded ? ticket.woid : null
)
@@ -144,6 +147,9 @@ export default function TicketCard({ ticket, onUpdate }) {
loading={worksheetsLoading}
canEditTimes={(ws) => canEditWorksheetTimes(ws, user, employee)}
onEditTimes={setEditTimesWorksheet}
canAddSupport={(ws) => canAddOwnSupport(ws, user, employee)}
onOwnSupport={setOwnSupportWorksheet}
currentUserId={user?.$id}
onTogglePlan={handleTogglePlan}
aboHoursAllowed={aboHoursAllowed}
/>
@@ -156,9 +162,18 @@ export default function TicketCard({ ticket, onUpdate }) {
)}
<CreateWorksheetModal isOpen={showCreateWorksheet} onClose={() => setShowCreateWorksheet(false)} workorder={ticket} onCreate={handleCreateWorksheet} aboHoursAllowed={aboHoursAllowed} />
{ownSupportWorksheet && (
<OwnSupportModal
worksheet={ownSupportWorksheet}
currentUserId={user?.$id}
onClose={() => setOwnSupportWorksheet(null)}
onSave={(ws, task) => saveOwnSupport(ws, task, user, employee)}
/>
)}
{editTimesWorksheet && (
<WorksheetTimesModal
worksheet={editTimesWorksheet}
currentUserId={user?.$id}
onClose={() => setEditTimesWorksheet(null)}
onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee)}
/>
+37 -2
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp, FaPen, FaCheck } from 'react-icons/fa'
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp, FaPen, FaCheck, FaHandsHelping } from 'react-icons/fa'
import { serviceTypeLabel, needsTimeEntry, worksheetTone } from '../lib/ticketForm'
import { statusLabel } from '../lib/ticketStatus'
import { parseSupporters, supportMinutes, firstName } from '../lib/support'
// Kopffarbe je Zustand (worksheetTone): orange = Git-Zeit fehlt, gruen = Git-Zeit eingetragen
const TONES = {
@@ -24,7 +25,7 @@ const headerButtonStyle = {
marginRight: '8px',
}
export default function WorksheetList({ worksheets, totalTime, loading, onTogglePlan, aboHoursAllowed = true, canEditTimes, onEditTimes }) {
export default function WorksheetList({ worksheets, totalTime, loading, onTogglePlan, aboHoursAllowed = true, canEditTimes, onEditTimes, canAddSupport, onOwnSupport, currentUserId }) {
const [expandedWorksheets, setExpandedWorksheets] = useState({})
if (loading) {
return (
@@ -75,6 +76,7 @@ export default function WorksheetList({ worksheets, totalTime, loading, onToggle
const isGit = ws.serviceType === 'GIT'
const tone = worksheetTone(ws)
const colors = TONES[tone]
const supporters = ws.isComment ? [] : parseSupporters(ws.supporters)
return (
<div key={ws.$id} className="timeline-item mb-4" style={{
@@ -133,6 +135,11 @@ export default function WorksheetList({ worksheets, totalTime, loading, onToggle
<FaCheck className="me-1" /> Zeit eingetragen
</span>
)}
{supporters.length > 0 && (
<span className="badge ms-2" title="Weitere Personen haben unterstützt" style={headerBadgeStyle}>
+{supporters.length} Unterstützung
</span>
)}
{ws.countsToPlan && !ws.isComment && (
<span className="badge ms-2" title="Wird vom Stundenguthaben des Wartungs-Abos abgezogen" style={{ ...headerBadgeStyle, background: '#9d3dfe' }}>
Abo-Stunden
@@ -177,6 +184,23 @@ export default function WorksheetList({ worksheets, totalTime, loading, onToggle
: <><FaPen className="me-1" /> Zeiten bearbeiten</>}
</button>
)}
{onOwnSupport && canAddSupport?.(ws) && (
<button
type="button"
className="btn btn-sm"
title="Eintragen, dass du bei dieser Arbeit unterstützt hast"
style={headerButtonStyle}
onClick={(e) => {
e.stopPropagation()
onOwnSupport(ws)
}}
>
<FaHandsHelping className="me-1" />{' '}
{parseSupporters(ws.supporters).some((s) => s.employeeId === currentUserId)
? 'Meine Unterstützung bearbeiten'
: 'Ich habe unterstützt'}
</button>
)}
{onTogglePlan && !ws.isComment && Number(ws.totalTime) > 0 && (aboHoursAllowed || ws.countsToPlan) && (
<button
type="button"
@@ -237,6 +261,17 @@ export default function WorksheetList({ worksheets, totalTime, loading, onToggle
</div>
</div>
{/* Unterstuetzung: je Person die Haelfte der Arbeitszeit, aufgerundet auf 15 Minuten */}
{supporters.length > 0 && (
<div className="mb-3 worksheet-supporters">
{supporters.map((sup) => (
<div key={sup.employeeId} style={{ fontSize: '0.9rem', color: 'var(--text-muted)' }}>
↳ Unterstützung {firstName(sup.name)} · {sup.task} · {formatTime(supportMinutes(ws.totalTime))}
</div>
))}
</div>
)}
{/* Service Type */}
<div className="mb-3">
<span className="badge px-3 py-2" style={{
+3 -2
View File
@@ -1,4 +1,5 @@
import { needsTimeEntry } from '../lib/ticketForm'
import { worksheetMinutes } from '../lib/support'
const formatTime = (minutes) => {
const hours = Math.floor(minutes / 60)
@@ -6,12 +7,12 @@ const formatTime = (minutes) => {
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
}
// Eine Zeile ueber der Arbeitsblatt-Liste: Arbeitszeit, Anzahl, offene Git-Zeiten
// Eine Zeile ueber der Arbeitsblatt-Liste: Arbeitszeit (inkl. Unterstuetzung), Anzahl, offene Git-Zeiten
export default function WorksheetSummary({ worksheets }) {
if (!worksheets || worksheets.length === 0) return null
const minutes = worksheets
.filter((ws) => !ws.isComment)
.reduce((sum, ws) => sum + (Number(ws.totalTime) || 0), 0)
.reduce((sum, ws) => sum + worksheetMinutes(ws), 0)
const open = worksheets.filter(needsTimeEntry).length
return (
+38 -17
View File
@@ -1,7 +1,10 @@
import { useState } from 'react'
import { FaTimes } from 'react-icons/fa'
import { isoToGermanDate, timeToWoms, germanDateToIso, womsTimeToInput, minutesBetween, isGitWorksheet, needsTimeEntry } from '../lib/ticketForm'
import { DateTimeInput } from './ui'
import { DateTimeInput, DurationSelect } from './ui'
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
import { parseSupporters, validateSupporters } from '../lib/support'
import SupportersEditor from './SupportersEditor'
// Gespeichert ist dd.mm.yyyy / hhmm, die Kalender-Felder brauchen yyyy-mm-dd / hh:mm
const formFromWorksheet = (worksheet) => ({
@@ -15,19 +18,24 @@ const formFromWorksheet = (worksheet) => ({
* Beginn, Ende und Arbeitszeit eines Arbeitsblatts nachtraeglich aendern.
* Wer das darf, entscheidet canEditWorksheetTimes (lib/ticketForm).
*/
export default function WorksheetTimesModal({ worksheet, onClose, onSave }) {
export default function WorksheetTimesModal({ worksheet, currentUserId, onClose, onSave }) {
const isGit = isGitWorksheet(worksheet)
const [formData, setFormData] = useState(() => formFromWorksheet(worksheet))
// null = die Arbeitszeit folgt Beginn und Ende. Passte sie schon vorher nicht dazu,
// wurde sie von Hand eingetragen (z.B. ohne Pause) und bleibt stehen.
// null = die Arbeitszeit folgt Beginn und Ende (aufgerundet auf 15 Minuten). Passte sie
// schon vorher nicht dazu, wurde sie von Hand gewaehlt (z.B. ohne Pause) und bleibt stehen.
// Alte Zeiten ausserhalb des Rasters (z.B. 70) werden auf den naechsten Schritt aufgerundet.
const [manualTotal, setManualTotal] = useState(() => {
const stored = Number(worksheet.totalTime) || 0
return !stored || stored === minutesBetween(formData.startTime, formData.endTime) ? null : stored
const calculated = minutesBetween(formData.startTime, formData.endTime)
if (!stored || (calculated !== null && stored === roundUpToStep(calculated))) return null
return isValidStep(stored) ? stored : roundUpToStep(stored)
})
const [supporters, setSupporters] = useState(() => parseSupporters(worksheet.supporters))
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const calculatedTotal = minutesBetween(formData.startTime, formData.endTime)
const calculatedMinutes = minutesBetween(formData.startTime, formData.endTime)
const calculatedTotal = calculatedMinutes === null ? null : roundUpToStep(calculatedMinutes)
const totalTime = manualTotal ?? calculatedTotal ?? 0
const handleChange = (field, value) => {
@@ -45,6 +53,15 @@ export default function WorksheetTimesModal({ worksheet, onClose, onSave }) {
const handleSubmit = async (e) => {
e.preventDefault()
if (!isValidStep(totalTime)) {
setError('Bitte die Arbeitszeit auswählen.')
return
}
const supportError = validateSupporters(supporters, currentUserId)
if (supportError) {
setError(supportError)
return
}
setSaving(true)
setError('')
try {
@@ -53,7 +70,9 @@ export default function WorksheetTimesModal({ worksheet, onClose, onSave }) {
startTime: timeToWoms(formData.startTime),
endDate: isoToGermanDate(formData.endDate),
endTime: timeToWoms(formData.endTime),
totalTime: parseInt(totalTime, 10) || 0
totalTime: Number(totalTime),
supporters,
supportersBefore: worksheet.supporters
})
if (result?.success) {
onClose()
@@ -115,24 +134,26 @@ export default function WorksheetTimesModal({ worksheet, onClose, onSave }) {
</div>
<div className="form-group">
<label className="form-label" htmlFor="worksheet-times-total">Arbeitszeit (Minuten)</label>
<input
<label className="form-label" htmlFor="worksheet-times-total">Arbeitszeit</label>
<DurationSelect
id="worksheet-times-total"
type="number"
className="form-control"
min="0"
step="1"
value={totalTime}
onChange={(e) => setManualTotal(e.target.value)}
placeholder="0"
onChange={(value) => setManualTotal(value)}
/>
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
{manualTotal === null && calculatedTotal !== null
? '✓ Automatisch berechnet'
: 'Manuell eingeben'}
? '✓ Aus Beginn und Ende berechnet, aufgerundet auf 15 Minuten'
: 'Von Hand gewählt'}
</small>
</div>
<SupportersEditor
value={supporters}
onChange={setSupporters}
excludeUserId={currentUserId}
minutes={totalTime}
/>
<div className="text-center mt-2">
<button type="submit" className="btn btn-dark" disabled={saving}>
{saving ? 'Speichert...' : 'Speichern'}
+20
View File
@@ -0,0 +1,20 @@
import { stepOptions } from '../../lib/timeSteps'
/* Arbeitszeit nur in 15-Minuten-Schritten (15 min bis 12 h), siehe lib/timeSteps */
export default function DurationSelect({ id, value, onChange, disabled }) {
return (
<select
id={id}
className="form-control"
value={value}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
>
{/* Kommentar ohne Arbeitszeit */}
{!value && <option value={0}>–</option>}
{stepOptions(value).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)
}
+1
View File
@@ -9,3 +9,4 @@ export { default as Field } from './Field'
export { default as Kpi } from './Kpi'
export { default as AutocompleteInput } from './AutocompleteInput'
export { default as DateTimeInput } from './DateTimeInput'
export { default as DurationSelect } from './DurationSelect'
+59 -6
View File
@@ -1,6 +1,10 @@
import { useState, useEffect, useCallback } from 'react'
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
import { canEditWorksheetTimes, isGitWorksheet } from '../lib/ticketForm'
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
import { parseSupporters, serializeSupporters, canAddOwnSupport, upsertSupporter, removeSupporter, worksheetMinutes } from '../lib/support'
const STEP_ERROR = 'Die Arbeitszeit muss in 15-Minuten-Schritten angegeben werden.'
export function useWorksheets(woid = null) {
const [worksheets, setWorksheets] = useState([])
@@ -143,14 +147,17 @@ export function useWorksheets(woid = null) {
// WSID generieren
const wsid = await generateWSID()
// Automatische Zeitberechnung (wenn nicht manuell angegeben)
let totalTime = data.totalTime || 0
if (!data.isComment && data.startTime && data.endTime && !data.totalTime) {
// Automatische Zeitberechnung (wenn nicht manuell angegeben), aufgerundet auf 15 Minuten
let totalTime = data.isComment ? 0 : Number(data.totalTime) || 0
if (!data.isComment && data.startTime && data.endTime && !totalTime) {
const calculatedTime = calculateTime(data.startTime, data.endTime)
if (calculatedTime !== null) {
totalTime = calculatedTime
totalTime = roundUpToStep(calculatedTime)
}
}
if (!data.isComment && !isValidStep(totalTime)) {
return { success: false, error: STEP_ERROR }
}
// Worksheet-Daten vorbereiten
const worksheetData = {
@@ -165,7 +172,7 @@ export function useWorksheets(woid = null) {
newStatus: data.newStatus || data.oldStatus || '',
oldResponseLevel: data.oldResponseLevel || '',
newResponseLevel: data.newResponseLevel || data.oldResponseLevel || '',
totalTime: parseInt(totalTime) || 0,
totalTime,
startDate: data.startDate || '',
startTime: data.startTime || '',
endDate: data.endDate || data.startDate || '',
@@ -175,6 +182,10 @@ export function useWorksheets(woid = null) {
countsToPlan: !data.isComment && Boolean(data.countsToPlan),
createdAt: new Date().toISOString()
}
// Nur mitschicken, wenn es Unterstuetzung gibt
if (!data.isComment && data.supporters?.length) {
worksheetData.supporters = serializeSupporters(data.supporters)
}
console.log('Creating worksheet with data:', worksheetData)
@@ -224,12 +235,53 @@ export function useWorksheets(woid = null) {
return { success: false, error: 'Nur wer das Arbeitsblatt erstellt hat, kann die Zeiten ändern.' }
}
const { startDate, startTime, endDate, endTime, totalTime } = times
if (!isValidStep(totalTime)) return { success: false, error: STEP_ERROR }
const data = { startDate, startTime, endDate, endTime, totalTime }
// Unterstuetzung nur schreiben, wenn sie sich geaendert hat. Hat inzwischen jemand anderes
// (z. B. ueber "Ich habe unterstuetzt") etwas geaendert, nicht still ueberschreiben.
if (times.supporters) {
const next = serializeSupporters(times.supporters)
if (next !== serializeSupporters(parseSupporters(times.supportersBefore))) {
try {
const fresh = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKSHEETS, worksheet.$id)
if ((fresh.supporters ?? '') !== (times.supportersBefore ?? '')) {
return { success: false, error: 'Inzwischen hat jemand die Unterstützung geändert. Bitte das Formular schließen und neu öffnen.' }
}
} catch (err) {
return { success: false, error: err.message }
}
data.supporters = next
}
}
// Mit nachgetragener Zeit zaehlt ein Git-Push als Arbeitszeit, nicht mehr als Kommentar
if (isGitWorksheet(worksheet)) data.isComment = false
return updateWorksheet(worksheet.$id, data)
}
/**
* "Ich habe unterstuetzt": eigenen Eintrag setzen (task) oder entfernen (task = null).
* Liest das Blatt frisch, damit gleichzeitige Eintraege anderer erhalten bleiben.
*/
const saveOwnSupport = async (worksheet, task, currentUser, employee) => {
if (!canAddOwnSupport(worksheet, currentUser, employee)) {
return { success: false, error: 'Hier kannst du keine Unterstützung eintragen.' }
}
try {
const fresh = await databases.getDocument(DATABASE_ID, COLLECTIONS.WORKSHEETS, worksheet.$id)
const current = parseSupporters(fresh.supporters)
const next = task === null
? removeSupporter(current, currentUser.$id)
: upsertSupporter(current, {
employeeId: currentUser.$id,
name: employee?.displayName || currentUser.name || '',
task: String(task).trim().slice(0, 200)
})
return updateWorksheet(worksheet.$id, { supporters: serializeSupporters(next) })
} catch (err) {
return { success: false, error: err.message }
}
}
/**
* Löscht ein Worksheet (sollte normalerweise nicht erlaubt sein - Audit Trail!)
*/
@@ -255,7 +307,7 @@ export function useWorksheets(woid = null) {
const getTotalTime = useCallback(() => {
return worksheets
.filter(ws => !ws.isComment)
.reduce((sum, ws) => sum + (ws.totalTime || 0), 0)
.reduce((sum, ws) => sum + worksheetMinutes(ws), 0)
}, [worksheets])
return {
@@ -265,6 +317,7 @@ export function useWorksheets(woid = null) {
createWorksheet,
updateWorksheet,
updateWorksheetTimes,
saveOwnSupport,
deleteWorksheet,
refresh: fetchWorksheets,
getTotalTime,
+85
View File
@@ -0,0 +1,85 @@
/* Unterstuetzung bei Arbeitsblaettern.
Im Arbeitsblatt steht im Feld "supporters" eine JSON-Liste
[{ employeeId, name, task }]. Die Minuten werden nie gespeichert: jede
unterstuetzende Person bekommt die Haelfte der Arbeitszeit, aufgerundet auf
15 Minuten. Das Kundenportal rechnet mit derselben Regel
(server/services/worksheetMinutes.js). */
import { TIME_STEP } from './timeSteps'
import { isWorksheetCreator } from './ticketForm'
const MAX_TASK = 200
export function supportMinutes(totalTime) {
const total = Number(totalTime) || 0
if (total <= 0) return 0
return Math.ceil(total / 2 / TIME_STEP) * TIME_STEP
}
// Nie eine Exception: kaputte Werte ergeben eine leere Liste, kaputte Eintraege fallen weg
export function parseSupporters(raw) {
let list = raw
if (typeof raw === 'string') {
if (!raw.trim()) return []
try {
list = JSON.parse(raw)
} catch {
return []
}
}
if (!Array.isArray(list)) return []
const seen = new Set()
const result = []
for (const entry of list) {
if (!entry || typeof entry !== 'object') continue
const employeeId = String(entry.employeeId || '').trim()
const task = String(entry.task || '').trim().slice(0, MAX_TASK)
if (!employeeId || !task || seen.has(employeeId)) continue
seen.add(employeeId)
result.push({ employeeId, name: String(entry.name || '').trim(), task })
}
return result
}
export function serializeSupporters(list) {
return JSON.stringify((list || []).map(({ employeeId, name, task }) => ({ employeeId, name, task })))
}
// Arbeitszeit des Blatts plus die Unterstuetzung aller Personen
export function worksheetMinutes(worksheet) {
const base = Number(worksheet?.totalTime) || 0
return base + parseSupporters(worksheet?.supporters).length * supportMinutes(base)
}
export function firstName(name) {
return String(name || '').trim().split(/\s+/)[0] || ''
}
// "Ich habe unterstuetzt": fremde Blaetter mit Arbeitszeit (Git-Pushes erst nach "Zeit nachtragen")
export function canAddOwnSupport(worksheet, user, employee) {
if (!user?.$id || !worksheet) return false
if (worksheet.isComment || !(Number(worksheet.totalTime) > 0)) return false
return !isWorksheetCreator(worksheet, user, employee)
}
export function upsertSupporter(list, entry) {
const current = list || []
return current.some((s) => s.employeeId === entry.employeeId)
? current.map((s) => (s.employeeId === entry.employeeId ? { ...s, ...entry } : s))
: [...current, entry]
}
export function removeSupporter(list, employeeId) {
return (list || []).filter((s) => s.employeeId !== employeeId)
}
// Pruefung im Formular des Erstellers; null = in Ordnung
export function validateSupporters(list, creatorUserId) {
const rows = list || []
if (rows.some((s) => !String(s.employeeId || '').trim())) return 'Bitte in jeder Zeile eine Person auswählen.'
if (rows.some((s) => !String(s.task || '').trim())) return 'Bitte bei jeder Person angeben, wobei sie unterstützt hat.'
const ids = rows.map((s) => s.employeeId)
if (new Set(ids).size !== ids.length) return 'Jede Person nur einmal eintragen.'
if (creatorUserId && ids.includes(creatorUserId)) return 'Du kannst dich nicht selbst als Unterstützung eintragen.'
return null
}
+39
View File
@@ -0,0 +1,39 @@
/* Arbeitszeit wird nur in 15-Minuten-Schritten erfasst (15 min bis 12 h).
Aus Beginn und Ende berechnete Zeiten werden auf den naechsten Schritt aufgerundet.
12 h ist auch das Maximum der Push-Zeitmail im Kundenportal. */
export const TIME_STEP = 15
export const MAX_STEP_MINUTES = 720
// 50 -> 60, 61 -> 75; keine oder negative Dauer -> 15 (kleinster Schritt)
export function roundUpToStep(minutes) {
const value = Number(minutes)
if (!Number.isFinite(value) || value <= 0) return TIME_STEP
return Math.ceil(value / TIME_STEP) * TIME_STEP
}
export function isValidStep(minutes) {
if (typeof minutes !== 'number' && typeof minutes !== 'string') return false
const value = Number(minutes)
return Number.isInteger(value) && value > 0 && value % TIME_STEP === 0 && value <= MAX_STEP_MINUTES
}
// 15 -> "15 min", 60 -> "1 h", 75 -> "1 h 15 min"
export function stepLabel(minutes) {
const value = Math.max(0, Number(minutes) || 0)
const hours = Math.floor(value / 60)
const mins = value % 60
if (!hours) return `${mins} min`
return mins ? `${hours} h ${mins} min` : `${hours} h`
}
// Auswahl 15 min … 12 h. Ein alter, laengerer Wert im Raster bleibt waehlbar.
export function stepOptions(current) {
const options = []
for (let m = TIME_STEP; m <= MAX_STEP_MINUTES; m += TIME_STEP) options.push({ value: m, label: stepLabel(m) })
const value = Number(current)
if (Number.isInteger(value) && value > MAX_STEP_MINUTES && value % TIME_STEP === 0) {
options.push({ value, label: stepLabel(value) })
}
return options
}
+16
View File
@@ -0,0 +1,16 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ATTRIBUTE, planAttribute } from '../scripts/setup-supporters.mjs'
test('Attribut supporters: String 4000, nicht Pflicht', () => {
assert.deepEqual(ATTRIBUTE, { key: 'supporters', type: 'string', size: 4000, required: false })
})
test('planAttribute: anlegen, vorhanden oder Konflikt', () => {
assert.equal(planAttribute([]), 'create')
assert.equal(planAttribute([{ key: 'wsid', type: 'string', size: 10 }]), 'create')
assert.equal(planAttribute([{ key: 'supporters', type: 'string', size: 4000 }]), 'exists')
assert.equal(planAttribute([{ key: 'supporters', type: 'string', size: 8000 }]), 'exists')
assert.equal(planAttribute([{ key: 'supporters', type: 'string', size: 255 }]), 'conflict')
assert.equal(planAttribute([{ key: 'supporters', type: 'integer' }]), 'conflict')
})
+114
View File
@@ -0,0 +1,114 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { TIME_STEP, MAX_STEP_MINUTES, roundUpToStep, isValidStep, stepOptions, stepLabel } = await loadModule('src/lib/timeSteps.js')
const {
supportMinutes, parseSupporters, serializeSupporters, worksheetMinutes, firstName,
canAddOwnSupport, upsertSupporter, removeSupporter, validateSupporters,
} = await loadModule('src/lib/support.js')
const andrej = { employeeId: 'a', name: 'Andrej Stevanovski', task: 'Farbkonzept' }
const nico = { employeeId: 'n', name: 'Nico Rech', task: 'Texte' }
test('Raster: 15 Minuten bis 12 Stunden', () => {
assert.equal(TIME_STEP, 15)
assert.equal(MAX_STEP_MINUTES, 720)
})
test('roundUpToStep rundet auf, 0 und leer werden 15', () => {
assert.equal(roundUpToStep(0), 15)
assert.equal(roundUpToStep(null), 15)
assert.equal(roundUpToStep(-10), 15)
assert.equal(roundUpToStep(50), 60)
assert.equal(roundUpToStep(60), 60)
assert.equal(roundUpToStep(61), 75)
assert.equal(roundUpToStep(70), 75)
})
test('isValidStep: nur ganze Vielfache von 15 bis 720', () => {
for (const ok of [15, 45, 720]) assert.equal(isValidStep(ok), true, String(ok))
for (const bad of [70, 0, 735, 'x', 7.5, -15, null]) assert.equal(isValidStep(bad), false, String(bad))
})
test('stepOptions: 48 Werte mit deutschen Beschriftungen', () => {
const opts = stepOptions()
assert.equal(opts.length, 48)
assert.deepEqual(opts[0], { value: 15, label: '15 min' })
assert.deepEqual(opts[3], { value: 60, label: '1 h' })
assert.deepEqual(opts[4], { value: 75, label: '1 h 15 min' })
assert.deepEqual(opts[47], { value: 720, label: '12 h' })
// Ein gueltiger Wert ueber 12 h (altes Blatt) bleibt waehlbar
assert.equal(stepOptions(900).length, 49)
assert.equal(stepOptions(60).length, 48)
assert.equal(stepLabel(135), '2 h 15 min')
})
test('supportMinutes: Hälfte, aufgerundet auf 15', () => {
const cases = [[15, 15], [30, 15], [45, 30], [60, 30], [75, 45], [70, 45], [0, 0], [-5, 0], ['60', 30], [null, 0]]
for (const [total, expected] of cases) assert.equal(supportMinutes(total), expected, `${total}`)
})
test('parseSupporters ist fehlertolerant', () => {
assert.deepEqual(parseSupporters('kaputt'), [])
assert.deepEqual(parseSupporters('{}'), [])
assert.deepEqual(parseSupporters(null), [])
assert.deepEqual(parseSupporters(''), [])
const raw = '[{"employeeId":"a","name":"Andrej S","task":" Farbe "},{"employeeId":"a","name":"x","task":"y"},{"employeeId":"b","name":"Nico"},null,"x"]'
assert.deepEqual(parseSupporters(raw), [{ employeeId: 'a', name: 'Andrej S', task: 'Farbe' }])
assert.equal(parseSupporters([{ employeeId: 'a', name: 'A', task: 'x'.repeat(250) }])[0].task.length, 200)
})
test('serializeSupporters und parseSupporters passen zusammen', () => {
assert.deepEqual(parseSupporters(serializeSupporters([andrej, nico])), [andrej, nico])
assert.equal(serializeSupporters([]), '[]')
})
test('worksheetMinutes: Arbeitszeit plus je Person die Unterstützung', () => {
assert.equal(worksheetMinutes({ totalTime: 60, supporters: serializeSupporters([andrej, nico]) }), 120)
assert.equal(worksheetMinutes({ totalTime: 70, supporters: serializeSupporters([andrej]) }), 115)
assert.equal(worksheetMinutes({ totalTime: 45 }), 45)
assert.equal(worksheetMinutes({ totalTime: 0, supporters: serializeSupporters([andrej]) }), 0)
assert.equal(worksheetMinutes({ totalTime: 60, supporters: 'kaputt' }), 60)
})
test('firstName', () => {
assert.equal(firstName('Andrej Stevanovski'), 'Andrej')
assert.equal(firstName(' Nico '), 'Nico')
assert.equal(firstName(''), '')
})
test('canAddOwnSupport: nur fremde Blätter mit Arbeitszeit', () => {
const kenso = { $id: 'kenso-admin', name: 'Kenso Grimm' }
const kensoEmp = { shortcode: 'KNSO', displayName: 'Kenso Grimm' }
const andrejUser = { $id: 'andrej', name: 'Andrej Stevanovski' }
const andrejEmp = { shortcode: 'ANDJ', displayName: 'Andrej Stevanovski' }
const fremd = { employeeId: 'justin', totalTime: 60, isComment: false, serviceType: 'Remote' }
assert.equal(canAddOwnSupport(fremd, kenso, kensoEmp), true)
assert.equal(canAddOwnSupport({ ...fremd, employeeId: 'kenso-admin' }, kenso, kensoEmp), false)
assert.equal(canAddOwnSupport({ ...fremd, isComment: true }, kenso, kensoEmp), false)
assert.equal(canAddOwnSupport({ ...fremd, totalTime: 0 }, kenso, kensoEmp), false)
assert.equal(canAddOwnSupport(fremd, null, null), false)
const gitOffen = { employeeId: 'gitea', employeeName: 'knso', serviceType: 'GIT', isComment: true, totalTime: 0 }
assert.equal(canAddOwnSupport(gitOffen, andrejUser, andrejEmp), false)
const gitFertig = { ...gitOffen, isComment: false, totalTime: 45, startTime: '1400' }
assert.equal(canAddOwnSupport(gitFertig, kenso, kensoEmp), false)
assert.equal(canAddOwnSupport(gitFertig, andrejUser, andrejEmp), true)
})
test('upsertSupporter und removeSupporter', () => {
const list = upsertSupporter([andrej], nico)
assert.deepEqual(list, [andrej, nico])
assert.deepEqual(upsertSupporter(list, { ...andrej, task: 'Logo' }), [{ ...andrej, task: 'Logo' }, nico])
assert.deepEqual(removeSupporter(list, 'a'), [nico])
assert.deepEqual(removeSupporter(list, 'x'), list)
})
test('validateSupporters', () => {
assert.equal(validateSupporters([andrej, nico], 'justin'), null)
assert.equal(validateSupporters([], 'justin'), null)
assert.equal(validateSupporters([{ ...andrej, task: ' ' }], 'justin'), 'Bitte bei jeder Person angeben, wobei sie unterstützt hat.')
assert.equal(validateSupporters([{ ...andrej, employeeId: '' }], 'justin'), 'Bitte in jeder Zeile eine Person auswählen.')
assert.equal(validateSupporters([andrej, { ...andrej, task: 'x' }], 'justin'), 'Jede Person nur einmal eintragen.')
assert.equal(validateSupporters([andrej], 'a'), 'Du kannst dich nicht selbst als Unterstützung eintragen.')
})