diff --git a/docs/craftvia/ABNAHME.md b/docs/craftvia/ABNAHME.md index e23bdeb..4e7774b 100644 --- a/docs/craftvia/ABNAHME.md +++ b/docs/craftvia/ABNAHME.md @@ -69,7 +69,7 @@ | Berichtsvorlage | generische Vorlage L5 mit Mandantenlogo/-namen | Layout/Pflichtinhalte des Pilotkunden | | Pflichtfelder je Auftragsart | über Checklisten-Vorlagen je Auftragsart konfigurierbar | konkrete Pflichtfelder | | Fotoarten | Standard-Pflichtfotos §14.2 als Vorschläge | verbindliche Liste | -| Arbeitszeitkorrektur | nur mit `field:correct_time` (Teamleiter/Admin), Grund Pflicht, Audit | Genehmigungsprozess? | +| Arbeitszeitkorrektur | **Entschieden (L12 Zeiterfassung, 2026-09-15):** Monteure tragen eigene Zeiten der letzten 7 Tage nach bzw. schlagen Korrekturen vor (`field:record_own_time`), Begründung Pflicht, Kennzeichen „manuell“; Freigabe durch Teamleiter (eigene Teams) oder Backoffice (`time:approve`), nie durch die erfassende Person selbst; bis zur Freigabe zählen Nachträge nicht für Bericht/Summen, Korrekturvorschläge lassen den alten Wert gelten; Abrechnungsfreigabe gesperrt, solange Zeiten offen sind. Direktkorrektur/Nachtrag für Teammitglieder weiterhin mit `field:correct_time` (sofort freigegeben). Grund, Audit before/after, Events `time.approval_requested/approved/rejected` | Freigabefrist/Eskalation, Export an Lohnabrechnung | | Aufbewahrungsfristen | Soft Delete, keine automatische Löschung; KI-Protokoll: `AI_GENERATION_RETENTION_DAYS` (Default 180): täglicher Worker-Job leert Ein-/Ausgaben und Personenbezug im KI-Protokoll; monatliches Token-Kontingent je Mandant (`/settings/lotse`) | Fristen je Dokumentart | | Empfänger Abrechnungsbenachrichtigung | `/settings/email` Abrechnungsempfänger je Mandant | Adressen | | OCR-/KI-Anbieter | Claude (Anthropic) für Extraktion + Lotse, Whisper-kompatibel für Transkription (Entscheidung 2026-09-14) | Vertrag/AVV, Region | diff --git a/docs/craftvia/lanes/zeiterfassung.md b/docs/craftvia/lanes/zeiterfassung.md new file mode 100644 index 0000000..f97cf79 --- /dev/null +++ b/docs/craftvia/lanes/zeiterfassung.md @@ -0,0 +1,94 @@ +# Lane L12 – Zeiterfassung (`lane/zeiterfassung`) + +Stand: 2026-09-15 · Basis `d3bc7f2` (`feature/craftvia-mvp`, L1–L11 integriert) + +Ziel: Zeiterfassung der Monteure deutlich einfacher – laufende Uhr überall, „Für heute beenden“, Auto-Wechsel zwischen Aufträgen, Zeit nachtragen + Tagesübersicht. **Entscheidung des Nutzers:** Monteure tragen eigene Zeiten nach / schlagen Korrekturen vor – mit Freigabe durch Teamleiter (eigene Teams) oder Backoffice; bis zur Freigabe zählen sie nicht für Bericht und Abrechnung. + +## 1. Umfang + +| Punkt | Umsetzung | +|---|---| +| Datenmodell | Migration `20260915120000_zeiterfassung_freigabe` (nur additive Spalten, keine neue Tabelle → keine RLS-/TENANT_MODELS-Änderung): `TimeEntry.source` (`tracked`/`manual`), `approvalStatus` (`approved`/`pending`/`rejected`, Default approved → Bestand unverändert), `approvedById` (entscheidende Person, auch bei Ablehnung), `approvedAt`, `rejectionReason`, `pendingChange` (Json: vorgeschlagene Korrektur `{startedAt, endedAt, type, reason, requestedAt, requestedById}`), `note`; Index `(tenant_id, approval_status)`. `WorkSession.manual` (Boolean). Begründung eines Nachtrags liegt in `correctionReason`. PII: `TimeEntry.approvedById` in `pii-fields.ts`. | +| Manuelle Session (Begründung) | Nachträge hängen an einer „manuellen“ Session je User + Auftrag + lokalem Tag (`manual = true`, Status `ended`, Start/Ende = min/max der Einträge). So bleiben alle bestehenden Lesepfade (Auftrag → Sessions → Einträge: Bericht, Zeiten-Tab, Bundle, Lotse, Abschluss-Blocker) ohne Sonderfälle korrekt; `ended` löst keine Blocker/aktive-Session-Logik aus. Eine eigene Tabelle hätte jeden Lesepfad verdoppelt. | +| Rechte | `field:record_own_time` → technician, team-lead; `time:approve` → team-lead, backoffice, tenant-admin (Katalog). `scripts/sync-role-permissions.ts` zieht bestehende Mandanten nach (lokal ausgeführt: +12 Zuordnungen). | +| Services `services/field/time-entries.ts` | `addManualTimeEntry` (Scope, Status nachtragbar = Feldstatus + `in_review`, ≤ 7 Tage zurück ab Tagesbeginn Mandanten-TZ, nicht Zukunft, Ende > Beginn, ≤ 16 h, keine Überlappung mit eigenen nicht abgelehnten Einträgen → `invalid`/`overlap`; eigener Eintrag → pending + Event; `forUserId` mit `field:correct_time` nur für aktive Mitglieder geführter Teams bzw. Backoffice → approved; idempotent über `clientId`), `proposeTimeCorrection` (nur eigener, abgeschlossener Eintrag ≤ 7 Tage; approved bleibt gültig, `pendingChange`; bei noch offenem Nachtrag werden die offenen Werte direkt geändert), `approveTimeEntry`/`approveTimeEntries`/`rejectTimeEntry` (`time:approve`, Team-Scope, nie eigene Einträge, Überlappungsprüfung bei Freigabe, Korrektur → Werte übernommen + `corrected`), `listPendingTimeEntries`, `countPendingTimeEntries`, `pendingTimeOfOrder`, `getMyTimeOverview`, `listRecordableOrders`, `listRecordableUsers`. Alle Mehrschritt-Writes in `inTransaction`, Audit before/after, Events nach Commit. | +| Team-Scope | Backoffice (`work_order:read_all`) → alle; Teamleiter → Einträge auf Aufträgen seiner geführten aktiven Teams, Aufträge mit `teamLeadUserId = er` oder von aktiven Mitgliedern seiner Teams. Außerhalb → `not_found`. | +| Sessions `services/field/sessions.ts` | Alle Session-Mutationen jetzt in `inTransaction`. **Auto-Wechsel:** `startSession`/`resumeSession` werfen `conflict other_session_running {workOrderId, number, title}`, wenn der Nutzer auf einem anderen Auftrag eine laufende/Anfahrts-Session hat; mit `switchFromOther: true` wird diese in derselben Transaktion pausiert (Pause-Segment, Auftrag `paused`, falls sonst niemand arbeitet). `stopForToday` (Session beendet, Auftrag bleibt offen → `paused` über `transitionWorkOrder`, kein Abschluss). `switchSegment` (`work`/`return_travel`/`material_procurement`). `getMyActiveSession` (laufende vor pausierter, Segmentstart, aufsummierte Dauer ohne Pausen). `correctTimeEntry`: eigene Einträge → `forbidden own_entry`, räumt offene Vorschläge ab. | +| Zählen nur freigegeben | `reports/build-content.ts` (Summen nur approved, neues optionales Feld `time.pendingMinutes` im Snapshot), Backoffice Zeiten-Tab (`workMinutes` approved, `pendingMinutes`), mobile Auftragsdetail/Zeiten, Lotse-Vollständigkeit, Meine Zeiten. **Abrechnungsfreigabe** (`completion.ts#transitionBlockers`) → `blocked` mit `missing_field: pending_time_entries`, solange Nachträge oder Korrekturvorschläge offen sind. Bericht einreichen warnt nur (Hinweis auf `/m/orders/[id]/report`). | +| Sync/Offline | Ops `session.stop_day`, `session.segment`, `time.add_manual`, `time.propose_correction` (Envelope + Zod in `lib/sync/ops.ts`, Dispatch in `sync/apply.ts`), `session.start`/`session.resume` mit `switchFromOther?`. Outbox ohne Sonderlogik; `VERSION_CHANGING_OPS` um stop_day/segment ergänzt (verkettete baseVersion). Optimistische Ansicht (`bundle-core.ts`): stop_day → Uhr aus, Auftrag pausiert; add_manual → `local.manualTimes` mit pending. Abgelehnte Ops im Klartext auf `/m/sync` (`offline.problem.timeOverlap`, `timeWindow`, `otherSession`). Konfliktmeldung des Auto-Wechsels trägt die Auftragsdaten (`other_session_running:{…}`). | +| Events/Benachrichtigungen | `time.approval_requested` → Teamleiter des Auftrags-Teams bzw. der Teams des Monteurs (mit `time:approve`, ohne Antragsteller), sonst Backoffice (`read_all` + `time:approve`); In-App + E-Mail (über Einstellungen abbestellbar). `time.approved`/`time.rejected` → Monteur, nur In-App, Ablehnungsgrund im Text. Links: Backoffice `/work-orders/time-approvals`, Teamleiter `/m/approvals`, Monteur `/m/time`. | + +## 2. Screens / Routen + +| Route | Inhalt | +|---|---| +| Mobile-Shell (alle `/m`-Seiten) | **Laufende-Uhr-Leiste** über der Bottom-Nav: Status (Text + Punkt), „A-00042 · Titel · 1:23 h“ (Sekunden-Tick), Tipp → Auftrag, Pause/Weiter, „Für heute beenden“ (zweistufig), Link „Meine Zeiten“; offline aus lokalem Zustand (ohne Dauer). Badge-Zähler offener Freigaben am „Profil“. | +| `/m`, `/m/orders` | je Karte direkter Button Arbeit starten / Pause / Weiter; laufende Karte mit breiter Statuskante + Text „Zeit läuft“/„Pausiert“. | +| Auto-Wechsel | Bottom-Sheet „A-00041 läuft noch. Pausieren und A-00042 starten?“ [Wechseln] [Abbrechen] – aus Karte, Auftragsdetail und Uhr-Leiste. | +| `/m/orders/[id]` | laufend: primär „Pause“, sekundär „Für heute beenden“ (mit Hinweis), Textlink „Auftrag abschließen …“ (bestehender Flow mit Bestätigung/Blockern); kleinere Segment-Aktionen „Rückfahrt starten“, „Material holen“, „Zurück zur Arbeit“. | +| `/m/orders/[id]/time` | Badges manuell/zur Freigabe/abgelehnt/Korrektur beantragt, freigegebene + offene Summe, eigene Einträge „Korrektur vorschlagen“, fremde (Teamleiter) Direktkorrektur, „Zeit nachtragen“ für den Auftrag. | +| `/m/time` | **Meine Zeiten:** Tages-Chips (Heute, Gestern, weitere 6 Tage), Summen freigegeben/offen, Einträge je Auftrag mit Art, von–bis, Dauer, Badges, „Korrektur vorschlagen“, nicht übertragene lokale Nachträge. | +| `/m/time/new` | **Zeit nachtragen:** Auftrag (letzte 7 Tage/laufend), optional „Für“ (Teammitglied, nur `field:correct_time`), Art-Chips, Datum, Von–Bis oder Dauer (15/30/60 min), Begründung Pflicht mit Vorschlägen, Notiz, Hinweis „Wird nach Freigabe durch Teamleiter oder Büro gezählt“. | +| `/m/approvals` | Teamleiter (`time:approve`): offene Nachträge/Korrekturen (alt → neu), große Buttons Freigeben / Ablehnen mit Grund. | +| `/work-orders/time-approvals` | Backoffice: Tabelle Mitarbeiter, Auftrag, Datum, von–bis (alt → neu), Dauer, Art, Begründung; Einzel- und Sammelfreigabe, Ablehnen mit Grund. Navigation „Zeiten zur Freigabe“, Dashboard-Kachel. | +| `/work-orders/[id]?tab=times` | Summe freigegeben + offen, Badges, Freigabe/Ablehnung inline. | + +## 3. Dateien + +**Neu** +- `prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql` +- `src/server/services/field/time-entries.ts`, `src/lib/field/time-rules.ts` +- `src/server/actions/work_orders/time-approvals.ts` +- `src/components/field/`: `running-clock-bar.tsx`, `switch-session-sheet.tsx`, `card-time-button.tsx`, `time-entry-badges.tsx`, `correction-proposal-form.tsx`, `manual-time-form.tsx`, `local-pending-times.tsx`, `approval-actions.tsx`, `pending-time-notice.tsx` +- `src/components/work-orders/time-approval-forms.tsx` +- `src/app/(field)/m/(core)/time/page.tsx`, `time/new/page.tsx`, `approvals/page.tsx`, `src/app/(app)/work-orders/time-approvals/page.tsx` +- `scripts/test-zeiterfassung-service.ts`, `scripts/test-zeiterfassung-sync.ts` + +**Geändert (Field/Sync, L4/L7-Pfade)** +- `src/server/services/field/{sessions,time-correction,queries}.ts`, `src/server/actions/field/time.ts` +- `src/components/field/{primary-action,order-card,bottom-nav}.tsx`, `src/app/(field)/m/layout.tsx`, `(core)/page.tsx`, `(core)/orders/page.tsx`, `(core)/orders/[id]/{page,time/page,report/page}.tsx`, `(core)/profile/page.tsx` +- `src/lib/sync/{envelope,ops}.ts`, `src/server/services/sync/apply.ts` +- `src/lib/offline/{outbox-core,bundle-core}.ts`, `src/components/offline/offline-view.tsx` (Aktion „Für heute beenden“) + +**Geändert außerhalb field/sync** +- `prisma/schema.prisma`; `src/server/rbac.ts` (2 Rechte + Rollen); `src/server/dsgvo/pii-fields.ts` (1 Zeile) +- `src/lib/events.ts` (3 Events, entityType `time_entry`); `src/server/services/notifications/{recipients,handle-event}.ts` (Empfänger, Link, Grund im Text) +- `src/server/services/reports/build-content.ts`, `src/lib/reports/content.ts` (optional `pendingMinutes`) +- `src/server/services/work-orders/{completion,detail,dashboard}.ts`, `src/components/work-orders/detail-tabs.tsx` (Zeiten-Tab), `src/app/(app)/dashboard/page.tsx` (Kachel), `src/lib/nav.ts` (1 Eintrag) +- `src/server/services/lotse/completeness.ts` (nur freigegebene Arbeitszeit) +- `messages/{de,en}/{field,offline,notifications,nav,dashboard,workOrders}.json` +- `scripts/test-security-roles.ts` (Matrix-Proben für die neuen Rechte), `scripts/smoke-auth.ts` +- `docs/craftvia/ABNAHME.md` §4 „Arbeitszeitkorrektur“ + +Keine neuen npm-Abhängigkeiten, keine neuen Tabellen, keine Stubs. + +## 4. Tests + +- `scripts/test-zeiterfassung-service.ts` – **78 Prüfungen**: Validierung (Überlappung → `invalid overlap`, > 7 Tage, Zukunft, > 16 h, Ende vor Beginn, ohne Grund, ohne Zuweisung → not_found, Backoffice ohne Recht, Monteur für Kollegen), manuelle Session, Events (Teamleiter statt Monteur/Backoffice; eigener Teamleiter-Nachtrag → Backoffice) + Audit, pending zählt nicht (Tagesbericht, Zeiten-Tab, Meine Zeiten), Freigabe-Rollen (Monteur nie, Teamleiter nicht eigene, fremder Teamleiter not_found, Backoffice alle, Sammelfreigabe, Direktkorrektur eigener Einträge forbidden), Listen/Badge/Dashboard, Freigabe → zählt + `time.approved`, Ablehnung mit Grund + `time.rejected` (Text, Link) + abgelehnter Zeitraum frei, Korrekturvorschlag (alter Wert bis Freigabe, danach neu + corrected; abgelehnter Vorschlag lässt Eintrag gültig), Direktanlage für Teammitglied (approved) bzw. Nicht-Mitglied forbidden, Abrechnungsfreigabe blocked `pending_time_entries` und nach Freigabe möglich, „Für heute beenden“ (Session ended, Auftrag paused, kein Blocker), Auto-Wechsel (conflict mit Nummer, nichts angelegt, fehlgeschlagener Wechsel lässt andere Session laufen, mit Flag pausiert + genau eine laufende Uhr, Weiter ebenfalls geprüft), Segmente + `getMyActiveSession`, Mandantentrennung (B kann weder freigeben, ablehnen, sehen, nachtragen noch vorschlagen). +- `scripts/test-zeiterfassung-sync.ts` – **29 Prüfungen**: Payload-Schemas, 4 neue Ops in einem Batch applied (idMap, entityVersion), gleiche clientOpIds → duplicate ohne Doppelanlage, Event über Sync, Überlappung → rejected mit Klartext-Schlüssel (`timeOverlap`/`timeWindow`), Korrekturvorschlag über Sync, Auto-Wechsel-Konflikt mit Auftragsnummer (`otherSession`) und Wechsel mit Flag, verkettete baseVersion, optimistische Ansicht (stop_day, add_manual pending), Mandant B / Monteur ohne Zuweisung → not_found. +- Bestehend angepasst: `test-security-roles.ts` (Proben `field:record_own_time`, `time:approve`). +- Gate/RLS: siehe §7. + +**HTTP-Smoke** (Dev-Server :3113, `scripts/smoke-auth.ts`, Demo-Seed, Rechte per `sync-role-permissions` nachgezogen): **105/105 grün** – neu: Backoffice `/work-orders/time-approvals`, Dashboard-Kachel; Teamleiter `/m/time`, `/m/approvals`, Profil-Link; Monteur Uhr-Leiste auf `/m` („Laufende Zeiterfassung“, „Für heute beenden“), `/m/time` (heute/gestern), `/m/time/new`, `/m/approvals` ohne Recht → Hinweis, Zeiten-Seite des Auftrags. Visuelle Browserprüfung nicht durchgeführt (Login würde Passworteingabe/Token-Einschleusen durch den Agenten erfordern). + +## 5. Bekannte Lücken / Hinweise + +1. **Demo-/Bestandsdaten mit zwei laufenden Sessions** eines Nutzers (vor L12 möglich, z. B. Seed: `monteur@` auf A-00009 und N-00001) bleiben bestehen; die Uhr-Leiste zeigt die neueste, der nächste Start/Weiter erzwingt den Wechsel. +2. **Backoffice hat kein `field:correct_time`** (Rollenfilter schließt `field:*` aus): Direktanlage für Mitarbeiter mobil nur für Teamleiter/Mandantenadmin; Backoffice gibt frei/lehnt ab. Falls gewünscht, Recht im Katalog ergänzen. +3. **Offline-Uhr** kennt keine Dauer (Bundle ohne Einträge) – offline nur Auftrag + Status. Auto-Wechsel-Dialog erscheint offline erst beim Sync (Op wird dann auf `/m/sync` im Klartext abgelehnt); der optimistische Wechsel pausiert die andere Auftragsansicht lokal nicht. +4. **Zeitzone**: Formular nutzt die Gerätezeit, Server prüft das 7-Tage-Fenster in der Mandanten-Zeitzone (`TenantSettings.timezone`). +5. **Transaktionen + Events**: `transitionWorkOrder` emittiert Events innerhalb der Session-Transaktion (bestehendes Verhalten, ABNAHME §5); pg meldet dabei eine Deprecation-Warnung für parallele Queries auf dem Transaktions-Client (harmlos). +6. **Nachträge auf abgerechneten/stornierten Aufträgen** nicht möglich (`work_order_status`); nach Abrechnungsfreigabe erfasste Korrekturen gibt es damit nicht. +7. Freigabefrist/Eskalation und Lohn-Export nicht umgesetzt (ABNAHME §4 „zu klären“). + +## 6. Migration + +`20260915120000_zeiterfassung_freigabe` – rein additiv (2 Enums, 7 Spalten `time_entries`, 1 Spalte `work_sessions`, 1 Index). Bestehende Zeilen: `tracked` + `approved` → unverändertes Verhalten. Deploy: `prisma migrate deploy` + `scripts/sync-role-permissions.ts` (Nutzer neu anmelden). + +## 7. Gate + +`npm run gate` **grün**: prisma generate, tsc, lint (0 Fehler, 3 bestehende Warnungen außerhalb L12), build inkl. Modul-Guard-Check (33 Action-Dateien), **67/67 Testskripte** (davon neu `test-zeiterfassung-service` 78 ✓, `test-zeiterfassung-sync` 29 ✓). + +Im ersten Gate-Lauf schlugen `test-notdienst-flow`/`test-notdienst-review` fehl: Die Notdienst-Erfassung startet eine Session, während der Monteur noch auf einem anderen Auftrag arbeitet → neue Regel „eine laufende Uhr“ lieferte `other_session_running`. Behoben in `services/emergency/create.ts` (`switchFromOther: true` – ein Notdiensteinsatz pausiert die laufende Uhr automatisch). + +**RLS-Lauf** `RLS_ENFORCED=true npm run test` (RLS_DATABASE_URL auf die Lane-DB `craftvia_zeiterfassung`): **67/67 Testskripte grün** – alle Session-/Freigabe-Writes laufen über `inTransaction` und sind damit auch unter scharfer RLS atomar. diff --git a/messages/de/dashboard.json b/messages/de/dashboard.json index 33e7ba1..7388acd 100644 --- a/messages/de/dashboard.json +++ b/messages/de/dashboard.json @@ -16,7 +16,8 @@ "billing": "Zur Abrechnung", "emergency_new": "Neue Notdienste", "missing_signatures": "Fehlende Unterschriften", - "sync_conflicts": "Sync-Konflikte" + "sync_conflicts": "Sync-Konflikte", + "time_approvals": "Zeiten zur Freigabe" }, "hints": { "open": "Noch nicht abgerechnet oder storniert", @@ -29,7 +30,8 @@ "billing": "Bereit zur Abrechnung", "emergency_new": "Neu oder zur Prüfung", "missing_signatures": "Unterschrift ausstehend", - "sync_conflicts": "Offline-Änderungen prüfen" + "sync_conflicts": "Offline-Änderungen prüfen", + "time_approvals": "Nachträge und Korrekturen prüfen" }, "open": "Anzeigen" } diff --git a/messages/de/field.json b/messages/de/field.json index ba53ccf..c1b3f21 100644 --- a/messages/de/field.json +++ b/messages/de/field.json @@ -5,7 +5,8 @@ "orders": "Aufträge", "emergency": "Notdienst", "sync": "Sync", - "profile": "Profil" + "profile": "Profil", + "approvalsBadge": "{count} Zeiten zur Freigabe" }, "connection": { "online": "Online", @@ -35,7 +36,13 @@ "noDate": "Ohne Termin", "emergency": "Notdienst", "urgent": "Dringend", - "high": "Hoch" + "high": "Hoch", + "running": "Zeit läuft", + "paused": "Pausiert", + "enRoute": "In Anfahrt", + "startWork": "Arbeit starten", + "pause": "Pause", + "resume": "Weiter" }, "statusGroup": { "new": "Neu", @@ -78,7 +85,17 @@ "saving": "Wird gespeichert …", "blocked": "Vor dem Abschluss noch offen:", "reportNext": "Bericht und Unterschrift folgen im nächsten Schritt.", - "nothing": "Keine Aktion erforderlich." + "nothing": "Keine Aktion erforderlich.", + "stop_day": "Für heute beenden", + "confirmStopDay": "Jetzt für heute beenden", + "stopDayHint": "Die Zeiterfassung endet, der Auftrag bleibt offen und kann morgen fortgesetzt werden.", + "completeLink": "Auftrag abschließen …", + "segment": { + "return_travel": "Rückfahrt starten", + "material_procurement": "Material holen", + "work": "Zurück zur Arbeit" + }, + "currentSegment": "Aktuell: {type}" }, "blocker": { "checklist_item": "Checkliste: {label}", @@ -264,7 +281,8 @@ "en_route": "In Anfahrt", "running": "Läuft", "paused": "Pausiert", - "ended": "Beendet" + "ended": "Beendet", + "manual": "Nachgetragen" }, "running": "läuft", "offlineStarted": "offline gestartet", @@ -277,7 +295,10 @@ "save": "Korrektur speichern", "saved": "Korrektur gespeichert.", "empty": "Noch keine Zeiten erfasst.", - "totalWork": "Arbeitszeit gesamt: {duration}" + "totalWork": "Arbeitszeit gesamt: {duration}", + "propose": "Korrektur vorschlagen", + "totalPending": "Zur Freigabe offen: {duration}", + "ownEntryHint": "Eigene Zeiten korrigieren Sie über „Korrektur vorschlagen“." }, "profile": { "title": "Profil", @@ -287,12 +308,133 @@ "roles": "Rolle", "language": "Sprache", "logout": "Abmelden", - "backoffice": "Zum Büro-Bereich" + "backoffice": "Zum Büro-Bereich", + "myTime": "Meine Zeiten", + "approvals": "Zeiten freigeben" }, "sync": { "title": "Synchronisation", "immediate": "Änderungen werden sofort an das Büro übertragen.", "offlineHint": "Ohne Verbindung können Eingaben noch nicht zwischengespeichert werden – die Offline-Warteschlange folgt.", "status": "Verbindung" + }, + "clock": { + "label": "Laufende Zeiterfassung", + "running": "Läuft", + "paused": "Pausiert", + "enRoute": "Anfahrt", + "openOrder": "Auftrag {number} öffnen", + "pause": "Pause", + "resume": "Weiter", + "stopDay": "Für heute beenden", + "stopDayConfirm": "Beenden?", + "myTime": "Meine Zeiten", + "offline": "offline" + }, + "switch": { + "title": "Andere Zeiterfassung läuft", + "text": "{from} läuft noch. Pausieren und {to} starten?", + "textUnknown": "Auf einem anderen Auftrag läuft noch eine Zeiterfassung. Pausieren und {to} starten?", + "confirm": "Wechseln", + "cancel": "Abbrechen" + }, + "myTime": { + "title": "Meine Zeiten", + "sub": "Ihre Zeiten je Tag über alle Aufträge.", + "today": "Heute", + "yesterday": "Gestern", + "days": "Tag wählen", + "empty": "An diesem Tag sind keine Zeiten erfasst.", + "approved": "Freigegeben", + "pending": "Zur Freigabe", + "dayTotal": "Tagessumme", + "totalApproved": "Freigegeben: {duration}", + "totalPending": "Offen: {duration}", + "add": "Zeit nachtragen", + "running": "läuft", + "badge": { + "manual": "Manuell", + "pending": "Zur Freigabe", + "rejected": "Abgelehnt: {reason}", + "correctionRequested": "Korrektur beantragt", + "correctionRejected": "Korrektur abgelehnt: {reason}", + "corrected": "Korrigiert" + }, + "proposed": "Vorschlag: {from}–{to} · {type}", + "localPending": "Wartet auf Übertragung", + "localTitle": "Noch nicht übertragen", + "form": { + "title": "Zeit nachtragen", + "order": "Auftrag", + "orderPlaceholder": "Auftrag wählen", + "noOrders": "Keine Aufträge der letzten 7 Tage verfügbar.", + "forUser": "Für", + "me": "Mich selbst", + "type": "Art", + "date": "Datum", + "mode": "Erfassung", + "modeRange": "Von – Bis", + "modeDuration": "Dauer", + "from": "Von", + "to": "Bis", + "duration": "Dauer in Minuten", + "quick": "{minutes} min", + "reason": "Begründung", + "reasonHint": "Pflichtangabe, mindestens 3 Zeichen.", + "note": "Notiz (optional)", + "approvalHint": "Wird nach Freigabe durch Teamleiter oder Büro gezählt.", + "directHint": "Für Teammitglieder erfasste Zeiten gelten sofort als freigegeben.", + "save": "Nachtrag speichern", + "saved": "Nachtrag gespeichert – wartet auf Freigabe.", + "savedDirect": "Zeit gespeichert.", + "queued": "Offline gespeichert – wird übertragen, sobald Verbindung besteht.", + "cancel": "Abbrechen" + }, + "correction": { + "title": "Korrektur vorschlagen", + "hint": "Bis zur Freigabe gilt die bisherige Zeit.", + "save": "Vorschlag senden", + "saved": "Korrektur beantragt." + }, + "reasons": { + "forgotStart": "Start vergessen", + "noNetwork": "Kein Netz", + "recordedLater": "Nachträglich erfasst" + }, + "errors": { + "overlap": "Der Zeitraum überschneidet sich mit einer bereits erfassten Zeit.", + "too_old": "Nachtragen ist nur für die letzten 7 Tage möglich.", + "in_future": "Die Zeit darf nicht in der Zukunft liegen.", + "too_long": "Ein Eintrag darf höchstens 16 Stunden lang sein.", + "end_before_start": "Das Ende muss nach dem Beginn liegen.", + "work_order_status": "Für diesen Auftrag können keine Zeiten mehr erfasst werden.", + "not_own_entry": "Sie können nur eigene Zeiten korrigieren.", + "own_entry": "Eigene Zeiten können Sie nicht selbst freigeben.", + "entry_running": "Die Zeit läuft noch." + } + }, + "approvals": { + "title": "Zeiten freigeben", + "sub": "Nachträge und Korrekturen Ihres Teams.", + "empty": "Keine offenen Nachträge oder Korrekturen.", + "link": "Zeiten freigeben", + "count": "{count, plural, =0 {Nichts offen} one {# offen} other {# offen}}", + "manual": "Nachtrag", + "correction": "Korrektur", + "reason": "Begründung", + "note": "Notiz", + "old": "Bisher", + "new": "Neu", + "approve": "Freigeben", + "reject": "Ablehnen", + "rejectReason": "Grund der Ablehnung", + "rejectConfirm": "Ablehnung senden", + "cancel": "Abbrechen", + "approved": "Freigegeben.", + "rejected": "Abgelehnt." + }, + "pendingTime": { + "report": "Hinweis: {duration} nachgetragene Zeit ist noch nicht freigegeben und fehlt im Bericht.", + "proposals": "Hinweis: {count, plural, one {# Zeitkorrektur wartet} other {# Zeitkorrekturen warten}} auf Freigabe." } } diff --git a/messages/de/nav.json b/messages/de/nav.json index efed3cc..f49d785 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -16,5 +16,6 @@ "lotse": "Lotse (KI)", "admin": "Admin-Konsole", "openMenu": "Menü öffnen", - "closeMenu": "Menü schließen" + "closeMenu": "Menü schließen", + "timeApprovals": "Zeiten zur Freigabe" } diff --git a/messages/de/notifications.json b/messages/de/notifications.json index 3689c31..5685b8c 100644 --- a/messages/de/notifications.json +++ b/messages/de/notifications.json @@ -45,26 +45,92 @@ "emergency_completed": "Notdiensteinsatz abgeschlossen", "import_ready_for_review": "Import zur Prüfung", "import_failed": "Fehler bei Dokumentverarbeitung", - "sync_failed": "Synchronisationsfehler" + "sync_failed": "Synchronisationsfehler", + "time_approval_requested": "Zeit zur Freigabe", + "time_approved": "Zeit freigegeben", + "time_rejected": "Zeit abgelehnt" }, "events": { - "work_order_assigned": { "title": "Neuer Auftrag {number}", "message": "{title} – {customer} wurde Ihrem Team zugewiesen." }, - "work_order_changed": { "title": "Auftrag {number} geändert", "message": "{title} – {customer}: Angaben wurden aktualisiert." }, - "work_order_cancelled": { "title": "Auftrag {number} storniert", "message": "{title} – {customer} wurde storniert." }, - "work_order_started": { "title": "Auftrag {number} gestartet", "message": "{actor} hat den Einsatz bei {customer} begonnen." }, - "work_order_daily_report_created": { "title": "Tagesbericht zu {number}", "message": "{actor} hat einen Tagesbericht für {customer} erstellt." }, - "work_order_technically_completed": { "title": "Auftrag {number} technisch abgeschlossen", "message": "{title} – {customer}: Einsatz vor Ort beendet." }, - "work_order_signature_missing": { "title": "Unterschrift fehlt: {number}", "message": "{title} – {customer}: Kundenunterschrift liegt nicht vor." }, - "work_order_missing_required": { "title": "Pflichtangaben fehlen: {number}", "message": "{title} – {customer}: Dokumentation unvollständig." }, - "work_order_released_for_billing": { "title": "Auftrag {number} zur Abrechnung", "message": "{title} – {customer} ist bereit zur Abrechnung." }, - "report_submitted": { "title": "Bericht zur Prüfung: {number}", "message": "{actor} hat einen Bericht zu {title} eingereicht." }, - "report_approved": { "title": "Bericht freigegeben: {number}", "message": "Der Bericht zu {title} wurde freigegeben." }, - "report_rejected": { "title": "Bericht zurückgewiesen: {number}", "message": "Der Bericht zu {title} braucht eine Korrektur." }, - "emergency_created": { "title": "Notdiensteinsatz {number} erstellt", "message": "{actor} hat einen Notdiensteinsatz bei {customer} angelegt." }, - "emergency_completed": { "title": "Notdiensteinsatz {number} abgeschlossen", "message": "{customer}: zur Prüfung und Abrechnung." }, - "import_ready_for_review": { "title": "Import zur Prüfung bereit", "message": "Die Daten aus {fileName} liegen zur Prüfung vor." }, - "import_failed": { "title": "Fehler bei Dokumentverarbeitung", "message": "{fileName} konnte nicht verarbeitet werden." }, - "sync_failed": { "title": "Synchronisation fehlgeschlagen", "message": "Eine Änderung konnte nicht übernommen werden ({reason})." } + "work_order_assigned": { + "title": "Neuer Auftrag {number}", + "message": "{title} – {customer} wurde Ihrem Team zugewiesen." + }, + "work_order_changed": { + "title": "Auftrag {number} geändert", + "message": "{title} – {customer}: Angaben wurden aktualisiert." + }, + "work_order_cancelled": { + "title": "Auftrag {number} storniert", + "message": "{title} – {customer} wurde storniert." + }, + "work_order_started": { + "title": "Auftrag {number} gestartet", + "message": "{actor} hat den Einsatz bei {customer} begonnen." + }, + "work_order_daily_report_created": { + "title": "Tagesbericht zu {number}", + "message": "{actor} hat einen Tagesbericht für {customer} erstellt." + }, + "work_order_technically_completed": { + "title": "Auftrag {number} technisch abgeschlossen", + "message": "{title} – {customer}: Einsatz vor Ort beendet." + }, + "work_order_signature_missing": { + "title": "Unterschrift fehlt: {number}", + "message": "{title} – {customer}: Kundenunterschrift liegt nicht vor." + }, + "work_order_missing_required": { + "title": "Pflichtangaben fehlen: {number}", + "message": "{title} – {customer}: Dokumentation unvollständig." + }, + "work_order_released_for_billing": { + "title": "Auftrag {number} zur Abrechnung", + "message": "{title} – {customer} ist bereit zur Abrechnung." + }, + "report_submitted": { + "title": "Bericht zur Prüfung: {number}", + "message": "{actor} hat einen Bericht zu {title} eingereicht." + }, + "report_approved": { + "title": "Bericht freigegeben: {number}", + "message": "Der Bericht zu {title} wurde freigegeben." + }, + "report_rejected": { + "title": "Bericht zurückgewiesen: {number}", + "message": "Der Bericht zu {title} braucht eine Korrektur." + }, + "emergency_created": { + "title": "Notdiensteinsatz {number} erstellt", + "message": "{actor} hat einen Notdiensteinsatz bei {customer} angelegt." + }, + "emergency_completed": { + "title": "Notdiensteinsatz {number} abgeschlossen", + "message": "{customer}: zur Prüfung und Abrechnung." + }, + "import_ready_for_review": { + "title": "Import zur Prüfung bereit", + "message": "Die Daten aus {fileName} liegen zur Prüfung vor." + }, + "import_failed": { + "title": "Fehler bei Dokumentverarbeitung", + "message": "{fileName} konnte nicht verarbeitet werden." + }, + "sync_failed": { + "title": "Synchronisation fehlgeschlagen", + "message": "Eine Änderung konnte nicht übernommen werden ({reason})." + }, + "time_approval_requested": { + "title": "Zeit zur Freigabe: {number}", + "message": "{actor} hat eine Zeit zu {number} nachgetragen oder eine Korrektur beantragt." + }, + "time_approved": { + "title": "Zeit freigegeben: {number}", + "message": "{actor} hat Ihre Zeit zu {number} freigegeben." + }, + "time_rejected": { + "title": "Zeit abgelehnt: {number}", + "message": "{actor} hat Ihre Zeit zu {number} abgelehnt: {reason}" + } }, "fallback": { "unknown": "unbekannt", diff --git a/messages/de/offline.json b/messages/de/offline.json index 07a4bbb..89336f8 100644 --- a/messages/de/offline.json +++ b/messages/de/offline.json @@ -89,7 +89,10 @@ "upload": "Die Datei wurde nicht angenommen (Format oder Größe).", "unauthorized": "Anmeldung abgelaufen – bitte neu anmelden.", "network": "Keine Verbindung – wird automatisch erneut versucht.", - "internal": "Technischer Fehler – wird automatisch erneut versucht." + "internal": "Technischer Fehler – wird automatisch erneut versucht.", + "timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.", + "timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.", + "otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln." }, "view": { "title": "Offline-Ansicht", diff --git a/messages/de/workOrders.json b/messages/de/workOrders.json index 0cb6433..94eb659 100644 --- a/messages/de/workOrders.json +++ b/messages/de/workOrders.json @@ -237,7 +237,21 @@ "material_procurement": "Materialbeschaffung", "return_travel": "Rückfahrt", "interruption": "Unterbrechung" - } + }, + "pendingMinutes": "Zur Freigabe offen: {minutes} min", + "approvedMinutes": "Freigegeben", + "badgeManual": "Manuell", + "badgePending": "Zur Freigabe", + "badgeRejected": "Abgelehnt: {reason}", + "badgeCorrection": "Korrektur beantragt: {from}–{to}", + "reason": "Begründung: {reason}", + "manualSession": "Nachgetragen", + "approve": "Freigeben", + "reject": "Ablehnen", + "rejectReason": "Grund", + "approved": "Freigegeben.", + "rejected": "Abgelehnt.", + "openApprovals": "Alle offenen Freigaben" }, "photos": { "empty": "Noch keine Fotos.", @@ -460,8 +474,35 @@ "running_session": "Ein Einsatz läuft noch", "field": { "signature": "Unterschrift fehlt", - "approved_completion_report": "Abschlussbericht ist noch nicht freigegeben" + "approved_completion_report": "Abschlussbericht ist noch nicht freigegeben", + "pending_time_entries": "Zeiten warten noch auf Freigabe" } }, - "saved": "Gespeichert." + "saved": "Gespeichert.", + "timeApprovals": { + "crumb": "Aufträge", + "title": "Zeiten zur Freigabe", + "sub": "Nachträge und Korrekturen der Monteure. Erst nach Freigabe zählen sie für Bericht und Abrechnung.", + "empty": "Keine offenen Nachträge oder Korrekturen.", + "employee": "Mitarbeiter", + "order": "Auftrag", + "date": "Datum", + "range": "Von–Bis", + "duration": "Dauer", + "type": "Art", + "reason": "Begründung", + "kindManual": "Nachtrag", + "kindCorrection": "Korrektur", + "oldNew": "{old} → {new}", + "select": "Auswählen", + "selectAll": "Alle auswählen", + "approveSelected": "Ausgewählte freigeben", + "approve": "Freigeben", + "reject": "Ablehnen", + "rejectReason": "Grund der Ablehnung", + "approved": "Freigegeben.", + "bulkApproved": "{ok} freigegeben, {failed} nicht möglich.", + "rejected": "Abgelehnt.", + "count": "{count, plural, one {# offener Eintrag} other {# offene Einträge}}" + } } diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index eee1199..3f36189 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -16,7 +16,8 @@ "billing": "Ready for billing", "emergency_new": "New emergencies", "missing_signatures": "Missing signatures", - "sync_conflicts": "Sync conflicts" + "sync_conflicts": "Sync conflicts", + "time_approvals": "Time approvals" }, "hints": { "open": "Not yet billed or cancelled", @@ -29,7 +30,8 @@ "billing": "Ready to invoice", "emergency_new": "New or in review", "missing_signatures": "Signature pending", - "sync_conflicts": "Review offline changes" + "sync_conflicts": "Review offline changes", + "time_approvals": "Review added time and corrections" }, "open": "Show" } diff --git a/messages/en/field.json b/messages/en/field.json index d8a5456..57a6825 100644 --- a/messages/en/field.json +++ b/messages/en/field.json @@ -5,7 +5,8 @@ "orders": "Orders", "emergency": "Emergency", "sync": "Sync", - "profile": "Profile" + "profile": "Profile", + "approvalsBadge": "{count} time entries to approve" }, "connection": { "online": "Online", @@ -35,7 +36,13 @@ "noDate": "No date", "emergency": "Emergency", "urgent": "Urgent", - "high": "High" + "high": "High", + "running": "Time running", + "paused": "Paused", + "enRoute": "On the way", + "startWork": "Start work", + "pause": "Pause", + "resume": "Resume" }, "statusGroup": { "new": "New", @@ -78,7 +85,17 @@ "saving": "Saving …", "blocked": "Still missing before completion:", "reportNext": "Report and signature follow in the next step.", - "nothing": "No action required." + "nothing": "No action required.", + "stop_day": "Stop for today", + "confirmStopDay": "Stop for today now", + "stopDayHint": "Time recording ends, the order stays open and can be continued tomorrow.", + "completeLink": "Complete order …", + "segment": { + "return_travel": "Start return trip", + "material_procurement": "Fetch material", + "work": "Back to work" + }, + "currentSegment": "Current: {type}" }, "blocker": { "checklist_item": "Checklist: {label}", @@ -264,7 +281,8 @@ "en_route": "On the way", "running": "Running", "paused": "Paused", - "ended": "Ended" + "ended": "Ended", + "manual": "Recorded later" }, "running": "running", "offlineStarted": "started offline", @@ -277,7 +295,10 @@ "save": "Save correction", "saved": "Correction saved.", "empty": "No time recorded yet.", - "totalWork": "Total working time: {duration}" + "totalWork": "Total working time: {duration}", + "propose": "Propose correction", + "totalPending": "Awaiting approval: {duration}", + "ownEntryHint": "Correct your own time via “Propose correction”." }, "profile": { "title": "Profile", @@ -287,12 +308,133 @@ "roles": "Role", "language": "Language", "logout": "Sign out", - "backoffice": "Go to office area" + "backoffice": "Go to office area", + "myTime": "My time", + "approvals": "Approve time" }, "sync": { "title": "Synchronisation", "immediate": "Changes are sent to the office immediately.", "offlineHint": "Without a connection, entries cannot be buffered yet – the offline queue will follow.", "status": "Connection" + }, + "clock": { + "label": "Running time recording", + "running": "Running", + "paused": "Paused", + "enRoute": "Travel", + "openOrder": "Open order {number}", + "pause": "Pause", + "resume": "Resume", + "stopDay": "Stop for today", + "stopDayConfirm": "Stop?", + "myTime": "My time", + "offline": "offline" + }, + "switch": { + "title": "Another time recording is running", + "text": "{from} is still running. Pause it and start {to}?", + "textUnknown": "Time is still running on another order. Pause it and start {to}?", + "confirm": "Switch", + "cancel": "Cancel" + }, + "myTime": { + "title": "My time", + "sub": "Your time per day across all orders.", + "today": "Today", + "yesterday": "Yesterday", + "days": "Choose day", + "empty": "No time recorded on this day.", + "approved": "Approved", + "pending": "Awaiting approval", + "dayTotal": "Day total", + "totalApproved": "Approved: {duration}", + "totalPending": "Open: {duration}", + "add": "Add time", + "running": "running", + "badge": { + "manual": "Manual", + "pending": "Awaiting approval", + "rejected": "Rejected: {reason}", + "correctionRequested": "Correction requested", + "correctionRejected": "Correction rejected: {reason}", + "corrected": "Corrected" + }, + "proposed": "Proposal: {from}–{to} · {type}", + "localPending": "Waiting to be sent", + "localTitle": "Not sent yet", + "form": { + "title": "Add time", + "order": "Order", + "orderPlaceholder": "Choose order", + "noOrders": "No orders from the last 7 days available.", + "forUser": "For", + "me": "Myself", + "type": "Type", + "date": "Date", + "mode": "Entry", + "modeRange": "From – to", + "modeDuration": "Duration", + "from": "From", + "to": "To", + "duration": "Duration in minutes", + "quick": "{minutes} min", + "reason": "Reason", + "reasonHint": "Required, at least 3 characters.", + "note": "Note (optional)", + "approvalHint": "Counts once approved by the team lead or office.", + "directHint": "Time recorded for team members is approved immediately.", + "save": "Save entry", + "saved": "Entry saved – awaiting approval.", + "savedDirect": "Time saved.", + "queued": "Saved offline – will be sent once connected.", + "cancel": "Cancel" + }, + "correction": { + "title": "Propose correction", + "hint": "The current time stays valid until approved.", + "save": "Send proposal", + "saved": "Correction requested." + }, + "reasons": { + "forgotStart": "Forgot to start", + "noNetwork": "No network", + "recordedLater": "Recorded afterwards" + }, + "errors": { + "overlap": "The period overlaps with time already recorded.", + "too_old": "Time can only be added for the last 7 days.", + "in_future": "The time must not be in the future.", + "too_long": "An entry may be at most 16 hours long.", + "end_before_start": "The end must be after the start.", + "work_order_status": "Time can no longer be recorded for this order.", + "not_own_entry": "You can only correct your own time.", + "own_entry": "You cannot approve your own time.", + "entry_running": "The time is still running." + } + }, + "approvals": { + "title": "Approve time", + "sub": "Added entries and corrections of your team.", + "empty": "No open entries or corrections.", + "link": "Approve time", + "count": "{count, plural, =0 {Nothing open} one {# open} other {# open}}", + "manual": "Added entry", + "correction": "Correction", + "reason": "Reason", + "note": "Note", + "old": "Before", + "new": "New", + "approve": "Approve", + "reject": "Reject", + "rejectReason": "Reason for rejection", + "rejectConfirm": "Send rejection", + "cancel": "Cancel", + "approved": "Approved.", + "rejected": "Rejected." + }, + "pendingTime": { + "report": "Note: {duration} of added time is not approved yet and missing from the report.", + "proposals": "Note: {count, plural, one {# time correction is} other {# time corrections are}} awaiting approval." } } diff --git a/messages/en/nav.json b/messages/en/nav.json index 91f4ba6..5e97423 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -16,5 +16,6 @@ "lotse": "Lotse (AI)", "admin": "Admin console", "openMenu": "Open menu", - "closeMenu": "Close menu" + "closeMenu": "Close menu", + "timeApprovals": "Time approvals" } diff --git a/messages/en/notifications.json b/messages/en/notifications.json index 3206fdf..13ca202 100644 --- a/messages/en/notifications.json +++ b/messages/en/notifications.json @@ -45,26 +45,92 @@ "emergency_completed": "Emergency call-out completed", "import_ready_for_review": "Import ready for review", "import_failed": "Document processing failed", - "sync_failed": "Sync failed" + "sync_failed": "Sync failed", + "time_approval_requested": "Time to approve", + "time_approved": "Time approved", + "time_rejected": "Time rejected" }, "events": { - "work_order_assigned": { "title": "New work order {number}", "message": "{title} – {customer} was assigned to your team." }, - "work_order_changed": { "title": "Work order {number} changed", "message": "{title} – {customer}: details were updated." }, - "work_order_cancelled": { "title": "Work order {number} cancelled", "message": "{title} – {customer} was cancelled." }, - "work_order_started": { "title": "Work order {number} started", "message": "{actor} started work at {customer}." }, - "work_order_daily_report_created": { "title": "Daily report for {number}", "message": "{actor} created a daily report for {customer}." }, - "work_order_technically_completed": { "title": "Work order {number} technically completed", "message": "{title} – {customer}: on-site work finished." }, - "work_order_signature_missing": { "title": "Signature missing: {number}", "message": "{title} – {customer}: no customer signature." }, - "work_order_missing_required": { "title": "Required information missing: {number}", "message": "{title} – {customer}: documentation incomplete." }, - "work_order_released_for_billing": { "title": "Work order {number} ready for billing", "message": "{title} – {customer} is ready for billing." }, - "report_submitted": { "title": "Report for review: {number}", "message": "{actor} submitted a report for {title}." }, - "report_approved": { "title": "Report approved: {number}", "message": "The report for {title} was approved." }, - "report_rejected": { "title": "Report rejected: {number}", "message": "The report for {title} needs a correction." }, - "emergency_created": { "title": "Emergency call-out {number} created", "message": "{actor} created an emergency call-out at {customer}." }, - "emergency_completed": { "title": "Emergency call-out {number} completed", "message": "{customer}: ready for review and billing." }, - "import_ready_for_review": { "title": "Import ready for review", "message": "The data from {fileName} is ready for review." }, - "import_failed": { "title": "Document processing failed", "message": "{fileName} could not be processed." }, - "sync_failed": { "title": "Sync failed", "message": "A change could not be applied ({reason})." } + "work_order_assigned": { + "title": "New work order {number}", + "message": "{title} – {customer} was assigned to your team." + }, + "work_order_changed": { + "title": "Work order {number} changed", + "message": "{title} – {customer}: details were updated." + }, + "work_order_cancelled": { + "title": "Work order {number} cancelled", + "message": "{title} – {customer} was cancelled." + }, + "work_order_started": { + "title": "Work order {number} started", + "message": "{actor} started work at {customer}." + }, + "work_order_daily_report_created": { + "title": "Daily report for {number}", + "message": "{actor} created a daily report for {customer}." + }, + "work_order_technically_completed": { + "title": "Work order {number} technically completed", + "message": "{title} – {customer}: on-site work finished." + }, + "work_order_signature_missing": { + "title": "Signature missing: {number}", + "message": "{title} – {customer}: no customer signature." + }, + "work_order_missing_required": { + "title": "Required information missing: {number}", + "message": "{title} – {customer}: documentation incomplete." + }, + "work_order_released_for_billing": { + "title": "Work order {number} ready for billing", + "message": "{title} – {customer} is ready for billing." + }, + "report_submitted": { + "title": "Report for review: {number}", + "message": "{actor} submitted a report for {title}." + }, + "report_approved": { + "title": "Report approved: {number}", + "message": "The report for {title} was approved." + }, + "report_rejected": { + "title": "Report rejected: {number}", + "message": "The report for {title} needs a correction." + }, + "emergency_created": { + "title": "Emergency call-out {number} created", + "message": "{actor} created an emergency call-out at {customer}." + }, + "emergency_completed": { + "title": "Emergency call-out {number} completed", + "message": "{customer}: ready for review and billing." + }, + "import_ready_for_review": { + "title": "Import ready for review", + "message": "The data from {fileName} is ready for review." + }, + "import_failed": { + "title": "Document processing failed", + "message": "{fileName} could not be processed." + }, + "sync_failed": { + "title": "Sync failed", + "message": "A change could not be applied ({reason})." + }, + "time_approval_requested": { + "title": "Time to approve: {number}", + "message": "{actor} added time or requested a correction for {number}." + }, + "time_approved": { + "title": "Time approved: {number}", + "message": "{actor} approved your time for {number}." + }, + "time_rejected": { + "title": "Time rejected: {number}", + "message": "{actor} rejected your time for {number}: {reason}" + } }, "fallback": { "unknown": "unknown", diff --git a/messages/en/offline.json b/messages/en/offline.json index 2a982be..b2ec4a9 100644 --- a/messages/en/offline.json +++ b/messages/en/offline.json @@ -89,7 +89,10 @@ "upload": "The file was not accepted (format or size).", "unauthorized": "Your session expired – please sign in again.", "network": "No connection – will retry automatically.", - "internal": "Technical error – will retry automatically." + "internal": "Technical error – will retry automatically.", + "timeOverlap": "The added time overlaps with time already recorded and was not accepted.", + "timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.", + "otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch." }, "view": { "title": "Offline view", diff --git a/messages/en/workOrders.json b/messages/en/workOrders.json index 166e853..9ff39e2 100644 --- a/messages/en/workOrders.json +++ b/messages/en/workOrders.json @@ -237,7 +237,21 @@ "material_procurement": "Material procurement", "return_travel": "Return travel", "interruption": "Interruption" - } + }, + "pendingMinutes": "Awaiting approval: {minutes} min", + "approvedMinutes": "Approved", + "badgeManual": "Manual", + "badgePending": "Awaiting approval", + "badgeRejected": "Rejected: {reason}", + "badgeCorrection": "Correction requested: {from}–{to}", + "reason": "Reason: {reason}", + "manualSession": "Recorded later", + "approve": "Approve", + "reject": "Reject", + "rejectReason": "Reason", + "approved": "Approved.", + "rejected": "Rejected.", + "openApprovals": "All open approvals" }, "photos": { "empty": "No photos yet.", @@ -460,8 +474,35 @@ "running_session": "A work session is still running", "field": { "signature": "Signature missing", - "approved_completion_report": "Completion report not approved yet" + "approved_completion_report": "Completion report not approved yet", + "pending_time_entries": "Time entries are still awaiting approval" } }, - "saved": "Saved." + "saved": "Saved.", + "timeApprovals": { + "crumb": "Work orders", + "title": "Time approvals", + "sub": "Added time and corrections by technicians. They count for reports and billing only after approval.", + "empty": "No open entries or corrections.", + "employee": "Employee", + "order": "Order", + "date": "Date", + "range": "From–to", + "duration": "Duration", + "type": "Type", + "reason": "Reason", + "kindManual": "Added entry", + "kindCorrection": "Correction", + "oldNew": "{old} → {new}", + "select": "Select", + "selectAll": "Select all", + "approveSelected": "Approve selected", + "approve": "Approve", + "reject": "Reject", + "rejectReason": "Reason for rejection", + "approved": "Approved.", + "bulkApproved": "{ok} approved, {failed} not possible.", + "rejected": "Rejected.", + "count": "{count, plural, one {# open entry} other {# open entries}}" + } } diff --git a/prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql b/prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql new file mode 100644 index 0000000..992aac3 --- /dev/null +++ b/prisma/migrations/20260915120000_zeiterfassung_freigabe/migration.sql @@ -0,0 +1,24 @@ +-- L12 Zeiterfassung: approval workflow for manual time entries and correction proposals. +-- Additive columns only (existing rows: tracked + approved = unchanged behaviour). No new table -> no RLS/TENANT_MODELS change. + +-- CreateEnum +CREATE TYPE "TimeEntrySource" AS ENUM ('tracked', 'manual'); + +-- CreateEnum +CREATE TYPE "TimeApprovalStatus" AS ENUM ('approved', 'pending', 'rejected'); + +-- AlterTable +ALTER TABLE "time_entries" ADD COLUMN "approval_status" "TimeApprovalStatus" NOT NULL DEFAULT 'approved', +ADD COLUMN "approved_at" TIMESTAMP(3), +ADD COLUMN "approved_by_id" TEXT, +ADD COLUMN "note" TEXT, +ADD COLUMN "pending_change" JSONB, +ADD COLUMN "rejection_reason" TEXT, +ADD COLUMN "source" "TimeEntrySource" NOT NULL DEFAULT 'tracked'; + +-- AlterTable +ALTER TABLE "work_sessions" ADD COLUMN "manual" BOOLEAN NOT NULL DEFAULT false; + +-- CreateIndex +CREATE INDEX "time_entries_tenant_id_approval_status_idx" ON "time_entries"("tenant_id", "approval_status"); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bc29a6c..b0f5a6d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -894,6 +894,8 @@ model WorkSession { startedOffline Boolean @default(false) @map("started_offline") deviceInfo String? @map("device_info") clientId String? @map("client_id") + /// L12: container session for manually recorded entries (one per user + order + day, always `ended`) + manual Boolean @default(false) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -917,6 +919,17 @@ enum TimeEntryType { interruption } +enum TimeEntrySource { + tracked + manual +} + +enum TimeApprovalStatus { + approved + pending + rejected +} + model TimeEntry { id String @id @default(cuid()) tenantId String @map("tenant_id") @@ -930,6 +943,16 @@ model TimeEntry { correctionReason String? @map("correction_reason") correctedById String? @map("corrected_by_id") clientId String? @map("client_id") + // L12 Zeiterfassung: manual entries and correction proposals need approval; only + // `approvalStatus = approved` counts for reports, totals and billing. + source TimeEntrySource @default(tracked) + approvalStatus TimeApprovalStatus @default(approved) @map("approval_status") + approvedById String? @map("approved_by_id") + approvedAt DateTime? @map("approved_at") + rejectionReason String? @map("rejection_reason") + /// proposed correction of this entry {startedAt, endedAt, type, reason, requestedAt}; old values stay valid until approval + pendingChange Json? @map("pending_change") + note String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -937,6 +960,7 @@ model TimeEntry { @@index([tenantId, workSessionId]) @@index([tenantId, userId, startedAt]) + @@index([tenantId, approvalStatus]) @@unique([tenantId, clientId]) @@map("time_entries") } diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts index 2ef0bca..d2fab2e 100644 --- a/scripts/smoke-auth.ts +++ b/scripts/smoke-auth.ts @@ -81,6 +81,9 @@ function plans(x: Awaited>): Plan[] { ...tabs.map((t) => ({ path: wo(x.d07, `?tab=${t}`), mustContain: x.d07.number })), { path: wo(x.d18, "?tab=reports"), mustContain: x.d18.number }, { path: "/work-orders/conflicts" }, + // L12 Zeiterfassung + { path: "/work-orders/time-approvals", mustContain: "Zeiten zur Freigabe" }, + { path: "/dashboard", mustContain: "Zeiten zur Freigabe" }, { path: "/work-orders/emergency-review", mustContain: x.emergency?.number }, ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, mustContain: x.emergency.number }] : []), { path: "/imports" }, @@ -113,6 +116,10 @@ function plans(x: Awaited>): Plan[] { { path: "/reports" }, ...(x.inReview ? [{ path: `/reports/${x.inReview.id}`, mustContain: x.d16?.number }] : []), { path: m(x.d08), expect: [404] }, + // L12 Zeiterfassung + { path: "/m/time", mustContain: ["Meine Zeiten", "Zeit nachtragen"] }, + { path: "/m/approvals", mustContain: "Zeiten freigeben" }, + { path: "/m/profile", mustContain: "Zeiten freigeben" }, ], }, { @@ -136,7 +143,14 @@ function plans(x: Awaited>): Plan[] { { path: "/m/emergency" }, { path: "/m/sync" }, { path: "/m/offline" }, - { path: "/m/profile", mustContain: "Max Monteur" }, + { path: "/m/profile", mustContain: ["Max Monteur", "Meine Zeiten"] }, + // L12 Zeiterfassung + { path: "/m", mustContain: ["Laufende Zeiterfassung", "Für heute beenden"] }, + { path: "/m/time", mustContain: ["Meine Zeiten", "Zeit nachtragen"] }, + { path: `/m/time?date=${new Date(Date.now() - 86400_000).toISOString().slice(0, 10)}`, mustContain: "Meine Zeiten" }, + { path: "/m/time/new", mustContain: ["Zeit nachtragen", "Begründung"] }, + { path: "/m/approvals", mustContain: "nicht freigeschaltet" }, + { path: m(x.d07, "/time"), mustContain: x.d07.number }, ...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [200] }] : []), { path: m(x.d08), expect: [404] }, { path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] }, diff --git a/scripts/test-security-roles.ts b/scripts/test-security-roles.ts index 6c8e0f5..1a556d9 100644 --- a/scripts/test-security-roles.ts +++ b/scripts/test-security-roles.ts @@ -26,6 +26,7 @@ import { createOrderType } from "../src/server/services/work-orders/settings"; import { listImports } from "../src/server/services/imports/queries"; import { createNote } from "../src/server/services/field/notes"; import { correctTimeEntry } from "../src/server/services/field/time-correction"; +import { addManualTimeEntry, approveTimeEntry } from "../src/server/services/field/time-entries"; import { createDailyReport } from "../src/server/services/reports/create"; import { approveReport } from "../src/server/services/reports/approve"; import { listReports } from "../src/server/services/reports/queries"; @@ -98,6 +99,21 @@ runSuite("Sicherheit: Rollen-Matrix", [SLUG], async () => { { permission: "import:write", label: "Importe", run: (c) => listImports(c) }, { permission: "field:execute", label: "Tätigkeitsnotiz erfassen", run: (c) => createNote(c, noteCreatePayload.parse({ workOrderId: shared.id, text: "Rollenprüfung" })) }, { permission: "field:correct_time", label: "Arbeitszeit korrigieren", run: (c) => correctTimeEntry(c, { timeEntryId: entry.id, startedAt: session.startedAt, endedAt: session.endedAt, reason: "Rollenprüfung" }) }, + // L12 Zeiterfassung + { + permission: "field:record_own_time", + label: "Eigene Zeit nachtragen", + run: (c) => addManualTimeEntry(c, { workOrderId: shared.id, type: "work", startedAt: new Date(Date.now() - 26 * 3600_000), durationMinutes: 30, reason: "Rollenprüfung" }), + }, + { + permission: "time:approve", + label: "Zeitnachtrag freigeben", + run: async (c) => { + const s = await prisma.workSession.create({ data: { tenantId: A.tenantId, workOrderId: shared.id, userId: A.users.tech2.id, status: "ended", manual: true, startedAt: new Date(Date.now() - 7200_000), endedAt: new Date(Date.now() - 3600_000) } }); + const e = await prisma.timeEntry.create({ data: { tenantId: A.tenantId, workSessionId: s.id, userId: A.users.tech2.id, startedAt: s.startedAt, endedAt: s.endedAt, source: "manual", approvalStatus: "pending", correctionReason: "Rollenprüfung" } }); + return approveTimeEntry(c, e.id); + }, + }, { permission: "report:write", label: "Tagesbericht anlegen", run: async (c) => createDailyReport(c, { workOrderId: (await rawOrder(A, "planned")).id }) }, { permission: "report:approve_team", label: "Bericht freigeben (Teamleiter oder Backoffice)", run: (c) => approveReport(c, { reportId: draftReport.id }, { dispatchPdf: async () => undefined }) }, { permission: "emergency:create", label: "Notdienst-Kundensuche", run: (c) => searchCustomersForEmergency(c, "Hausverwaltung") }, diff --git a/scripts/test-zeiterfassung-service.ts b/scripts/test-zeiterfassung-service.ts new file mode 100644 index 0000000..9372f01 --- /dev/null +++ b/scripts/test-zeiterfassung-service.ts @@ -0,0 +1,237 @@ +// Lane L12 „Zeiterfassung" — Service-Tests: manuelle Nachträge (Überlappung, 7 Tage, Zukunft, +// 16 h), Freigabe/Ablehnung (Rollen, Team-Scope, nie eigene Einträge), Korrekturvorschlag (alte +// Werte zählen bis zur Freigabe), Bericht-/Summen nur freigegeben, Abrechnungsfreigabe blockiert, +// „Für heute beenden", Auto-Wechsel (conflict ohne Flag, atomar mit Flag), Segmente, Events, +// Audit und Mandantentrennung. +// +// Lauf: npx tsx scripts/test-zeiterfassung-service.ts (lokale Postgres-DB aus .env) + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import type { ServiceCtx } from "../src/server/services/context"; +import { + addManualTimeEntry, + approveTimeEntries, + approveTimeEntry, + countPendingTimeEntries, + getMyTimeOverview, + listPendingTimeEntries, + pendingTimeOfOrder, + proposeTimeCorrection, + rejectTimeEntry, +} from "../src/server/services/field/time-entries"; +import { getMyActiveSession, pauseSession, resumeSession, startSession, stopForToday, switchSegment } from "../src/server/services/field/sessions"; +import { correctTimeEntry } from "../src/server/services/field/time-correction"; +import { buildReportContent } from "../src/server/services/reports/build-content"; +import { getTimesTab } from "../src/server/services/work-orders/detail"; +import { getDashboardTiles } from "../src/server/services/work-orders/dashboard"; +import { computeCompletionBlockers } from "../src/server/services/work-orders/completion"; +import { releaseForBilling } from "../src/server/services/work-orders/release-billing"; +import { sessionStartPayload, sessionControlPayload } from "../src/lib/sync/ops"; +import { ServiceError } from "../src/server/services/context"; +import { berlinAt, codeOf, createTenant, dayKey, expectCode, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-l12-time-a"; +const SLUG_B = "zz-l12-time-b"; + +async function order(A: TenantFixture, status: string, extra: Record = {}) { + return prisma.workOrder.create({ + data: { + tenantId: A.tenantId, + number: `Z-${randomUUID().slice(0, 6)}`, + customerId: A.customerId, + siteId: A.siteId, + title: "Wärmepumpe", + status: status as never, + assignedTeamId: A.teamId, + ...extra, + }, + }); +} + +async function errorOf(p: Promise): Promise { + try { + await p; + return null; + } catch (err) { + return err instanceof ServiceError ? err : null; + } +} + +const notif = (tenantId: string, userId: string, type: string, entityId: string) => + prisma.notification.count({ where: { tenantId, userId, type, entityId } }); + +async function contentOf(ctx: ServiceCtx, workOrderId: string, date?: string) { + return buildReportContent(ctx, { workOrderId, type: date ? "daily" : "completion", reportDate: date ?? dayKey(0), reportNumber: "B-TEST", version: 1, technicianUserId: null }); +} + +runSuite("L12 Zeiterfassung (Service)", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const wo = await order(A, "in_progress", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); + const woB = await order(B, "in_progress"); + + section("Nachtragen: Validierung"); + const base = { workOrderId: wo.id, type: "work" as const, reason: "Start vergessen" }; + const y8 = berlinAt(-1, 8); + const y10 = berlinAt(-1, 10); + + const e1 = await addManualTimeEntry(A.ctx.tech, { ...base, startedAt: y8, endedAt: y10 }); + ok(e1.approvalStatus === "pending" && e1.source === "manual" && e1.correctionReason === "Start vergessen", "Monteur-Nachtrag → pending, source manual, Begründung gespeichert"); + const manualSession = await prisma.workSession.findUnique({ where: { id: e1.workSessionId } }); + ok(!!manualSession?.manual && manualSession.status === "ended" && manualSession.userId === A.users.tech.id, "manuelle Session (ended, manual) je User+Auftrag+Tag"); + const e1b = await addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-1, 13), durationMinutes: 30 }); + ok(e1b.workSessionId === e1.workSessionId && e1b.endedAt!.getTime() - e1b.startedAt.getTime() === 30 * 60_000, "Dauer statt Ende; gleiche manuelle Session am selben Tag"); + + const overlap = await errorOf(addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-1, 9), endedAt: berlinAt(-1, 11) })); + ok(overlap?.code === "invalid" && overlap.message === "overlap" && (overlap.details as { reason?: string })?.reason === "overlap", "Überlappung mit eigenem Eintrag → invalid (overlap)"); + ok((await errorOf(addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-9, 8), endedAt: berlinAt(-9, 9) })))?.message === "too_old", "älter als 7 Tage → invalid (too_old)"); + ok((await errorOf(addManualTimeEntry(A.ctx.tech, { ...base, startedAt: new Date(Date.now() + 3600_000), durationMinutes: 30 })))?.message === "in_future", "in der Zukunft → invalid (in_future)"); + ok((await errorOf(addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-2, 1), endedAt: berlinAt(-2, 18) })))?.message === "too_long", "länger als 16 h → invalid (too_long)"); + ok((await errorOf(addManualTimeEntry(A.ctx.tech, { ...base, startedAt: y10, endedAt: y8 })))?.code === "invalid", "Ende vor Beginn → invalid"); + await expectCode(() => addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-3, 8), durationMinutes: 30, reason: "" }), "invalid", "ohne Begründung → invalid"); + await expectCode(() => addManualTimeEntry(A.ctx.outsider, { ...base, startedAt: berlinAt(-3, 8), durationMinutes: 30 }), "not_found", "Monteur ohne Zuweisung (Team Süd) → not_found"); + await expectCode(() => addManualTimeEntry(A.ctx.backoffice, { ...base, startedAt: berlinAt(-3, 8), durationMinutes: 30 }), "forbidden", "Backoffice ohne field:record_own_time → forbidden"); + await expectCode(() => addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-3, 8), durationMinutes: 30, forUserId: A.users.tech2.id }), "forbidden", "Monteur für Kollegen → forbidden"); + + section("Events + Audit"); + ok((await notif(A.tenantId, A.users.lead.id, "time.approval_requested", e1.id)) === 1, "time.approval_requested → Teamleiter des Auftrags-Teams (In-App)"); + ok((await notif(A.tenantId, A.users.tech.id, "time.approval_requested", e1.id)) === 0, "… nicht an den Monteur selbst"); + ok((await notif(A.tenantId, A.users.backoffice.id, "time.approval_requested", e1.id)) === 0, "… Backoffice nur ohne zuständigen Teamleiter"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "time_entry", entityId: e1.id, action: "create" } })) === 1, "Audit create für den Nachtrag"); + + section("Pending zählt nicht"); + let content = await contentOf(A.ctx.backoffice, wo.id, dayKey(-1)); + ok(content.time.totalMinutes === 0 && content.time.pendingMinutes === 150, `Tagesbericht: 0 min gezählt, 150 min zur Freigabe offen (${content.time.totalMinutes}/${content.time.pendingMinutes})`); + const tab = await getTimesTab(A.ctx.backoffice, wo.id); + ok(tab.reduce((s, x) => s + x.workMinutes, 0) === 0 && tab.reduce((s, x) => s + x.pendingMinutes, 0) === 150, "Backoffice Zeiten-Tab: freigegeben 0, offen 150"); + const ov = await getMyTimeOverview(A.ctx.tech, { date: dayKey(-1) }); + ok(ov.approvedSeconds === 0 && ov.pendingSeconds === 150 * 60 && ov.groups[0]?.entries.length === 2, "Meine Zeiten: getrennt freigegeben/offen"); + ok((await pendingTimeOfOrder(A.ctx.backoffice, wo.id)).pendingEntries === 2, "pendingTimeOfOrder zählt offene Nachträge"); + + section("Freigabe: Rollen und Scope"); + await expectCode(() => approveTimeEntry(A.ctx.tech, e1.id), "forbidden", "Monteur darf nicht freigeben"); + await expectCode(() => approveTimeEntry(A.ctx.tech2, e1.id), "forbidden", "Monteur-Kollege darf nicht freigeben"); + await expectCode(() => approveTimeEntry(A.ctx.lead2, e1.id), "not_found", "Teamleiter Süd sieht Eintrag von Team Nord nicht → not_found"); + const leadOwn = await addManualTimeEntry(A.ctx.lead, { ...base, startedAt: berlinAt(-1, 8), endedAt: berlinAt(-1, 9) }); + ok(leadOwn.approvalStatus === "pending", "Teamleiter-eigener Nachtrag → ebenfalls pending"); + await expectCode(() => approveTimeEntry(A.ctx.lead, leadOwn.id), "forbidden", "Teamleiter kann eigenen Eintrag nicht freigeben"); + ok((await notif(A.tenantId, A.users.backoffice.id, "time.approval_requested", leadOwn.id)) === 1, "eigener Teamleiter-Nachtrag → Backoffice benachrichtigt (kein anderer Teamleiter)"); + await expectCode(() => correctTimeEntry(A.ctx.lead, { timeEntryId: leadOwn.id, startedAt: berlinAt(-1, 8), endedAt: berlinAt(-1, 9), reason: "selbst" }), "forbidden", "Direktkorrektur eigener Einträge → forbidden"); + + const leadList = await listPendingTimeEntries(A.ctx.lead); + ok(leadList.some((i) => i.id === e1.id) && !leadList.some((i) => i.id === leadOwn.id), "Teamleiter-Liste: Team-Einträge, nicht die eigenen"); + ok((await listPendingTimeEntries(A.ctx.lead2)).length === 0, "Teamleiter Süd: leere Liste"); + const boList = await listPendingTimeEntries(A.ctx.backoffice); + ok(boList.some((i) => i.id === e1.id) && boList.some((i) => i.id === leadOwn.id), "Backoffice-Liste: alle"); + ok((await countPendingTimeEntries(A.ctx.lead)) === 2 && (await countPendingTimeEntries(A.ctx.tech)) === 0, "Badge-Zähler (Teamleiter 2, Monteur 0)"); + ok((await getDashboardTiles(A.ctx.backoffice, {})).timeApprovals === 3, "Dashboard-Kachel „Zeiten zur Freigabe“"); + + const approved = await approveTimeEntry(A.ctx.lead, e1.id); + ok(approved.approvalStatus === "approved" && approved.approvedById === A.users.lead.id && !!approved.approvedAt, "Teamleiter gibt Team-Eintrag frei"); + await expectCode(() => approveTimeEntry(A.ctx.lead, e1.id), "invalid", "zweite Freigabe → invalid (nicht offen)"); + ok((await notif(A.tenantId, A.users.tech.id, "time.approved", e1.id)) === 1, "time.approved → Monteur (In-App)"); + const boApproved = await approveTimeEntries(A.ctx.backoffice, [e1b.id, leadOwn.id]); + ok(boApproved.every((r) => r.ok), "Backoffice Sammelfreigabe (auch Teamleiter-Eintrag)"); + content = await contentOf(A.ctx.backoffice, wo.id, dayKey(-1)); + ok(content.time.totalMinutes === 210 && (content.time.pendingMinutes ?? 0) === 0, `nach Freigabe zählt der Nachtrag im Bericht (${content.time.totalMinutes} min)`); + + section("Ablehnung"); + const e2 = await addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-2, 8), endedAt: berlinAt(-2, 9) }); + await expectCode(() => rejectTimeEntry(A.ctx.backoffice, e2.id, ""), "invalid", "Ablehnen ohne Grund → invalid"); + const rejected = await rejectTimeEntry(A.ctx.backoffice, e2.id, "Nicht plausibel"); + ok(rejected.approvalStatus === "rejected" && rejected.rejectionReason === "Nicht plausibel", "abgelehnt mit Grund"); + ok((await notif(A.tenantId, A.users.tech.id, "time.rejected", e2.id)) === 1, "time.rejected → Monteur"); + const notification = await prisma.notification.findFirst({ where: { tenantId: A.tenantId, userId: A.users.tech.id, type: "time.rejected", entityId: e2.id } }); + ok(!!notification?.message.includes("Nicht plausibel") && notification.link === "/m/time", "Text enthält den Grund, Link „Meine Zeiten“"); + content = await contentOf(A.ctx.backoffice, wo.id, dayKey(-2)); + ok(content.time.totalMinutes === 0, "abgelehnter Eintrag zählt nicht"); + const again = await addManualTimeEntry(A.ctx.tech, { ...base, startedAt: berlinAt(-2, 8), endedAt: berlinAt(-2, 9), reason: "Neu erfasst" }); + ok(again.approvalStatus === "pending", "abgelehnter Zeitraum blockiert keinen neuen Nachtrag"); + + section("Korrekturvorschlag"); + const tracked = await prisma.workSession.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, userId: A.users.tech.id, status: "ended", startedAt: berlinAt(-3, 8), endedAt: berlinAt(-3, 12) } }); + const te = await prisma.timeEntry.create({ data: { tenantId: A.tenantId, workSessionId: tracked.id, userId: A.users.tech.id, type: "work", startedAt: berlinAt(-3, 8), endedAt: berlinAt(-3, 12) } }); + await expectCode(() => proposeTimeCorrection(A.ctx.tech2, { timeEntryId: te.id, startedAt: berlinAt(-3, 9), endedAt: berlinAt(-3, 12), reason: "fremd" }), "forbidden", "Vorschlag für fremden Eintrag → forbidden"); + const proposed = await proposeTimeCorrection(A.ctx.tech, { timeEntryId: te.id, startedAt: berlinAt(-3, 8, 30), endedAt: berlinAt(-3, 12), reason: "Start zu früh gestempelt" }); + ok(proposed.approvalStatus === "approved" && proposed.startedAt.getTime() === berlinAt(-3, 8).getTime() && !!proposed.pendingChange, "Vorschlag: alte Werte bleiben, pendingChange gesetzt"); + content = await contentOf(A.ctx.backoffice, wo.id, dayKey(-3)); + ok(content.time.totalMinutes === 240, "bis zur Freigabe zählt der alte Wert (240 min)"); + ok((await notif(A.tenantId, A.users.lead.id, "time.approval_requested", te.id)) === 1, "Event für Korrekturvorschlag"); + await expectCode(() => approveTimeEntry(A.ctx.lead2, te.id), "not_found", "fremder Teamleiter → not_found"); + const corrected = await approveTimeEntry(A.ctx.lead, te.id); + ok(corrected.corrected && corrected.startedAt.getTime() === berlinAt(-3, 8, 30).getTime() && corrected.pendingChange === null && corrected.correctionReason === "Start zu früh gestempelt", "nach Freigabe: neue Werte + corrected + Grund"); + content = await contentOf(A.ctx.backoffice, wo.id, dayKey(-3)); + ok(content.time.totalMinutes === 210, "danach zählt der neue Wert (210 min)"); + await proposeTimeCorrection(A.ctx.tech, { timeEntryId: te.id, startedAt: berlinAt(-3, 7), endedAt: berlinAt(-3, 12), reason: "doch früher" }); + await rejectTimeEntry(A.ctx.lead, te.id, "Stempelzeit passt"); + const afterReject = await prisma.timeEntry.findUniqueOrThrow({ where: { id: te.id } }); + ok(afterReject.approvalStatus === "approved" && afterReject.pendingChange === null && afterReject.startedAt.getTime() === berlinAt(-3, 8, 30).getTime() && afterReject.rejectionReason === "Stempelzeit passt", "abgelehnter Vorschlag: Eintrag unverändert gültig, Grund sichtbar"); + + section("Direkt für Teammitglied (field:correct_time)"); + const direct = await addManualTimeEntry(A.ctx.lead, { ...base, startedAt: berlinAt(-1, 14), endedAt: berlinAt(-1, 15), forUserId: A.users.tech2.id }); + ok(direct.approvalStatus === "approved" && direct.source === "manual" && direct.userId === A.users.tech2.id && direct.approvedById === A.users.lead.id, "Teamleiter legt Eintrag für Teammitglied an → approved, manual"); + await expectCode(() => addManualTimeEntry(A.ctx.lead, { ...base, startedAt: berlinAt(-1, 14), endedAt: berlinAt(-1, 15), forUserId: A.users.outsider.id }), "forbidden", "Teamleiter für Nicht-Teammitglied → forbidden"); + await expectCode(() => addManualTimeEntry(A.ctx.lead2, { ...base, startedAt: berlinAt(-1, 14), endedAt: berlinAt(-1, 15), forUserId: A.users.tech.id }), "not_found", "Teamleiter Süd auf Auftrag von Team Nord → not_found"); + + section("Abrechnungsfreigabe blockiert bei offenen Zeiten"); + const review = await order(A, "in_review", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); + await prisma.report.create({ data: { tenantId: A.tenantId, workOrderId: review.id, type: "completion", reportDate: new Date(), lineageId: randomUUID(), status: "approved", content: {} } }); + const late = await addManualTimeEntry(A.ctx.tech, { ...base, workOrderId: review.id, startedAt: berlinAt(-4, 8), endedAt: berlinAt(-4, 9) }); + const blocked = await errorOf(releaseForBilling(A.ctx.backoffice, { workOrderId: review.id })); + ok(blocked?.code === "blocked" && JSON.stringify(blocked.details).includes("pending_time_entries"), "release-billing → blocked (pending_time_entries)"); + await approveTimeEntry(A.ctx.backoffice, late.id); + ok((await codeOf(releaseForBilling(A.ctx.backoffice, { workOrderId: review.id }))) === "ok", "nach Freigabe → Abrechnungsfreigabe möglich"); + + section("Für heute beenden"); + const multi = await order(A, "accepted", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); + await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: multi.id, mode: "work" })); + const stopped = await stopForToday(A.ctx.tech, sessionControlPayload.parse({ workOrderId: multi.id })); + const multiAfter = await prisma.workOrder.findUniqueOrThrow({ where: { id: multi.id } }); + ok(stopped.status === "ended" && multiAfter.status === "paused", `Session beendet, Auftrag bleibt offen (${multiAfter.status})`); + ok(!(await computeCompletionBlockers(A.ctx.backoffice, multi.id)).some((b) => b.kind === "running_session"), "keine laufende Session mehr (kein Abschluss-Blocker)"); + await expectCode(() => stopForToday(A.ctx.tech, sessionControlPayload.parse({ workOrderId: multi.id })), "invalid", "erneut beenden ohne Session → invalid"); + + section("Auto-Wechsel"); + const woA = await order(A, "accepted", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); + const woB2 = await order(A, "accepted", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); + await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: woA.id, mode: "work" })); + const conflict = await errorOf(startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: woB2.id, mode: "work" }))); + ok(conflict?.code === "conflict" && conflict.message === "other_session_running" && (conflict.details as { number?: string })?.number === woA.number, "ohne Flag → conflict other_session_running mit Auftragsnummer"); + ok((await prisma.workSession.count({ where: { workOrderId: woB2.id } })) === 0, "… auf dem zweiten Auftrag nichts angelegt"); + // failing start with flag (travel while the target already has an own session is impossible here) → use an invalid target status + const planned = await order(A, "planned", { assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] } }); + await expectCode(() => startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: planned.id, mode: "work", switchFromOther: true })), "invalid", "Wechsel auf nicht startbaren Auftrag → invalid"); + ok((await prisma.workSession.findFirstOrThrow({ where: { workOrderId: woA.id, userId: A.users.tech.id } })).status === "running", "… andere Session läuft unverändert weiter (atomar)"); + const switched = await startSession(A.ctx.tech, sessionStartPayload.parse({ workOrderId: woB2.id, mode: "work", switchFromOther: true })); + const sessA = await prisma.workSession.findFirstOrThrow({ where: { workOrderId: woA.id, userId: A.users.tech.id }, include: { entries: { orderBy: { startedAt: "asc" } } } }); + const woAAfter = await prisma.workOrder.findUniqueOrThrow({ where: { id: woA.id } }); + ok(switched.status === "running" && sessA.status === "paused" && sessA.entries.at(-1)?.type === "break" && !sessA.entries.at(-1)?.endedAt && woAAfter.status === "paused", "mit Flag: andere Session pausiert (Pause-Segment, Auftrag paused), neue läuft"); + ok((await prisma.workSession.count({ where: { userId: A.users.tech.id, status: { in: ["running", "en_route"] } } })) === 1, "genau eine laufende Uhr je Nutzer"); + const resumeConflict = await errorOf(resumeSession(A.ctx.tech, { workOrderId: woA.id })); + ok(resumeConflict?.message === "other_session_running", "Weiter auf A während B läuft → ebenfalls conflict"); + + section("Segmente + aktive Session"); + await switchSegment(A.ctx.tech, { workOrderId: woB2.id, type: "return_travel" }); + let active = await getMyActiveSession(A.ctx.tech); + ok(active?.workOrderId === woB2.id && active.segmentType === "return_travel" && active.number === woB2.number && active.title === "Wärmepumpe", "getMyActiveSession: Auftrag, Nummer, Titel, Segment Rückfahrt"); + await expectCode(() => switchSegment(A.ctx.tech, { workOrderId: woB2.id, type: "return_travel" }), "invalid", "gleiches Segment erneut → invalid"); + await switchSegment(A.ctx.tech, { workOrderId: woB2.id, type: "material_procurement" }); + await pauseSession(A.ctx.tech, { workOrderId: woB2.id }); + active = await getMyActiveSession(A.ctx.tech); + ok(active?.status === "paused" && active.segmentType === "break", "pausierte Session wird angezeigt (neueste)"); + const types = (await prisma.timeEntry.findMany({ where: { workSession: { workOrderId: woB2.id } }, orderBy: { startedAt: "asc" } })).map((e) => e.type).join(","); + ok(types === "work,return_travel,material_procurement,break", `Segmentfolge ${types}`); + + section("Mandantentrennung"); + await expectCode(() => approveTimeEntry(B.ctx.backoffice, again.id), "not_found", "Mandant B (Backoffice) kann A-Eintrag nicht freigeben"); + await expectCode(() => rejectTimeEntry(B.ctx.admin, again.id, "fremd"), "not_found", "Mandant B (Admin) kann A-Eintrag nicht ablehnen"); + ok(!(await listPendingTimeEntries(B.ctx.backoffice)).some((i) => i.id === again.id), "Mandant B sieht A-Einträge nicht in der Liste"); + await expectCode(() => addManualTimeEntry(B.ctx.tech, { ...base, startedAt: berlinAt(-5, 8), durationMinutes: 30 }), "not_found", "Mandant B kann auf A-Auftrag nicht nachtragen"); + await expectCode(() => proposeTimeCorrection(B.ctx.tech, { timeEntryId: te.id, startedAt: berlinAt(-3, 9), endedAt: berlinAt(-3, 12), reason: "fremd" }), "not_found", "Mandant B kann A-Eintrag keine Korrektur vorschlagen"); + ok((await getMyTimeOverview(B.ctx.tech, { date: dayKey(-1) })).groups.length === 0, "Meine Zeiten in B ohne A-Daten"); + ok((await countPendingTimeEntries(B.ctx.backoffice)) === 0 && !!woB, "Badge in B = 0"); + const aStill = await prisma.timeEntry.findUniqueOrThrow({ where: { id: again.id } }); + ok(aStill.approvalStatus === "pending", "A-Eintrag unverändert"); +}); diff --git a/scripts/test-zeiterfassung-sync.ts b/scripts/test-zeiterfassung-sync.ts new file mode 100644 index 0000000..4dd7d6b --- /dev/null +++ b/scripts/test-zeiterfassung-sync.ts @@ -0,0 +1,141 @@ +// Lane L12 „Zeiterfassung" — Sync/Offline: neue Ops (time.add_manual, time.propose_correction, +// session.stop_day, session.segment, session.start mit switchFromOther) über denselben Pfad wie +// die Outbox (applyOperations), Idempotenz (gleiche clientOpId → duplicate), Überlappung → rejected +// mit Klartext-Schlüssel, Auto-Wechsel-Konflikt mit Auftragsnummer, optimistische lokale Ansicht, +// Mandantentrennung und Scope. +// +// Lauf: npx tsx scripts/test-zeiterfassung-sync.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { applyOperations } from "../src/server/services/sync/apply"; +import type { SyncOperationInput, SyncOpType } from "../src/lib/sync/envelope"; +import { OP_PAYLOAD_SCHEMAS } from "../src/lib/sync/ops"; +import { VERSION_CHANGING_OPS, createEntry, problemKey } from "../src/lib/offline/outbox-core"; +import { buildOrderView } from "../src/lib/offline/bundle-core"; +import type { BundleOrderData } from "../src/lib/offline/types"; +import { otherSessionOf, timeErrorKey } from "../src/lib/field/time-rules"; +import { berlinAt, createTenant, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; + +const SLUG_A = "zz-l12-sync-a"; +const SLUG_B = "zz-l12-sync-b"; +const DEVICE = "device-l12"; + +const op = (opType: SyncOpType, payload: Record, clientOpId: string = randomUUID()): SyncOperationInput => ({ + clientOpId, + opType, + payload, + clientCreatedAt: new Date().toISOString(), +}); + +async function order(A: TenantFixture, status: string) { + return prisma.workOrder.create({ + data: { + tenantId: A.tenantId, + number: `S-${randomUUID().slice(0, 6)}`, + customerId: A.customerId, + siteId: A.siteId, + title: "Heizung", + status: status as never, + assignedTeamId: A.teamId, + assignees: { create: [{ tenantId: A.tenantId, userId: A.users.tech.id }] }, + }, + }); +} + +runSuite("L12 Zeiterfassung (Sync/Offline)", [SLUG_A, SLUG_B], async () => { + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const wo1 = await order(A, "accepted"); + const wo2 = await order(A, "accepted"); + + section("Payload-Schemas"); + ok(!OP_PAYLOAD_SCHEMAS["time.add_manual"].safeParse({ workOrderId: wo1.id, startedAt: new Date().toISOString(), durationMinutes: 30 }).success, "time.add_manual ohne Begründung → ungültig"); + ok(OP_PAYLOAD_SCHEMAS["session.start"].safeParse({ workOrderId: wo1.id, switchFromOther: true }).success, "session.start akzeptiert switchFromOther"); + ok(!OP_PAYLOAD_SCHEMAS["session.segment"].safeParse({ workOrderId: wo1.id, type: "break" }).success, "session.segment nur work/return_travel/material_procurement"); + + section("Ops angewendet + idempotent"); + const manualClient = randomUUID(); + const batch = [ + op("time.add_manual", { workOrderId: wo1.id, clientId: manualClient, type: "work", startedAt: berlinAt(-1, 8).toISOString(), endedAt: berlinAt(-1, 9).toISOString(), reason: "Kein Netz" }), + op("session.start", { workOrderId: wo1.id, mode: "work", clientId: randomUUID(), offline: true }), + op("session.segment", { workOrderId: wo1.id, type: "material_procurement" }), + op("session.stop_day", { workOrderId: wo1.id }), + ]; + const first = await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: batch }); + ok(first.results.every((r) => r.status === "applied"), `4 Ops applied (${first.results.map((r) => `${r.status}${r.message ? `:${r.message}` : ""}`).join(", ")})`); + const manualId = first.results[0].idMap?.[manualClient]; + const manual = manualId ? await prisma.timeEntry.findUnique({ where: { id: manualId } }) : null; + ok(manual?.approvalStatus === "pending" && manual.source === "manual", "idMap liefert den Nachtrag (pending, manual)"); + const wo1After = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo1.id } }); + ok(wo1After.status === "paused" && first.results[3].entityVersion === wo1After.version, `stop_day: Auftrag paused, entityVersion ${first.results[3].entityVersion}`); + const countBefore = await prisma.timeEntry.count({ where: { tenantId: A.tenantId } }); + const again = await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: batch }); + ok(again.results.every((r) => r.status === "duplicate"), "gleiche clientOpIds → duplicate"); + ok((await prisma.timeEntry.count({ where: { tenantId: A.tenantId } })) === countBefore, "keine Doppelanlage"); + ok(again.results[0].idMap?.[manualClient] === manualId, "duplicate liefert gespeicherte idMap"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, userId: A.users.lead.id, type: "time.approval_requested", entityId: manualId } })) === 1, "Event über Sync ausgelöst (Teamleiter)"); + + section("Überlappung → rejected mit Klartext"); + const overlapOp = op("time.add_manual", { workOrderId: wo1.id, clientId: randomUUID(), type: "work", startedAt: berlinAt(-1, 8, 30).toISOString(), endedAt: berlinAt(-1, 9, 30).toISOString(), reason: "Kein Netz" }); + const [overlap] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [overlapOp] })).results; + ok(overlap.status === "rejected" && overlap.errorCode === "invalid" && overlap.message === "overlap", "Überlappung → rejected invalid (overlap)"); + ok(timeErrorKey(overlap) === "overlap", "Client-Schlüssel field.myTime.errors.overlap"); + ok(problemKey({ status: "rejected", opType: "time.add_manual", lastError: { code: "invalid", message: overlap.message } }) === "timeOverlap", "/m/sync: Klartext offline.problem.timeOverlap"); + ok(problemKey({ status: "rejected", opType: "time.add_manual", lastError: { code: "invalid", message: "too_old" } }) === "timeWindow", "/m/sync: Zeitfenster → offline.problem.timeWindow"); + const stored = await prisma.syncOperation.findFirst({ where: { clientOpId: overlapOp.clientOpId } }); + ok(stored?.status === "rejected" && stored.errorCode === "invalid", "SyncOperation rejected gespeichert (deterministisch)"); + + section("Korrekturvorschlag über Sync"); + const workEntry = await prisma.timeEntry.findFirstOrThrow({ where: { userId: A.users.tech.id, source: "tracked", type: "work", workSession: { workOrderId: wo1.id } } }); + const [proposal] = ( + await applyOperations(A.ctx.tech, { + deviceId: DEVICE, + operations: [op("time.propose_correction", { workOrderId: wo1.id, timeEntryId: workEntry.id, startedAt: new Date(workEntry.startedAt.getTime() - 10 * 60_000).toISOString(), endedAt: workEntry.endedAt!.toISOString(), reason: "Start vergessen" })], + }) + ).results; + const proposed = await prisma.timeEntry.findUniqueOrThrow({ where: { id: workEntry.id } }); + ok(proposal.status === "applied" && !!proposed.pendingChange && proposed.startedAt.getTime() === workEntry.startedAt.getTime(), "time.propose_correction applied, alte Werte bleiben"); + + section("Auto-Wechsel über Sync"); + const [resume1] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [op("session.start", { workOrderId: wo1.id, mode: "work", clientId: randomUUID() })] })).results; + ok(resume1.status === "applied", "Arbeit auf Auftrag 1 gestartet"); + const conflictOp = op("session.start", { workOrderId: wo2.id, mode: "work", clientId: randomUUID() }); + const [conflict] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [conflictOp] })).results; + const other = otherSessionOf(conflict); + ok(conflict.status === "rejected" && conflict.errorCode === "conflict" && other?.number === wo1.number && other.workOrderId === wo1.id, `ohne Flag → rejected conflict mit Auftragsnummer ${other?.number}`); + ok(problemKey({ status: "rejected", opType: "session.start", lastError: { code: "conflict", message: conflict.message } }) === "otherSession", "/m/sync: Klartext offline.problem.otherSession"); + const [dupConflict] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [conflictOp] })).results; + ok(dupConflict.status === "duplicate" && dupConflict.errorCode === "conflict", "Wiederholung der abgelehnten Op → duplicate (Ergebnis gespeichert)"); + const [switched] = (await applyOperations(A.ctx.tech, { deviceId: DEVICE, operations: [op("session.start", { workOrderId: wo2.id, mode: "work", clientId: randomUUID(), switchFromOther: true })] })).results; + const s1 = await prisma.workSession.findFirstOrThrow({ where: { workOrderId: wo1.id, userId: A.users.tech.id, status: { not: "ended" } } }); + ok(switched.status === "applied" && s1.status === "paused", "mit switchFromOther → applied, Auftrag 1 pausiert"); + ok(VERSION_CHANGING_OPS.includes("session.stop_day") && VERSION_CHANGING_OPS.includes("session.segment"), "Outbox verkettet baseVersion auch nach stop_day/segment"); + + section("Optimistische lokale Ansicht"); + const ctx = { tenantId: A.tenantId, userId: A.users.tech.id }; + const now = new Date(); + const data = { + id: wo2.id, number: wo2.number, title: "Heizung", status: "in_progress", statusGroup: "in_progress", priority: "normal", isEmergency: false, + plannedStart: null, plannedEnd: null, version: 3, mySession: { id: "s", status: "running", startedAt: now.toISOString() }, + customer: { companyName: "K", firstName: null, lastName: null, street: null, houseNumber: null, postalCode: null, city: null }, + checklistItems: [], photoRequirements: [], materialPlans: [], materialUsages: [], documents: [], siteHistory: [], + } as BundleOrderData; + const stopEntry = createEntry(ctx, { opType: "session.stop_day", payload: { workOrderId: wo2.id, at: now.toISOString() } }, { clientOpId: randomUUID(), now, existing: [] }); + const manualEntry = createEntry(ctx, { opType: "time.add_manual", payload: { workOrderId: wo2.id, clientId: randomUUID(), type: "work", startedAt: berlinAt(-2, 8).toISOString(), durationMinutes: 30, reason: "Kein Netz" } }, { clientOpId: randomUUID(), now, existing: [stopEntry] }); + const view = buildOrderView({ ctxKey: `${ctx.tenantId}:${ctx.userId}`, workOrderId: wo2.id, data, syncedAt: now.toISOString() }, [stopEntry, manualEntry]); + ok(view.local.session === null && view.status === "paused", "stop_day offline: Uhr aus, Auftrag pausiert (offen)"); + ok(view.local.manualTimes.length === 1 && view.local.manualTimes[0].pending && view.local.manualTimes[0].durationMinutes === 30, "add_manual offline: Nachtrag mit pending-Badge sichtbar"); + + section("Mandantentrennung + Scope"); + const [foreign] = (await applyOperations(B.ctx.tech, { deviceId: DEVICE, operations: [op("time.add_manual", { workOrderId: wo1.id, type: "work", startedAt: berlinAt(-2, 8).toISOString(), durationMinutes: 30, reason: "fremd" })] })).results; + ok(foreign.status === "rejected" && foreign.errorCode === "not_found", "Mandant B: time.add_manual auf A-Auftrag → not_found"); + const [foreignStop] = (await applyOperations(B.ctx.tech, { deviceId: DEVICE, operations: [op("session.stop_day", { workOrderId: wo2.id })] })).results; + ok(foreignStop.status === "rejected" && foreignStop.errorCode === "not_found", "Mandant B: session.stop_day → not_found"); + const [foreignProposal] = (await applyOperations(B.ctx.tech, { deviceId: DEVICE, operations: [op("time.propose_correction", { workOrderId: wo1.id, timeEntryId: workEntry.id, startedAt: berlinAt(-1, 8).toISOString(), endedAt: berlinAt(-1, 9).toISOString(), reason: "fremd" })] })).results; + ok(foreignProposal.status === "rejected" && foreignProposal.errorCode === "not_found", "Mandant B: Korrekturvorschlag → not_found"); + const [outsider] = (await applyOperations(A.ctx.outsider, { deviceId: DEVICE, operations: [op("session.segment", { workOrderId: wo2.id, type: "return_travel" })] })).results; + ok(outsider.status === "rejected" && outsider.errorCode === "not_found", "Monteur ohne Zuweisung: session.segment → not_found"); + ok((await prisma.timeEntry.count({ where: { tenantId: B.tenantId } })) === 0, "in Mandant B nichts angelegt"); +}); diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 0261dee..d828066 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -12,6 +12,7 @@ import { Receipt, RefreshCw, Siren, + Timer, Wrench, type LucideIcon, } from "lucide-react"; @@ -27,7 +28,7 @@ import { listOrderTypes } from "@/server/services/work-orders/settings"; type SP = Record; -const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[] = [ +const TILES: { key: Preset | "sync_conflicts" | "time_approvals"; icon: LucideIcon; tone: string }[] = [ { key: "open", icon: ClipboardList, tone: "var(--ui-primary)" }, { key: "today", icon: CalendarDays, tone: "var(--info)" }, { key: "running", icon: Wrench, tone: "var(--ui-accent)" }, @@ -39,6 +40,7 @@ const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[ { key: "emergency_new", icon: Siren, tone: "var(--risk)" }, { key: "missing_signatures", icon: PenLine, tone: "var(--warn)" }, { key: "sync_conflicts", icon: RefreshCw, tone: "var(--risk)" }, + { key: "time_approvals", icon: Timer, tone: "var(--warn)" }, ]; /** Backoffice dashboard (spec §21). Field roles are sent to the mobile start page. */ @@ -142,9 +144,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
    - {TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => { - const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; - const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`; + {TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve"))).map(({ key, icon: Icon, tone }) => { + const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; + const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`; return (
  • new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { hour: "2-digit", minute: "2-digit", timeZone: tz }).format(d); + const span = (s: Date, e: Date | null) => `${time(s)}–${e ? time(e) : "…"}`; + + return ( +
    + + + {t("detail.back")} + + + + {items.length === 0 ? ( + {t("timeApprovals.empty")} + ) : ( +
    +
    +

    {t("timeApprovals.count", { count: items.length })}

    + +
    +
    + + + + + + + + + + + + + + + {items.map((i) => ( + + + + + + + + + + + + ))} + +
    + + {t("timeApprovals.employee")}{t("timeApprovals.order")}{t("timeApprovals.date")}{t("timeApprovals.range")}{t("timeApprovals.duration")}{t("timeApprovals.type")}{t("timeApprovals.reason")} +
    + + +

    {i.userName}

    +

    {i.kind === "correction" ? t("timeApprovals.kindCorrection") : t("timeApprovals.kindManual")}

    +
    + + {i.number} + +

    {i.title}

    +
    {formatDate(i.proposed?.startedAt ?? i.startedAt, locale, tz)} + {i.proposed ? ( + t("timeApprovals.oldNew", { old: span(i.startedAt, i.endedAt), new: span(i.proposed.startedAt, i.proposed.endedAt) }) + ) : ( + span(i.startedAt, i.endedAt) + )} + + {i.proposed ? t("timeApprovals.oldNew", { old: `${i.minutes} min`, new: `${i.proposed.minutes} min` }) : `${i.minutes} min`} + + {i.proposed && i.proposed.type !== i.type + ? t("timeApprovals.oldNew", { old: tf(`time.type.${i.type}`), new: tf(`time.type.${i.proposed.type}`) }) + : tf(`time.type.${i.proposed?.type ?? i.type}`)} + +

    {i.reason}

    + {i.note &&

    {i.note}

    } +

    {formatDateTime(i.requestedAt, locale, tz)}

    +
    + + + + + + + + + +
    +
    +
    + )} +
    + ); +} diff --git a/src/app/(field)/m/(core)/approvals/page.tsx b/src/app/(field)/m/(core)/approvals/page.tsx new file mode 100644 index 0000000..2be1ca6 --- /dev/null +++ b/src/app/(field)/m/(core)/approvals/page.tsx @@ -0,0 +1,75 @@ +import Link from "next/link"; +import { getLocale, getTranslations } from "next-intl/server"; +import { can } from "@/server/services/context"; +import { fieldPageContext } from "@/server/services/field/page-context"; +import { listPendingTimeEntries } from "@/server/services/field/time-entries"; +import { fmtDate, fmtDuration, fmtTime } from "@/lib/field/format"; +import { card } from "@/components/field/ui"; +import { ApprovalActions } from "@/components/field/approval-actions"; + +/** `/m/approvals` — team lead mobile (L12, `time:approve`): open manual entries and correction proposals of the team. */ +export default async function ApprovalsPage() { + const ctx = await fieldPageContext(); + const t = await getTranslations("field"); + const locale = await getLocale(); + if (!can(ctx, "time:approve")) return

    {t("noAccess")}

    ; + const items = await listPendingTimeEntries(ctx); + const range = (s: Date, e: Date | null) => `${fmtDate(s, locale)} · ${fmtTime(s, locale)}–${e ? fmtTime(e, locale) : t("myTime.running")}`; + + return ( +
    +
    +

    {t("approvals.title")}

    +

    {t("approvals.sub")}

    +

    {t("approvals.count", { count: items.length })}

    +
    + {items.length === 0 ? ( +

    {t("approvals.empty")}

    + ) : ( +
      + {items.map((i) => ( +
    • +

      {i.kind === "correction" ? t("approvals.correction") : t("approvals.manual")}

      +

      {i.userName}

      + + {i.number} · {i.title} + +
      +
      +
      {i.kind === "correction" ? t("approvals.old") : t("myTime.form.type")}
      +
      + {i.proposed && {t("approvals.old")}: } + {t(`time.type.${i.type}`)} · {range(i.startedAt, i.endedAt)} · {fmtDuration(i.minutes * 60)} +
      +
      + {i.proposed && ( +
      +
      {t("approvals.new")}
      +
      + {t("approvals.new")}: {t(`time.type.${i.proposed.type}`)} · {range(i.proposed.startedAt, i.proposed.endedAt)} · {fmtDuration(i.proposed.minutes * 60)} +
      +
      + )} + {i.reason && ( +
      +
      {t("approvals.reason")}:
      +
      {i.reason}
      +
      + )} + {i.note && ( +
      +
      {t("approvals.note")}:
      +
      {i.note}
      +
      + )} +
      +
      + +
      +
    • + ))} +
    + )} +
    + ); +} diff --git a/src/app/(field)/m/(core)/orders/[id]/page.tsx b/src/app/(field)/m/(core)/orders/[id]/page.tsx index 565a21f..988534f 100644 --- a/src/app/(field)/m/(core)/orders/[id]/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/page.tsx @@ -112,12 +112,15 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id const requirementsDone = order.photoRequirements.filter((r) => r._count.photos > 0).length; const checklistDone = order.checklistItems.filter((i) => i.checked).length; const plannedDone = order.materialPlans.filter((p) => order.materialUsages.some((u) => u.materialPlanId === p.id)).length; + // L12: only approved entries count const myWorkSeconds = order.workSessions .filter((s) => s.userId === ctx.userId) .flatMap((s) => s.entries) - .filter((e) => e.type === "work") + .filter((e) => e.type === "work" && e.approvalStatus === "approved") .reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); const editable = can(ctx, "field:execute"); + const ownActive = order.workSessions.find((s) => s.userId === ctx.userId && !s.manual && s.status !== "ended"); + const segmentType = ownActive?.entries.find((e) => !e.endedAt)?.type ?? null; const quick = [ { href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote }, @@ -146,7 +149,7 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id

    {editable && (
    - +
    )} diff --git a/src/app/(field)/m/(core)/orders/[id]/report/page.tsx b/src/app/(field)/m/(core)/orders/[id]/report/page.tsx index 9211595..2cd98a7 100644 --- a/src/app/(field)/m/(core)/orders/[id]/report/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/report/page.tsx @@ -1,7 +1,13 @@ import { ReportScreen } from "@/components/reports/mobile/report-screen"; +import { PendingTimeNotice } from "@/components/field/pending-time-notice"; -/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. */ +/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. L12: warning about unapproved time (not blocking). */ export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ type?: string }> }) { const [{ id }, sp] = await Promise.all([params, searchParams]); - return ; + return ( + <> + + + + ); } diff --git a/src/app/(field)/m/(core)/orders/[id]/time/page.tsx b/src/app/(field)/m/(core)/orders/[id]/time/page.tsx index e5191b1..2ab6a9b 100644 --- a/src/app/(field)/m/(core)/orders/[id]/time/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/time/page.tsx @@ -1,51 +1,75 @@ +import Link from "next/link"; import { getLocale, getTranslations } from "next-intl/server"; +import { Plus } from "lucide-react"; import { can } from "@/server/services/context"; +import { parsePendingChange } from "@/server/services/field/time-entries"; import { fmtDateTime, fmtDuration, fmtTime, secondsBetween } from "@/lib/field/format"; import { SubPageHeader } from "@/components/field/sub-page-header"; import { TimeCorrectionForm } from "@/components/field/time-correction-form"; -import { card } from "@/components/field/ui"; +import { CorrectionProposalForm } from "@/components/field/correction-proposal-form"; +import { TimeEntryBadges } from "@/components/field/time-entry-badges"; +import { btnSecondary, card } from "@/components/field/ui"; import { loadOrder } from "../load"; -/** `/m/orders/[id]/time` — sessions and time segments, corrections with reason (Spec §12.2). */ +/** + * `/m/orders/[id]/time` — sessions and time segments (Spec §12.2). + * L12: only approved entries count; own entries → „Korrektur vorschlagen" (approval), entries of + * team members → direct correction with `field:correct_time`; badges manual/pending/rejected. + */ export default async function TimePage({ params }: { params: Promise<{ id: string }> }) { const { ctx, order } = await loadOrder(params); const t = await getTranslations("field"); const locale = await getLocale(); const canCorrect = can(ctx, "field:correct_time"); - const totalWork = order.workSessions - .flatMap((s) => s.entries) - .filter((e) => e.type === "work") - .reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); + const canPropose = can(ctx, "field:record_own_time") || canCorrect; + const entries = order.workSessions.flatMap((s) => s.entries); + const totalWork = entries.filter((e) => e.type === "work" && e.approvalStatus === "approved").reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); + const pendingWork = entries.filter((e) => e.type !== "break" && e.approvalStatus === "pending").reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); return (

    {t("time.totalWork", { duration: fmtDuration(totalWork) })}

    + {pendingWork > 0 &&

    {t("time.totalPending", { duration: fmtDuration(pendingWork) })}

    } + {canPropose && ( + + + {t("myTime.add")} + + )} {order.workSessions.length === 0 &&

    {t("time.empty")}

    } {order.workSessions.map((s) => (

    {s.user.name}

    - {t(`time.session.${s.status}`)} + {s.manual ? t("time.session.manual") : t(`time.session.${s.status}`)} {s.startedOffline ? ` · ${t("time.offlineStarted")}` : ""}

    {fmtDateTime(s.startedAt, locale)}

      - {s.entries.map((e) => ( -
    • -
      - {t(`time.type.${e.type}`)} - - {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))} - -
      - {e.corrected &&

      {t("time.corrected", { reason: e.correctionReason ?? "" })}

      } - {canCorrect && } -
    • - ))} + {s.entries.map((e) => { + const own = e.userId === ctx.userId; + const pendingChange = parsePendingChange(e.pendingChange); + return ( +
    • +
      + {t(`time.type.${e.type}`)} + + {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))} + +
      + + {e.corrected &&

      {t("time.corrected", { reason: e.correctionReason ?? "" })}

      } + {own && canPropose && e.endedAt && e.approvalStatus !== "rejected" && ( + + )} + {!own && canCorrect && } +
    • + ); + })}
    ))} diff --git a/src/app/(field)/m/(core)/orders/page.tsx b/src/app/(field)/m/(core)/orders/page.tsx index d20248e..f8e6348 100644 --- a/src/app/(field)/m/(core)/orders/page.tsx +++ b/src/app/(field)/m/(core)/orders/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { cn } from "@/lib/utils"; +import { can } from "@/server/services/context"; import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; import { listFieldOrders, ORDER_TABS, type OrderTab } from "@/server/services/field/queries"; import { OrderCard } from "@/components/field/order-card"; @@ -15,6 +16,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: Promi const { tab: rawTab } = await searchParams; const tab: OrderTab = (ORDER_TABS as readonly string[]).includes(rawTab ?? "") ? (rawTab as OrderTab) : "running"; const orders = await listFieldOrders(ctx, tab); + const canExecute = can(ctx, "field:execute"); return (
    @@ -36,7 +38,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: Promi
      {orders.map((o) => (
    • - +
    • ))}
    diff --git a/src/app/(field)/m/(core)/page.tsx b/src/app/(field)/m/(core)/page.tsx index 0802068..698ca65 100644 --- a/src/app/(field)/m/(core)/page.tsx +++ b/src/app/(field)/m/(core)/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { ChevronRight } from "lucide-react"; +import { can } from "@/server/services/context"; import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; import { listTodayOrders } from "@/server/services/field/queries"; import { OrderCard } from "@/components/field/order-card"; @@ -12,6 +13,7 @@ export default async function TodayPage() { const t = await getTranslations("field"); if (!canUseFieldApp(ctx)) return

    {t("noAccess")}

    ; + const canExecute = can(ctx, "field:execute"); const [orders, me] = await Promise.all([listTodayOrders(ctx), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } })]); return ( @@ -27,7 +29,7 @@ export default async function TodayPage() {
      {orders.map((o) => (
    • - +
    • ))}
    diff --git a/src/app/(field)/m/(core)/profile/page.tsx b/src/app/(field)/m/(core)/profile/page.tsx index 90ad296..32ad5a1 100644 --- a/src/app/(field)/m/(core)/profile/page.tsx +++ b/src/app/(field)/m/(core)/profile/page.tsx @@ -4,6 +4,7 @@ import { auth, signOut } from "@/server/auth"; import { prisma } from "@/server/db"; import { can } from "@/server/services/context"; import { activeTeamIds } from "@/server/services/work-orders/visibility"; +import { countPendingTimeEntries } from "@/server/services/field/time-entries"; import { fieldPageContext } from "@/server/services/field/page-context"; import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { btnSecondary, card } from "@/components/field/ui"; @@ -15,7 +16,7 @@ export default async function ProfilePage() { const ctx = await fieldPageContext(); const t = await getTranslations("field.profile"); const session = await auth(); - const teamIds = await activeTeamIds(ctx); + const [teamIds, approvals] = await Promise.all([activeTeamIds(ctx), countPendingTimeEntries(ctx)]); const [me, teams, identity] = await Promise.all([ ctx.db.user.findFirst({ where: { id: ctx.userId }, @@ -58,6 +59,17 @@ export default async function ProfilePage() {
    + {can(ctx, "field:execute") && ( + + {t("myTime")} + + )} + {can(ctx, "time:approve") && ( + + {t("approvals")} + {approvals > 0 && {approvals}} + + )} {(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && ( {t("backoffice")} diff --git a/src/app/(field)/m/(core)/time/new/page.tsx b/src/app/(field)/m/(core)/time/new/page.tsx new file mode 100644 index 0000000..ac20763 --- /dev/null +++ b/src/app/(field)/m/(core)/time/new/page.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { ChevronLeft } from "lucide-react"; +import { can } from "@/server/services/context"; +import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; +import { getMyTimeOverview, listRecordableOrders, listRecordableUsers } from "@/server/services/field/time-entries"; +import { ManualTimeForm } from "@/components/field/manual-time-form"; + +/** `/m/time/new` — „Zeit nachtragen" (L12). */ +export default async function NewTimePage({ searchParams }: { searchParams: Promise<{ date?: string; order?: string }> }) { + const ctx = await fieldPageContext(); + const t = await getTranslations("field"); + const canRecord = can(ctx, "field:record_own_time") || can(ctx, "field:correct_time"); + if (!canUseFieldApp(ctx) || !canRecord) return

    {t("noAccess")}

    ; + + const sp = await searchParams; + const [overview, orders, users] = await Promise.all([getMyTimeOverview(ctx, { date: sp.date }), listRecordableOrders(ctx), listRecordableUsers(ctx)]); + + return ( +
    + + + {t("myTime.title")} + +

    {t("myTime.form.title")}

    + o.id === sp.order) ? sp.order! : null} + /> +
    + ); +} diff --git a/src/app/(field)/m/(core)/time/page.tsx b/src/app/(field)/m/(core)/time/page.tsx new file mode 100644 index 0000000..804c85d --- /dev/null +++ b/src/app/(field)/m/(core)/time/page.tsx @@ -0,0 +1,105 @@ +import Link from "next/link"; +import { getLocale, getTranslations } from "next-intl/server"; +import { ChevronRight, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { can } from "@/server/services/context"; +import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; +import { getMyTimeOverview } from "@/server/services/field/time-entries"; +import { fmtDate, fmtDuration, fmtTime } from "@/lib/field/format"; +import { btnPrimary, card, chip } from "@/components/field/ui"; +import { CorrectionProposalForm } from "@/components/field/correction-proposal-form"; +import { LocalPendingTimes } from "@/components/field/local-pending-times"; +import { TimeEntryBadges } from "@/components/field/time-entry-badges"; + +/** `/m/time` — „Meine Zeiten" (L12): own entries of a day across all orders, approved vs. pending totals. */ +export default async function MyTimePage({ searchParams }: { searchParams: Promise<{ date?: string }> }) { + const ctx = await fieldPageContext(); + const t = await getTranslations("field"); + const locale = await getLocale(); + if (!canUseFieldApp(ctx) || !can(ctx, "field:execute")) return

    {t("noAccess")}

    ; + + const { date } = await searchParams; + const overview = await getMyTimeOverview(ctx, { date }); + const canRecord = can(ctx, "field:record_own_time") || can(ctx, "field:correct_time"); + const dayLabel = (key: string, i: number) => (i === 0 ? t("myTime.today") : i === 1 ? t("myTime.yesterday") : fmtDate(`${key}T12:00:00Z`, locale)); + + return ( +
    +
    +

    {t("myTime.title")}

    +

    {t("myTime.sub")}

    +
    + + + +
    +
    +

    {t("myTime.approved")}

    +

    {fmtDuration(overview.approvedSeconds)}

    +
    +
    +

    {t("myTime.pending")}

    +

    0 && "text-[var(--warn)]")}>{fmtDuration(overview.pendingSeconds)}

    +
    +
    + + {canRecord && ( + + + {t("myTime.add")} + + )} + + + + {overview.groups.length === 0 ? ( +

    {t("myTime.empty")}

    + ) : ( + overview.groups.map((g) => ( +
    + + + {g.number} + {g.title} + + + +

    + {t("myTime.totalApproved", { duration: fmtDuration(g.approvedSeconds) })} + {g.pendingSeconds > 0 && · {t("myTime.totalPending", { duration: fmtDuration(g.pendingSeconds) })}} +

    +
      + {g.entries.map((e) => ( +
    • +
      + {t(`time.type.${e.type}`)} + + {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("myTime.running")} · {fmtDuration(e.seconds)} + +
      + + {canRecord && e.editable && ( + + )} +
    • + ))} +
    +
    + )) + )} +
    + ); +} diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx index 5c7619b..ca6f601 100644 --- a/src/app/(field)/m/layout.tsx +++ b/src/app/(field)/m/layout.tsx @@ -1,21 +1,45 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { requireAppAccess } from "@/server/app-access"; +import { can } from "@/server/services/context"; +import { fieldPageContext } from "@/server/services/field/page-context"; +import { getMyActiveSession } from "@/server/services/field/sessions"; +import { countPendingTimeEntries } from "@/server/services/field/time-entries"; +import { cn } from "@/lib/utils"; import { CraftviaLogo } from "@/components/brand/craftvia-logo"; import { AccountInactiveNotice } from "@/components/account-inactive-notice"; import { BottomNav } from "@/components/field/bottom-nav"; import { OnlineBadge } from "@/components/field/online-badge"; +import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar"; import { OfflineRuntime } from "@/components/offline/offline-runtime"; +/** L12: own running/paused session + open approvals for the shell (never blocks the page). */ +async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> { + try { + const ctx = await fieldPageContext(); + const [session, approvals] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx)]); + return { + clock: session + ? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds } + : null, + approvals, + }; + } catch { + // module "field" disabled or no field permissions: shell without clock/badge + return { clock: null, approvals: 0 }; + } +} + /** * Mobile shell `/m` (ARCHITEKTUR §5): same session/account/MFA checks as the backoffice * (src/server/app-access.ts), no sidebar, bottom navigation, online/offline badge. * Module gates live one level below: (core) → "field", emergency → "emergency". + * L12: running clock bar above the bottom navigation on every /m page. */ export default async function FieldShell({ children }: Readonly<{ children: React.ReactNode }>) { const access = await requireAppAccess(); if (access.kind === "inactive") return ; - const t = await getTranslations("field.nav"); + const [t, time] = await Promise.all([getTranslations("field.nav"), shellTimeState()]); return (
    @@ -29,8 +53,9 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
    -
    {children}
    - +
    {children}
    + + ); } diff --git a/src/components/field/approval-actions.tsx b/src/components/field/approval-actions.tsx new file mode 100644 index 0000000..63029bb --- /dev/null +++ b/src/components/field/approval-actions.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Check, TriangleAlert, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { approveTime, rejectTime } from "@/server/actions/field/time"; +import { timeErrorKey } from "@/lib/field/time-rules"; +import { btnPrimary, btnSecondary, inputClass, noticeError } from "./ui"; + +/** L12 team lead mobile: approve / reject (reason mandatory) with large buttons. */ +export function ApprovalActions({ timeEntryId }: { timeEntryId: string }) { + const t = useTranslations("field"); + const router = useRouter(); + const [rejecting, setRejecting] = useState(false); + const [reason, setReason] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function run(kind: "approve" | "reject") { + setError(null); + if (kind === "reject" && reason.trim().length < 3) { + setError(t("myTime.form.reasonHint")); + return; + } + setBusy(true); + const result = await (kind === "approve" ? approveTime({ timeEntryId }) : rejectTime({ timeEntryId, reason: reason.trim() })).catch(() => ({ ok: false as const, error: "failed" as const, message: undefined })); + setBusy(false); + if (!result.ok) { + const key = timeErrorKey({ message: result.message }); + setError(key ? t(`myTime.errors.${key}`) : t(`errors.${result.error === "failed" ? "internal" : result.error}`)); + return; + } + router.refresh(); + } + + return ( +
    + {!rejecting ? ( +
    + + +
    + ) : ( +
    +