Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24e4f054c4 | ||
|
|
bc9d673ff7 | ||
|
|
d83bbfb48d | ||
|
|
3535f18a2b | ||
|
|
32bc3d7c3d | ||
|
|
1497f7a4dc | ||
|
|
564fecdde4 | ||
|
|
a336dc07fc |
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.
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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.')
|
||||
})
|
||||
Reference in new issue
Block a user