Merge lane/zeiterfassung in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,7 @@
|
|||||||
| Berichtsvorlage | generische Vorlage L5 mit Mandantenlogo/-namen | Layout/Pflichtinhalte des Pilotkunden |
|
| Berichtsvorlage | generische Vorlage L5 mit Mandantenlogo/-namen | Layout/Pflichtinhalte des Pilotkunden |
|
||||||
| Pflichtfelder je Auftragsart | über Checklisten-Vorlagen je Auftragsart konfigurierbar | konkrete Pflichtfelder |
|
| Pflichtfelder je Auftragsart | über Checklisten-Vorlagen je Auftragsart konfigurierbar | konkrete Pflichtfelder |
|
||||||
| Fotoarten | Standard-Pflichtfotos §14.2 als Vorschläge | verbindliche Liste |
|
| 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 |
|
| 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 |
|
| 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 |
|
| OCR-/KI-Anbieter | Claude (Anthropic) für Extraktion + Lotse, Whisper-kompatibel für Transkription (Entscheidung 2026-09-14) | Vertrag/AVV, Region |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"billing": "Zur Abrechnung",
|
"billing": "Zur Abrechnung",
|
||||||
"emergency_new": "Neue Notdienste",
|
"emergency_new": "Neue Notdienste",
|
||||||
"missing_signatures": "Fehlende Unterschriften",
|
"missing_signatures": "Fehlende Unterschriften",
|
||||||
"sync_conflicts": "Sync-Konflikte"
|
"sync_conflicts": "Sync-Konflikte",
|
||||||
|
"time_approvals": "Zeiten zur Freigabe"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"open": "Noch nicht abgerechnet oder storniert",
|
"open": "Noch nicht abgerechnet oder storniert",
|
||||||
@@ -29,7 +30,8 @@
|
|||||||
"billing": "Bereit zur Abrechnung",
|
"billing": "Bereit zur Abrechnung",
|
||||||
"emergency_new": "Neu oder zur Prüfung",
|
"emergency_new": "Neu oder zur Prüfung",
|
||||||
"missing_signatures": "Unterschrift ausstehend",
|
"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"
|
"open": "Anzeigen"
|
||||||
}
|
}
|
||||||
|
|||||||
+148
-6
@@ -5,7 +5,8 @@
|
|||||||
"orders": "Aufträge",
|
"orders": "Aufträge",
|
||||||
"emergency": "Notdienst",
|
"emergency": "Notdienst",
|
||||||
"sync": "Sync",
|
"sync": "Sync",
|
||||||
"profile": "Profil"
|
"profile": "Profil",
|
||||||
|
"approvalsBadge": "{count} Zeiten zur Freigabe"
|
||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
@@ -35,7 +36,13 @@
|
|||||||
"noDate": "Ohne Termin",
|
"noDate": "Ohne Termin",
|
||||||
"emergency": "Notdienst",
|
"emergency": "Notdienst",
|
||||||
"urgent": "Dringend",
|
"urgent": "Dringend",
|
||||||
"high": "Hoch"
|
"high": "Hoch",
|
||||||
|
"running": "Zeit läuft",
|
||||||
|
"paused": "Pausiert",
|
||||||
|
"enRoute": "In Anfahrt",
|
||||||
|
"startWork": "Arbeit starten",
|
||||||
|
"pause": "Pause",
|
||||||
|
"resume": "Weiter"
|
||||||
},
|
},
|
||||||
"statusGroup": {
|
"statusGroup": {
|
||||||
"new": "Neu",
|
"new": "Neu",
|
||||||
@@ -78,7 +85,17 @@
|
|||||||
"saving": "Wird gespeichert …",
|
"saving": "Wird gespeichert …",
|
||||||
"blocked": "Vor dem Abschluss noch offen:",
|
"blocked": "Vor dem Abschluss noch offen:",
|
||||||
"reportNext": "Bericht und Unterschrift folgen im nächsten Schritt.",
|
"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": {
|
"blocker": {
|
||||||
"checklist_item": "Checkliste: {label}",
|
"checklist_item": "Checkliste: {label}",
|
||||||
@@ -264,7 +281,8 @@
|
|||||||
"en_route": "In Anfahrt",
|
"en_route": "In Anfahrt",
|
||||||
"running": "Läuft",
|
"running": "Läuft",
|
||||||
"paused": "Pausiert",
|
"paused": "Pausiert",
|
||||||
"ended": "Beendet"
|
"ended": "Beendet",
|
||||||
|
"manual": "Nachgetragen"
|
||||||
},
|
},
|
||||||
"running": "läuft",
|
"running": "läuft",
|
||||||
"offlineStarted": "offline gestartet",
|
"offlineStarted": "offline gestartet",
|
||||||
@@ -277,7 +295,10 @@
|
|||||||
"save": "Korrektur speichern",
|
"save": "Korrektur speichern",
|
||||||
"saved": "Korrektur gespeichert.",
|
"saved": "Korrektur gespeichert.",
|
||||||
"empty": "Noch keine Zeiten erfasst.",
|
"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": {
|
"profile": {
|
||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
@@ -287,12 +308,133 @@
|
|||||||
"roles": "Rolle",
|
"roles": "Rolle",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"logout": "Abmelden",
|
"logout": "Abmelden",
|
||||||
"backoffice": "Zum Büro-Bereich"
|
"backoffice": "Zum Büro-Bereich",
|
||||||
|
"myTime": "Meine Zeiten",
|
||||||
|
"approvals": "Zeiten freigeben"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"title": "Synchronisation",
|
"title": "Synchronisation",
|
||||||
"immediate": "Änderungen werden sofort an das Büro übertragen.",
|
"immediate": "Änderungen werden sofort an das Büro übertragen.",
|
||||||
"offlineHint": "Ohne Verbindung können Eingaben noch nicht zwischengespeichert werden – die Offline-Warteschlange folgt.",
|
"offlineHint": "Ohne Verbindung können Eingaben noch nicht zwischengespeichert werden – die Offline-Warteschlange folgt.",
|
||||||
"status": "Verbindung"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,6 @@
|
|||||||
"lotse": "Lotse (KI)",
|
"lotse": "Lotse (KI)",
|
||||||
"admin": "Admin-Konsole",
|
"admin": "Admin-Konsole",
|
||||||
"openMenu": "Menü öffnen",
|
"openMenu": "Menü öffnen",
|
||||||
"closeMenu": "Menü schließen"
|
"closeMenu": "Menü schließen",
|
||||||
|
"timeApprovals": "Zeiten zur Freigabe"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,26 +45,92 @@
|
|||||||
"emergency_completed": "Notdiensteinsatz abgeschlossen",
|
"emergency_completed": "Notdiensteinsatz abgeschlossen",
|
||||||
"import_ready_for_review": "Import zur Prüfung",
|
"import_ready_for_review": "Import zur Prüfung",
|
||||||
"import_failed": "Fehler bei Dokumentverarbeitung",
|
"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": {
|
"events": {
|
||||||
"work_order_assigned": { "title": "Neuer Auftrag {number}", "message": "{title} – {customer} wurde Ihrem Team zugewiesen." },
|
"work_order_assigned": {
|
||||||
"work_order_changed": { "title": "Auftrag {number} geändert", "message": "{title} – {customer}: Angaben wurden aktualisiert." },
|
"title": "Neuer Auftrag {number}",
|
||||||
"work_order_cancelled": { "title": "Auftrag {number} storniert", "message": "{title} – {customer} wurde storniert." },
|
"message": "{title} – {customer} wurde Ihrem Team zugewiesen."
|
||||||
"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_changed": {
|
||||||
"work_order_technically_completed": { "title": "Auftrag {number} technisch abgeschlossen", "message": "{title} – {customer}: Einsatz vor Ort beendet." },
|
"title": "Auftrag {number} geändert",
|
||||||
"work_order_signature_missing": { "title": "Unterschrift fehlt: {number}", "message": "{title} – {customer}: Kundenunterschrift liegt nicht vor." },
|
"message": "{title} – {customer}: Angaben wurden aktualisiert."
|
||||||
"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." },
|
"work_order_cancelled": {
|
||||||
"report_submitted": { "title": "Bericht zur Prüfung: {number}", "message": "{actor} hat einen Bericht zu {title} eingereicht." },
|
"title": "Auftrag {number} storniert",
|
||||||
"report_approved": { "title": "Bericht freigegeben: {number}", "message": "Der Bericht zu {title} wurde freigegeben." },
|
"message": "{title} – {customer} wurde storniert."
|
||||||
"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." },
|
"work_order_started": {
|
||||||
"emergency_completed": { "title": "Notdiensteinsatz {number} abgeschlossen", "message": "{customer}: zur Prüfung und Abrechnung." },
|
"title": "Auftrag {number} gestartet",
|
||||||
"import_ready_for_review": { "title": "Import zur Prüfung bereit", "message": "Die Daten aus {fileName} liegen zur Prüfung vor." },
|
"message": "{actor} hat den Einsatz bei {customer} begonnen."
|
||||||
"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_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": {
|
"fallback": {
|
||||||
"unknown": "unbekannt",
|
"unknown": "unbekannt",
|
||||||
|
|||||||
@@ -89,7 +89,10 @@
|
|||||||
"upload": "Die Datei wurde nicht angenommen (Format oder Größe).",
|
"upload": "Die Datei wurde nicht angenommen (Format oder Größe).",
|
||||||
"unauthorized": "Anmeldung abgelaufen – bitte neu anmelden.",
|
"unauthorized": "Anmeldung abgelaufen – bitte neu anmelden.",
|
||||||
"network": "Keine Verbindung – wird automatisch erneut versucht.",
|
"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": {
|
"view": {
|
||||||
"title": "Offline-Ansicht",
|
"title": "Offline-Ansicht",
|
||||||
|
|||||||
@@ -237,7 +237,21 @@
|
|||||||
"material_procurement": "Materialbeschaffung",
|
"material_procurement": "Materialbeschaffung",
|
||||||
"return_travel": "Rückfahrt",
|
"return_travel": "Rückfahrt",
|
||||||
"interruption": "Unterbrechung"
|
"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": {
|
"photos": {
|
||||||
"empty": "Noch keine Fotos.",
|
"empty": "Noch keine Fotos.",
|
||||||
@@ -460,8 +474,35 @@
|
|||||||
"running_session": "Ein Einsatz läuft noch",
|
"running_session": "Ein Einsatz läuft noch",
|
||||||
"field": {
|
"field": {
|
||||||
"signature": "Unterschrift fehlt",
|
"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}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"billing": "Ready for billing",
|
"billing": "Ready for billing",
|
||||||
"emergency_new": "New emergencies",
|
"emergency_new": "New emergencies",
|
||||||
"missing_signatures": "Missing signatures",
|
"missing_signatures": "Missing signatures",
|
||||||
"sync_conflicts": "Sync conflicts"
|
"sync_conflicts": "Sync conflicts",
|
||||||
|
"time_approvals": "Time approvals"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"open": "Not yet billed or cancelled",
|
"open": "Not yet billed or cancelled",
|
||||||
@@ -29,7 +30,8 @@
|
|||||||
"billing": "Ready to invoice",
|
"billing": "Ready to invoice",
|
||||||
"emergency_new": "New or in review",
|
"emergency_new": "New or in review",
|
||||||
"missing_signatures": "Signature pending",
|
"missing_signatures": "Signature pending",
|
||||||
"sync_conflicts": "Review offline changes"
|
"sync_conflicts": "Review offline changes",
|
||||||
|
"time_approvals": "Review added time and corrections"
|
||||||
},
|
},
|
||||||
"open": "Show"
|
"open": "Show"
|
||||||
}
|
}
|
||||||
|
|||||||
+148
-6
@@ -5,7 +5,8 @@
|
|||||||
"orders": "Orders",
|
"orders": "Orders",
|
||||||
"emergency": "Emergency",
|
"emergency": "Emergency",
|
||||||
"sync": "Sync",
|
"sync": "Sync",
|
||||||
"profile": "Profile"
|
"profile": "Profile",
|
||||||
|
"approvalsBadge": "{count} time entries to approve"
|
||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
@@ -35,7 +36,13 @@
|
|||||||
"noDate": "No date",
|
"noDate": "No date",
|
||||||
"emergency": "Emergency",
|
"emergency": "Emergency",
|
||||||
"urgent": "Urgent",
|
"urgent": "Urgent",
|
||||||
"high": "High"
|
"high": "High",
|
||||||
|
"running": "Time running",
|
||||||
|
"paused": "Paused",
|
||||||
|
"enRoute": "On the way",
|
||||||
|
"startWork": "Start work",
|
||||||
|
"pause": "Pause",
|
||||||
|
"resume": "Resume"
|
||||||
},
|
},
|
||||||
"statusGroup": {
|
"statusGroup": {
|
||||||
"new": "New",
|
"new": "New",
|
||||||
@@ -78,7 +85,17 @@
|
|||||||
"saving": "Saving …",
|
"saving": "Saving …",
|
||||||
"blocked": "Still missing before completion:",
|
"blocked": "Still missing before completion:",
|
||||||
"reportNext": "Report and signature follow in the next step.",
|
"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": {
|
"blocker": {
|
||||||
"checklist_item": "Checklist: {label}",
|
"checklist_item": "Checklist: {label}",
|
||||||
@@ -264,7 +281,8 @@
|
|||||||
"en_route": "On the way",
|
"en_route": "On the way",
|
||||||
"running": "Running",
|
"running": "Running",
|
||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
"ended": "Ended"
|
"ended": "Ended",
|
||||||
|
"manual": "Recorded later"
|
||||||
},
|
},
|
||||||
"running": "running",
|
"running": "running",
|
||||||
"offlineStarted": "started offline",
|
"offlineStarted": "started offline",
|
||||||
@@ -277,7 +295,10 @@
|
|||||||
"save": "Save correction",
|
"save": "Save correction",
|
||||||
"saved": "Correction saved.",
|
"saved": "Correction saved.",
|
||||||
"empty": "No time recorded yet.",
|
"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": {
|
"profile": {
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
@@ -287,12 +308,133 @@
|
|||||||
"roles": "Role",
|
"roles": "Role",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"logout": "Sign out",
|
"logout": "Sign out",
|
||||||
"backoffice": "Go to office area"
|
"backoffice": "Go to office area",
|
||||||
|
"myTime": "My time",
|
||||||
|
"approvals": "Approve time"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"title": "Synchronisation",
|
"title": "Synchronisation",
|
||||||
"immediate": "Changes are sent to the office immediately.",
|
"immediate": "Changes are sent to the office immediately.",
|
||||||
"offlineHint": "Without a connection, entries cannot be buffered yet – the offline queue will follow.",
|
"offlineHint": "Without a connection, entries cannot be buffered yet – the offline queue will follow.",
|
||||||
"status": "Connection"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,6 @@
|
|||||||
"lotse": "Lotse (AI)",
|
"lotse": "Lotse (AI)",
|
||||||
"admin": "Admin console",
|
"admin": "Admin console",
|
||||||
"openMenu": "Open menu",
|
"openMenu": "Open menu",
|
||||||
"closeMenu": "Close menu"
|
"closeMenu": "Close menu",
|
||||||
|
"timeApprovals": "Time approvals"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,26 +45,92 @@
|
|||||||
"emergency_completed": "Emergency call-out completed",
|
"emergency_completed": "Emergency call-out completed",
|
||||||
"import_ready_for_review": "Import ready for review",
|
"import_ready_for_review": "Import ready for review",
|
||||||
"import_failed": "Document processing failed",
|
"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": {
|
"events": {
|
||||||
"work_order_assigned": { "title": "New work order {number}", "message": "{title} – {customer} was assigned to your team." },
|
"work_order_assigned": {
|
||||||
"work_order_changed": { "title": "Work order {number} changed", "message": "{title} – {customer}: details were updated." },
|
"title": "New work order {number}",
|
||||||
"work_order_cancelled": { "title": "Work order {number} cancelled", "message": "{title} – {customer} was cancelled." },
|
"message": "{title} – {customer} was assigned to your team."
|
||||||
"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_changed": {
|
||||||
"work_order_technically_completed": { "title": "Work order {number} technically completed", "message": "{title} – {customer}: on-site work finished." },
|
"title": "Work order {number} changed",
|
||||||
"work_order_signature_missing": { "title": "Signature missing: {number}", "message": "{title} – {customer}: no customer signature." },
|
"message": "{title} – {customer}: details were updated."
|
||||||
"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." },
|
"work_order_cancelled": {
|
||||||
"report_submitted": { "title": "Report for review: {number}", "message": "{actor} submitted a report for {title}." },
|
"title": "Work order {number} cancelled",
|
||||||
"report_approved": { "title": "Report approved: {number}", "message": "The report for {title} was approved." },
|
"message": "{title} – {customer} was cancelled."
|
||||||
"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}." },
|
"work_order_started": {
|
||||||
"emergency_completed": { "title": "Emergency call-out {number} completed", "message": "{customer}: ready for review and billing." },
|
"title": "Work order {number} started",
|
||||||
"import_ready_for_review": { "title": "Import ready for review", "message": "The data from {fileName} is ready for review." },
|
"message": "{actor} started work at {customer}."
|
||||||
"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_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": {
|
"fallback": {
|
||||||
"unknown": "unknown",
|
"unknown": "unknown",
|
||||||
|
|||||||
@@ -89,7 +89,10 @@
|
|||||||
"upload": "The file was not accepted (format or size).",
|
"upload": "The file was not accepted (format or size).",
|
||||||
"unauthorized": "Your session expired – please sign in again.",
|
"unauthorized": "Your session expired – please sign in again.",
|
||||||
"network": "No connection – will retry automatically.",
|
"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": {
|
"view": {
|
||||||
"title": "Offline view",
|
"title": "Offline view",
|
||||||
|
|||||||
@@ -237,7 +237,21 @@
|
|||||||
"material_procurement": "Material procurement",
|
"material_procurement": "Material procurement",
|
||||||
"return_travel": "Return travel",
|
"return_travel": "Return travel",
|
||||||
"interruption": "Interruption"
|
"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": {
|
"photos": {
|
||||||
"empty": "No photos yet.",
|
"empty": "No photos yet.",
|
||||||
@@ -460,8 +474,35 @@
|
|||||||
"running_session": "A work session is still running",
|
"running_session": "A work session is still running",
|
||||||
"field": {
|
"field": {
|
||||||
"signature": "Signature missing",
|
"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}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
@@ -894,6 +894,8 @@ model WorkSession {
|
|||||||
startedOffline Boolean @default(false) @map("started_offline")
|
startedOffline Boolean @default(false) @map("started_offline")
|
||||||
deviceInfo String? @map("device_info")
|
deviceInfo String? @map("device_info")
|
||||||
clientId String? @map("client_id")
|
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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@ -917,6 +919,17 @@ enum TimeEntryType {
|
|||||||
interruption
|
interruption
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TimeEntrySource {
|
||||||
|
tracked
|
||||||
|
manual
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TimeApprovalStatus {
|
||||||
|
approved
|
||||||
|
pending
|
||||||
|
rejected
|
||||||
|
}
|
||||||
|
|
||||||
model TimeEntry {
|
model TimeEntry {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
@@ -930,6 +943,16 @@ model TimeEntry {
|
|||||||
correctionReason String? @map("correction_reason")
|
correctionReason String? @map("correction_reason")
|
||||||
correctedById String? @map("corrected_by_id")
|
correctedById String? @map("corrected_by_id")
|
||||||
clientId String? @map("client_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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@ -937,6 +960,7 @@ model TimeEntry {
|
|||||||
|
|
||||||
@@index([tenantId, workSessionId])
|
@@index([tenantId, workSessionId])
|
||||||
@@index([tenantId, userId, startedAt])
|
@@index([tenantId, userId, startedAt])
|
||||||
|
@@index([tenantId, approvalStatus])
|
||||||
@@unique([tenantId, clientId])
|
@@unique([tenantId, clientId])
|
||||||
@@map("time_entries")
|
@@map("time_entries")
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -81,6 +81,9 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
|||||||
...tabs.map((t) => ({ path: wo(x.d07, `?tab=${t}`), mustContain: x.d07.number })),
|
...tabs.map((t) => ({ path: wo(x.d07, `?tab=${t}`), mustContain: x.d07.number })),
|
||||||
{ path: wo(x.d18, "?tab=reports"), mustContain: x.d18.number },
|
{ path: wo(x.d18, "?tab=reports"), mustContain: x.d18.number },
|
||||||
{ path: "/work-orders/conflicts" },
|
{ 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 },
|
{ path: "/work-orders/emergency-review", mustContain: x.emergency?.number },
|
||||||
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, mustContain: x.emergency.number }] : []),
|
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, mustContain: x.emergency.number }] : []),
|
||||||
{ path: "/imports" },
|
{ path: "/imports" },
|
||||||
@@ -113,6 +116,10 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
|||||||
{ path: "/reports" },
|
{ path: "/reports" },
|
||||||
...(x.inReview ? [{ path: `/reports/${x.inReview.id}`, mustContain: x.d16?.number }] : []),
|
...(x.inReview ? [{ path: `/reports/${x.inReview.id}`, mustContain: x.d16?.number }] : []),
|
||||||
{ path: m(x.d08), expect: [404] },
|
{ 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<ReturnType<typeof ids>>): Plan[] {
|
|||||||
{ path: "/m/emergency" },
|
{ path: "/m/emergency" },
|
||||||
{ path: "/m/sync" },
|
{ path: "/m/sync" },
|
||||||
{ path: "/m/offline" },
|
{ 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] }] : []),
|
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [200] }] : []),
|
||||||
{ path: m(x.d08), expect: [404] },
|
{ path: m(x.d08), expect: [404] },
|
||||||
{ path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] },
|
{ path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] },
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { createOrderType } from "../src/server/services/work-orders/settings";
|
|||||||
import { listImports } from "../src/server/services/imports/queries";
|
import { listImports } from "../src/server/services/imports/queries";
|
||||||
import { createNote } from "../src/server/services/field/notes";
|
import { createNote } from "../src/server/services/field/notes";
|
||||||
import { correctTimeEntry } from "../src/server/services/field/time-correction";
|
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 { createDailyReport } from "../src/server/services/reports/create";
|
||||||
import { approveReport } from "../src/server/services/reports/approve";
|
import { approveReport } from "../src/server/services/reports/approve";
|
||||||
import { listReports } from "../src/server/services/reports/queries";
|
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: "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: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" }) },
|
{ 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: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: "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") },
|
{ permission: "emergency:create", label: "Notdienst-Kundensuche", run: (c) => searchCustomersForEmergency(c, "Hausverwaltung") },
|
||||||
|
|||||||
@@ -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<string, unknown> = {}) {
|
||||||
|
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<unknown>): Promise<ServiceError | null> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
@@ -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<string, unknown>, 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");
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Receipt,
|
Receipt,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Siren,
|
Siren,
|
||||||
|
Timer,
|
||||||
Wrench,
|
Wrench,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -27,7 +28,7 @@ import { listOrderTypes } from "@/server/services/work-orders/settings";
|
|||||||
|
|
||||||
type SP = Record<string, string | string[] | undefined>;
|
type SP = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
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: "open", icon: ClipboardList, tone: "var(--ui-primary)" },
|
||||||
{ key: "today", icon: CalendarDays, tone: "var(--info)" },
|
{ key: "today", icon: CalendarDays, tone: "var(--info)" },
|
||||||
{ key: "running", icon: Wrench, tone: "var(--ui-accent)" },
|
{ 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: "emergency_new", icon: Siren, tone: "var(--risk)" },
|
||||||
{ key: "missing_signatures", icon: PenLine, tone: "var(--warn)" },
|
{ key: "missing_signatures", icon: PenLine, tone: "var(--warn)" },
|
||||||
{ key: "sync_conflicts", icon: RefreshCw, tone: "var(--risk)" },
|
{ 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. */
|
/** 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
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => {
|
{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 === "reports_in_review" ? tiles.reportsToReview : tiles[key];
|
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 === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: 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 (
|
return (
|
||||||
<li key={key}>
|
<li key={key}>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
|
import { ActionForm } from "@/components/work-orders/action-form";
|
||||||
|
import { pageContext } from "@/components/work-orders/page-context";
|
||||||
|
import { BulkApproveForm, SelectAllBox } from "@/components/work-orders/time-approval-forms";
|
||||||
|
import { Empty, inputCls } from "@/components/work-orders/ui";
|
||||||
|
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
|
||||||
|
import { approveTimeEntriesAction, approveTimeEntryAction, rejectTimeEntryAction } from "@/server/actions/work_orders/time-approvals";
|
||||||
|
import { listPendingTimeEntries } from "@/server/services/field/time-entries";
|
||||||
|
|
||||||
|
const BULK_FORM = "time-approvals-bulk";
|
||||||
|
|
||||||
|
/** L12 `/work-orders/time-approvals` — open manual entries and correction proposals (`time:approve`), single and bulk approval. */
|
||||||
|
export default async function TimeApprovalsPage() {
|
||||||
|
const { ctx, locale, tz, can } = await pageContext();
|
||||||
|
if (!can("time:approve")) redirect("/work-orders");
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const tf = await getTranslations("field");
|
||||||
|
const items = await listPendingTimeEntries(ctx);
|
||||||
|
const time = (d: Date) => 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 (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<Link href="/work-orders" className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="size-4" aria-hidden />
|
||||||
|
{t("detail.back")}
|
||||||
|
</Link>
|
||||||
|
<PageHead crumb={t("timeApprovals.crumb")} title={t("timeApprovals.title")} sub={t("timeApprovals.sub")} />
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Empty>{t("timeApprovals.empty")}</Empty>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-semibold">{t("timeApprovals.count", { count: items.length })}</p>
|
||||||
|
<BulkApproveForm id={BULK_FORM} action={approveTimeEntriesAction} label={t("timeApprovals.approveSelected")} successText={t("timeApprovals.approved")} />
|
||||||
|
</div>
|
||||||
|
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[980px] text-sm">
|
||||||
|
<thead className="border-b text-left text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="p-3">
|
||||||
|
<SelectAllBox formId={BULK_FORM} label={t("timeApprovals.selectAll")} />
|
||||||
|
</th>
|
||||||
|
<th className="p-3">{t("timeApprovals.employee")}</th>
|
||||||
|
<th className="p-3">{t("timeApprovals.order")}</th>
|
||||||
|
<th className="p-3">{t("timeApprovals.date")}</th>
|
||||||
|
<th className="p-3">{t("timeApprovals.range")}</th>
|
||||||
|
<th className="p-3">{t("timeApprovals.duration")}</th>
|
||||||
|
<th className="p-3">{t("timeApprovals.type")}</th>
|
||||||
|
<th className="p-3">{t("timeApprovals.reason")}</th>
|
||||||
|
<th className="p-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((i) => (
|
||||||
|
<tr key={i.id} className="border-b align-top last:border-0">
|
||||||
|
<td className="p-3">
|
||||||
|
<input type="checkbox" name="ids" value={i.id} form={BULK_FORM} aria-label={`${t("timeApprovals.select")}: ${i.userName} ${i.number}`} className="size-5" />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<p className="font-semibold">{i.userName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{i.kind === "correction" ? t("timeApprovals.kindCorrection") : t("timeApprovals.kindManual")}</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Link href={`/work-orders/${i.workOrderId}?tab=times`} className="font-semibold text-[var(--primary)] hover:underline">
|
||||||
|
{i.number}
|
||||||
|
</Link>
|
||||||
|
<p className="text-xs text-muted-foreground">{i.title}</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 whitespace-nowrap">{formatDate(i.proposed?.startedAt ?? i.startedAt, locale, tz)}</td>
|
||||||
|
<td className="p-3 whitespace-nowrap">
|
||||||
|
{i.proposed ? (
|
||||||
|
t("timeApprovals.oldNew", { old: span(i.startedAt, i.endedAt), new: span(i.proposed.startedAt, i.proposed.endedAt) })
|
||||||
|
) : (
|
||||||
|
span(i.startedAt, i.endedAt)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 whitespace-nowrap">
|
||||||
|
{i.proposed ? t("timeApprovals.oldNew", { old: `${i.minutes} min`, new: `${i.proposed.minutes} min` }) : `${i.minutes} min`}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{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}`)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<p>{i.reason}</p>
|
||||||
|
{i.note && <p className="text-xs text-muted-foreground">{i.note}</p>}
|
||||||
|
<p className="text-xs text-muted-foreground">{formatDateTime(i.requestedAt, locale, tz)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="space-y-2 p-3">
|
||||||
|
<ActionForm action={approveTimeEntryAction} submitLabel={t("timeApprovals.approve")} variant="primary" successText={t("timeApprovals.approved")} footerClassName="mt-0">
|
||||||
|
<input type="hidden" name="timeEntryId" value={i.id} />
|
||||||
|
<input type="hidden" name="workOrderId" value={i.workOrderId} />
|
||||||
|
</ActionForm>
|
||||||
|
<ActionForm action={rejectTimeEntryAction} submitLabel={t("timeApprovals.reject")} variant="outline" successText={t("timeApprovals.rejected")} footerClassName="mt-2">
|
||||||
|
<input type="hidden" name="timeEntryId" value={i.id} />
|
||||||
|
<input type="hidden" name="workOrderId" value={i.workOrderId} />
|
||||||
|
<input name="reason" required minLength={3} maxLength={500} placeholder={t("timeApprovals.rejectReason")} aria-label={t("timeApprovals.rejectReason")} className={inputCls} />
|
||||||
|
</ActionForm>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <p className="p-4 text-[15px]">{t("noAccess")}</p>;
|
||||||
|
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 (
|
||||||
|
<main className="space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[26px]">{t("approvals.title")}</h1>
|
||||||
|
<p className="text-[14px] text-muted-foreground">{t("approvals.sub")}</p>
|
||||||
|
<p className="text-[15px] font-semibold">{t("approvals.count", { count: items.length })}</p>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("approvals.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{items.map((i) => (
|
||||||
|
<li key={i.id} className={`${card} border-l-4 border-l-[var(--warn)]`}>
|
||||||
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">{i.kind === "correction" ? t("approvals.correction") : t("approvals.manual")}</p>
|
||||||
|
<p className="text-[18px] font-semibold">{i.userName}</p>
|
||||||
|
<Link href={`/m/orders/${i.workOrderId}`} className="block text-[15px] text-primary underline-offset-4 hover:underline">
|
||||||
|
{i.number} · {i.title}
|
||||||
|
</Link>
|
||||||
|
<dl className="mt-2 space-y-1 text-[15px]">
|
||||||
|
<div>
|
||||||
|
<dt className="sr-only">{i.kind === "correction" ? t("approvals.old") : t("myTime.form.type")}</dt>
|
||||||
|
<dd className={i.proposed ? "text-muted-foreground line-through" : "font-semibold"}>
|
||||||
|
{i.proposed && <span className="no-underline">{t("approvals.old")}: </span>}
|
||||||
|
{t(`time.type.${i.type}`)} · {range(i.startedAt, i.endedAt)} · {fmtDuration(i.minutes * 60)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{i.proposed && (
|
||||||
|
<div>
|
||||||
|
<dt className="sr-only">{t("approvals.new")}</dt>
|
||||||
|
<dd className="font-semibold">
|
||||||
|
{t("approvals.new")}: {t(`time.type.${i.proposed.type}`)} · {range(i.proposed.startedAt, i.proposed.endedAt)} · {fmtDuration(i.proposed.minutes * 60)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{i.reason && (
|
||||||
|
<div>
|
||||||
|
<dt className="inline text-muted-foreground">{t("approvals.reason")}: </dt>
|
||||||
|
<dd className="inline">{i.reason}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{i.note && (
|
||||||
|
<div>
|
||||||
|
<dt className="inline text-muted-foreground">{t("approvals.note")}: </dt>
|
||||||
|
<dd className="inline">{i.note}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
<div className="mt-3">
|
||||||
|
<ApprovalActions timeEntryId={i.id} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 requirementsDone = order.photoRequirements.filter((r) => r._count.photos > 0).length;
|
||||||
const checklistDone = order.checklistItems.filter((i) => i.checked).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;
|
const plannedDone = order.materialPlans.filter((p) => order.materialUsages.some((u) => u.materialPlanId === p.id)).length;
|
||||||
|
// L12: only approved entries count
|
||||||
const myWorkSeconds = order.workSessions
|
const myWorkSeconds = order.workSessions
|
||||||
.filter((s) => s.userId === ctx.userId)
|
.filter((s) => s.userId === ctx.userId)
|
||||||
.flatMap((s) => s.entries)
|
.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);
|
.reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
|
||||||
const editable = can(ctx, "field:execute");
|
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 = [
|
const quick = [
|
||||||
{ href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote },
|
{ href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote },
|
||||||
@@ -146,7 +149,7 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
|
|||||||
</p>
|
</p>
|
||||||
{editable && (
|
{editable && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<PrimaryAction workOrderId={order.id} status={order.status} version={order.version} mySession={order.mySession && order.mySession.status !== "ended" ? order.mySession.status : null} blockers={blockers} />
|
<PrimaryAction workOrderId={order.id} number={order.number} segmentType={segmentType} status={order.status} version={order.version} mySession={order.mySession && order.mySession.status !== "ended" ? order.mySession.status : null} blockers={blockers} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { ReportScreen } from "@/components/reports/mobile/report-screen";
|
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 }> }) {
|
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ type?: string }> }) {
|
||||||
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||||
return <ReportScreen workOrderId={id} type={sp.type === "daily" ? "daily" : "completion"} />;
|
return (
|
||||||
|
<>
|
||||||
|
<PendingTimeNotice workOrderId={id} />
|
||||||
|
<ReportScreen workOrderId={id} type={sp.type === "daily" ? "daily" : "completion"} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,75 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
import { can } from "@/server/services/context";
|
import { can } from "@/server/services/context";
|
||||||
|
import { parsePendingChange } from "@/server/services/field/time-entries";
|
||||||
import { fmtDateTime, fmtDuration, fmtTime, secondsBetween } from "@/lib/field/format";
|
import { fmtDateTime, fmtDuration, fmtTime, secondsBetween } from "@/lib/field/format";
|
||||||
import { SubPageHeader } from "@/components/field/sub-page-header";
|
import { SubPageHeader } from "@/components/field/sub-page-header";
|
||||||
import { TimeCorrectionForm } from "@/components/field/time-correction-form";
|
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";
|
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 }> }) {
|
export default async function TimePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { ctx, order } = await loadOrder(params);
|
const { ctx, order } = await loadOrder(params);
|
||||||
const t = await getTranslations("field");
|
const t = await getTranslations("field");
|
||||||
const locale = await getLocale();
|
const locale = await getLocale();
|
||||||
const canCorrect = can(ctx, "field:correct_time");
|
const canCorrect = can(ctx, "field:correct_time");
|
||||||
const totalWork = order.workSessions
|
const canPropose = can(ctx, "field:record_own_time") || canCorrect;
|
||||||
.flatMap((s) => s.entries)
|
const entries = order.workSessions.flatMap((s) => s.entries);
|
||||||
.filter((e) => e.type === "work")
|
const totalWork = entries.filter((e) => e.type === "work" && e.approvalStatus === "approved").reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
|
||||||
.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 (
|
return (
|
||||||
<main className="space-y-4 pb-4">
|
<main className="space-y-4 pb-4">
|
||||||
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("time.title")} />
|
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("time.title")} />
|
||||||
<div className="space-y-3 px-4">
|
<div className="space-y-3 px-4">
|
||||||
<p className="text-[15px] font-semibold">{t("time.totalWork", { duration: fmtDuration(totalWork) })}</p>
|
<p className="text-[15px] font-semibold">{t("time.totalWork", { duration: fmtDuration(totalWork) })}</p>
|
||||||
|
{pendingWork > 0 && <p className="text-[15px] font-semibold text-[var(--warn)]">{t("time.totalPending", { duration: fmtDuration(pendingWork) })}</p>}
|
||||||
|
{canPropose && (
|
||||||
|
<Link href={`/m/time/new?order=${order.id}`} className={btnSecondary}>
|
||||||
|
<Plus className="size-5" aria-hidden />
|
||||||
|
{t("myTime.add")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{order.workSessions.length === 0 && <p className="text-[15px] text-muted-foreground">{t("time.empty")}</p>}
|
{order.workSessions.length === 0 && <p className="text-[15px] text-muted-foreground">{t("time.empty")}</p>}
|
||||||
{order.workSessions.map((s) => (
|
{order.workSessions.map((s) => (
|
||||||
<section key={s.id} className={card}>
|
<section key={s.id} className={card}>
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
<h2 className="text-[17px]">{s.user.name}</h2>
|
<h2 className="text-[17px]">{s.user.name}</h2>
|
||||||
<span className="text-[13px] font-semibold">
|
<span className="text-[13px] font-semibold">
|
||||||
{t(`time.session.${s.status}`)}
|
{s.manual ? t("time.session.manual") : t(`time.session.${s.status}`)}
|
||||||
{s.startedOffline ? ` · ${t("time.offlineStarted")}` : ""}
|
{s.startedOffline ? ` · ${t("time.offlineStarted")}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] text-muted-foreground">{fmtDateTime(s.startedAt, locale)}</p>
|
<p className="text-[13px] text-muted-foreground">{fmtDateTime(s.startedAt, locale)}</p>
|
||||||
<ul className="mt-2 divide-y">
|
<ul className="mt-2 divide-y">
|
||||||
{s.entries.map((e) => (
|
{s.entries.map((e) => {
|
||||||
<li key={e.id} className="space-y-2 py-2.5">
|
const own = e.userId === ctx.userId;
|
||||||
<div className="flex items-baseline justify-between gap-2 text-[15px]">
|
const pendingChange = parsePendingChange(e.pendingChange);
|
||||||
<span className="font-semibold">{t(`time.type.${e.type}`)}</span>
|
return (
|
||||||
<span>
|
<li key={e.id} className="space-y-2 py-2.5">
|
||||||
{fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))}
|
<div className="flex items-baseline justify-between gap-2 text-[15px]">
|
||||||
</span>
|
<span className="font-semibold">{t(`time.type.${e.type}`)}</span>
|
||||||
</div>
|
<span>
|
||||||
{e.corrected && <p className="text-[13px] text-[var(--warn)]">{t("time.corrected", { reason: e.correctionReason ?? "" })}</p>}
|
{fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))}
|
||||||
{canCorrect && <TimeCorrectionForm workOrderId={order.id} entry={{ id: e.id, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt?.toISOString() ?? null }} />}
|
</span>
|
||||||
</li>
|
</div>
|
||||||
))}
|
<TimeEntryBadges entry={{ source: e.source, approvalStatus: e.approvalStatus, rejectionReason: e.rejectionReason, corrected: e.corrected, pendingChange }} />
|
||||||
|
{e.corrected && <p className="text-[13px] text-[var(--warn)]">{t("time.corrected", { reason: e.correctionReason ?? "" })}</p>}
|
||||||
|
{own && canPropose && e.endedAt && e.approvalStatus !== "rejected" && (
|
||||||
|
<CorrectionProposalForm workOrderId={order.id} entry={{ id: e.id, type: e.type, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt.toISOString() }} />
|
||||||
|
)}
|
||||||
|
{!own && canCorrect && <TimeCorrectionForm workOrderId={order.id} entry={{ id: e.id, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt?.toISOString() ?? null }} />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { can } from "@/server/services/context";
|
||||||
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
|
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
|
||||||
import { listFieldOrders, ORDER_TABS, type OrderTab } from "@/server/services/field/queries";
|
import { listFieldOrders, ORDER_TABS, type OrderTab } from "@/server/services/field/queries";
|
||||||
import { OrderCard } from "@/components/field/order-card";
|
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: rawTab } = await searchParams;
|
||||||
const tab: OrderTab = (ORDER_TABS as readonly string[]).includes(rawTab ?? "") ? (rawTab as OrderTab) : "running";
|
const tab: OrderTab = (ORDER_TABS as readonly string[]).includes(rawTab ?? "") ? (rawTab as OrderTab) : "running";
|
||||||
const orders = await listFieldOrders(ctx, tab);
|
const orders = await listFieldOrders(ctx, tab);
|
||||||
|
const canExecute = can(ctx, "field:execute");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="space-y-4 p-4">
|
<main className="space-y-4 p-4">
|
||||||
@@ -36,7 +38,7 @@ export default async function OrdersPage({ searchParams }: { searchParams: Promi
|
|||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{orders.map((o) => (
|
{orders.map((o) => (
|
||||||
<li key={o.id}>
|
<li key={o.id}>
|
||||||
<OrderCard order={o} />
|
<OrderCard order={o} canExecute={canExecute} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { can } from "@/server/services/context";
|
||||||
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
|
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
|
||||||
import { listTodayOrders } from "@/server/services/field/queries";
|
import { listTodayOrders } from "@/server/services/field/queries";
|
||||||
import { OrderCard } from "@/components/field/order-card";
|
import { OrderCard } from "@/components/field/order-card";
|
||||||
@@ -12,6 +13,7 @@ export default async function TodayPage() {
|
|||||||
const t = await getTranslations("field");
|
const t = await getTranslations("field");
|
||||||
if (!canUseFieldApp(ctx)) return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
|
if (!canUseFieldApp(ctx)) return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
|
||||||
|
|
||||||
|
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 } })]);
|
const [orders, me] = await Promise.all([listTodayOrders(ctx), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } })]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +29,7 @@ export default async function TodayPage() {
|
|||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{orders.map((o) => (
|
{orders.map((o) => (
|
||||||
<li key={o.id}>
|
<li key={o.id}>
|
||||||
<OrderCard order={o} />
|
<OrderCard order={o} canExecute={canExecute} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { auth, signOut } from "@/server/auth";
|
|||||||
import { prisma } from "@/server/db";
|
import { prisma } from "@/server/db";
|
||||||
import { can } from "@/server/services/context";
|
import { can } from "@/server/services/context";
|
||||||
import { activeTeamIds } from "@/server/services/work-orders/visibility";
|
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 { fieldPageContext } from "@/server/services/field/page-context";
|
||||||
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
|
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
|
||||||
import { btnSecondary, card } from "@/components/field/ui";
|
import { btnSecondary, card } from "@/components/field/ui";
|
||||||
@@ -15,7 +16,7 @@ export default async function ProfilePage() {
|
|||||||
const ctx = await fieldPageContext();
|
const ctx = await fieldPageContext();
|
||||||
const t = await getTranslations("field.profile");
|
const t = await getTranslations("field.profile");
|
||||||
const session = await auth();
|
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([
|
const [me, teams, identity] = await Promise.all([
|
||||||
ctx.db.user.findFirst({
|
ctx.db.user.findFirst({
|
||||||
where: { id: ctx.userId },
|
where: { id: ctx.userId },
|
||||||
@@ -58,6 +59,17 @@ export default async function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
{can(ctx, "field:execute") && (
|
||||||
|
<Link href="/m/time" className={btnSecondary}>
|
||||||
|
{t("myTime")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{can(ctx, "time:approve") && (
|
||||||
|
<Link href="/m/approvals" className={btnSecondary}>
|
||||||
|
{t("approvals")}
|
||||||
|
{approvals > 0 && <span className="rounded-full bg-cta px-2 text-[13px] font-bold text-cta-foreground">{approvals}</span>}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && (
|
{(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && (
|
||||||
<Link href="/dashboard" className={btnSecondary}>
|
<Link href="/dashboard" className={btnSecondary}>
|
||||||
{t("backoffice")}
|
{t("backoffice")}
|
||||||
|
|||||||
@@ -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 <p className="p-4 text-[15px]">{t("noAccess")}</p>;
|
||||||
|
|
||||||
|
const sp = await searchParams;
|
||||||
|
const [overview, orders, users] = await Promise.all([getMyTimeOverview(ctx, { date: sp.date }), listRecordableOrders(ctx), listRecordableUsers(ctx)]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="space-y-4 p-4">
|
||||||
|
<Link href={`/m/time?date=${overview.date}`} className="-ml-2 inline-flex min-h-12 items-center gap-1 rounded-xl px-2 text-[15px] font-semibold text-primary">
|
||||||
|
<ChevronLeft className="size-5" aria-hidden />
|
||||||
|
{t("myTime.title")}
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-[26px]">{t("myTime.form.title")}</h1>
|
||||||
|
<ManualTimeForm
|
||||||
|
orders={orders}
|
||||||
|
users={users}
|
||||||
|
days={overview.days}
|
||||||
|
defaultDate={overview.date}
|
||||||
|
defaultWorkOrderId={orders.some((o) => o.id === sp.order) ? sp.order! : null}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <p className="p-4 text-[15px]">{t("noAccess")}</p>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<main className="space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[26px]">{t("myTime.title")}</h1>
|
||||||
|
<p className="text-[14px] text-muted-foreground">{t("myTime.sub")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav aria-label={t("myTime.days")} className="-mx-4 overflow-x-auto px-4">
|
||||||
|
<ul className="flex gap-2">
|
||||||
|
{overview.days.map((key, i) => (
|
||||||
|
<li key={key} className="shrink-0">
|
||||||
|
<Link href={`/m/time?date=${key}`} aria-current={overview.date === key ? "page" : undefined} className={cn(chip(overview.date === key), "px-4")}>
|
||||||
|
{dayLabel(key, i)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section className={cn(card, "grid grid-cols-2 gap-3")} aria-label={t("myTime.dayTotal")}>
|
||||||
|
<div>
|
||||||
|
<p className="text-[13px] font-semibold text-muted-foreground">{t("myTime.approved")}</p>
|
||||||
|
<p className="font-heading text-[22px] font-bold tabular-nums">{fmtDuration(overview.approvedSeconds)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[13px] font-semibold text-muted-foreground">{t("myTime.pending")}</p>
|
||||||
|
<p className={cn("font-heading text-[22px] font-bold tabular-nums", overview.pendingSeconds > 0 && "text-[var(--warn)]")}>{fmtDuration(overview.pendingSeconds)}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{canRecord && (
|
||||||
|
<Link href={`/m/time/new?date=${overview.date}`} className={btnPrimary}>
|
||||||
|
<Plus className="size-5" aria-hidden />
|
||||||
|
{t("myTime.add")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LocalPendingTimes />
|
||||||
|
|
||||||
|
{overview.groups.length === 0 ? (
|
||||||
|
<p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("myTime.empty")}</p>
|
||||||
|
) : (
|
||||||
|
overview.groups.map((g) => (
|
||||||
|
<section key={g.workOrderId} className={card}>
|
||||||
|
<Link href={`/m/orders/${g.workOrderId}`} className="flex min-h-12 items-center gap-2">
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block font-mono text-[13px] font-semibold text-muted-foreground">{g.number}</span>
|
||||||
|
<span className="block text-[17px] font-semibold leading-snug">{g.title}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="size-5 text-muted-foreground" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
<p className="text-[14px]">
|
||||||
|
{t("myTime.totalApproved", { duration: fmtDuration(g.approvedSeconds) })}
|
||||||
|
{g.pendingSeconds > 0 && <span className="font-semibold text-[var(--warn)]"> · {t("myTime.totalPending", { duration: fmtDuration(g.pendingSeconds) })}</span>}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 divide-y">
|
||||||
|
{g.entries.map((e) => (
|
||||||
|
<li key={e.id} className="space-y-2 py-2.5">
|
||||||
|
<div className="flex items-baseline justify-between gap-2 text-[15px]">
|
||||||
|
<span className="font-semibold">{t(`time.type.${e.type}`)}</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("myTime.running")} · {fmtDuration(e.seconds)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<TimeEntryBadges entry={e} />
|
||||||
|
{canRecord && e.editable && (
|
||||||
|
<CorrectionProposalForm
|
||||||
|
workOrderId={g.workOrderId}
|
||||||
|
entry={{ id: e.id, type: e.type, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt?.toISOString() ?? null }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,45 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { requireAppAccess } from "@/server/app-access";
|
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 { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||||
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
||||||
import { BottomNav } from "@/components/field/bottom-nav";
|
import { BottomNav } from "@/components/field/bottom-nav";
|
||||||
import { OnlineBadge } from "@/components/field/online-badge";
|
import { OnlineBadge } from "@/components/field/online-badge";
|
||||||
|
import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar";
|
||||||
import { OfflineRuntime } from "@/components/offline/offline-runtime";
|
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
|
* 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.
|
* (src/server/app-access.ts), no sidebar, bottom navigation, online/offline badge.
|
||||||
* Module gates live one level below: (core) → "field", emergency → "emergency".
|
* 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 }>) {
|
export default async function FieldShell({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
const access = await requireAppAccess();
|
const access = await requireAppAccess();
|
||||||
if (access.kind === "inactive") return <AccountInactiveNotice />;
|
if (access.kind === "inactive") return <AccountInactiveNotice />;
|
||||||
const t = await getTranslations("field.nav");
|
const [t, time] = await Promise.all([getTranslations("field.nav"), shellTimeState()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-1 flex-col bg-background">
|
<div className="flex min-h-screen flex-1 flex-col bg-background">
|
||||||
@@ -29,8 +53,9 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
|
|||||||
<OnlineBadge />
|
<OnlineBadge />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="mx-auto w-full max-w-xl flex-1 pb-28">{children}</div>
|
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
|
||||||
<BottomNav />
|
<RunningClockBar initial={time.clock} />
|
||||||
|
<BottomNav approvals={time.approvals} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{!rejecting ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button type="button" className={btnPrimary} disabled={busy} onClick={() => run("approve")}>
|
||||||
|
<Check className="size-5" aria-hidden />
|
||||||
|
{t("approvals.approve")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={cn(btnSecondary, "min-h-14")} disabled={busy} onClick={() => setRejecting(true)}>
|
||||||
|
<X className="size-5" aria-hidden />
|
||||||
|
{t("approvals.reject")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5 rounded-xl bg-muted p-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("approvals.rejectReason")}</span>
|
||||||
|
<textarea rows={2} required minLength={3} maxLength={500} className={cn(inputClass, "py-3")} value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<button type="button" className={btnPrimary} disabled={busy} onClick={() => run("reject")}>
|
||||||
|
{busy ? t("action.saving") : t("approvals.rejectConfirm")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={btnSecondary} disabled={busy} onClick={() => setRejecting(false)}>
|
||||||
|
{t("approvals.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className={noticeError} role="alert">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,8 +14,11 @@ const ITEMS = [
|
|||||||
{ href: "/m/profile", key: "profile", icon: User, exact: false },
|
{ href: "/m/profile", key: "profile", icon: User, exact: false },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil. */
|
/**
|
||||||
export function BottomNav() {
|
* Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil.
|
||||||
|
* L12: badge counter of open time approvals on „Profil" (number + accessible label).
|
||||||
|
*/
|
||||||
|
export function BottomNav({ approvals = 0 }: { approvals?: number }) {
|
||||||
const t = useTranslations("field.nav");
|
const t = useTranslations("field.nav");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
return (
|
return (
|
||||||
@@ -23,6 +26,7 @@ export function BottomNav() {
|
|||||||
<ul className="mx-auto grid max-w-xl grid-cols-5">
|
<ul className="mx-auto grid max-w-xl grid-cols-5">
|
||||||
{ITEMS.map((item) => {
|
{ITEMS.map((item) => {
|
||||||
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
|
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
const badge = item.key === "profile" && approvals > 0 ? approvals : 0;
|
||||||
return (
|
return (
|
||||||
<li key={item.href}>
|
<li key={item.href}>
|
||||||
<Link
|
<Link
|
||||||
@@ -33,10 +37,16 @@ export function BottomNav() {
|
|||||||
active ? "text-primary" : "text-muted-foreground hover:text-foreground",
|
active ? "text-primary" : "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn("grid h-8 w-12 place-items-center rounded-full", active && "bg-accent")}>
|
<span className={cn("relative grid h-8 w-12 place-items-center rounded-full", active && "bg-accent")}>
|
||||||
<item.icon className="size-5.5" aria-hidden />
|
<item.icon className="size-5.5" aria-hidden />
|
||||||
|
{badge > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-0.5 grid min-w-5 place-items-center rounded-full bg-cta px-1 text-[11px] leading-5 font-bold text-cta-foreground" aria-hidden>
|
||||||
|
{badge > 99 ? "99+" : badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{t(item.key)}
|
{t(item.key)}
|
||||||
|
{badge > 0 && <span className="sr-only">{t("approvalsBadge", { count: badge })}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { LoaderCircle, Pause, Play, TriangleAlert, Wrench } from "lucide-react";
|
||||||
|
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { errorKey, isSuccess, submitOp } from "@/lib/field/client-ops";
|
||||||
|
import { btnSecondary, noticeError } from "./ui";
|
||||||
|
import { useSessionStart } from "./switch-session-sheet";
|
||||||
|
|
||||||
|
const STARTABLE: WorkOrderStatus[] = ["accepted", "en_route", "in_progress", "paused", "waiting_material", "daily_report_created"];
|
||||||
|
|
||||||
|
/** L12: direct start / pause / resume on an order card (Heute, Aufträge) without opening the order. */
|
||||||
|
export function CardTimeButton({ workOrderId, number, status, mySession }: { workOrderId: string; number: string; status: WorkOrderStatus; mySession: "en_route" | "running" | "paused" | null }) {
|
||||||
|
const t = useTranslations("field");
|
||||||
|
const router = useRouter();
|
||||||
|
const [pausing, setPausing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { start, busy, sheet } = useSessionStart(workOrderId, number, (r) => setError(isSuccess(r) ? null : t(`errors.${errorKey(r)}`)));
|
||||||
|
|
||||||
|
const action: "start" | "pause" | "resume" | null =
|
||||||
|
mySession === "running" ? "pause" : mySession === "paused" ? "resume" : mySession === "en_route" || STARTABLE.includes(status) ? "start" : null;
|
||||||
|
if (!action) return null;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
setError(null);
|
||||||
|
if (action === "pause") {
|
||||||
|
setPausing(true);
|
||||||
|
const r = await submitOp({ opType: "session.pause", payload: { workOrderId, at: new Date().toISOString() } });
|
||||||
|
setPausing(false);
|
||||||
|
if (!isSuccess(r)) setError(t(`errors.${errorKey(r)}`));
|
||||||
|
router.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await start(action === "resume" ? "resume" : "work");
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = pausing || busy !== null;
|
||||||
|
const Icon = loading ? LoaderCircle : action === "pause" ? Pause : action === "resume" ? Play : Wrench;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className={btnSecondary} onClick={run} disabled={loading}>
|
||||||
|
<Icon className={loading ? "size-5 animate-spin" : "size-5"} aria-hidden />
|
||||||
|
{action === "pause" ? t("card.pause") : action === "resume" ? t("card.resume") : t("card.startWork")}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p className={noticeError} role="alert">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sheet}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { PenLine, TriangleAlert } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { errorKey, isQueued, isSuccess, submitOp } from "@/lib/field/client-ops";
|
||||||
|
import { MANUAL_TIME_TYPES, REASON_SUGGESTIONS, timeErrorKey } from "@/lib/field/time-rules";
|
||||||
|
import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk } from "./ui";
|
||||||
|
|
||||||
|
/** ISO → value of <input type="datetime-local"> in the device time zone. */
|
||||||
|
export function toLocalInput(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L12 „Korrektur vorschlagen" for an own entry (instead of direct editing): the old values stay
|
||||||
|
* valid until a team lead / the office approves. Sent as sync op (works offline).
|
||||||
|
*/
|
||||||
|
export function CorrectionProposalForm({ workOrderId, entry }: { workOrderId: string; entry: { id: string; type: string; startedAt: string; endedAt: string | null } }) {
|
||||||
|
const t = useTranslations("field");
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [start, setStart] = useState("");
|
||||||
|
const [end, setEnd] = useState("");
|
||||||
|
const [type, setType] = useState(entry.type);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(btnSecondary, "min-h-12")}
|
||||||
|
onClick={() => {
|
||||||
|
setStart(toLocalInput(entry.startedAt));
|
||||||
|
setEnd(entry.endedAt ? toLocalInput(entry.endedAt) : "");
|
||||||
|
setType(entry.type);
|
||||||
|
setDone(null);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PenLine className="size-4.5" aria-hidden />
|
||||||
|
{t("time.propose")}
|
||||||
|
</button>
|
||||||
|
{done && (
|
||||||
|
<p className={noticeOk} role="status">
|
||||||
|
{done}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (reason.trim().length < 3) {
|
||||||
|
setError(t("myTime.form.reasonHint"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!start || !end) {
|
||||||
|
setError(t("errors.invalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
const result = await submitOp({
|
||||||
|
opType: "time.propose_correction",
|
||||||
|
payload: { workOrderId, timeEntryId: entry.id, type: type as (typeof MANUAL_TIME_TYPES)[number], startedAt: new Date(start).toISOString(), endedAt: new Date(end).toISOString(), reason: reason.trim() },
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (!isSuccess(result)) {
|
||||||
|
const key = timeErrorKey(result);
|
||||||
|
setError(key ? t(`myTime.errors.${key}`) : t(`errors.${errorKey(result)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
setReason("");
|
||||||
|
setDone(isQueued(result) ? t("myTime.form.queued") : t("myTime.correction.saved"));
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const types: readonly string[] = (MANUAL_TIME_TYPES as readonly string[]).includes(entry.type) ? MANUAL_TIME_TYPES : [...MANUAL_TIME_TYPES, entry.type];
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="space-y-3 rounded-xl bg-muted p-3">
|
||||||
|
<p className="text-[15px] font-semibold">{t("myTime.correction.title")}</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground">{t("myTime.correction.hint")}</p>
|
||||||
|
<fieldset>
|
||||||
|
<legend className="mb-1.5 text-[14px] font-semibold">{t("myTime.form.type")}</legend>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{types.map((ty) => (
|
||||||
|
<button key={ty} type="button" aria-pressed={type === ty} className={chip(type === ty)} onClick={() => setType(ty)}>
|
||||||
|
{t(`time.type.${ty}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("time.start")}</span>
|
||||||
|
<input type="datetime-local" required className={inputClass} value={start} onChange={(e) => setStart(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("time.end")}</span>
|
||||||
|
<input type="datetime-local" required className={inputClass} value={end} min={start} onChange={(e) => setEnd(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<ReasonField value={reason} onChange={setReason} />
|
||||||
|
{error && (
|
||||||
|
<p className={noticeError} role="alert">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" className={btnPrimary} disabled={busy}>
|
||||||
|
{busy ? t("action.saving") : t("myTime.correction.save")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={btnSecondary} disabled={busy} onClick={() => setOpen(false)}>
|
||||||
|
{t("myTime.form.cancel")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mandatory reason with suggestion chips („Start vergessen", „Kein Netz", „Nachträglich erfasst"). */
|
||||||
|
export function ReasonField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
const t = useTranslations("field.myTime");
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("form.reason")}</span>
|
||||||
|
<textarea rows={2} required minLength={3} maxLength={500} className={cn(inputClass, "py-3")} value={value} onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{REASON_SUGGESTIONS.map((key) => {
|
||||||
|
const text = t(`reasons.${key}`);
|
||||||
|
return (
|
||||||
|
<button key={key} type="button" aria-pressed={value === text} className={chip(value === text)} onClick={() => onChange(text)}>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 block text-[13px] text-muted-foreground">{t("form.reasonHint")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { CloudOff } from "lucide-react";
|
||||||
|
import { fmtDateTime } from "@/lib/field/format";
|
||||||
|
import { listOutbox, subscribeOffline } from "@/lib/offline/outbox";
|
||||||
|
import { isPending } from "@/lib/offline/outbox-core";
|
||||||
|
import type { OutboxEntry } from "@/lib/offline/types";
|
||||||
|
import { card } from "./ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L12 optimistic view: manual time entries and „Für heute beenden" stored on this device but not
|
||||||
|
* yet transmitted (offline outbox) — shown with a pending badge until the server confirms them.
|
||||||
|
*/
|
||||||
|
export function LocalPendingTimes() {
|
||||||
|
const t = useTranslations("field");
|
||||||
|
const locale = useLocale();
|
||||||
|
const [ops, setOps] = useState<OutboxEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = () =>
|
||||||
|
void listOutbox()
|
||||||
|
.then((l) => {
|
||||||
|
if (!cancelled) setOps(l.ops.filter((o) => isPending(o) && (o.opType === "time.add_manual" || o.opType === "time.propose_correction" || o.opType === "session.stop_day")));
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
load();
|
||||||
|
const unsubscribe = subscribeOffline(load);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!ops.length) return null;
|
||||||
|
return (
|
||||||
|
<section className={card} aria-label={t("myTime.localTitle")}>
|
||||||
|
<h2 className="flex items-center gap-2 text-[17px]">
|
||||||
|
<CloudOff className="size-5 text-muted-foreground" aria-hidden />
|
||||||
|
{t("myTime.localTitle")}
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-2 divide-y">
|
||||||
|
{ops.map((o) => {
|
||||||
|
const p = o.payload as { type?: string; startedAt?: string; endedAt?: string; durationMinutes?: number; at?: string };
|
||||||
|
const label =
|
||||||
|
o.opType === "session.stop_day" ? t("action.stop_day") : o.opType === "time.propose_correction" ? t("time.propose") : `${t("myTime.add")} · ${t(`time.type.${p.type ?? "work"}`)}`;
|
||||||
|
const when = p.startedAt ?? p.at ?? o.clientCreatedAt;
|
||||||
|
return (
|
||||||
|
<li key={o.clientOpId} className="flex flex-wrap items-baseline justify-between gap-2 py-2.5 text-[15px]">
|
||||||
|
<span className="font-semibold">{label}</span>
|
||||||
|
<span className="text-[14px]">
|
||||||
|
{fmtDateTime(when, locale)}
|
||||||
|
{p.durationMinutes ? ` · ${p.durationMinutes} min` : ""}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex min-h-7 items-center rounded-lg bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] px-2 text-[13px] font-semibold">{t("myTime.localPending")}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { Info, TriangleAlert } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { fmtDate } from "@/lib/field/format";
|
||||||
|
import { errorKey, isQueued, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
||||||
|
import { DURATION_QUICK_PICKS, MANUAL_TIME_TYPES, MAX_ENTRY_MINUTES, timeErrorKey } from "@/lib/field/time-rules";
|
||||||
|
import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk, noticeWarn } from "./ui";
|
||||||
|
import { ReasonField } from "./correction-proposal-form";
|
||||||
|
|
||||||
|
type Option = { id: string; number: string; title: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L12 „Zeit nachtragen": order, type (chips), date, from–to OR duration (quick picks 15/30/60),
|
||||||
|
* mandatory reason with suggestions. Own entries are pending until approved; with
|
||||||
|
* `field:correct_time` time can be recorded for team members (approved directly).
|
||||||
|
* Sent through the offline outbox (op `time.add_manual`).
|
||||||
|
*/
|
||||||
|
export function ManualTimeForm({ orders, users, days, defaultDate, defaultWorkOrderId }: { orders: Option[]; users: Array<{ id: string; name: string }>; days: string[]; defaultDate: string; defaultWorkOrderId: string | null }) {
|
||||||
|
const t = useTranslations("field");
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const [workOrderId, setWorkOrderId] = useState(defaultWorkOrderId ?? "");
|
||||||
|
const [forUserId, setForUserId] = useState("");
|
||||||
|
const [type, setType] = useState<(typeof MANUAL_TIME_TYPES)[number]>("work");
|
||||||
|
const [date, setDate] = useState(defaultDate);
|
||||||
|
const [mode, setMode] = useState<"range" | "duration">("range");
|
||||||
|
const [from, setFrom] = useState("08:00");
|
||||||
|
const [to, setTo] = useState("09:00");
|
||||||
|
const [duration, setDuration] = useState(30);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (orders.length === 0) return <p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("myTime.form.noOrders")}</p>;
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setDone(null);
|
||||||
|
if (!workOrderId || reason.trim().length < 3) {
|
||||||
|
setError(!workOrderId ? t("myTime.form.orderPlaceholder") : t("myTime.form.reasonHint"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// wall time of the device (same zone as the displayed times)
|
||||||
|
const start = new Date(`${date}T${from}`);
|
||||||
|
const end = mode === "range" ? new Date(`${date}T${to}`) : null;
|
||||||
|
if (Number.isNaN(start.getTime()) || (end && Number.isNaN(end.getTime()))) {
|
||||||
|
setError(t("errors.invalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
const result = await submitOp({
|
||||||
|
opType: "time.add_manual",
|
||||||
|
payload: {
|
||||||
|
workOrderId,
|
||||||
|
clientId: newClientId(),
|
||||||
|
type,
|
||||||
|
startedAt: start.toISOString(),
|
||||||
|
...(end ? { endedAt: end.toISOString() } : { durationMinutes: duration }),
|
||||||
|
reason: reason.trim(),
|
||||||
|
...(note.trim() ? { note: note.trim() } : {}),
|
||||||
|
...(forUserId ? { forUserId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (!isSuccess(result)) {
|
||||||
|
const key = timeErrorKey(result);
|
||||||
|
setError(key ? t(`myTime.errors.${key}`) : t(`errors.${errorKey(result)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isQueued(result)) {
|
||||||
|
setDone(t("myTime.form.queued"));
|
||||||
|
setReason("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(`/m/time?date=${date}`);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = "mb-1.5 block text-[14px] font-semibold";
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("myTime.form.order")}</span>
|
||||||
|
<select required className={inputClass} value={workOrderId} onChange={(e) => setWorkOrderId(e.target.value)}>
|
||||||
|
<option value="">{t("myTime.form.orderPlaceholder")}</option>
|
||||||
|
{orders.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.number} · {o.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{users.length > 0 && (
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("myTime.form.forUser")}</span>
|
||||||
|
<select className={inputClass} value={forUserId} onChange={(e) => setForUserId(e.target.value)}>
|
||||||
|
<option value="">{t("myTime.form.me")}</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend className={label}>{t("myTime.form.type")}</legend>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{MANUAL_TIME_TYPES.map((ty) => (
|
||||||
|
<button key={ty} type="button" aria-pressed={type === ty} className={chip(type === ty)} onClick={() => setType(ty)}>
|
||||||
|
{t(`time.type.${ty}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("myTime.form.date")}</span>
|
||||||
|
<select className={inputClass} value={date} onChange={(e) => setDate(e.target.value)}>
|
||||||
|
{days.map((d, i) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{i === 0 ? t("myTime.today") : i === 1 ? t("myTime.yesterday") : fmtDate(`${d}T12:00:00Z`, locale)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend className={label}>{t("myTime.form.mode")}</legend>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button type="button" aria-pressed={mode === "range"} className={chip(mode === "range")} onClick={() => setMode("range")}>
|
||||||
|
{t("myTime.form.modeRange")}
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-pressed={mode === "duration"} className={chip(mode === "duration")} onClick={() => setMode("duration")}>
|
||||||
|
{t("myTime.form.modeDuration")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("myTime.form.from")}</span>
|
||||||
|
<input type="time" required className={inputClass} value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
{mode === "range" ? (
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("myTime.form.to")}</span>
|
||||||
|
<input type="time" required className={inputClass} value={to} onChange={(e) => setTo(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("myTime.form.duration")}</span>
|
||||||
|
<input type="number" inputMode="numeric" min={1} max={MAX_ENTRY_MINUTES} required className={inputClass} value={duration} onChange={(e) => setDuration(Number(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{mode === "duration" && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DURATION_QUICK_PICKS.map((m) => (
|
||||||
|
<button key={m} type="button" aria-pressed={duration === m} className={cn(chip(duration === m), "flex-1")} onClick={() => setDuration(m)}>
|
||||||
|
{t("myTime.form.quick", { minutes: m })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReasonField value={reason} onChange={setReason} />
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className={label}>{t("myTime.form.note")}</span>
|
||||||
|
<textarea rows={2} maxLength={2000} className={cn(inputClass, "py-3")} value={note} onChange={(e) => setNote(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className={forUserId ? noticeOk : noticeWarn}>
|
||||||
|
<Info className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{forUserId ? t("myTime.form.directHint") : t("myTime.form.approvalHint")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className={noticeError} role="alert">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{done && (
|
||||||
|
<p className={noticeOk} role="status">
|
||||||
|
{done}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" className={btnPrimary} disabled={busy}>
|
||||||
|
{busy ? t("action.saving") : t("myTime.form.save")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={btnSecondary} disabled={busy} onClick={() => router.push(`/m/time?date=${date}`)}>
|
||||||
|
{t("myTime.form.cancel")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,37 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { ChevronRight, Clock, MapPin, Navigation, Siren } from "lucide-react";
|
import { ChevronRight, Clock, MapPin, Navigation, Siren, Timer } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { fmtWindow } from "@/lib/field/format";
|
import { fmtWindow } from "@/lib/field/format";
|
||||||
import type { OrderCard as OrderCardData } from "@/server/services/field/queries";
|
import type { OrderCard as OrderCardData } from "@/server/services/field/queries";
|
||||||
import { StatusBadge } from "./status-badge";
|
import { StatusBadge } from "./status-badge";
|
||||||
import { btnPrimary, toneClasses } from "./ui";
|
import { btnPrimary, btnSecondary, toneClasses } from "./ui";
|
||||||
|
import { CardTimeButton } from "./card-time-button";
|
||||||
|
|
||||||
/** Large order card (Spec §22): number, customer, site address with map link, time window, status, primary button. */
|
/** Large order card (Spec §22): number, customer, site address with map link, time window, status, primary button. */
|
||||||
export function OrderCard({ order }: { order: OrderCardData }) {
|
export function OrderCard({ order, canExecute = false }: { order: OrderCardData; canExecute?: boolean }) {
|
||||||
const t = useTranslations("field.card");
|
const t = useTranslations("field.card");
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const window = fmtWindow(order.plannedStart, order.plannedEnd, locale);
|
const window = fmtWindow(order.plannedStart, order.plannedEnd, locale);
|
||||||
|
const clock = order.mySession;
|
||||||
return (
|
return (
|
||||||
<article className={cn("rounded-xl border border-l-4 bg-card p-4 shadow-card", toneClasses(order.statusGroup).edge)}>
|
<article
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border border-l-4 bg-card p-4 shadow-card",
|
||||||
|
// L12: the card whose clock runs is highlighted by edge AND text (never by colour alone)
|
||||||
|
clock === "running" || clock === "en_route" ? "border-l-8 border-l-[var(--ok)] ring-2 ring-[color-mix(in_oklch,var(--ok)_35%,transparent)]" : clock === "paused" ? "border-l-8 border-l-[var(--warn)]" : toneClasses(order.statusGroup).edge,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="font-mono text-[13px] font-semibold text-muted-foreground">{order.number}</span>
|
<span className="font-mono text-[13px] font-semibold text-muted-foreground">{order.number}</span>
|
||||||
<StatusBadge status={order.status} />
|
<StatusBadge status={order.status} />
|
||||||
</div>
|
</div>
|
||||||
|
{clock && (
|
||||||
|
<p className={cn("mt-2 inline-flex items-center gap-1.5 text-[14px] font-bold", clock === "paused" ? "text-[var(--warn)]" : "text-[var(--ok)]")}>
|
||||||
|
<Timer className="size-4.5" aria-hidden />
|
||||||
|
{clock === "paused" ? t("paused") : clock === "en_route" ? t("enRoute") : t("running")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<h2 className="mt-2 text-[18px] leading-snug">{order.title}</h2>
|
<h2 className="mt-2 text-[18px] leading-snug">{order.title}</h2>
|
||||||
<p className="mt-0.5 text-[15px] font-semibold text-foreground">{order.customerName}</p>
|
<p className="mt-0.5 text-[15px] font-semibold text-foreground">{order.customerName}</p>
|
||||||
{(order.isEmergency || order.priority === "urgent" || order.priority === "high") && (
|
{(order.isEmergency || order.priority === "urgent" || order.priority === "high") && (
|
||||||
@@ -52,10 +66,13 @@ export function OrderCard({ order }: { order: OrderCardData }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
<Link href={`/m/orders/${order.id}`} className={cn(btnPrimary, "mt-4")}>
|
<div className="mt-4 space-y-2">
|
||||||
{t("open")}
|
{canExecute && <CardTimeButton workOrderId={order.id} number={order.number} status={order.status} mySession={order.mySession} />}
|
||||||
<ChevronRight className="size-5" aria-hidden />
|
<Link href={`/m/orders/${order.id}`} className={canExecute && order.mySession ? btnSecondary : btnPrimary}>
|
||||||
</Link>
|
{t("open")}
|
||||||
|
<ChevronRight className="size-5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Clock } from "lucide-react";
|
||||||
|
import { fmtDuration } from "@/lib/field/format";
|
||||||
|
import { fieldPageContext } from "@/server/services/field/page-context";
|
||||||
|
import { pendingTimeOfOrder } from "@/server/services/field/time-entries";
|
||||||
|
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||||
|
import { noticeWarn } from "./ui";
|
||||||
|
|
||||||
|
async function loadPending(workOrderId: string) {
|
||||||
|
try {
|
||||||
|
const ctx = await fieldPageContext();
|
||||||
|
await requireVisibleWorkOrder(ctx, workOrderId, { id: true });
|
||||||
|
return await pendingTimeOfOrder(ctx, workOrderId);
|
||||||
|
} catch {
|
||||||
|
// not visible / no field context: the report screen handles access itself
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L12: warns (does not block) before submitting a report while manual time entries or correction
|
||||||
|
* proposals of the order still wait for approval — they are not part of the report totals.
|
||||||
|
*/
|
||||||
|
export async function PendingTimeNotice({ workOrderId }: { workOrderId: string }) {
|
||||||
|
const pending = await loadPending(workOrderId);
|
||||||
|
if (!pending || (pending.pendingEntries === 0 && pending.openProposals === 0)) return null;
|
||||||
|
const t = await getTranslations("field.pendingTime");
|
||||||
|
return (
|
||||||
|
<div className="px-4 pt-4">
|
||||||
|
<p className={noticeWarn} role="note">
|
||||||
|
<Clock className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
<span>
|
||||||
|
{pending.pendingEntries > 0 && t("report", { duration: fmtDuration(pending.pendingMinutes * 60) })}
|
||||||
|
{pending.pendingEntries > 0 && pending.openProposals > 0 && " "}
|
||||||
|
{pending.openProposals > 0 && t("proposals", { count: pending.openProposals })}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,90 +3,93 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { CircleCheck, LoaderCircle, Pause, Play, TriangleAlert, Truck, Wrench, Handshake } from "lucide-react";
|
import { CircleCheck, LoaderCircle, Package, Pause, Play, Square, TriangleAlert, Truck, Undo2, Wrench, Handshake } from "lucide-react";
|
||||||
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
import { errorKey, isSuccess, submitOp } from "@/lib/field/client-ops";
|
||||||
import { currentPosition } from "@/lib/field/image";
|
import { cn } from "@/lib/utils";
|
||||||
import { btnPrimary, btnSecondary, noticeError, noticeWarn } from "./ui";
|
import { btnPrimary, btnSecondary, noticeError, noticeWarn } from "./ui";
|
||||||
|
import { useSessionStart } from "./switch-session-sheet";
|
||||||
|
|
||||||
type SessionState = "en_route" | "running" | "paused" | null;
|
type SessionState = "en_route" | "running" | "paused" | null;
|
||||||
type ActionKey = "accept" | "travel" | "start" | "pause" | "resume" | "complete";
|
type ActionKey = "accept" | "travel" | "start" | "pause" | "resume" | "stop_day" | "complete";
|
||||||
|
type SegmentKey = "work" | "return_travel" | "material_procurement";
|
||||||
|
|
||||||
const WORKING: WorkOrderStatus[] = ["in_progress", "paused", "waiting_material", "daily_report_created"];
|
const WORKING: WorkOrderStatus[] = ["in_progress", "paused", "waiting_material", "daily_report_created"];
|
||||||
|
|
||||||
/** One primary action per state (Brandbook §12.1): Annehmen → Losfahren → Arbeit starten → Pause/Weiter → Abschließen. */
|
/**
|
||||||
export function resolveActions(status: WorkOrderStatus, mySession: SessionState): { primary: ActionKey | null; secondary: ActionKey | null } {
|
* One primary action per state (Brandbook §12.1): Annehmen → Losfahren → Arbeit starten → Pause/Weiter.
|
||||||
if (status === "assigned") return { primary: "accept", secondary: null };
|
* L12: with an own session the primary action is Pause (or Weiter), secondary „Für heute beenden",
|
||||||
if (status === "accepted") return { primary: "travel", secondary: "start" };
|
* „Abschließen" as a separate text link (`link`) — max. 2 large buttons + 1 link.
|
||||||
if (status === "en_route") return { primary: "start", secondary: null };
|
*/
|
||||||
|
export function resolveActions(
|
||||||
|
status: WorkOrderStatus,
|
||||||
|
mySession: SessionState,
|
||||||
|
): { primary: ActionKey | null; secondary: ActionKey | null; link: "complete" | null } {
|
||||||
|
if (status === "assigned") return { primary: "accept", secondary: null, link: null };
|
||||||
|
if (status === "accepted") return { primary: "travel", secondary: "start", link: null };
|
||||||
|
if (status === "en_route") return { primary: "start", secondary: mySession === "en_route" ? "stop_day" : null, link: null };
|
||||||
if (WORKING.includes(status)) {
|
if (WORKING.includes(status)) {
|
||||||
if (mySession === "running") return { primary: "complete", secondary: "pause" };
|
if (mySession === "running") return { primary: "pause", secondary: "stop_day", link: "complete" };
|
||||||
if (mySession === "paused") return { primary: "resume", secondary: "complete" };
|
if (mySession === "paused") return { primary: "resume", secondary: "stop_day", link: "complete" };
|
||||||
return { primary: "start", secondary: status === "in_progress" ? "complete" : null };
|
if (mySession === "en_route") return { primary: "start", secondary: "stop_day", link: null };
|
||||||
|
return { primary: "start", secondary: null, link: status === "in_progress" ? "complete" : null };
|
||||||
}
|
}
|
||||||
return { primary: null, secondary: null };
|
return { primary: null, secondary: null, link: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICONS = { accept: Handshake, travel: Truck, start: Wrench, pause: Pause, resume: Play, complete: CircleCheck } as const;
|
const ICONS = { accept: Handshake, travel: Truck, start: Wrench, pause: Pause, resume: Play, stop_day: Square, complete: CircleCheck } as const;
|
||||||
|
const SEGMENT_ICONS = { work: Undo2, return_travel: Truck, material_procurement: Package } as const;
|
||||||
async function sessionStartPayload(workOrderId: string, mode: "travel" | "work") {
|
|
||||||
const pos = await currentPosition(3000);
|
|
||||||
return {
|
|
||||||
workOrderId,
|
|
||||||
mode,
|
|
||||||
clientId: newClientId(),
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
offline: typeof navigator !== "undefined" ? !navigator.onLine : false,
|
|
||||||
deviceInfo: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 200) : undefined,
|
|
||||||
...(pos ?? {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PrimaryAction({
|
export function PrimaryAction({
|
||||||
workOrderId,
|
workOrderId,
|
||||||
|
number,
|
||||||
status,
|
status,
|
||||||
version,
|
version,
|
||||||
mySession,
|
mySession,
|
||||||
|
segmentType,
|
||||||
blockers,
|
blockers,
|
||||||
}: {
|
}: {
|
||||||
workOrderId: string;
|
workOrderId: string;
|
||||||
|
number: string;
|
||||||
status: WorkOrderStatus;
|
status: WorkOrderStatus;
|
||||||
version: number;
|
version: number;
|
||||||
mySession: SessionState;
|
mySession: SessionState;
|
||||||
|
/** type of the open segment of the own session (L12 segment switch) */
|
||||||
|
segmentType?: string | null;
|
||||||
/** translated completion blockers (without the user's own session, which is ended on completion) */
|
/** translated completion blockers (without the user's own session, which is ended on completion) */
|
||||||
blockers: string[];
|
blockers: string[];
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("field");
|
const t = useTranslations("field");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [busy, setBusy] = useState<ActionKey | null>(null);
|
const [busy, setBusy] = useState<ActionKey | SegmentKey | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [confirm, setConfirm] = useState(false);
|
const [confirm, setConfirm] = useState<"complete" | "stop_day" | null>(null);
|
||||||
const { primary, secondary } = resolveActions(status, mySession);
|
const { primary, secondary, link } = resolveActions(status, mySession);
|
||||||
|
const starter = useSessionStart(workOrderId, number, (r) => setError(isSuccess(r) ? null : t(`errors.${errorKey(r)}`)));
|
||||||
|
|
||||||
async function run(action: ActionKey) {
|
async function run(action: ActionKey) {
|
||||||
if (action === "complete" && !confirm) {
|
if ((action === "complete" || action === "stop_day") && confirm !== action) {
|
||||||
setConfirm(true);
|
setConfirm(action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
if (action === "travel" || action === "start" || action === "resume") {
|
||||||
|
setConfirm(null);
|
||||||
|
await starter.start(action === "travel" ? "travel" : action === "resume" ? "resume" : "work");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(action);
|
setBusy(action);
|
||||||
setError(null);
|
|
||||||
const at = new Date().toISOString();
|
const at = new Date().toISOString();
|
||||||
let result;
|
let result;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "accept":
|
case "accept":
|
||||||
result = await submitOp({ opType: "work_order.transition", baseVersion: version, payload: { workOrderId, to: "accepted" } });
|
result = await submitOp({ opType: "work_order.transition", baseVersion: version, payload: { workOrderId, to: "accepted" } });
|
||||||
break;
|
break;
|
||||||
case "travel":
|
|
||||||
result = await submitOp({ opType: "session.start", payload: await sessionStartPayload(workOrderId, "travel") });
|
|
||||||
break;
|
|
||||||
case "start":
|
|
||||||
result = await submitOp({ opType: "session.start", payload: await sessionStartPayload(workOrderId, "work") });
|
|
||||||
break;
|
|
||||||
case "pause":
|
case "pause":
|
||||||
result = await submitOp({ opType: "session.pause", payload: { workOrderId, at } });
|
result = await submitOp({ opType: "session.pause", payload: { workOrderId, at } });
|
||||||
break;
|
break;
|
||||||
case "resume":
|
case "stop_day":
|
||||||
result = await submitOp({ opType: "session.resume", payload: { workOrderId, at } });
|
result = await submitOp({ opType: "session.stop_day", payload: { workOrderId, at } });
|
||||||
break;
|
break;
|
||||||
case "complete": {
|
case "complete": {
|
||||||
let base = version;
|
let base = version;
|
||||||
@@ -103,7 +106,16 @@ export function PrimaryAction({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
setConfirm(false);
|
setConfirm(null);
|
||||||
|
if (result && !isSuccess(result)) setError(t(`errors.${errorKey(result)}`));
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function segment(type: SegmentKey) {
|
||||||
|
setBusy(type);
|
||||||
|
setError(null);
|
||||||
|
const result = await submitOp({ opType: "session.segment", payload: { workOrderId, type, at: new Date().toISOString() } });
|
||||||
|
setBusy(null);
|
||||||
if (!isSuccess(result)) setError(t(`errors.${errorKey(result)}`));
|
if (!isSuccess(result)) setError(t(`errors.${errorKey(result)}`));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
@@ -113,33 +125,83 @@ export function PrimaryAction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const completeBlocked = blockers.length > 0;
|
const completeBlocked = blockers.length > 0;
|
||||||
|
const anyBusy = busy !== null || starter.busy !== null;
|
||||||
const renderButton = (action: ActionKey, variant: "primary" | "secondary") => {
|
const renderButton = (action: ActionKey, variant: "primary" | "secondary") => {
|
||||||
const Icon = busy === action ? LoaderCircle : ICONS[action];
|
const loading = busy === action || (starter.busy !== null && (action === "travel" || action === "start" || action === "resume"));
|
||||||
const disabled = busy !== null || (action === "complete" && completeBlocked);
|
const Icon = loading ? LoaderCircle : ICONS[action];
|
||||||
|
const disabled = anyBusy || (action === "complete" && completeBlocked);
|
||||||
|
const label = loading ? t("action.saving") : confirm === action ? t(action === "complete" ? "action.confirmComplete" : "action.confirmStopDay") : t(`action.${action}`);
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={() => run(action)} disabled={disabled} className={variant === "primary" ? btnPrimary : btnSecondary}>
|
<button type="button" onClick={() => run(action)} disabled={disabled} className={variant === "primary" ? btnPrimary : btnSecondary}>
|
||||||
<Icon className={busy === action ? "size-5 animate-spin" : "size-5"} aria-hidden />
|
<Icon className={loading ? "size-5 animate-spin" : "size-5"} aria-hidden />
|
||||||
{busy === action ? t("action.saving") : action === "complete" && confirm ? t("action.confirmComplete") : t(`action.${action}`)}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// segment switches (smaller actions) while the own session is running or paused
|
||||||
|
const segments: SegmentKey[] =
|
||||||
|
mySession === "running" || mySession === "paused"
|
||||||
|
? (["return_travel", "material_procurement", "work"] as SegmentKey[]).filter((s) => (s === "work" ? mySession === "running" && !!segmentType && segmentType !== "work" : s !== segmentType))
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{renderButton(primary, "primary")}
|
{renderButton(primary, "primary")}
|
||||||
{secondary && renderButton(secondary, "secondary")}
|
{secondary && renderButton(secondary, "secondary")}
|
||||||
{confirm && (
|
{segments.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{mySession === "running" && segmentType && segmentType !== "work" && (
|
||||||
|
<p className="w-full text-[13px] font-semibold text-muted-foreground">{t("action.currentSegment", { type: t(`time.type.${segmentType}`) })}</p>
|
||||||
|
)}
|
||||||
|
{segments.map((s) => {
|
||||||
|
const Icon = busy === s ? LoaderCircle : SEGMENT_ICONS[s];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
disabled={anyBusy}
|
||||||
|
onClick={() => segment(s)}
|
||||||
|
className="inline-flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-xl border border-border bg-card px-3 text-[14px] font-semibold text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Icon className={cn("size-4.5", busy === s && "animate-spin")} aria-hidden />
|
||||||
|
{t(`action.segment.${s}`)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{link === "complete" && confirm !== "complete" && (
|
||||||
|
<button type="button" disabled={anyBusy || completeBlocked} onClick={() => run("complete")} className="inline-flex min-h-12 items-center px-1 text-[15px] font-semibold text-primary underline underline-offset-4 disabled:opacity-50">
|
||||||
|
{t("action.completeLink")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{confirm === "complete" && (
|
||||||
|
<>
|
||||||
|
{renderButton("complete", "secondary")}
|
||||||
|
<div className={noticeWarn}>
|
||||||
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
<span>
|
||||||
|
{t("action.confirmHint")}{" "}
|
||||||
|
<button type="button" className="ml-1 font-semibold underline" onClick={() => setConfirm(null)}>
|
||||||
|
{t("photos.discard")}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{confirm === "stop_day" && (
|
||||||
<div className={noticeWarn}>
|
<div className={noticeWarn}>
|
||||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
<span>
|
<span>
|
||||||
{t("action.confirmHint")}{" "}
|
{t("action.stopDayHint")}{" "}
|
||||||
<button type="button" className="ml-1 font-semibold underline" onClick={() => setConfirm(false)}>
|
<button type="button" className="ml-1 font-semibold underline" onClick={() => setConfirm(null)}>
|
||||||
{t("photos.discard")}
|
{t("photos.discard")}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{completeBlocked && (primary === "complete" || secondary === "complete") && (
|
{completeBlocked && (primary === "complete" || secondary === "complete" || link === "complete") && (
|
||||||
<div className={noticeWarn} role="note">
|
<div className={noticeWarn} role="note">
|
||||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
|
||||||
<div>
|
<div>
|
||||||
@@ -158,6 +220,7 @@ export function PrimaryAction({
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{starter.sheet}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { History, LoaderCircle, Pause, Play, Square } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { isSuccess, submitOp } from "@/lib/field/client-ops";
|
||||||
|
import { subscribeOffline } from "@/lib/offline/outbox";
|
||||||
|
import { readOrders } from "@/lib/offline/read";
|
||||||
|
import type { MyActiveSession } from "@/server/services/field/sessions";
|
||||||
|
import { useSessionStart } from "./switch-session-sheet";
|
||||||
|
|
||||||
|
export type ClockSession = Pick<MyActiveSession, "status" | "workOrderId" | "number" | "title" | "segmentStartedAt" | "closedSeconds"> & { segmentType: string | null };
|
||||||
|
|
||||||
|
/** "1:23 h" */
|
||||||
|
function fmtClock(seconds: number): string {
|
||||||
|
const total = Math.max(0, Math.floor(seconds / 60));
|
||||||
|
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elapsed(s: ClockSession, now: number): number {
|
||||||
|
const running = s.status !== "paused" && s.segmentStartedAt && s.segmentType !== "break" ? Math.max(0, (now - new Date(s.segmentStartedAt).getTime()) / 1000) : 0;
|
||||||
|
return s.closedSeconds + running;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offline fallback: the own session from the local bundle + queued ops (no durations available locally). */
|
||||||
|
async function localSession(): Promise<ClockSession | null> {
|
||||||
|
const read = await readOrders().catch(() => null);
|
||||||
|
if (!read?.ready) return null;
|
||||||
|
const order = read.orders.find((o) => o.local.session === "running" || o.local.session === "en_route") ?? read.orders.find((o) => o.local.session === "paused");
|
||||||
|
if (!order) return null;
|
||||||
|
return {
|
||||||
|
status: order.local.session as ClockSession["status"],
|
||||||
|
workOrderId: order.id,
|
||||||
|
number: order.number,
|
||||||
|
title: order.title,
|
||||||
|
segmentStartedAt: order.mySession?.startedAt ?? null,
|
||||||
|
segmentType: order.local.session === "paused" ? "break" : "work",
|
||||||
|
closedSeconds: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L12 running clock: fixed above the bottom navigation on every /m page while an own session runs
|
||||||
|
* or is paused — „A-00042 · Wärmepumpe · 1:23 h", Pause/Weiter, „Für heute beenden", tap → order.
|
||||||
|
* Server data on every render (router.refresh after actions); offline from the local state.
|
||||||
|
*/
|
||||||
|
export function RunningClockBar({ initial }: { initial: ClockSession | null }) {
|
||||||
|
const t = useTranslations("field.clock");
|
||||||
|
const router = useRouter();
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const [offlineSession, setOfflineSession] = useState<ClockSession | null | undefined>(undefined);
|
||||||
|
const [busy, setBusy] = useState<"pause" | "stop" | null>(null);
|
||||||
|
const [confirmStop, setConfirmStop] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const refresh = () => {
|
||||||
|
if (typeof navigator === "undefined" || navigator.onLine) {
|
||||||
|
setOfflineSession(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void localSession().then((s) => {
|
||||||
|
if (!cancelled) setOfflineSession(s);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
refresh();
|
||||||
|
const unsubscribe = subscribeOffline(refresh);
|
||||||
|
window.addEventListener("online", refresh);
|
||||||
|
window.addEventListener("offline", refresh);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubscribe();
|
||||||
|
window.removeEventListener("online", refresh);
|
||||||
|
window.removeEventListener("offline", refresh);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const session = offlineSession === undefined ? initial : offlineSession;
|
||||||
|
const resume = useSessionStart(session?.workOrderId ?? "", session?.number ?? "");
|
||||||
|
if (!session) return null;
|
||||||
|
const offline = offlineSession !== undefined;
|
||||||
|
|
||||||
|
async function pause() {
|
||||||
|
if (!session) return;
|
||||||
|
setBusy("pause");
|
||||||
|
await submitOp({ opType: "session.pause", payload: { workOrderId: session.workOrderId, at: new Date().toISOString() } });
|
||||||
|
setBusy(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopDay() {
|
||||||
|
if (!session) return;
|
||||||
|
if (!confirmStop) {
|
||||||
|
setConfirmStop(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy("stop");
|
||||||
|
const r = await submitOp({ opType: "session.stop_day", payload: { workOrderId: session.workOrderId, at: new Date().toISOString() } });
|
||||||
|
setBusy(null);
|
||||||
|
setConfirmStop(false);
|
||||||
|
if (isSuccess(r)) router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = session.status === "paused" ? t("paused") : session.status === "en_route" ? t("enRoute") : t("running");
|
||||||
|
const btn = "inline-flex min-h-12 min-w-12 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-[14px] font-semibold disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={t("label")}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-x-0 z-30 border-t border-l-4 bg-card shadow-card",
|
||||||
|
"bottom-[calc(4rem+1px+env(safe-area-inset-bottom))]",
|
||||||
|
session.status === "paused" ? "border-l-[var(--warn)]" : "border-l-[var(--ok)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex max-w-xl items-center gap-2 px-3 py-2">
|
||||||
|
<Link href={`/m/orders/${session.workOrderId}`} aria-label={t("openOrder", { number: session.number })} className="flex min-h-12 min-w-0 flex-1 flex-col justify-center">
|
||||||
|
<span className="flex items-center gap-1.5 text-[12px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
<span className={cn("inline-block size-2 rounded-full", session.status === "paused" ? "bg-[var(--warn)]" : "animate-pulse bg-[var(--ok)]")} aria-hidden />
|
||||||
|
{statusLabel}
|
||||||
|
{offline && ` · ${t("offline")}`}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[15px] font-semibold">
|
||||||
|
{session.number} · {session.title}
|
||||||
|
{!offline && <span className="tabular-nums"> · {fmtClock(elapsed(session, now))}</span>}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/m/time" aria-label={t("myTime")} className={cn(btn, "text-primary")}>
|
||||||
|
<History className="size-5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
{session.status === "paused" ? (
|
||||||
|
<button type="button" className={cn(btn, "bg-cta text-cta-foreground")} disabled={resume.busy !== null} onClick={() => resume.start("resume")}>
|
||||||
|
{resume.busy ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Play className="size-5" aria-hidden />}
|
||||||
|
{t("resume")}
|
||||||
|
</button>
|
||||||
|
) : session.status === "running" ? (
|
||||||
|
<button type="button" className={cn(btn, "border border-border text-primary")} disabled={busy !== null} onClick={pause}>
|
||||||
|
{busy === "pause" ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Pause className="size-5" aria-hidden />}
|
||||||
|
{t("pause")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(btn, confirmStop ? "bg-destructive text-primary-foreground" : "border border-border text-foreground")}
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={stopDay}
|
||||||
|
onBlur={() => setConfirmStop(false)}
|
||||||
|
aria-label={t("stopDay")}
|
||||||
|
>
|
||||||
|
{busy === "stop" ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Square className="size-4.5" aria-hidden />}
|
||||||
|
<span className="max-[400px]:sr-only">{confirmStop ? t("stopDayConfirm") : t("stopDay")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{resume.sheet}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ArrowLeftRight, LoaderCircle } from "lucide-react";
|
||||||
|
import type { SyncOpResult } from "@/lib/sync/envelope";
|
||||||
|
import { isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
||||||
|
import { currentPosition } from "@/lib/field/image";
|
||||||
|
import { otherSessionOf, type OtherSession } from "@/lib/field/time-rules";
|
||||||
|
import { btnPrimary, btnSecondary } from "./ui";
|
||||||
|
|
||||||
|
type StartKind = "travel" | "work" | "resume";
|
||||||
|
|
||||||
|
async function startPayload(workOrderId: string, mode: "travel" | "work", switchFromOther: boolean) {
|
||||||
|
const pos = await currentPosition(3000);
|
||||||
|
return {
|
||||||
|
workOrderId,
|
||||||
|
mode,
|
||||||
|
clientId: newClientId(),
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
offline: typeof navigator !== "undefined" ? !navigator.onLine : false,
|
||||||
|
deviceInfo: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 200) : undefined,
|
||||||
|
...(switchFromOther ? { switchFromOther: true } : {}),
|
||||||
|
...(pos ?? {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends session.start / session.resume (optionally with the auto switch flag). */
|
||||||
|
export async function submitStart(workOrderId: string, kind: StartKind, switchFromOther = false): Promise<SyncOpResult> {
|
||||||
|
if (kind === "resume") {
|
||||||
|
return submitOp({ opType: "session.resume", payload: { workOrderId, at: new Date().toISOString(), ...(switchFromOther ? { switchFromOther: true } : {}) } });
|
||||||
|
}
|
||||||
|
return submitOp({ opType: "session.start", payload: await startPayload(workOrderId, kind, switchFromOther) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L12 auto switch: start/resume work; when another order is still on the clock the server answers
|
||||||
|
* `other_session_running` and the bottom sheet asks „A-00041 läuft noch. Pausieren und A-00042 starten?".
|
||||||
|
*/
|
||||||
|
export function useSessionStart(workOrderId: string, number: string, onDone?: (result: SyncOpResult) => void) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [busy, setBusy] = useState<StartKind | null>(null);
|
||||||
|
const [pending, setPending] = useState<{ kind: StartKind; other: OtherSession } | null>(null);
|
||||||
|
const [result, setResult] = useState<SyncOpResult | null>(null);
|
||||||
|
|
||||||
|
async function start(kind: StartKind, switchFromOther = false) {
|
||||||
|
setBusy(kind);
|
||||||
|
const r = await submitStart(workOrderId, kind, switchFromOther);
|
||||||
|
setBusy(null);
|
||||||
|
const other = otherSessionOf(r);
|
||||||
|
if (other && !switchFromOther) {
|
||||||
|
setPending({ kind, other });
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
setPending(null);
|
||||||
|
setResult(r);
|
||||||
|
onDone?.(r);
|
||||||
|
if (isSuccess(r)) router.refresh();
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sheet = pending ? (
|
||||||
|
<SwitchSessionSheet
|
||||||
|
from={pending.other.number}
|
||||||
|
to={number}
|
||||||
|
busy={busy !== null}
|
||||||
|
onConfirm={() => start(pending.kind, true)}
|
||||||
|
onCancel={() => setPending(null)}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return { start, busy, result, sheet };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwitchSessionSheet({ from, to, busy, onConfirm, onCancel }: { from: string; to: string; busy: boolean; onConfirm: () => void; onCancel: () => void }) {
|
||||||
|
const t = useTranslations("field.switch");
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40" onClick={onCancel}>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="switch-session-title"
|
||||||
|
className="w-full max-w-xl space-y-4 rounded-t-2xl bg-card p-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] shadow-card"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ArrowLeftRight className="mt-1 size-6 shrink-0 text-primary" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h2 id="switch-session-title" className="text-[19px]">
|
||||||
|
{t("title")}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-[16px]">{from ? t("text", { from, to }) : t("textUnknown", { to })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className={btnPrimary} disabled={busy} onClick={onConfirm} autoFocus>
|
||||||
|
{busy ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <ArrowLeftRight className="size-5" aria-hidden />}
|
||||||
|
{t("confirm")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={btnSecondary} disabled={busy} onClick={onCancel}>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { CircleCheck, Clock, PenLine, TriangleAlert } from "lucide-react";
|
||||||
|
import { fmtTime } from "@/lib/field/format";
|
||||||
|
|
||||||
|
type BadgeEntry = {
|
||||||
|
source: "tracked" | "manual";
|
||||||
|
approvalStatus: "approved" | "pending" | "rejected";
|
||||||
|
rejectionReason: string | null;
|
||||||
|
corrected: boolean;
|
||||||
|
pendingChange: { startedAt: string; endedAt: string; type: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const badge = "inline-flex min-h-7 items-center gap-1 rounded-lg px-2 text-[13px] font-semibold";
|
||||||
|
|
||||||
|
/** L12 status badges of a time entry — always text + icon, never colour alone. */
|
||||||
|
export function TimeEntryBadges({ entry }: { entry: BadgeEntry }) {
|
||||||
|
const t = useTranslations("field.myTime.badge");
|
||||||
|
const tt = useTranslations("field");
|
||||||
|
const locale = useLocale();
|
||||||
|
const items: React.ReactNode[] = [];
|
||||||
|
if (entry.source === "manual") {
|
||||||
|
items.push(
|
||||||
|
<span key="manual" className={`${badge} bg-muted text-foreground`}>
|
||||||
|
<PenLine className="size-3.5" aria-hidden />
|
||||||
|
{t("manual")}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.approvalStatus === "pending") {
|
||||||
|
items.push(
|
||||||
|
<span key="pending" className={`${badge} bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-foreground`}>
|
||||||
|
<Clock className="size-3.5" aria-hidden />
|
||||||
|
{t("pending")}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.approvalStatus === "rejected") {
|
||||||
|
items.push(
|
||||||
|
<span key="rejected" className={`${badge} bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]`}>
|
||||||
|
<TriangleAlert className="size-3.5" aria-hidden />
|
||||||
|
{t("rejected", { reason: entry.rejectionReason ?? "" })}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.approvalStatus === "approved" && entry.pendingChange) {
|
||||||
|
items.push(
|
||||||
|
<span key="proposal" className={`${badge} bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-foreground`}>
|
||||||
|
<Clock className="size-3.5" aria-hidden />
|
||||||
|
{t("correctionRequested")} ·{" "}
|
||||||
|
{tt("myTime.proposed", { from: fmtTime(entry.pendingChange.startedAt, locale), to: fmtTime(entry.pendingChange.endedAt, locale), type: tt(`time.type.${entry.pendingChange.type}`) })}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.approvalStatus === "approved" && !entry.pendingChange && entry.rejectionReason) {
|
||||||
|
items.push(
|
||||||
|
<span key="proposalRejected" className={`${badge} bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]`}>
|
||||||
|
<TriangleAlert className="size-3.5" aria-hidden />
|
||||||
|
{t("correctionRejected", { reason: entry.rejectionReason })}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.corrected) {
|
||||||
|
items.push(
|
||||||
|
<span key="corrected" className={`${badge} bg-[color-mix(in_oklch,var(--ok)_12%,transparent)] text-[var(--ok)]`}>
|
||||||
|
<CircleCheck className="size-3.5" aria-hidden />
|
||||||
|
{t("corrected")}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!items.length) return null;
|
||||||
|
return <div className="flex flex-wrap gap-1.5">{items}</div>;
|
||||||
|
}
|
||||||
@@ -399,18 +399,20 @@ function OrderDetail({ order, onChanged }: { order: OrderView; onChanged: () =>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action = "accept" | "travel" | "start" | "pause" | "resume" | "end";
|
type Action = "accept" | "travel" | "start" | "pause" | "resume" | "end" | "stop_day";
|
||||||
|
|
||||||
function TimeActions({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
|
function TimeActions({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
|
||||||
const t = useTranslations("offline.view");
|
const t = useTranslations("offline.view");
|
||||||
|
const tf = useTranslations("field");
|
||||||
const { feedback, report } = useFeedback();
|
const { feedback, report } = useFeedback();
|
||||||
const [busy, setBusy] = useState<Action | null>(null);
|
const [busy, setBusy] = useState<Action | null>(null);
|
||||||
const resolved = resolveActions(order.status as WorkOrderStatus, order.local.session);
|
const resolved = resolveActions(order.status as WorkOrderStatus, order.local.session);
|
||||||
const actions = [resolved.primary, resolved.secondary]
|
const actions = [resolved.primary, resolved.secondary]
|
||||||
.map((a) => (a === "complete" ? (order.local.session === "running" || order.local.session === "paused" ? "end" : null) : a))
|
.map((a) => (a === "complete" ? (order.local.session === "running" || order.local.session === "paused" ? "end" : null) : a))
|
||||||
.filter((a, i, arr): a is Action => !!a && arr.indexOf(a) === i);
|
.filter((a, i, arr): a is Action => !!a && arr.indexOf(a) === i);
|
||||||
const showCompleteHint = resolved.primary === "complete" || resolved.secondary === "complete";
|
// L12: „Abschließen" is a text link in resolveActions; offline it stays a hint
|
||||||
const labels: Record<Action, string> = { accept: t("actionAccept"), travel: t("actionTravel"), start: t("actionStart"), pause: t("actionPause"), resume: t("actionResume"), end: t("actionEnd") };
|
const showCompleteHint = resolved.primary === "complete" || resolved.secondary === "complete" || resolved.link === "complete";
|
||||||
|
const labels: Record<Action, string> = { accept: t("actionAccept"), travel: t("actionTravel"), start: t("actionStart"), pause: t("actionPause"), resume: t("actionResume"), end: t("actionEnd"), stop_day: tf("action.stop_day") };
|
||||||
|
|
||||||
async function run(action: Action) {
|
async function run(action: Action) {
|
||||||
setBusy(action);
|
setBusy(action);
|
||||||
@@ -423,7 +425,7 @@ function TimeActions({ order, onChanged }: { order: OrderView; onChanged: () =>
|
|||||||
? await submitOp({ opType: "work_order.transition", baseVersion: order.version, payload: { workOrderId, to: "accepted" } })
|
? await submitOp({ opType: "work_order.transition", baseVersion: order.version, payload: { workOrderId, to: "accepted" } })
|
||||||
: action === "travel" || action === "start"
|
: action === "travel" || action === "start"
|
||||||
? await submitOp({ opType: "session.start", payload: startPayload(action === "travel" ? "travel" : "work") })
|
? await submitOp({ opType: "session.start", payload: startPayload(action === "travel" ? "travel" : "work") })
|
||||||
: await submitOp({ opType: action === "pause" ? "session.pause" : action === "resume" ? "session.resume" : "session.end", payload: { workOrderId, at } });
|
: await submitOp({ opType: action === "pause" ? "session.pause" : action === "resume" ? "session.resume" : action === "stop_day" ? "session.stop_day" : "session.end", payload: { workOrderId, at } });
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
report(result);
|
report(result);
|
||||||
onChanged();
|
onChanged();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
removePhotoRequirementAction,
|
removePhotoRequirementAction,
|
||||||
} from "@/server/actions/work_orders/work-orders";
|
} from "@/server/actions/work_orders/work-orders";
|
||||||
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
|
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
|
||||||
|
import { approveTimeEntryAction, rejectTimeEntryAction } from "@/server/actions/work_orders/time-approvals";
|
||||||
import { ActionForm } from "@/components/work-orders/action-form";
|
import { ActionForm } from "@/components/work-orders/action-form";
|
||||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||||
import { Check, Dl, Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
|
import { Check, Dl, Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
|
||||||
@@ -300,10 +301,35 @@ export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
|
|||||||
const t = await getTranslations("workOrders");
|
const t = await getTranslations("workOrders");
|
||||||
const sessions = await getTimesTab(ctx, wo.id);
|
const sessions = await getTimesTab(ctx, wo.id);
|
||||||
if (sessions.length === 0) return <Empty>{t("times.empty")}</Empty>;
|
if (sessions.length === 0) return <Empty>{t("times.empty")}</Empty>;
|
||||||
|
// L12: approved vs. pending totals, badges and inline approval
|
||||||
|
const canApprove = ctx.permissions.has("time:approve");
|
||||||
|
const pendingTotal = sessions.reduce((sum, s) => sum + s.pendingMinutes, 0);
|
||||||
|
const openDecisions = sessions.flatMap((s) => s.entries).filter((e) => e.approvalStatus === "pending" || e.pendingChange !== null).length;
|
||||||
|
const badge = "inline-flex items-center rounded px-1.5 py-0.5 text-xs font-semibold";
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{t("times.approvedMinutes")}: {t("times.minutes", { minutes: sessions.reduce((sum, s) => sum + s.workMinutes, 0) })}
|
||||||
|
</span>
|
||||||
|
{pendingTotal > 0 && <span className="font-semibold text-[var(--warn)]">{t("times.pendingMinutes", { minutes: pendingTotal })}</span>}
|
||||||
|
{canApprove && openDecisions > 0 && (
|
||||||
|
<Link href="/work-orders/time-approvals" className="font-semibold text-[var(--primary)] hover:underline">
|
||||||
|
{t("times.openApprovals")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{sessions.map((s) => (
|
{sessions.map((s) => (
|
||||||
<Section key={s.id} title={`${s.user.name} · ${t(`times.sessionStatus.${s.status}`)}`} actions={<span className="text-sm font-semibold">{t("times.duration")}: {t("times.minutes", { minutes: s.workMinutes })}</span>}>
|
<Section
|
||||||
|
key={s.id}
|
||||||
|
title={`${s.user.name} · ${s.manual ? t("times.manualSession") : t(`times.sessionStatus.${s.status}`)}`}
|
||||||
|
actions={
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{t("times.duration")}: {t("times.minutes", { minutes: s.workMinutes })}
|
||||||
|
{s.pendingMinutes > 0 && <span className="ml-2 text-[var(--warn)]">{t("times.pendingMinutes", { minutes: s.pendingMinutes })}</span>}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[520px] text-sm">
|
<table className="w-full min-w-[520px] text-sm">
|
||||||
<thead className="border-b text-left text-xs text-muted-foreground">
|
<thead className="border-b text-left text-xs text-muted-foreground">
|
||||||
@@ -320,7 +346,36 @@ export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
|
|||||||
<td className="py-1.5 pr-3">{t(`times.entryType.${e.type}`)}</td>
|
<td className="py-1.5 pr-3">{t(`times.entryType.${e.type}`)}</td>
|
||||||
<td className="py-1.5 pr-3">{formatDateTime(e.startedAt, locale, tz)}</td>
|
<td className="py-1.5 pr-3">{formatDateTime(e.startedAt, locale, tz)}</td>
|
||||||
<td className="py-1.5 pr-3">{formatDateTime(e.endedAt, locale, tz) || "…"}</td>
|
<td className="py-1.5 pr-3">{formatDateTime(e.endedAt, locale, tz) || "…"}</td>
|
||||||
<td className="py-1.5 text-xs text-[var(--warn)]">{e.corrected ? t("times.corrected", { reason: e.correctionReason ?? "" }) : ""}</td>
|
<td className="space-y-1 py-1.5 text-xs">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{e.source === "manual" && <span className={`${badge} bg-muted text-foreground`}>{t("times.badgeManual")}</span>}
|
||||||
|
{e.approvalStatus === "pending" && <span className={`${badge} bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-foreground`}>{t("times.badgePending")}</span>}
|
||||||
|
{e.approvalStatus === "rejected" && <span className={`${badge} text-[var(--risk)]`}>{t("times.badgeRejected", { reason: e.rejectionReason ?? "" })}</span>}
|
||||||
|
{e.pendingChange !== null && (
|
||||||
|
<span className={`${badge} bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-foreground`}>
|
||||||
|
{t("times.badgeCorrection", {
|
||||||
|
from: formatDateTime(new Date(String((e.pendingChange as { startedAt?: string }).startedAt)), locale, tz),
|
||||||
|
to: formatDateTime(new Date(String((e.pendingChange as { endedAt?: string }).endedAt)), locale, tz),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{e.corrected && <p className="text-[var(--warn)]">{t("times.corrected", { reason: e.correctionReason ?? "" })}</p>}
|
||||||
|
{!e.corrected && e.source === "manual" && e.correctionReason && <p className="text-muted-foreground">{t("times.reason", { reason: e.correctionReason })}</p>}
|
||||||
|
{canApprove && e.userId !== ctx.userId && (e.approvalStatus === "pending" || e.pendingChange !== null) && (
|
||||||
|
<div className="flex flex-wrap items-start gap-2 pt-1">
|
||||||
|
<ActionForm action={approveTimeEntryAction} submitLabel={t("times.approve")} variant="primary" successText={t("times.approved")} footerClassName="mt-0">
|
||||||
|
<Hidden name="timeEntryId" value={e.id} />
|
||||||
|
<Hidden name="workOrderId" value={wo.id} />
|
||||||
|
</ActionForm>
|
||||||
|
<ActionForm action={rejectTimeEntryAction} submitLabel={t("times.reject")} variant="outline" successText={t("times.rejected")} className="flex flex-wrap items-start gap-2" footerClassName="mt-0">
|
||||||
|
<Hidden name="timeEntryId" value={e.id} />
|
||||||
|
<Hidden name="workOrderId" value={wo.id} />
|
||||||
|
<input name="reason" required minLength={3} maxLength={500} placeholder={t("times.rejectReason")} aria-label={t("times.rejectReason")} className={inputCls} />
|
||||||
|
</ActionForm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
import { IDLE_STATE, type ActionState } from "@/lib/work-orders/action-state";
|
||||||
|
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||||
|
|
||||||
|
type Action = (prev: ActionState, fd: FormData) => Promise<ActionState>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L12 bulk approval form with a DOM id, so row checkboxes elsewhere in the table can join it via
|
||||||
|
* the `form` attribute (row approve/reject forms stay separate forms — no nesting).
|
||||||
|
*/
|
||||||
|
export function BulkApproveForm({ id, action, label, successText }: { id: string; action: Action; label: string; successText: string }) {
|
||||||
|
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
|
||||||
|
const t = useTranslations("workOrders");
|
||||||
|
return (
|
||||||
|
<form id={id} action={formAction} className="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="submit" disabled={pending} className={buttonCls("primary")}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{state.status === "error" && (
|
||||||
|
<span role="alert" className="flex items-center gap-1.5 text-sm text-[var(--risk)]">
|
||||||
|
<AlertCircle className="size-4" aria-hidden />
|
||||||
|
{t.has(`errors.${state.message}`) ? t(`errors.${state.message}`) : t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.internal")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state.status === "ok" && (
|
||||||
|
<span role="status" className="flex items-center gap-1.5 text-sm text-[var(--ok)]">
|
||||||
|
<CheckCircle2 className="size-4" aria-hidden />
|
||||||
|
{successText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „Alle auswählen" for the checkboxes of one bulk form. */
|
||||||
|
export function SelectAllBox({ formId, label }: { formId: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={label}
|
||||||
|
className="size-5"
|
||||||
|
onChange={(e) => {
|
||||||
|
document.querySelectorAll<HTMLInputElement>(`input[type=checkbox][form="${formId}"][name="ids"]`).forEach((box) => {
|
||||||
|
box.checked = e.currentTarget.checked;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+5
-1
@@ -19,13 +19,17 @@ export const EVENT_TYPES = [
|
|||||||
"import.ready_for_review",
|
"import.ready_for_review",
|
||||||
"import.failed",
|
"import.failed",
|
||||||
"sync.failed",
|
"sync.failed",
|
||||||
|
// L12 Zeiterfassung
|
||||||
|
"time.approval_requested",
|
||||||
|
"time.approved",
|
||||||
|
"time.rejected",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type EventType = (typeof EVENT_TYPES)[number];
|
export type EventType = (typeof EVENT_TYPES)[number];
|
||||||
|
|
||||||
export type DomainEvent = {
|
export type DomainEvent = {
|
||||||
type: EventType;
|
type: EventType;
|
||||||
entityType: "work_order" | "report" | "import_job" | "sync_operation";
|
entityType: "work_order" | "report" | "import_job" | "sync_operation" | "time_entry";
|
||||||
entityId: string;
|
entityId: string;
|
||||||
/** Short, human-readable facts for templates (no PII beyond what the recipient may see). */
|
/** Short, human-readable facts for templates (no PII beyond what the recipient may see). */
|
||||||
data?: Record<string, string | number | boolean | null>;
|
data?: Record<string, string | number | boolean | null>;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { SyncOpResult } from "@/lib/sync/envelope";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-safe rules of the L12 time recording (shared by the mobile forms and the services).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Own time may be recorded/corrected for today and this many previous local days. */
|
||||||
|
export const MAX_BACKDATE_DAYS = 7;
|
||||||
|
/** Maximum length of a single manual entry. */
|
||||||
|
export const MAX_ENTRY_MINUTES = 16 * 60;
|
||||||
|
/** Quick picks of the duration mode. */
|
||||||
|
export const DURATION_QUICK_PICKS = [15, 30, 60] as const;
|
||||||
|
/** Entry types offered in „Zeit nachtragen" (chips). */
|
||||||
|
export const MANUAL_TIME_TYPES = ["work", "travel", "return_travel", "material_procurement", "break"] as const;
|
||||||
|
/** Reason suggestions (i18n keys field.myTime.reasons.*). */
|
||||||
|
export const REASON_SUGGESTIONS = ["forgotStart", "noNetwork", "recordedLater"] as const;
|
||||||
|
|
||||||
|
/** Error messages of the time services that have a plain-language text (field.myTime.errors.*). */
|
||||||
|
export const TIME_ERROR_KEYS = ["overlap", "too_old", "in_future", "too_long", "end_before_start", "work_order_status", "not_own_entry", "own_entry", "entry_running"] as const;
|
||||||
|
export type TimeErrorKey = (typeof TIME_ERROR_KEYS)[number];
|
||||||
|
|
||||||
|
export function timeErrorKey(result: Pick<SyncOpResult, "message"> | { message?: string | null }): TimeErrorKey | null {
|
||||||
|
const m = result.message ?? "";
|
||||||
|
return (TIME_ERROR_KEYS as readonly string[]).find((k) => m === k || m.startsWith(`${k}:`)) as TimeErrorKey | undefined ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OtherSession = { workOrderId: string; number: string; title?: string };
|
||||||
|
|
||||||
|
/** `session.start`/`session.resume` rejected because another order is on the clock (auto switch dialog). */
|
||||||
|
export function otherSessionOf(result: Pick<SyncOpResult, "status" | "errorCode" | "message">): OtherSession | null {
|
||||||
|
if (result.errorCode !== "conflict" || !result.message?.startsWith("other_session_running")) return null;
|
||||||
|
try {
|
||||||
|
const details = JSON.parse(result.message.slice("other_session_running:".length)) as OtherSession;
|
||||||
|
return typeof details?.number === "string" ? details : null;
|
||||||
|
} catch {
|
||||||
|
return { workOrderId: "", number: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ListChecks,
|
ListChecks,
|
||||||
Siren,
|
Siren,
|
||||||
Compass,
|
Compass,
|
||||||
|
Timer,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ModuleKey } from "@/lib/modules";
|
import type { ModuleKey } from "@/lib/modules";
|
||||||
@@ -49,6 +50,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
|||||||
permissions: ["work_order:read_all", "work_order:read_team"],
|
permissions: ["work_order:read_all", "work_order:read_team"],
|
||||||
section: "main",
|
section: "main",
|
||||||
},
|
},
|
||||||
|
{ href: "/work-orders/time-approvals", label: "timeApprovals", icon: Timer, module: "work_orders", permissions: ["time:approve"], section: "main" },
|
||||||
{ href: "/work-orders/emergency-review", label: "emergencyReview", icon: Siren, module: "emergency", permissions: ["emergency:review"], section: "main" },
|
{ href: "/work-orders/emergency-review", label: "emergencyReview", icon: Siren, module: "emergency", permissions: ["emergency:review"], section: "main" },
|
||||||
{ href: "/imports", label: "imports", icon: FileInput, module: "imports", permissions: ["import:write"], section: "main" },
|
{ href: "/imports", label: "imports", icon: FileInput, module: "imports", permissions: ["import:write"], section: "main" },
|
||||||
{ href: "/customers", label: "customers", icon: Users, module: "customers", permissions: ["customer:read"], section: "main" },
|
{ href: "/customers", label: "customers", icon: Users, module: "customers", permissions: ["customer:read"], section: "main" },
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export function toBundleRecords(ctxKey: string, orders: BundleOrderData[], synce
|
|||||||
export type LocalNote = { id: string; kind: string; text: string; createdAt: string; pending: boolean };
|
export type LocalNote = { id: string; kind: string; text: string; createdAt: string; pending: boolean };
|
||||||
export type LocalPhoto = { id: string; phase: string | null; comment: string | null; createdAt: string; pending: boolean; blobClientId: string | null };
|
export type LocalPhoto = { id: string; phase: string | null; comment: string | null; createdAt: string; pending: boolean; blobClientId: string | null };
|
||||||
export type SessionState = "en_route" | "running" | "paused" | null;
|
export type SessionState = "en_route" | "running" | "paused" | null;
|
||||||
|
/** L12: manual time entry recorded on this device, not yet confirmed by the server snapshot. */
|
||||||
|
export type LocalManualTime = { id: string; type: string; startedAt: string; endedAt: string | null; durationMinutes: number | null; reason: string; pending: boolean };
|
||||||
|
|
||||||
export type OrderView = BundleOrderData & {
|
export type OrderView = BundleOrderData & {
|
||||||
local: {
|
local: {
|
||||||
@@ -53,6 +55,7 @@ export type OrderView = BundleOrderData & {
|
|||||||
photos: LocalPhoto[];
|
photos: LocalPhoto[];
|
||||||
voiceNotes: number;
|
voiceNotes: number;
|
||||||
session: SessionState;
|
session: SessionState;
|
||||||
|
manualTimes: LocalManualTime[];
|
||||||
pendingOps: number;
|
pendingOps: number;
|
||||||
conflict: boolean;
|
conflict: boolean;
|
||||||
rejected: boolean;
|
rejected: boolean;
|
||||||
@@ -64,7 +67,7 @@ const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|||||||
/** Server snapshot + own ops (pending, or applied but not yet contained in the snapshot). */
|
/** Server snapshot + own ops (pending, or applied but not yet contained in the snapshot). */
|
||||||
export function buildOrderView(record: BundleRecord, ops: OutboxEntry[]): OrderView {
|
export function buildOrderView(record: BundleRecord, ops: OutboxEntry[]): OrderView {
|
||||||
const data: BundleOrderData = structuredCloneSafe(record.data);
|
const data: BundleOrderData = structuredCloneSafe(record.data);
|
||||||
const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data), pendingOps: 0, conflict: false, rejected: false } };
|
const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data), manualTimes: [], pendingOps: 0, conflict: false, rejected: false } };
|
||||||
const mine = ops.filter((o) => o.workOrderId === data.id).sort((a, b) => a.seq - b.seq);
|
const mine = ops.filter((o) => o.workOrderId === data.id).sort((a, b) => a.seq - b.seq);
|
||||||
|
|
||||||
for (const op of mine) {
|
for (const op of mine) {
|
||||||
@@ -116,6 +119,26 @@ function applyOp(view: OrderView, op: OutboxEntry) {
|
|||||||
case "session.end":
|
case "session.end":
|
||||||
view.local.session = null;
|
view.local.session = null;
|
||||||
break;
|
break;
|
||||||
|
case "session.stop_day":
|
||||||
|
// L12 „Für heute beenden": session ends, the order stays open (paused)
|
||||||
|
view.local.session = null;
|
||||||
|
if (view.status === "in_progress") view.status = "paused";
|
||||||
|
break;
|
||||||
|
case "session.segment":
|
||||||
|
if (view.status === "paused") view.status = "in_progress";
|
||||||
|
view.local.session = "running";
|
||||||
|
break;
|
||||||
|
case "time.add_manual":
|
||||||
|
view.local.manualTimes.push({
|
||||||
|
id: str(p.clientId) ?? op.clientOpId,
|
||||||
|
type: str(p.type) ?? "work",
|
||||||
|
startedAt: str(p.startedAt) ?? op.clientCreatedAt,
|
||||||
|
endedAt: str(p.endedAt),
|
||||||
|
durationMinutes: typeof p.durationMinutes === "number" ? p.durationMinutes : null,
|
||||||
|
reason: str(p.reason) ?? "",
|
||||||
|
pending: isPending(op),
|
||||||
|
});
|
||||||
|
break;
|
||||||
case "work_order.transition":
|
case "work_order.transition":
|
||||||
if (typeof p.to === "string") view.status = p.to;
|
if (typeof p.to === "string") view.status = p.to;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const BACKOFF_BASE_MS = 2_000;
|
|||||||
export const BACKOFF_MAX_MS = 5 * 60_000;
|
export const BACKOFF_MAX_MS = 5 * 60_000;
|
||||||
|
|
||||||
/** Ops that change WorkOrder.version on the server (status changes). */
|
/** Ops that change WorkOrder.version on the server (status changes). */
|
||||||
export const VERSION_CHANGING_OPS: readonly SyncOpType[] = ["session.start", "session.pause", "session.resume", "session.end", "work_order.transition", "report.submit"];
|
export const VERSION_CHANGING_OPS: readonly SyncOpType[] = ["session.start", "session.pause", "session.resume", "session.end", "session.stop_day", "session.segment", "work_order.transition", "report.submit"];
|
||||||
|
|
||||||
const TRANSIENT_CODES = new Set<OutboxError["code"]>(["internal", "network"]);
|
const TRANSIENT_CODES = new Set<OutboxError["code"]>(["internal", "network"]);
|
||||||
|
|
||||||
@@ -337,7 +337,13 @@ export function problemKey(e: Pick<OutboxEntry, "status" | "opType" | "lastError
|
|||||||
case "blocked":
|
case "blocked":
|
||||||
return "blocked";
|
return "blocked";
|
||||||
case "invalid":
|
case "invalid":
|
||||||
|
// L12: plain-language texts for rejected time entries
|
||||||
|
if (e.lastError?.message === "overlap") return "timeOverlap";
|
||||||
|
if (["too_old", "in_future", "too_long", "end_before_start"].includes(e.lastError?.message ?? "")) return "timeWindow";
|
||||||
return "invalid";
|
return "invalid";
|
||||||
|
case "conflict":
|
||||||
|
if (e.lastError?.message?.startsWith("other_session_running")) return "otherSession";
|
||||||
|
return "internal";
|
||||||
case "upload":
|
case "upload":
|
||||||
return "upload";
|
return "upload";
|
||||||
case "unauthorized":
|
case "unauthorized":
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ export const reportContentSchema = z.object({
|
|||||||
totalMinutes: z.number().int().nonnegative(),
|
totalMinutes: z.number().int().nonnegative(),
|
||||||
/** true if an entry was still running while the snapshot was built */
|
/** true if an entry was still running while the snapshot was built */
|
||||||
hasRunningEntries: z.boolean(),
|
hasRunningEntries: z.boolean(),
|
||||||
|
/** L12: minutes of manual entries still waiting for approval (not part of any total) */
|
||||||
|
pendingMinutes: z.number().int().nonnegative().optional(),
|
||||||
}),
|
}),
|
||||||
texts: reportTextsSchema,
|
texts: reportTextsSchema,
|
||||||
materials: z.object({
|
materials: z.object({
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export const SYNC_OP_TYPES = [
|
|||||||
"report.submit",
|
"report.submit",
|
||||||
"signature.capture",
|
"signature.capture",
|
||||||
"emergency.create",
|
"emergency.create",
|
||||||
|
// L12 Zeiterfassung (additive, conflict-free)
|
||||||
|
"session.stop_day",
|
||||||
|
"session.segment",
|
||||||
|
"time.add_manual",
|
||||||
|
"time.propose_correction",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type SyncOpType = (typeof SYNC_OP_TYPES)[number];
|
export type SyncOpType = (typeof SYNC_OP_TYPES)[number];
|
||||||
|
|||||||
+51
-1
@@ -50,6 +50,8 @@ export const sessionStartPayload = z.object({
|
|||||||
longitude: lng.optional(),
|
longitude: lng.optional(),
|
||||||
offline: z.boolean().default(false),
|
offline: z.boolean().default(false),
|
||||||
deviceInfo: z.string().max(200).optional(),
|
deviceInfo: z.string().max(200).optional(),
|
||||||
|
/** L12 auto switch: pause the user's running session on another order in the same transaction */
|
||||||
|
switchFromOther: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sessionControlPayload = z.object({
|
export const sessionControlPayload = z.object({
|
||||||
@@ -57,6 +59,50 @@ export const sessionControlPayload = z.object({
|
|||||||
at: isoDate.optional(),
|
at: isoDate.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** session.resume (L12): like session.start, may pause a running session on another order. */
|
||||||
|
export const sessionResumePayload = sessionControlPayload.extend({ switchFromOther: z.boolean().optional() });
|
||||||
|
|
||||||
|
/** Segment types a technician can switch to inside a running session (L12). */
|
||||||
|
export const SESSION_SEGMENT_TYPES = ["work", "return_travel", "material_procurement"] as const;
|
||||||
|
export type SessionSegmentType = (typeof SESSION_SEGMENT_TYPES)[number];
|
||||||
|
|
||||||
|
export const sessionSegmentPayload = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
type: z.enum(SESSION_SEGMENT_TYPES),
|
||||||
|
at: isoDate.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Time entry types (Prisma enum TimeEntryType). */
|
||||||
|
export const TIME_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const;
|
||||||
|
export type TimeType = (typeof TIME_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* time.add_manual (L12): manual time entry. Own entries become `pending` (approval by team lead /
|
||||||
|
* back office); with `field:correct_time` for a team member (`forUserId`) they are approved directly.
|
||||||
|
* Either `endedAt` or `durationMinutes` is required.
|
||||||
|
*/
|
||||||
|
export const timeAddManualPayload = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
clientId: clientId.optional(),
|
||||||
|
type: z.enum(TIME_TYPES).default("work"),
|
||||||
|
startedAt: isoDate,
|
||||||
|
endedAt: isoDate.optional(),
|
||||||
|
durationMinutes: z.number().int().min(1).max(16 * 60).optional(),
|
||||||
|
reason: z.string().trim().min(3).max(500),
|
||||||
|
note: z.string().trim().max(2000).optional(),
|
||||||
|
forUserId: id.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** time.propose_correction (L12): correction proposal for an own entry; old values count until approval. */
|
||||||
|
export const timeProposeCorrectionPayload = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
timeEntryId: id,
|
||||||
|
type: z.enum(TIME_TYPES).optional(),
|
||||||
|
startedAt: isoDate,
|
||||||
|
endedAt: isoDate,
|
||||||
|
reason: z.string().trim().min(3).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
export const workOrderTransitionPayload = z.object({
|
export const workOrderTransitionPayload = z.object({
|
||||||
workOrderId: id,
|
workOrderId: id,
|
||||||
to: z.enum(WORK_ORDER_STATUSES),
|
to: z.enum(WORK_ORDER_STATUSES),
|
||||||
@@ -138,7 +184,7 @@ const passthrough = z.record(z.string(), z.unknown());
|
|||||||
export const OP_PAYLOAD_SCHEMAS = {
|
export const OP_PAYLOAD_SCHEMAS = {
|
||||||
"session.start": sessionStartPayload,
|
"session.start": sessionStartPayload,
|
||||||
"session.pause": sessionControlPayload,
|
"session.pause": sessionControlPayload,
|
||||||
"session.resume": sessionControlPayload,
|
"session.resume": sessionResumePayload,
|
||||||
"session.end": sessionControlPayload,
|
"session.end": sessionControlPayload,
|
||||||
"work_order.transition": workOrderTransitionPayload,
|
"work_order.transition": workOrderTransitionPayload,
|
||||||
"note.create": noteCreatePayload,
|
"note.create": noteCreatePayload,
|
||||||
@@ -150,6 +196,10 @@ export const OP_PAYLOAD_SCHEMAS = {
|
|||||||
"report.submit": reportSubmitPayload,
|
"report.submit": reportSubmitPayload,
|
||||||
"signature.capture": passthrough,
|
"signature.capture": passthrough,
|
||||||
"emergency.create": emergencyCreatePayload,
|
"emergency.create": emergencyCreatePayload,
|
||||||
|
"session.stop_day": sessionControlPayload,
|
||||||
|
"session.segment": sessionSegmentPayload,
|
||||||
|
"time.add_manual": timeAddManualPayload,
|
||||||
|
"time.propose_correction": timeProposeCorrectionPayload,
|
||||||
} satisfies Record<SyncOpType, z.ZodType>;
|
} satisfies Record<SyncOpType, z.ZodType>;
|
||||||
|
|
||||||
export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
|
export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { moduleGuard } from "@/server/action-guard";
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
import { ctxFromGuard, ServiceError } from "@/server/services/context";
|
import { ctxFromGuard, ServiceError } from "@/server/services/context";
|
||||||
import { correctTimeEntry } from "@/server/services/field/time-correction";
|
import { correctTimeEntry } from "@/server/services/field/time-correction";
|
||||||
|
import { approveTimeEntry, rejectTimeEntry } from "@/server/services/field/time-entries";
|
||||||
|
|
||||||
const guard = moduleGuard("field");
|
const guard = moduleGuard("field");
|
||||||
|
|
||||||
export type TimeCorrectionResult = { ok: true } | { ok: false; error: "invalid" | "forbidden" | "not_found" | "failed" };
|
export type TimeCorrectionResult = { ok: true } | { ok: false; error: "invalid" | "forbidden" | "not_found" | "failed"; message?: string };
|
||||||
|
|
||||||
|
function toResult(err: unknown, label: string): TimeCorrectionResult {
|
||||||
|
if (err instanceof ServiceError && (err.code === "invalid" || err.code === "forbidden" || err.code === "not_found")) return { ok: false, error: err.code, message: err.message };
|
||||||
|
if (err instanceof ServiceError && err.code === "conflict") return { ok: false, error: "invalid", message: err.message };
|
||||||
|
console.error(`[field] ${label} failed:`, err);
|
||||||
|
return { ok: false, error: "failed" };
|
||||||
|
}
|
||||||
|
|
||||||
/** Manual time correction (Spec §12.2) — thin adapter over services/field/time-correction. */
|
/** Manual time correction (Spec §12.2) — thin adapter over services/field/time-correction. */
|
||||||
export async function correctTime(input: {
|
export async function correctTime(input: {
|
||||||
@@ -26,10 +34,32 @@ export async function correctTime(input: {
|
|||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ServiceError && (err.code === "invalid" || err.code === "forbidden" || err.code === "not_found")) return { ok: false, error: err.code };
|
return toResult(err, "time correction");
|
||||||
console.error("[field] time correction failed:", err);
|
|
||||||
return { ok: false, error: "failed" };
|
|
||||||
}
|
}
|
||||||
revalidatePath(`/m/orders/${input.workOrderId}/time`);
|
revalidatePath(`/m/orders/${input.workOrderId}/time`);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** L12: approve a manual entry / correction proposal (team lead mobile) — services/field/time-entries. */
|
||||||
|
export async function approveTime(input: { timeEntryId: string }): Promise<TimeCorrectionResult> {
|
||||||
|
const g = await guard("time:approve");
|
||||||
|
try {
|
||||||
|
await approveTimeEntry(ctxFromGuard(g), input.timeEntryId);
|
||||||
|
} catch (err) {
|
||||||
|
return toResult(err, "time approval");
|
||||||
|
}
|
||||||
|
revalidatePath("/m/approvals");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L12: reject with mandatory reason. */
|
||||||
|
export async function rejectTime(input: { timeEntryId: string; reason: string }): Promise<TimeCorrectionResult> {
|
||||||
|
const g = await guard("time:approve");
|
||||||
|
try {
|
||||||
|
await rejectTimeEntry(ctxFromGuard(g), input.timeEntryId, input.reason);
|
||||||
|
} catch (err) {
|
||||||
|
return toResult(err, "time rejection");
|
||||||
|
}
|
||||||
|
revalidatePath("/m/approvals");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import type { ActionState } from "@/lib/work-orders/action-state";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ctxFromGuard } from "@/server/services/context";
|
||||||
|
import { approveTimeEntries, approveTimeEntry, rejectTimeEntry } from "@/server/services/field/time-entries";
|
||||||
|
import { ok, str, toErrorState } from "./_form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L12 back office: approve / reject manual time entries and correction proposals (`time:approve`).
|
||||||
|
* Thin adapters — permission, team scope, "never own entries", audit and events live in
|
||||||
|
* services/field/time-entries.ts.
|
||||||
|
*/
|
||||||
|
const guard = moduleGuard("work_orders");
|
||||||
|
|
||||||
|
function revalidate(workOrderId?: string) {
|
||||||
|
revalidatePath("/work-orders/time-approvals");
|
||||||
|
if (workOrderId) revalidatePath(`/work-orders/${workOrderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveTimeEntryAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const id = str(fd, "timeEntryId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("time:approve");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await approveTimeEntry(ctxFromGuard(g), id);
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "time_entry", entityId: id });
|
||||||
|
}
|
||||||
|
revalidate(str(fd, "workOrderId"));
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectTimeEntryAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const id = str(fd, "timeEntryId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("time:approve");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await rejectTimeEntry(ctxFromGuard(g), id, str(fd, "reason") ?? "");
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "time_entry", entityId: id });
|
||||||
|
}
|
||||||
|
revalidate(str(fd, "workOrderId"));
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk approval of the selected entries (checkboxes `ids`); entries that cannot be approved stay open. */
|
||||||
|
export async function approveTimeEntriesAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const ids = fd.getAll("ids").filter((v): v is string => typeof v === "string" && v.length > 0);
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("time:approve");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
const results = await approveTimeEntries(ctxFromGuard(g), ids);
|
||||||
|
const failed = results.filter((r) => !r.ok).length;
|
||||||
|
revalidate();
|
||||||
|
if (ids.length === 0 || failed === results.length) return { status: "error", code: "invalid", message: "time_bulk_none", at: Date.now() };
|
||||||
|
return ok();
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "time_entry" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
|
|||||||
{ model: "WorkSession", field: "userId" },
|
{ model: "WorkSession", field: "userId" },
|
||||||
{ model: "TimeEntry", field: "userId" },
|
{ model: "TimeEntry", field: "userId" },
|
||||||
{ model: "TimeEntry", field: "correctedById" },
|
{ model: "TimeEntry", field: "correctedById" },
|
||||||
|
{ model: "TimeEntry", field: "approvedById" }, // L12: approving/rejecting person
|
||||||
{ model: "ActivityNote", field: "authorId" },
|
{ model: "ActivityNote", field: "authorId" },
|
||||||
{ model: "Document", field: "uploadedById" },
|
{ model: "Document", field: "uploadedById" },
|
||||||
{ model: "Photo", field: "takenById" },
|
{ model: "Photo", field: "takenById" },
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export const PERMISSIONS = [
|
|||||||
// Einsatz (Mobile): Einsatz starten, Zeiten, Material, Fotos, Notizen
|
// Einsatz (Mobile): Einsatz starten, Zeiten, Material, Fotos, Notizen
|
||||||
"field:execute",
|
"field:execute",
|
||||||
"field:correct_time",
|
"field:correct_time",
|
||||||
|
"field:record_own_time", // L12: eigene Zeiten nachtragen / Korrektur vorschlagen (mit Freigabe)
|
||||||
|
// Zeiterfassung: Nachträge/Korrekturen freigeben (Teamleiter: eigenes Team, Backoffice: alle)
|
||||||
|
"time:approve",
|
||||||
// Berichte
|
// Berichte
|
||||||
"report:read",
|
"report:read",
|
||||||
"report:write",
|
"report:write",
|
||||||
@@ -85,6 +88,8 @@ export const ROLE_DEFS: Record<RoleKey, { name: string; permissions: readonly Pe
|
|||||||
"work_order:read_team",
|
"work_order:read_team",
|
||||||
"field:execute",
|
"field:execute",
|
||||||
"field:correct_time",
|
"field:correct_time",
|
||||||
|
"field:record_own_time",
|
||||||
|
"time:approve",
|
||||||
"report:read",
|
"report:read",
|
||||||
"report:write",
|
"report:write",
|
||||||
"report:approve_team",
|
"report:approve_team",
|
||||||
@@ -102,6 +107,7 @@ export const ROLE_DEFS: Record<RoleKey, { name: string; permissions: readonly Pe
|
|||||||
"team:read",
|
"team:read",
|
||||||
"work_order:read_team",
|
"work_order:read_team",
|
||||||
"field:execute",
|
"field:execute",
|
||||||
|
"field:record_own_time",
|
||||||
"report:read",
|
"report:read",
|
||||||
"report:write",
|
"report:write",
|
||||||
"emergency:create",
|
"emergency:create",
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ export async function createEmergencyOrder(
|
|||||||
at: startedAt.toISOString(),
|
at: startedAt.toISOString(),
|
||||||
offline: input.offline,
|
offline: input.offline,
|
||||||
deviceInfo: input.deviceInfo,
|
deviceInfo: input.deviceInfo,
|
||||||
|
// L12: an emergency call-out pauses a running session on another order (one clock per user)
|
||||||
|
switchFromOther: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export type OrderCard = {
|
|||||||
plannedStart: Date | null;
|
plannedStart: Date | null;
|
||||||
plannedEnd: Date | null;
|
plannedEnd: Date | null;
|
||||||
version: number;
|
version: number;
|
||||||
|
/** L12: own active session on this order (card start/stop button) */
|
||||||
|
mySession: "en_route" | "running" | "paused" | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CARD_SELECT = {
|
const CARD_SELECT = {
|
||||||
@@ -119,9 +121,23 @@ function toCard(wo: Prisma.WorkOrderGetPayload<{ select: typeof CARD_SELECT }>):
|
|||||||
plannedStart: wo.plannedStart,
|
plannedStart: wo.plannedStart,
|
||||||
plannedEnd: wo.plannedEnd,
|
plannedEnd: wo.plannedEnd,
|
||||||
version: wo.version,
|
version: wo.version,
|
||||||
|
mySession: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** L12: attach the caller's own active session per card (one query). */
|
||||||
|
async function withMySessions(ctx: ServiceCtx, cards: OrderCard[]): Promise<OrderCard[]> {
|
||||||
|
if (!cards.length) return cards;
|
||||||
|
const sessions = await ctx.db.workSession.findMany({
|
||||||
|
where: { workOrderId: { in: cards.map((c) => c.id) }, userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES }, manual: false },
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
select: { workOrderId: true, status: true },
|
||||||
|
});
|
||||||
|
const byOrder = new Map<string, OrderCard["mySession"]>();
|
||||||
|
for (const s of sessions) if (!byOrder.has(s.workOrderId)) byOrder.set(s.workOrderId, s.status as OrderCard["mySession"]);
|
||||||
|
return cards.map((c) => ({ ...c, mySession: byOrder.get(c.id) ?? null }));
|
||||||
|
}
|
||||||
|
|
||||||
export async function listFieldOrders(ctx: ServiceCtx, tab: OrderTab): Promise<OrderCard[]> {
|
export async function listFieldOrders(ctx: ServiceCtx, tab: OrderTab): Promise<OrderCard[]> {
|
||||||
const scope = await workOrderScope(ctx);
|
const scope = await workOrderScope(ctx);
|
||||||
const rows = await ctx.db.workOrder.findMany({
|
const rows = await ctx.db.workOrder.findMany({
|
||||||
@@ -130,7 +146,7 @@ export async function listFieldOrders(ctx: ServiceCtx, tab: OrderTab): Promise<O
|
|||||||
take: tab === "past" ? 50 : 200,
|
take: tab === "past" ? 50 : 200,
|
||||||
select: CARD_SELECT,
|
select: CARD_SELECT,
|
||||||
});
|
});
|
||||||
return rows.map(toCard);
|
return withMySessions(ctx, rows.map(toCard));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Heute": orders planned for today (not yet done) plus all running and paused ones. */
|
/** "Heute": orders planned for today (not yet done) plus all running and paused ones. */
|
||||||
@@ -160,7 +176,7 @@ export async function listTodayOrders(ctx: ServiceCtx, now = new Date()): Promis
|
|||||||
take: 100,
|
take: 100,
|
||||||
select: CARD_SELECT,
|
select: CARD_SELECT,
|
||||||
});
|
});
|
||||||
return rows.map(toCard);
|
return withMySessions(ctx, rows.map(toCard));
|
||||||
}
|
}
|
||||||
|
|
||||||
const DETAIL_SELECT = {
|
const DETAIL_SELECT = {
|
||||||
@@ -217,7 +233,11 @@ const DETAIL_SELECT = {
|
|||||||
endedAt: true,
|
endedAt: true,
|
||||||
startedOffline: true,
|
startedOffline: true,
|
||||||
user: { select: { name: true } },
|
user: { select: { name: true } },
|
||||||
entries: { orderBy: { startedAt: "asc" }, select: { id: true, type: true, startedAt: true, endedAt: true, corrected: true, correctionReason: true } },
|
manual: true,
|
||||||
|
entries: {
|
||||||
|
orderBy: { startedAt: "asc" },
|
||||||
|
select: { id: true, userId: true, type: true, startedAt: true, endedAt: true, corrected: true, correctionReason: true, source: true, approvalStatus: true, rejectionReason: true, pendingChange: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies Prisma.WorkOrderSelect;
|
} satisfies Prisma.WorkOrderSelect;
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import type { TimeEntryType, WorkSessionStatus } from "@prisma/client";
|
import type { TimeEntryType, WorkSession, WorkSessionStatus } from "@prisma/client";
|
||||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
import { inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
import { canTransition, type WorkOrderStatus } from "@/lib/work-orders/status";
|
import { canTransition, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
import type { ParsedOpPayload } from "@/lib/sync/ops";
|
import type { ParsedOpPayload } from "@/lib/sync/ops";
|
||||||
import { audit, opTime, requireFieldOrder, type FieldOrder } from "./common";
|
import { audit, opTime, requireFieldOrder, type FieldOrder } from "./common";
|
||||||
// TODO(merge L2): replace with "@/server/services/work-orders/transition"
|
|
||||||
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Work sessions (Spec §12.1/§12.2): one active session per user + work order. A session consists
|
* Work sessions (Spec §12.1/§12.2): one active session per user + work order. A session consists
|
||||||
* of TimeEntry segments (travel → work ⇄ break). Status changes of the work order are delegated to
|
* of TimeEntry segments (travel → work ⇄ break, return travel, material procurement). Status
|
||||||
* transitionWorkOrder (never a direct status update).
|
* changes of the work order are delegated to transitionWorkOrder (never a direct status update).
|
||||||
|
*
|
||||||
|
* L12 Zeiterfassung: only ONE running/en-route session per user across all orders (auto switch
|
||||||
|
* with `switchFromOther`), "Für heute beenden" (`stopForToday`), segment switches and the
|
||||||
|
* current session for the clock bar (`getMyActiveSession`). Multi-step writes run in inTransaction.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const ACTIVE_SESSION_STATUSES: WorkSessionStatus[] = ["en_route", "running", "paused"];
|
export const ACTIVE_SESSION_STATUSES: WorkSessionStatus[] = ["en_route", "running", "paused"];
|
||||||
|
/** Sessions that are "on the clock" — at most one per user across all orders. */
|
||||||
|
const CLOCK_STATUSES: WorkSessionStatus[] = ["en_route", "running"];
|
||||||
|
/** Entry types that count as working time (everything except break). */
|
||||||
|
const NON_BREAK: TimeEntryType[] = ["travel", "work", "material_procurement", "return_travel", "interruption"];
|
||||||
|
|
||||||
export type SessionResult = {
|
export type SessionResult = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -30,14 +37,14 @@ export type SessionEndResult = SessionResult & {
|
|||||||
|
|
||||||
function activeSession(ctx: ServiceCtx, workOrderId: string) {
|
function activeSession(ctx: ServiceCtx, workOrderId: string) {
|
||||||
return ctx.db.workSession.findFirst({
|
return ctx.db.workSession.findFirst({
|
||||||
where: { workOrderId, userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES } },
|
where: { workOrderId, userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES }, manual: false },
|
||||||
orderBy: { startedAt: "desc" },
|
orderBy: { startedAt: "desc" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Other users' sessions that are still working on the order. */
|
/** Other users' sessions that are still working on the order. */
|
||||||
function othersRunning(ctx: ServiceCtx, workOrderId: string) {
|
function othersRunning(ctx: ServiceCtx, workOrderId: string) {
|
||||||
return ctx.db.workSession.count({ where: { workOrderId, userId: { not: ctx.userId }, status: { in: ["running", "en_route"] } } });
|
return ctx.db.workSession.count({ where: { workOrderId, userId: { not: ctx.userId }, status: { in: CLOCK_STATUSES } } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeOpenEntries(ctx: ServiceCtx, sessionId: string, at: Date) {
|
async function closeOpenEntries(ctx: ServiceCtx, sessionId: string, at: Date) {
|
||||||
@@ -58,30 +65,91 @@ async function moveOrder(ctx: ServiceCtx, wo: FieldOrder, to: WorkOrderStatus):
|
|||||||
return { status: r.status, version: r.version };
|
return { status: r.status, version: r.version };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.start">): Promise<SessionResult> {
|
/**
|
||||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
* L12 auto switch: a running/en-route session of the user on ANOTHER order. Without
|
||||||
const at = opTime(input.at);
|
* `switchFromOther` → conflict `other_session_running` (details: workOrderId, number); with it the
|
||||||
|
* other session is paused inside the caller's transaction.
|
||||||
if (input.clientId) {
|
*/
|
||||||
const replay = await ctx.db.workSession.findFirst({ where: { clientId: input.clientId } });
|
async function handleOtherRunning(ctx: ServiceCtx, workOrderId: string, switchFromOther: boolean | undefined, at: Date) {
|
||||||
if (replay) {
|
const other = await ctx.db.workSession.findFirst({
|
||||||
if (replay.workOrderId !== wo.id || replay.userId !== ctx.userId) throw new ServiceError("invalid", "clientId already used");
|
where: { userId: ctx.userId, workOrderId: { not: workOrderId }, status: { in: CLOCK_STATUSES }, workOrder: { deletedAt: null } },
|
||||||
return { sessionId: replay.id, status: replay.status, workOrderStatus: wo.status, workOrderVersion: wo.version };
|
orderBy: { startedAt: "desc" },
|
||||||
}
|
include: { workOrder: { select: { id: true, number: true, title: true, status: true, version: true, siteId: true, customerId: true, assignedTeamId: true } } },
|
||||||
|
});
|
||||||
|
if (!other) return;
|
||||||
|
if (!switchFromOther) {
|
||||||
|
throw new ServiceError("conflict", "other_session_running", { workOrderId: other.workOrder.id, number: other.workOrder.number, title: other.workOrder.title });
|
||||||
}
|
}
|
||||||
|
await pauseLoaded(ctx, other, other.workOrder as FieldOrder, at);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await activeSession(ctx, wo.id);
|
/** Pause a loaded running/en-route session (break segment) and move its order to `paused` when nobody else works on it. */
|
||||||
|
async function pauseLoaded(ctx: ServiceCtx, session: WorkSession, wo: FieldOrder, at: Date) {
|
||||||
|
await closeOpenEntries(ctx, session.id, at);
|
||||||
|
await openEntry(ctx, session.id, "break", at);
|
||||||
|
const updated = await ctx.db.workSession.update({ where: { id: session.id }, data: { status: "paused" } });
|
||||||
|
const moved = wo.status === "in_progress" && (await othersRunning(ctx, wo.id)) === 0 ? await moveOrder(ctx, wo, "paused") : { status: wo.status, version: wo.version };
|
||||||
|
await audit(ctx, "update", "work_session", session.id, { status: session.status }, { status: "paused", at });
|
||||||
|
return { session: updated, moved };
|
||||||
|
}
|
||||||
|
|
||||||
if (input.mode === "travel") {
|
export async function startSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.start">): Promise<SessionResult> {
|
||||||
if (existing) throw new ServiceError("conflict", "a session is already active for this work order");
|
const at = opTime(input.at);
|
||||||
const moved = await moveOrder(ctx, wo, "en_route");
|
return inTransaction(ctx, async (ctx) => {
|
||||||
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
|
|
||||||
|
if (input.clientId) {
|
||||||
|
const replay = await ctx.db.workSession.findFirst({ where: { clientId: input.clientId } });
|
||||||
|
if (replay) {
|
||||||
|
if (replay.workOrderId !== wo.id || replay.userId !== ctx.userId) throw new ServiceError("invalid", "clientId already used");
|
||||||
|
return { sessionId: replay.id, status: replay.status, workOrderStatus: wo.status, workOrderVersion: wo.version };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await activeSession(ctx, wo.id);
|
||||||
|
if (input.mode === "travel" && existing) throw new ServiceError("conflict", "a session is already active for this work order");
|
||||||
|
if (input.mode === "work" && existing && existing.status !== "en_route") throw new ServiceError("conflict", "a session is already running for this work order");
|
||||||
|
|
||||||
|
await handleOtherRunning(ctx, wo.id, input.switchFromOther, at);
|
||||||
|
|
||||||
|
if (input.mode === "travel") {
|
||||||
|
const moved = await moveOrder(ctx, wo, "en_route");
|
||||||
|
const session = await ctx.db.workSession.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
workOrderId: wo.id,
|
||||||
|
userId: ctx.userId,
|
||||||
|
teamId: wo.assignedTeamId,
|
||||||
|
status: "en_route",
|
||||||
|
startedAt: at,
|
||||||
|
startLat: input.latitude ?? null,
|
||||||
|
startLng: input.longitude ?? null,
|
||||||
|
startedOffline: input.offline,
|
||||||
|
deviceInfo: input.deviceInfo ?? null,
|
||||||
|
clientId: input.clientId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await openEntry(ctx, session.id, "travel", at);
|
||||||
|
await audit(ctx, "create", "work_session", session.id, null, { workOrderId: wo.id, status: "en_route", startedAt: at });
|
||||||
|
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
||||||
|
}
|
||||||
|
|
||||||
|
// mode "work"
|
||||||
|
const moved = await moveOrder(ctx, wo, "in_progress");
|
||||||
|
if (existing) {
|
||||||
|
await closeOpenEntries(ctx, existing.id, at);
|
||||||
|
await openEntry(ctx, existing.id, "work", at);
|
||||||
|
const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } });
|
||||||
|
await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "running", at });
|
||||||
|
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
||||||
|
}
|
||||||
const session = await ctx.db.workSession.create({
|
const session = await ctx.db.workSession.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
workOrderId: wo.id,
|
workOrderId: wo.id,
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
teamId: wo.assignedTeamId,
|
teamId: wo.assignedTeamId,
|
||||||
status: "en_route",
|
status: "running",
|
||||||
startedAt: at,
|
startedAt: at,
|
||||||
startLat: input.latitude ?? null,
|
startLat: input.latitude ?? null,
|
||||||
startLng: input.longitude ?? null,
|
startLng: input.longitude ?? null,
|
||||||
@@ -90,72 +158,40 @@ export async function startSession(ctx: ServiceCtx, input: ParsedOpPayload<"sess
|
|||||||
clientId: input.clientId ?? null,
|
clientId: input.clientId ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await openEntry(ctx, session.id, "travel", at);
|
await openEntry(ctx, session.id, "work", at);
|
||||||
await audit(ctx, "create", "work_session", session.id, null, { workOrderId: wo.id, status: "en_route", startedAt: at });
|
await audit(ctx, "create", "work_session", session.id, null, { workOrderId: wo.id, status: "running", startedAt: at });
|
||||||
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
||||||
}
|
|
||||||
|
|
||||||
// mode "work"
|
|
||||||
if (existing && existing.status !== "en_route") throw new ServiceError("conflict", "a session is already running for this work order");
|
|
||||||
const moved = await moveOrder(ctx, wo, "in_progress");
|
|
||||||
if (existing) {
|
|
||||||
await closeOpenEntries(ctx, existing.id, at);
|
|
||||||
await openEntry(ctx, existing.id, "work", at);
|
|
||||||
const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } });
|
|
||||||
await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "running", at });
|
|
||||||
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
|
||||||
}
|
|
||||||
const session = await ctx.db.workSession.create({
|
|
||||||
data: {
|
|
||||||
tenantId: ctx.tenantId,
|
|
||||||
workOrderId: wo.id,
|
|
||||||
userId: ctx.userId,
|
|
||||||
teamId: wo.assignedTeamId,
|
|
||||||
status: "running",
|
|
||||||
startedAt: at,
|
|
||||||
startLat: input.latitude ?? null,
|
|
||||||
startLng: input.longitude ?? null,
|
|
||||||
startedOffline: input.offline,
|
|
||||||
deviceInfo: input.deviceInfo ?? null,
|
|
||||||
clientId: input.clientId ?? null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
await openEntry(ctx, session.id, "work", at);
|
|
||||||
await audit(ctx, "create", "work_session", session.id, null, { workOrderId: wo.id, status: "running", startedAt: at });
|
|
||||||
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pauseSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.pause">): Promise<SessionResult> {
|
export async function pauseSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.pause">): Promise<SessionResult> {
|
||||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
|
||||||
const at = opTime(input.at);
|
const at = opTime(input.at);
|
||||||
const existing = await activeSession(ctx, wo.id);
|
return inTransaction(ctx, async (ctx) => {
|
||||||
if (!existing || existing.status !== "running") throw new ServiceError("invalid", "no running session to pause");
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
await closeOpenEntries(ctx, existing.id, at);
|
const existing = await activeSession(ctx, wo.id);
|
||||||
await openEntry(ctx, existing.id, "break", at);
|
if (!existing || existing.status !== "running") throw new ServiceError("invalid", "no running session to pause");
|
||||||
const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "paused" } });
|
const { session, moved } = await pauseLoaded(ctx, existing, wo, at);
|
||||||
const moved = wo.status === "in_progress" && (await othersRunning(ctx, wo.id)) === 0 ? await moveOrder(ctx, wo, "paused") : { status: wo.status, version: wo.version };
|
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
||||||
await audit(ctx, "update", "work_session", session.id, { status: "running" }, { status: "paused", at });
|
});
|
||||||
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resumeSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.resume">): Promise<SessionResult> {
|
export async function resumeSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.resume">): Promise<SessionResult> {
|
||||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
|
||||||
const at = opTime(input.at);
|
const at = opTime(input.at);
|
||||||
const existing = await activeSession(ctx, wo.id);
|
return inTransaction(ctx, async (ctx) => {
|
||||||
if (!existing || existing.status !== "paused") throw new ServiceError("invalid", "no paused session to resume");
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
await closeOpenEntries(ctx, existing.id, at);
|
const existing = await activeSession(ctx, wo.id);
|
||||||
await openEntry(ctx, existing.id, "work", at);
|
if (!existing || existing.status !== "paused") throw new ServiceError("invalid", "no paused session to resume");
|
||||||
const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } });
|
await handleOtherRunning(ctx, wo.id, input.switchFromOther, at);
|
||||||
const moved = await moveOrder(ctx, wo, "in_progress");
|
await closeOpenEntries(ctx, existing.id, at);
|
||||||
await audit(ctx, "update", "work_session", session.id, { status: "paused" }, { status: "running", at });
|
await openEntry(ctx, existing.id, "work", at);
|
||||||
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } });
|
||||||
|
const moved = await moveOrder(ctx, wo, "in_progress");
|
||||||
|
await audit(ctx, "update", "work_session", session.id, { status: "paused" }, { status: "running", at });
|
||||||
|
return { sessionId: session.id, status: session.status, workOrderStatus: moved.status, workOrderVersion: moved.version };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function endSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.end">): Promise<SessionEndResult> {
|
async function finishSession(ctx: ServiceCtx, wo: FieldOrder, existing: WorkSession, at: Date, extra: Record<string, unknown> = {}) {
|
||||||
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
|
||||||
const at = opTime(input.at);
|
|
||||||
const existing = await activeSession(ctx, wo.id);
|
|
||||||
if (!existing) throw new ServiceError("invalid", "no active session to end");
|
|
||||||
await closeOpenEntries(ctx, existing.id, at);
|
await closeOpenEntries(ctx, existing.id, at);
|
||||||
const endedAt = at < existing.startedAt ? existing.startedAt : at;
|
const endedAt = at < existing.startedAt ? existing.startedAt : at;
|
||||||
const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "ended", endedAt } });
|
const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "ended", endedAt } });
|
||||||
@@ -165,15 +201,107 @@ export async function endSession(ctx: ServiceCtx, input: ParsedOpPayload<"sessio
|
|||||||
const workSeconds = sum(["work", "material_procurement", "interruption"]);
|
const workSeconds = sum(["work", "material_procurement", "interruption"]);
|
||||||
const breakSeconds = sum(["break"]);
|
const breakSeconds = sum(["break"]);
|
||||||
const travelSeconds = sum(["travel", "return_travel"]);
|
const travelSeconds = sum(["travel", "return_travel"]);
|
||||||
await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "ended", endedAt, workSeconds, breakSeconds, travelSeconds });
|
await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "ended", endedAt, workSeconds, breakSeconds, travelSeconds, ...extra });
|
||||||
return {
|
return {
|
||||||
sessionId: session.id,
|
session,
|
||||||
status: session.status,
|
|
||||||
workOrderStatus: wo.status,
|
|
||||||
workOrderVersion: wo.version,
|
|
||||||
workSeconds,
|
workSeconds,
|
||||||
breakSeconds,
|
breakSeconds,
|
||||||
travelSeconds,
|
travelSeconds,
|
||||||
totalSeconds: Math.round((endedAt.getTime() - session.startedAt.getTime()) / 1000),
|
totalSeconds: Math.round((endedAt.getTime() - session.startedAt.getTime()) / 1000),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function endSession(ctx: ServiceCtx, input: ParsedOpPayload<"session.end">): Promise<SessionEndResult> {
|
||||||
|
const at = opTime(input.at);
|
||||||
|
return inTransaction(ctx, async (ctx) => {
|
||||||
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
|
const existing = await activeSession(ctx, wo.id);
|
||||||
|
if (!existing) throw new ServiceError("invalid", "no active session to end");
|
||||||
|
const r = await finishSession(ctx, wo, existing, at);
|
||||||
|
return { sessionId: r.session.id, status: r.session.status, workOrderStatus: wo.status, workOrderVersion: wo.version, workSeconds: r.workSeconds, breakSeconds: r.breakSeconds, travelSeconds: r.travelSeconds, totalSeconds: r.totalSeconds };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Für heute beenden" (L12): ends the own session, the order stays open. When nobody else is on
|
||||||
|
* the clock for the order it is moved to `paused` (only where the status machine allows it).
|
||||||
|
* No completion, no completion blockers.
|
||||||
|
*/
|
||||||
|
export async function stopForToday(ctx: ServiceCtx, input: ParsedOpPayload<"session.stop_day">): Promise<SessionEndResult> {
|
||||||
|
const at = opTime(input.at);
|
||||||
|
return inTransaction(ctx, async (ctx) => {
|
||||||
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
|
const existing = await activeSession(ctx, wo.id);
|
||||||
|
if (!existing) throw new ServiceError("invalid", "no active session to stop");
|
||||||
|
const r = await finishSession(ctx, wo, existing, at, { stoppedForToday: true });
|
||||||
|
const moved = wo.status === "in_progress" && (await othersRunning(ctx, wo.id)) === 0 ? await moveOrder(ctx, wo, "paused") : { status: wo.status, version: wo.version };
|
||||||
|
return { sessionId: r.session.id, status: r.session.status, workOrderStatus: moved.status, workOrderVersion: moved.version, workSeconds: r.workSeconds, breakSeconds: r.breakSeconds, travelSeconds: r.travelSeconds, totalSeconds: r.totalSeconds };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Segment switch inside the own active session (L12): „Rückfahrt starten", „Material holen", back to work. */
|
||||||
|
export async function switchSegment(ctx: ServiceCtx, input: ParsedOpPayload<"session.segment">): Promise<SessionResult> {
|
||||||
|
const at = opTime(input.at);
|
||||||
|
return inTransaction(ctx, async (ctx) => {
|
||||||
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
|
const existing = await activeSession(ctx, wo.id);
|
||||||
|
if (!existing || existing.status === "en_route") throw new ServiceError("invalid", "no running or paused session");
|
||||||
|
const open = await ctx.db.timeEntry.findFirst({ where: { workSessionId: existing.id, endedAt: null }, orderBy: { startedAt: "desc" } });
|
||||||
|
if (open?.type === input.type) throw new ServiceError("invalid", "segment already active");
|
||||||
|
if (existing.status === "paused") await handleOtherRunning(ctx, wo.id, true, at);
|
||||||
|
await closeOpenEntries(ctx, existing.id, at);
|
||||||
|
await openEntry(ctx, existing.id, input.type, at);
|
||||||
|
const session = existing.status === "running" ? existing : await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "running" } });
|
||||||
|
const moved = existing.status === "paused" ? await moveOrder(ctx, wo, "in_progress") : { status: wo.status, version: wo.version };
|
||||||
|
await audit(ctx, "update", "work_session", session.id, { status: existing.status, segment: open?.type ?? null }, { status: "running", segment: input.type, at });
|
||||||
|
return { sessionId: session.id, status: "running", workOrderStatus: moved.status, workOrderVersion: moved.version };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MyActiveSession = {
|
||||||
|
sessionId: string;
|
||||||
|
status: "en_route" | "running" | "paused";
|
||||||
|
workOrderId: string;
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
workOrderStatus: WorkOrderStatus;
|
||||||
|
workOrderVersion: number;
|
||||||
|
startedAt: string;
|
||||||
|
/** type + start of the open segment (null when none is open) */
|
||||||
|
segmentType: TimeEntryType | null;
|
||||||
|
segmentStartedAt: string | null;
|
||||||
|
/** sum of closed non-break segments of the session (seconds); the client adds the running segment */
|
||||||
|
closedSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The user's current session for the clock bar: running/en-route first, otherwise the newest paused one. */
|
||||||
|
export async function getMyActiveSession(ctx: ServiceCtx): Promise<MyActiveSession | null> {
|
||||||
|
const sessions = await ctx.db.workSession.findMany({
|
||||||
|
where: { userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES }, manual: false, workOrder: { deletedAt: null } },
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
take: 10,
|
||||||
|
include: {
|
||||||
|
workOrder: { select: { id: true, number: true, title: true, status: true, version: true } },
|
||||||
|
entries: { orderBy: { startedAt: "asc" }, select: { type: true, startedAt: true, endedAt: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const s = sessions.find((x) => CLOCK_STATUSES.includes(x.status)) ?? sessions[0];
|
||||||
|
if (!s) return null;
|
||||||
|
const open = [...s.entries].reverse().find((e) => !e.endedAt) ?? null;
|
||||||
|
const closedSeconds = Math.round(
|
||||||
|
s.entries.filter((e) => e.endedAt && NON_BREAK.includes(e.type)).reduce((acc, e) => acc + (e.endedAt!.getTime() - e.startedAt.getTime()), 0) / 1000,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
sessionId: s.id,
|
||||||
|
status: s.status as MyActiveSession["status"],
|
||||||
|
workOrderId: s.workOrder.id,
|
||||||
|
number: s.workOrder.number,
|
||||||
|
title: s.workOrder.title,
|
||||||
|
workOrderStatus: s.workOrder.status,
|
||||||
|
workOrderVersion: s.workOrder.version,
|
||||||
|
startedAt: s.startedAt.toISOString(),
|
||||||
|
segmentType: open?.type ?? null,
|
||||||
|
segmentStartedAt: open?.startedAt.toISOString() ?? null,
|
||||||
|
closedSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||||
import { audit } from "./common";
|
import { audit } from "./common";
|
||||||
|
|
||||||
/** Manual time corrections (Spec §12.2): only with `field:correct_time`, reason mandatory, always audited. */
|
/**
|
||||||
|
* Manual time corrections (Spec §12.2): only with `field:correct_time`, reason mandatory, always audited.
|
||||||
|
* L12: own entries cannot be corrected directly (nobody approves their own time) → proposeTimeCorrection.
|
||||||
|
* A direct correction supersedes an open correction proposal of the entry.
|
||||||
|
*/
|
||||||
|
|
||||||
export const TIME_ENTRY_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const;
|
export const TIME_ENTRY_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const;
|
||||||
|
|
||||||
@@ -31,6 +36,7 @@ export async function correctTimeEntry(ctx: ServiceCtx, raw: TimeCorrectionInput
|
|||||||
});
|
});
|
||||||
if (!entry) throw new ServiceError("not_found", "time entry not found");
|
if (!entry) throw new ServiceError("not_found", "time entry not found");
|
||||||
await requireVisibleWorkOrder(ctx, entry.workSession.workOrderId, { id: true });
|
await requireVisibleWorkOrder(ctx, entry.workSession.workOrderId, { id: true });
|
||||||
|
if (entry.userId === ctx.userId) throw new ServiceError("forbidden", "own_entry");
|
||||||
if (input.startedAt.getTime() > Date.now() + 60_000) throw new ServiceError("invalid", "start in the future");
|
if (input.startedAt.getTime() > Date.now() + 60_000) throw new ServiceError("invalid", "start in the future");
|
||||||
|
|
||||||
const before = { type: entry.type, startedAt: entry.startedAt, endedAt: entry.endedAt, corrected: entry.corrected, correctionReason: entry.correctionReason };
|
const before = { type: entry.type, startedAt: entry.startedAt, endedAt: entry.endedAt, corrected: entry.corrected, correctionReason: entry.correctionReason };
|
||||||
@@ -43,6 +49,7 @@ export async function correctTimeEntry(ctx: ServiceCtx, raw: TimeCorrectionInput
|
|||||||
corrected: true,
|
corrected: true,
|
||||||
correctionReason: input.reason,
|
correctionReason: input.reason,
|
||||||
correctedById: ctx.userId,
|
correctedById: ctx.userId,
|
||||||
|
pendingChange: Prisma.DbNull,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await audit(ctx, "update", "time_entry", entry.id, before, {
|
await audit(ctx, "update", "time_entry", entry.id, before, {
|
||||||
|
|||||||
@@ -0,0 +1,589 @@
|
|||||||
|
import { Prisma, type TimeEntryType } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { emitEvent } from "@/server/events";
|
||||||
|
import { assertCan, can, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { requireVisibleWorkOrder, workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
|
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||||
|
import { dayWindow, localDateKey } from "@/lib/reports/dates";
|
||||||
|
import { FIELD_EDITABLE, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { TIME_TYPES } from "@/lib/sync/ops";
|
||||||
|
import { MAX_BACKDATE_DAYS, MAX_ENTRY_MINUTES } from "@/lib/field/time-rules";
|
||||||
|
import { audit } from "./common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L12 Zeiterfassung — manual time entries, correction proposals and their approval.
|
||||||
|
*
|
||||||
|
* Rules (decision of the product owner):
|
||||||
|
* - Technicians record/correct only their OWN time of the last 7 days, reason mandatory, marked
|
||||||
|
* `source = manual`. Own entries are `pending`; proposals for existing entries are stored in
|
||||||
|
* `pendingChange` while the entry keeps its old (counted) values.
|
||||||
|
* - Team leads (entries of their teams / orders of their teams) and back office (all) approve with
|
||||||
|
* `time:approve`. Nobody approves their own entries.
|
||||||
|
* - `field:correct_time` records entries for team members directly (approved, source manual).
|
||||||
|
* - Only `approvalStatus = approved` counts for reports, totals and billing; billing release is
|
||||||
|
* blocked while pending entries or proposals exist on the order.
|
||||||
|
*
|
||||||
|
* Manual entries live in a "manual" WorkSession (flag `manual`, status `ended`) per user + order +
|
||||||
|
* local day, so every existing read path (sessions → entries → order) keeps working unchanged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Order statuses in which time may still be recorded afterwards (field statuses + in review). */
|
||||||
|
export const TIME_RECORDABLE: readonly WorkOrderStatus[] = [...FIELD_EDITABLE, "in_review"];
|
||||||
|
|
||||||
|
const NON_BREAK: TimeEntryType[] = ["travel", "work", "material_procurement", "return_travel", "interruption"];
|
||||||
|
const idSchema = z.string().min(1).max(64);
|
||||||
|
|
||||||
|
export type PendingChange = { startedAt: string; endedAt: string; type: TimeEntryType; reason: string; requestedAt: string; requestedById: string };
|
||||||
|
|
||||||
|
export function parsePendingChange(v: unknown): PendingChange | null {
|
||||||
|
if (!v || typeof v !== "object" || Array.isArray(v)) return null;
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
if (typeof o.startedAt !== "string" || typeof o.endedAt !== "string" || typeof o.reason !== "string") return null;
|
||||||
|
return {
|
||||||
|
startedAt: o.startedAt,
|
||||||
|
endedAt: o.endedAt,
|
||||||
|
type: (typeof o.type === "string" && (TIME_TYPES as readonly string[]).includes(o.type) ? o.type : "work") as TimeEntryType,
|
||||||
|
reason: o.reason,
|
||||||
|
requestedAt: typeof o.requestedAt === "string" ? o.requestedAt : "",
|
||||||
|
requestedById: typeof o.requestedById === "string" ? o.requestedById : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutesBetween = (a: Date, b: Date) => Math.max(0, Math.round((b.getTime() - a.getTime()) / 60_000));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- validation helpers
|
||||||
|
|
||||||
|
async function validateWindow(ctx: ServiceCtx, start: Date, end: Date, now = new Date()) {
|
||||||
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) throw new ServiceError("invalid", "invalid_timestamp");
|
||||||
|
if (end.getTime() <= start.getTime()) throw new ServiceError("invalid", "end_before_start");
|
||||||
|
if (end.getTime() - start.getTime() > MAX_ENTRY_MINUTES * 60_000) throw new ServiceError("invalid", "too_long");
|
||||||
|
if (end.getTime() > now.getTime() + 60_000) throw new ServiceError("invalid", "in_future");
|
||||||
|
if (start.getTime() < (await earliestStart(ctx, now)).getTime()) throw new ServiceError("invalid", "too_old");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start of the local day MAX_BACKDATE_DAYS ago (tenant time zone). */
|
||||||
|
export async function earliestStart(ctx: ServiceCtx, now = new Date()): Promise<Date> {
|
||||||
|
const tz = await tenantTimezone(ctx);
|
||||||
|
return dayWindow(localDateKey(new Date(now.getTime() - MAX_BACKDATE_DAYS * 86_400_000), tz), tz).start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Own entries (tracked or manual, not rejected) overlapping [start, end). */
|
||||||
|
async function assertNoOverlap(ctx: ServiceCtx, userId: string, start: Date, end: Date, excludeId?: string) {
|
||||||
|
const hit = await ctx.db.timeEntry.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
approvalStatus: { not: "rejected" },
|
||||||
|
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||||
|
startedAt: { lt: end },
|
||||||
|
OR: [{ endedAt: null }, { endedAt: { gt: start } }],
|
||||||
|
},
|
||||||
|
select: { id: true, startedAt: true, endedAt: true, type: true, workSession: { select: { workOrder: { select: { number: true } } } } },
|
||||||
|
});
|
||||||
|
if (hit) {
|
||||||
|
throw new ServiceError("invalid", "overlap", {
|
||||||
|
reason: "overlap",
|
||||||
|
timeEntryId: hit.id,
|
||||||
|
number: hit.workSession.workOrder.number,
|
||||||
|
startedAt: hit.startedAt.toISOString(),
|
||||||
|
endedAt: hit.endedAt?.toISOString() ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- approver scope
|
||||||
|
|
||||||
|
type ApproverScope = { all: true } | { all: false; teamIds: string[]; memberIds: string[] };
|
||||||
|
|
||||||
|
/** Back office (work_order:read_all) → all; team lead → teams led by the user and their active members. */
|
||||||
|
async function approverScope(ctx: ServiceCtx): Promise<ApproverScope> {
|
||||||
|
if (can(ctx, "work_order:read_all")) return { all: true };
|
||||||
|
const now = new Date();
|
||||||
|
const led = await ctx.db.team.findMany({ where: { leaderUserId: ctx.userId, status: "active", deletedAt: null }, select: { id: true } });
|
||||||
|
const teamIds = led.map((t) => t.id);
|
||||||
|
const members = teamIds.length
|
||||||
|
? await ctx.db.teamMember.findMany({
|
||||||
|
where: { teamId: { in: teamIds }, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] },
|
||||||
|
select: { userId: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
return { all: false, teamIds, memberIds: [...new Set(members.map((m) => m.userId))] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeWhere(scope: ApproverScope, userId: string): Prisma.TimeEntryWhereInput {
|
||||||
|
const base: Prisma.TimeEntryWhereInput = { workSession: { workOrder: { deletedAt: null } } };
|
||||||
|
if (scope.all) return base;
|
||||||
|
return {
|
||||||
|
AND: [
|
||||||
|
base,
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
...(scope.teamIds.length ? [{ workSession: { workOrder: { assignedTeamId: { in: scope.teamIds } } } }] : []),
|
||||||
|
{ workSession: { workOrder: { teamLeadUserId: userId } } },
|
||||||
|
...(scope.memberIds.length ? [{ userId: { in: scope.memberIds } }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Direct recording for another person: back office → anyone, team lead → active members of the teams they lead. */
|
||||||
|
function mayRecordFor(scope: ApproverScope, targetUserId: string): boolean {
|
||||||
|
return scope.all || scope.memberIds.includes(targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PENDING_WHERE: Prisma.TimeEntryWhereInput = { OR: [{ approvalStatus: "pending" }, { pendingChange: { not: Prisma.DbNull } }] };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- add manual entry
|
||||||
|
|
||||||
|
export const manualTimeSchema = z
|
||||||
|
.object({
|
||||||
|
workOrderId: idSchema,
|
||||||
|
type: z.enum(TIME_TYPES).default("work"),
|
||||||
|
startedAt: z.coerce.date(),
|
||||||
|
endedAt: z.coerce.date().nullish(),
|
||||||
|
durationMinutes: z.number().int().min(1).max(MAX_ENTRY_MINUTES).nullish(),
|
||||||
|
reason: z.string().trim().min(3).max(500),
|
||||||
|
note: z.string().trim().max(2000).nullish(),
|
||||||
|
forUserId: idSchema.nullish(),
|
||||||
|
clientId: z.string().uuid().nullish(),
|
||||||
|
})
|
||||||
|
.refine((v) => !!v.endedAt || !!v.durationMinutes, { message: "endedAt or durationMinutes required", path: ["endedAt"] });
|
||||||
|
|
||||||
|
export type ManualTimeInput = z.input<typeof manualTimeSchema>;
|
||||||
|
|
||||||
|
export async function addManualTimeEntry(ctx: ServiceCtx, raw: ManualTimeInput) {
|
||||||
|
const parsed = manualTimeSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) throw new ServiceError("invalid", "invalid_manual_time", parsed.error.issues);
|
||||||
|
const input = parsed.data;
|
||||||
|
const targetUserId = input.forUserId && input.forUserId !== ctx.userId ? input.forUserId : ctx.userId;
|
||||||
|
const own = targetUserId === ctx.userId;
|
||||||
|
if (own) {
|
||||||
|
if (!can(ctx, "field:record_own_time") && !can(ctx, "field:correct_time")) throw new ServiceError("forbidden", "missing permission field:record_own_time");
|
||||||
|
} else {
|
||||||
|
assertCan(ctx, "field:correct_time");
|
||||||
|
}
|
||||||
|
|
||||||
|
const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, number: true, status: true, assignedTeamId: true, teamLeadUserId: true });
|
||||||
|
if (!TIME_RECORDABLE.includes(wo.status)) throw new ServiceError("invalid", "work_order_status", { status: wo.status });
|
||||||
|
|
||||||
|
if (!own) {
|
||||||
|
const target = await ctx.db.user.findFirst({ where: { id: targetUserId, status: "ACTIVE" }, select: { id: true } });
|
||||||
|
if (!target) throw new ServiceError("not_found", "user not found");
|
||||||
|
if (!mayRecordFor(await approverScope(ctx), targetUserId)) throw new ServiceError("forbidden", "user_not_in_team");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.clientId) {
|
||||||
|
const replay = await ctx.db.timeEntry.findFirst({ where: { clientId: input.clientId } });
|
||||||
|
if (replay) {
|
||||||
|
if (replay.userId !== targetUserId) throw new ServiceError("invalid", "clientId already used");
|
||||||
|
return replay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = input.startedAt;
|
||||||
|
const end = input.endedAt ?? new Date(start.getTime() + (input.durationMinutes ?? 0) * 60_000);
|
||||||
|
await validateWindow(ctx, start, end);
|
||||||
|
const tz = await tenantTimezone(ctx);
|
||||||
|
|
||||||
|
const entry = await inTransaction(ctx, async (tx) => {
|
||||||
|
await assertNoOverlap(tx, targetUserId, start, end);
|
||||||
|
const day = dayWindow(localDateKey(start, tz), tz);
|
||||||
|
const existing = await tx.db.workSession.findFirst({
|
||||||
|
where: { workOrderId: wo.id, userId: targetUserId, manual: true, startedAt: { gte: day.start, lt: day.end } },
|
||||||
|
});
|
||||||
|
const session = existing
|
||||||
|
? await tx.db.workSession.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
startedAt: start < existing.startedAt ? start : existing.startedAt,
|
||||||
|
endedAt: !existing.endedAt || end > existing.endedAt ? end : existing.endedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await tx.db.workSession.create({
|
||||||
|
data: { tenantId: ctx.tenantId, workOrderId: wo.id, userId: targetUserId, teamId: wo.assignedTeamId, status: "ended", manual: true, startedAt: start, endedAt: end },
|
||||||
|
});
|
||||||
|
const now = new Date();
|
||||||
|
const created = await tx.db.timeEntry.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
workSessionId: session.id,
|
||||||
|
userId: targetUserId,
|
||||||
|
type: input.type,
|
||||||
|
startedAt: start,
|
||||||
|
endedAt: end,
|
||||||
|
source: "manual",
|
||||||
|
approvalStatus: own ? "pending" : "approved",
|
||||||
|
approvedById: own ? null : ctx.userId,
|
||||||
|
approvedAt: own ? null : now,
|
||||||
|
// the mandatory reason of a manual entry is stored like a correction reason
|
||||||
|
correctionReason: input.reason,
|
||||||
|
note: input.note ?? null,
|
||||||
|
clientId: input.clientId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await audit(tx, "create", "time_entry", created.id, null, {
|
||||||
|
workOrderId: wo.id,
|
||||||
|
userId: targetUserId,
|
||||||
|
type: created.type,
|
||||||
|
startedAt: start,
|
||||||
|
endedAt: end,
|
||||||
|
source: "manual",
|
||||||
|
approvalStatus: created.approvalStatus,
|
||||||
|
reason: input.reason,
|
||||||
|
});
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entry.approvalStatus === "pending") {
|
||||||
|
await emitEvent(ctx, { type: "time.approval_requested", entityType: "time_entry", entityId: entry.id, data: { number: wo.number, kind: "manual" } });
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- correction proposal
|
||||||
|
|
||||||
|
export const proposeCorrectionSchema = z.object({
|
||||||
|
timeEntryId: idSchema,
|
||||||
|
startedAt: z.coerce.date(),
|
||||||
|
endedAt: z.coerce.date(),
|
||||||
|
type: z.enum(TIME_TYPES).nullish(),
|
||||||
|
reason: z.string().trim().min(3).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function proposeTimeCorrection(ctx: ServiceCtx, raw: z.input<typeof proposeCorrectionSchema>) {
|
||||||
|
if (!can(ctx, "field:record_own_time") && !can(ctx, "field:correct_time")) throw new ServiceError("forbidden", "missing permission field:record_own_time");
|
||||||
|
const parsed = proposeCorrectionSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) throw new ServiceError("invalid", "invalid_correction", parsed.error.issues);
|
||||||
|
const input = parsed.data;
|
||||||
|
|
||||||
|
const entry = await ctx.db.timeEntry.findFirst({ where: { id: input.timeEntryId }, include: { workSession: { select: { workOrderId: true } } } });
|
||||||
|
if (!entry) throw new ServiceError("not_found", "time entry not found");
|
||||||
|
const wo = await requireVisibleWorkOrder(ctx, entry.workSession.workOrderId, { id: true, number: true, status: true });
|
||||||
|
if (entry.userId !== ctx.userId) throw new ServiceError("forbidden", "not_own_entry");
|
||||||
|
if (!TIME_RECORDABLE.includes(wo.status)) throw new ServiceError("invalid", "work_order_status", { status: wo.status });
|
||||||
|
if (entry.approvalStatus === "rejected") throw new ServiceError("invalid", "entry_rejected");
|
||||||
|
if (!entry.endedAt) throw new ServiceError("invalid", "entry_running");
|
||||||
|
if (entry.startedAt.getTime() < (await earliestStart(ctx)).getTime()) throw new ServiceError("invalid", "too_old");
|
||||||
|
await validateWindow(ctx, input.startedAt, input.endedAt);
|
||||||
|
const type = input.type ?? entry.type;
|
||||||
|
|
||||||
|
const before = { type: entry.type, startedAt: entry.startedAt, endedAt: entry.endedAt, approvalStatus: entry.approvalStatus, pendingChange: entry.pendingChange };
|
||||||
|
const updated = await inTransaction(ctx, async (tx) => {
|
||||||
|
await assertNoOverlap(tx, ctx.userId, input.startedAt, input.endedAt, entry.id);
|
||||||
|
if (entry.approvalStatus === "pending") {
|
||||||
|
// not yet approved: the pending values themselves are changed (nothing counts yet)
|
||||||
|
const u = await tx.db.timeEntry.update({
|
||||||
|
where: { id: entry.id },
|
||||||
|
data: { startedAt: input.startedAt, endedAt: input.endedAt, type, correctionReason: input.reason, rejectionReason: null },
|
||||||
|
});
|
||||||
|
await audit(tx, "update", "time_entry", entry.id, before, { type, startedAt: input.startedAt, endedAt: input.endedAt, approvalStatus: "pending", reason: input.reason });
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
const change: PendingChange = {
|
||||||
|
startedAt: input.startedAt.toISOString(),
|
||||||
|
endedAt: input.endedAt.toISOString(),
|
||||||
|
type,
|
||||||
|
reason: input.reason,
|
||||||
|
requestedAt: new Date().toISOString(),
|
||||||
|
requestedById: ctx.userId,
|
||||||
|
};
|
||||||
|
const u = await tx.db.timeEntry.update({ where: { id: entry.id }, data: { pendingChange: change, rejectionReason: null } });
|
||||||
|
await audit(tx, "update", "time_entry", entry.id, before, { pendingChange: change });
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
await emitEvent(ctx, { type: "time.approval_requested", entityType: "time_entry", entityId: entry.id, data: { number: wo.number, kind: entry.approvalStatus === "pending" ? "manual" : "correction" } });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- approval
|
||||||
|
|
||||||
|
async function loadForDecision(ctx: ServiceCtx, timeEntryId: string) {
|
||||||
|
assertCan(ctx, "time:approve");
|
||||||
|
const id = idSchema.safeParse(timeEntryId);
|
||||||
|
if (!id.success) throw new ServiceError("not_found", "time entry not found");
|
||||||
|
const scope = await approverScope(ctx);
|
||||||
|
const entry = await ctx.db.timeEntry.findFirst({
|
||||||
|
where: { AND: [{ id: id.data }, scopeWhere(scope, ctx.userId)] },
|
||||||
|
include: { workSession: { select: { workOrder: { select: { id: true, number: true } } } } },
|
||||||
|
});
|
||||||
|
if (!entry) throw new ServiceError("not_found", "time entry not found");
|
||||||
|
if (entry.userId === ctx.userId) throw new ServiceError("forbidden", "own_entry");
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveTimeEntry(ctx: ServiceCtx, timeEntryId: string) {
|
||||||
|
const entry = await loadForDecision(ctx, timeEntryId);
|
||||||
|
const change = parsePendingChange(entry.pendingChange);
|
||||||
|
if (!change && entry.approvalStatus !== "pending") throw new ServiceError("invalid", "not_pending");
|
||||||
|
const now = new Date();
|
||||||
|
const before = { type: entry.type, startedAt: entry.startedAt, endedAt: entry.endedAt, approvalStatus: entry.approvalStatus, pendingChange: entry.pendingChange };
|
||||||
|
|
||||||
|
const updated = await inTransaction(ctx, async (tx) => {
|
||||||
|
if (change) {
|
||||||
|
const start = new Date(change.startedAt);
|
||||||
|
const end = new Date(change.endedAt);
|
||||||
|
await assertNoOverlap(tx, entry.userId, start, end, entry.id);
|
||||||
|
const u = await tx.db.timeEntry.update({
|
||||||
|
where: { id: entry.id },
|
||||||
|
data: {
|
||||||
|
startedAt: start,
|
||||||
|
endedAt: end,
|
||||||
|
type: change.type,
|
||||||
|
corrected: true,
|
||||||
|
correctionReason: change.reason,
|
||||||
|
correctedById: ctx.userId,
|
||||||
|
pendingChange: Prisma.DbNull,
|
||||||
|
rejectionReason: null,
|
||||||
|
approvedById: ctx.userId,
|
||||||
|
approvedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await audit(tx, "update", "time_entry", entry.id, before, { type: u.type, startedAt: start, endedAt: end, corrected: true, correctionReason: change.reason, approvalStatus: u.approvalStatus, decision: "approved" });
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
if (entry.endedAt) await assertNoOverlap(tx, entry.userId, entry.startedAt, entry.endedAt, entry.id);
|
||||||
|
const res = await tx.db.timeEntry.updateMany({ where: { id: entry.id, approvalStatus: "pending" }, data: { approvalStatus: "approved", approvedById: ctx.userId, approvedAt: now, rejectionReason: null } });
|
||||||
|
if (res.count !== 1) throw new ServiceError("conflict", "entry changed concurrently");
|
||||||
|
await audit(tx, "update", "time_entry", entry.id, before, { approvalStatus: "approved", approvedById: ctx.userId, decision: "approved" });
|
||||||
|
return tx.db.timeEntry.findFirstOrThrow({ where: { id: entry.id } });
|
||||||
|
});
|
||||||
|
await emitEvent(ctx, { type: "time.approved", entityType: "time_entry", entityId: entry.id, data: { number: entry.workSession.workOrder.number, kind: change ? "correction" : "manual" } });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectTimeEntry(ctx: ServiceCtx, timeEntryId: string, rawReason: string) {
|
||||||
|
const entry = await loadForDecision(ctx, timeEntryId);
|
||||||
|
const reason = z.string().trim().min(3).max(500).safeParse(rawReason);
|
||||||
|
if (!reason.success) throw new ServiceError("invalid", "reason_required");
|
||||||
|
const change = parsePendingChange(entry.pendingChange);
|
||||||
|
if (!change && entry.approvalStatus !== "pending") throw new ServiceError("invalid", "not_pending");
|
||||||
|
const now = new Date();
|
||||||
|
const before = { approvalStatus: entry.approvalStatus, pendingChange: entry.pendingChange };
|
||||||
|
|
||||||
|
const updated = await inTransaction(ctx, async (tx) => {
|
||||||
|
// proposal rejected → entry keeps its approved values; new manual entry rejected → never counts
|
||||||
|
const u = await tx.db.timeEntry.update({
|
||||||
|
where: { id: entry.id },
|
||||||
|
data: change
|
||||||
|
? { pendingChange: Prisma.DbNull, rejectionReason: reason.data, approvedById: ctx.userId, approvedAt: now }
|
||||||
|
: { approvalStatus: "rejected", rejectionReason: reason.data, approvedById: ctx.userId, approvedAt: now },
|
||||||
|
});
|
||||||
|
await audit(tx, "update", "time_entry", entry.id, before, { approvalStatus: u.approvalStatus, pendingChange: null, rejectionReason: reason.data, decision: "rejected" });
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
await emitEvent(ctx, { type: "time.rejected", entityType: "time_entry", entityId: entry.id, data: { number: entry.workSession.workOrder.number, reason: reason.data, kind: change ? "correction" : "manual" } });
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bulk approval (back office list): every id is decided independently. */
|
||||||
|
export async function approveTimeEntries(ctx: ServiceCtx, ids: string[]): Promise<Array<{ id: string; ok: boolean; code?: string }>> {
|
||||||
|
assertCan(ctx, "time:approve");
|
||||||
|
const results: Array<{ id: string; ok: boolean; code?: string }> = [];
|
||||||
|
for (const id of [...new Set(ids)].slice(0, 200)) {
|
||||||
|
try {
|
||||||
|
await approveTimeEntry(ctx, id);
|
||||||
|
results.push({ id, ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ServiceError)) throw err;
|
||||||
|
results.push({ id, ok: false, code: err.code });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- read models
|
||||||
|
|
||||||
|
export type PendingTimeEntry = {
|
||||||
|
id: string;
|
||||||
|
kind: "manual" | "correction";
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
workOrderId: string;
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
type: TimeEntryType;
|
||||||
|
source: "tracked" | "manual";
|
||||||
|
startedAt: Date;
|
||||||
|
endedAt: Date | null;
|
||||||
|
minutes: number;
|
||||||
|
reason: string | null;
|
||||||
|
note: string | null;
|
||||||
|
proposed: { startedAt: Date; endedAt: Date; type: TimeEntryType; minutes: number } | null;
|
||||||
|
requestedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listPendingTimeEntries(ctx: ServiceCtx, filter: { workOrderId?: string; userId?: string; limit?: number } = {}): Promise<PendingTimeEntry[]> {
|
||||||
|
assertCan(ctx, "time:approve");
|
||||||
|
const scope = await approverScope(ctx);
|
||||||
|
const rows = await ctx.db.timeEntry.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
scopeWhere(scope, ctx.userId),
|
||||||
|
PENDING_WHERE,
|
||||||
|
{ userId: { not: ctx.userId } },
|
||||||
|
filter.workOrderId ? { workSession: { workOrderId: filter.workOrderId } } : {},
|
||||||
|
filter.userId ? { userId: filter.userId } : {},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [{ startedAt: "asc" }],
|
||||||
|
take: Math.min(500, filter.limit ?? 200),
|
||||||
|
include: { workSession: { select: { workOrder: { select: { id: true, number: true, title: true } } } } },
|
||||||
|
});
|
||||||
|
const users = rows.length ? await ctx.db.user.findMany({ where: { id: { in: [...new Set(rows.map((r) => r.userId))] } }, select: { id: true, name: true } }) : [];
|
||||||
|
const names = new Map(users.map((u) => [u.id, u.name]));
|
||||||
|
return rows.map((r) => {
|
||||||
|
const change = parsePendingChange(r.pendingChange);
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
kind: change ? "correction" : "manual",
|
||||||
|
userId: r.userId,
|
||||||
|
userName: names.get(r.userId) ?? "—",
|
||||||
|
workOrderId: r.workSession.workOrder.id,
|
||||||
|
number: r.workSession.workOrder.number,
|
||||||
|
title: r.workSession.workOrder.title,
|
||||||
|
type: r.type,
|
||||||
|
source: r.source,
|
||||||
|
startedAt: r.startedAt,
|
||||||
|
endedAt: r.endedAt,
|
||||||
|
minutes: r.endedAt ? minutesBetween(r.startedAt, r.endedAt) : 0,
|
||||||
|
reason: change ? change.reason : r.correctionReason,
|
||||||
|
note: r.note,
|
||||||
|
proposed: change
|
||||||
|
? { startedAt: new Date(change.startedAt), endedAt: new Date(change.endedAt), type: change.type, minutes: minutesBetween(new Date(change.startedAt), new Date(change.endedAt)) }
|
||||||
|
: null,
|
||||||
|
requestedAt: change?.requestedAt ? new Date(change.requestedAt) : r.createdAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of open decisions for the badge counters (0 without `time:approve`). */
|
||||||
|
export async function countPendingTimeEntries(ctx: ServiceCtx): Promise<number> {
|
||||||
|
if (!can(ctx, "time:approve")) return 0;
|
||||||
|
const scope = await approverScope(ctx);
|
||||||
|
return ctx.db.timeEntry.count({ where: { AND: [scopeWhere(scope, ctx.userId), PENDING_WHERE, { userId: { not: ctx.userId } }] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pending minutes (manual entries awaiting approval) and open proposals of one order — for totals/warnings. */
|
||||||
|
export async function pendingTimeOfOrder(ctx: ServiceCtx, workOrderId: string): Promise<{ pendingMinutes: number; pendingEntries: number; openProposals: number }> {
|
||||||
|
const rows = await ctx.db.timeEntry.findMany({
|
||||||
|
where: { workSession: { workOrderId }, ...PENDING_WHERE },
|
||||||
|
select: { type: true, startedAt: true, endedAt: true, approvalStatus: true },
|
||||||
|
});
|
||||||
|
const pending = rows.filter((r) => r.approvalStatus === "pending");
|
||||||
|
return {
|
||||||
|
pendingMinutes: pending.filter((r) => NON_BREAK.includes(r.type) && r.endedAt).reduce((acc, r) => acc + minutesBetween(r.startedAt, r.endedAt!), 0),
|
||||||
|
pendingEntries: pending.length,
|
||||||
|
openProposals: rows.length - pending.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MyTimeEntry = {
|
||||||
|
id: string;
|
||||||
|
type: TimeEntryType;
|
||||||
|
startedAt: Date;
|
||||||
|
endedAt: Date | null;
|
||||||
|
seconds: number;
|
||||||
|
source: "tracked" | "manual";
|
||||||
|
approvalStatus: "approved" | "pending" | "rejected";
|
||||||
|
rejectionReason: string | null;
|
||||||
|
reason: string | null;
|
||||||
|
corrected: boolean;
|
||||||
|
pendingChange: PendingChange | null;
|
||||||
|
editable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyTimeGroup = { workOrderId: string; number: string; title: string; entries: MyTimeEntry[]; approvedSeconds: number; pendingSeconds: number };
|
||||||
|
|
||||||
|
export type MyTimeOverview = {
|
||||||
|
date: string;
|
||||||
|
days: string[];
|
||||||
|
groups: MyTimeGroup[];
|
||||||
|
approvedSeconds: number;
|
||||||
|
pendingSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** „Meine Zeiten": own entries of one local day across all orders, totals approved vs. pending. */
|
||||||
|
export async function getMyTimeOverview(ctx: ServiceCtx, input: { date?: string | null } = {}, now = new Date()): Promise<MyTimeOverview> {
|
||||||
|
assertCan(ctx, "field:execute");
|
||||||
|
const tz = await tenantTimezone(ctx);
|
||||||
|
const days = Array.from({ length: MAX_BACKDATE_DAYS + 1 }, (_, i) => localDateKey(new Date(now.getTime() - i * 86_400_000), tz));
|
||||||
|
const date = input.date && days.includes(input.date) ? input.date : days[0];
|
||||||
|
const win = dayWindow(date, tz);
|
||||||
|
const earliest = dayWindow(days[days.length - 1], tz).start;
|
||||||
|
|
||||||
|
const rows = await ctx.db.timeEntry.findMany({
|
||||||
|
where: { userId: ctx.userId, startedAt: { gte: win.start, lt: win.end }, workSession: { workOrder: { deletedAt: null } } },
|
||||||
|
orderBy: { startedAt: "asc" },
|
||||||
|
include: { workSession: { select: { workOrder: { select: { id: true, number: true, title: true, status: true } } } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = new Map<string, MyTimeGroup>();
|
||||||
|
let approvedSeconds = 0;
|
||||||
|
let pendingSeconds = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
const wo = r.workSession.workOrder;
|
||||||
|
const g = groups.get(wo.id) ?? { workOrderId: wo.id, number: wo.number, title: wo.title, entries: [], approvedSeconds: 0, pendingSeconds: 0 };
|
||||||
|
const seconds = Math.max(0, Math.round(((r.endedAt ?? now).getTime() - r.startedAt.getTime()) / 1000));
|
||||||
|
const counts = NON_BREAK.includes(r.type);
|
||||||
|
if (counts && r.approvalStatus === "approved") {
|
||||||
|
g.approvedSeconds += seconds;
|
||||||
|
approvedSeconds += seconds;
|
||||||
|
} else if (counts && r.approvalStatus === "pending") {
|
||||||
|
g.pendingSeconds += seconds;
|
||||||
|
pendingSeconds += seconds;
|
||||||
|
}
|
||||||
|
g.entries.push({
|
||||||
|
id: r.id,
|
||||||
|
type: r.type,
|
||||||
|
startedAt: r.startedAt,
|
||||||
|
endedAt: r.endedAt,
|
||||||
|
seconds,
|
||||||
|
source: r.source,
|
||||||
|
approvalStatus: r.approvalStatus,
|
||||||
|
rejectionReason: r.rejectionReason,
|
||||||
|
reason: r.correctionReason,
|
||||||
|
corrected: r.corrected,
|
||||||
|
pendingChange: parsePendingChange(r.pendingChange),
|
||||||
|
editable: !!r.endedAt && r.approvalStatus !== "rejected" && r.startedAt >= earliest && TIME_RECORDABLE.includes(wo.status),
|
||||||
|
});
|
||||||
|
groups.set(wo.id, g);
|
||||||
|
}
|
||||||
|
return { date, days, groups: [...groups.values()], approvedSeconds, pendingSeconds };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Orders selectable in „Zeit nachtragen": visible, still recordable, touched in the last days or planned in that window. */
|
||||||
|
export async function listRecordableOrders(ctx: ServiceCtx, now = new Date()): Promise<Array<{ id: string; number: string; title: string }>> {
|
||||||
|
const scope = await workOrderScope(ctx);
|
||||||
|
const earliest = await earliestStart(ctx, now);
|
||||||
|
const end = new Date(now.getTime() + 86_400_000);
|
||||||
|
return ctx.db.workOrder.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
scope,
|
||||||
|
{ status: { in: [...TIME_RECORDABLE] } },
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ workSessions: { some: { userId: ctx.userId, startedAt: { gte: earliest } } } },
|
||||||
|
{ plannedStart: { gte: earliest, lt: end } },
|
||||||
|
{ plannedStart: { lt: end }, plannedEnd: { gte: earliest } },
|
||||||
|
{ status: { in: ["en_route", "in_progress", "paused", "waiting_material", "daily_report_created"] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [{ plannedStart: { sort: "desc", nulls: "last" } }],
|
||||||
|
take: 60,
|
||||||
|
select: { id: true, number: true, title: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** People the user may record time for directly (`field:correct_time`): team members, all field users for back office. */
|
||||||
|
export async function listRecordableUsers(ctx: ServiceCtx): Promise<Array<{ id: string; name: string }>> {
|
||||||
|
if (!can(ctx, "field:correct_time")) return [];
|
||||||
|
const scope = await approverScope(ctx);
|
||||||
|
const where: Prisma.UserWhereInput = scope.all
|
||||||
|
? { status: "ACTIVE", userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "field:execute" } } } } } } }
|
||||||
|
: { status: "ACTIVE", id: { in: scope.memberIds } };
|
||||||
|
const users = await ctx.db.user.findMany({ where: { AND: [where, { id: { not: ctx.userId } }] }, orderBy: { name: "asc" }, take: 200, select: { id: true, name: true } });
|
||||||
|
return users;
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export async function checkCompleteness(ctx: ServiceCtx, workOrderId: string): P
|
|||||||
ctx.db.checklistItem.findMany({ where: { workOrderId: wo.id, required: true, checked: false }, orderBy: { sortOrder: "asc" }, select: { label: true } }),
|
ctx.db.checklistItem.findMany({ where: { workOrderId: wo.id, required: true, checked: false }, orderBy: { sortOrder: "asc" }, select: { label: true } }),
|
||||||
ctx.db.materialPlan.findMany({ where: { workOrderId: wo.id }, orderBy: { sortOrder: "asc" }, select: { id: true, name: true, plannedQuantity: true } }),
|
ctx.db.materialPlan.findMany({ where: { workOrderId: wo.id }, orderBy: { sortOrder: "asc" }, select: { id: true, name: true, plannedQuantity: true } }),
|
||||||
ctx.db.materialUsage.findMany({ where: { workOrderId: wo.id }, select: { name: true, materialPlanId: true, usageStatus: true, actualQuantity: true, deviationReason: true } }),
|
ctx.db.materialUsage.findMany({ where: { workOrderId: wo.id }, select: { name: true, materialPlanId: true, usageStatus: true, actualQuantity: true, deviationReason: true } }),
|
||||||
ctx.db.timeEntry.findMany({ where: { type: "work", workSession: { workOrderId: wo.id } }, select: { startedAt: true, endedAt: true }, take: 50 }),
|
ctx.db.timeEntry.findMany({ where: { type: "work", approvalStatus: "approved", workSession: { workOrderId: wo.id } }, select: { startedAt: true, endedAt: true }, take: 50 }),
|
||||||
ctx.db.activityNote.count({ where: { workOrderId: wo.id, deletedAt: null, kind: { in: ["work_done", "general"] } } }),
|
ctx.db.activityNote.count({ where: { workOrderId: wo.id, deletedAt: null, kind: { in: ["work_done", "general"] } } }),
|
||||||
ctx.db.report.findMany({
|
ctx.db.report.findMany({
|
||||||
where: { workOrderId: wo.id, status: { not: "superseded" } },
|
where: { workOrderId: wo.id, status: { not: "superseded" } },
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ function textVars(locale: Locale, f: EventFacts): Record<string, string | undefi
|
|||||||
customer: f.customer,
|
customer: f.customer,
|
||||||
actor: f.actorName ?? fallbackText(locale, "system"),
|
actor: f.actorName ?? fallbackText(locale, "system"),
|
||||||
fileName: f.fileName ?? fallbackText(locale, "document"),
|
fileName: f.fileName ?? fallbackText(locale, "document"),
|
||||||
reason: f.syncErrorCode,
|
reason: f.syncErrorCode ?? f.rejectionReason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +152,10 @@ export function linkFor(event: DomainEvent, f: EventFacts, permissions: Readonly
|
|||||||
return `/imports/${event.entityId}`;
|
return `/imports/${event.entityId}`;
|
||||||
case "sync_operation":
|
case "sync_operation":
|
||||||
return backoffice ? "/work-orders/conflicts" : "/m/sync";
|
return backoffice ? "/work-orders/conflicts" : "/m/sync";
|
||||||
|
case "time_entry":
|
||||||
|
// L12: approvers open their approval list, the technician „Meine Zeiten"
|
||||||
|
if (event.type === "time.approval_requested") return backoffice && permissions.has("time:approve") ? "/work-orders/time-approvals" : "/m/approvals";
|
||||||
|
return "/m/time";
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export type EventFacts = {
|
|||||||
actorName?: string;
|
actorName?: string;
|
||||||
emergencyStart?: Date | null;
|
emergencyStart?: Date | null;
|
||||||
emergencyEnd?: Date | null;
|
emergencyEnd?: Date | null;
|
||||||
|
/** L12: reason of a rejected time entry */
|
||||||
|
rejectionReason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecipientPlan = {
|
export type RecipientPlan = {
|
||||||
@@ -89,6 +91,11 @@ export function backofficeWhere(): Prisma.UserWhereInput {
|
|||||||
return { AND: [hasPermissionWhere("work_order:read_all"), hasPermissionWhere("report:approve")] };
|
return { AND: [hasPermissionWhere("work_order:read_all"), hasPermissionWhere("report:approve")] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** L12: back office approving time entries (all entries): work_order:read_all AND time:approve. */
|
||||||
|
export function timeApproverBackofficeWhere(): Prisma.UserWhereInput {
|
||||||
|
return { AND: [hasPermissionWhere("work_order:read_all"), hasPermissionWhere("time:approve")] };
|
||||||
|
}
|
||||||
|
|
||||||
export function billingWhere(): Prisma.UserWhereInput {
|
export function billingWhere(): Prisma.UserWhereInput {
|
||||||
return hasPermissionWhere("work_order:release_billing");
|
return hasPermissionWhere("work_order:release_billing");
|
||||||
}
|
}
|
||||||
@@ -278,6 +285,38 @@ export async function resolveRecipients(
|
|||||||
rule = { users: [], userIds: job.importedById ? [job.importedById] : [] };
|
rule = { users: [], userIds: job.importedById ? [job.importedById] : [] };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "time.approval_requested":
|
||||||
|
case "time.approved":
|
||||||
|
case "time.rejected": {
|
||||||
|
const entry = await ctx.db.timeEntry.findFirst({
|
||||||
|
where: { id: event.entityId },
|
||||||
|
select: { id: true, userId: true, workSession: { select: { workOrderId: true } } },
|
||||||
|
});
|
||||||
|
if (!entry) return null;
|
||||||
|
const wo = await loadWorkOrder(ctx, entry.workSession.workOrderId);
|
||||||
|
if (!wo) return null;
|
||||||
|
Object.assign(facts, workOrderFacts(wo));
|
||||||
|
if (typeof event.data?.reason === "string") facts.rejectionReason = event.data.reason;
|
||||||
|
if (event.type === "time.approval_requested") {
|
||||||
|
// team leads of the order's team / the technician's teams (with time:approve), otherwise back office
|
||||||
|
const now = new Date();
|
||||||
|
const memberships = await ctx.db.teamMember.findMany({
|
||||||
|
where: { userId: entry.userId, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }], team: { status: "active", deletedAt: null } },
|
||||||
|
select: { team: { select: { leaderUserId: true } } },
|
||||||
|
});
|
||||||
|
const candidateIds = [...teamLeadIds(wo), ...memberships.map((m) => m.team.leaderUserId)].filter(
|
||||||
|
(id): id is string => !!id && id !== entry.userId && id !== ctx.userId,
|
||||||
|
);
|
||||||
|
const leads = candidateIds.length
|
||||||
|
? await ctx.db.user.findMany({ where: { AND: [{ id: { in: [...new Set(candidateIds)] } }, { status: "ACTIVE" }, hasPermissionWhere("time:approve")] }, select: { id: true } })
|
||||||
|
: [];
|
||||||
|
rule = leads.length ? { users: [], userIds: leads.map((l) => l.id) } : { users: [timeApproverBackofficeWhere()], userIds: [] };
|
||||||
|
} else {
|
||||||
|
// decision → the technician, in-app only
|
||||||
|
rule = { users: [], userIds: [entry.userId], mailToUsers: false };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "sync.failed": {
|
case "sync.failed": {
|
||||||
const op = await ctx.db.syncOperation.findFirst({
|
const op = await ctx.db.syncOperation.findFirst({
|
||||||
where: { id: event.entityId },
|
where: { id: event.entityId },
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInp
|
|||||||
const win = dayWindow(input.reportDate, timeZone);
|
const win = dayWindow(input.reportDate, timeZone);
|
||||||
const inDay = daily ? { gte: win.start, lt: win.end } : undefined;
|
const inDay = daily ? { gte: win.start, lt: win.end } : undefined;
|
||||||
|
|
||||||
const [wo, settings, tenant, entries, notes, photos, usages, plans, checklist, signature, technician] = await Promise.all([
|
const [wo, settings, tenant, allEntries, notes, photos, usages, plans, checklist, signature, technician] = await Promise.all([
|
||||||
ctx.db.workOrder.findFirstOrThrow({
|
ctx.db.workOrder.findFirstOrThrow({
|
||||||
where: { id: input.workOrderId },
|
where: { id: input.workOrderId },
|
||||||
include: {
|
include: {
|
||||||
@@ -69,10 +69,11 @@ export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInp
|
|||||||
}),
|
}),
|
||||||
ctx.db.tenantSettings.findFirst({ select: { orgName: true, address: true, phone: true, email: true } }),
|
ctx.db.tenantSettings.findFirst({ select: { orgName: true, address: true, phone: true, email: true } }),
|
||||||
ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { tenant: { select: { name: true } } } }),
|
ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { tenant: { select: { name: true } } } }),
|
||||||
|
// L12: only approved entries count; pending manual entries are reported separately (pendingMinutes)
|
||||||
ctx.db.timeEntry.findMany({
|
ctx.db.timeEntry.findMany({
|
||||||
where: { workSession: { workOrderId: input.workOrderId }, ...(inDay ? { startedAt: inDay } : {}) },
|
where: { workSession: { workOrderId: input.workOrderId }, approvalStatus: { in: ["approved", "pending"] }, ...(inDay ? { startedAt: inDay } : {}) },
|
||||||
orderBy: { startedAt: "asc" },
|
orderBy: { startedAt: "asc" },
|
||||||
select: { userId: true, type: true, startedAt: true, endedAt: true },
|
select: { userId: true, type: true, startedAt: true, endedAt: true, approvalStatus: true },
|
||||||
}),
|
}),
|
||||||
ctx.db.activityNote.findMany({
|
ctx.db.activityNote.findMany({
|
||||||
where: { workOrderId: input.workOrderId, deletedAt: null, ...(inDay ? { createdAt: inDay } : {}) },
|
where: { workOrderId: input.workOrderId, deletedAt: null, ...(inDay ? { createdAt: inDay } : {}) },
|
||||||
@@ -96,6 +97,10 @@ export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInp
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// ---- people & time ----
|
// ---- people & time ----
|
||||||
|
const pendingMinutes = allEntries
|
||||||
|
.filter((e) => e.approvalStatus === "pending" && e.type !== "break")
|
||||||
|
.reduce((acc, e) => acc + Math.max(0, Math.round(((e.endedAt ?? now).getTime() - e.startedAt.getTime()) / 60_000)), 0);
|
||||||
|
const entries = allEntries.filter((e) => e.approvalStatus === "approved");
|
||||||
const userIds = new Set<string>(entries.map((e) => e.userId));
|
const userIds = new Set<string>(entries.map((e) => e.userId));
|
||||||
if (signature?.capturedById) userIds.add(signature.capturedById);
|
if (signature?.capturedById) userIds.add(signature.capturedById);
|
||||||
const users = await ctx.db.user.findMany({ where: { id: { in: [...userIds] } }, select: { id: true, name: true } });
|
const users = await ctx.db.user.findMany({ where: { id: { in: [...userIds] } }, select: { id: true, name: true } });
|
||||||
@@ -252,6 +257,7 @@ export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInp
|
|||||||
totalsByPerson: [...byPerson.values()],
|
totalsByPerson: [...byPerson.values()],
|
||||||
totalMinutes,
|
totalMinutes,
|
||||||
hasRunningEntries,
|
hasRunningEntries,
|
||||||
|
pendingMinutes,
|
||||||
},
|
},
|
||||||
texts,
|
texts,
|
||||||
materials: { used, notUsed, additional },
|
materials: { used, notUsed, additional },
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
|||||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||||
import { CONFLICTING_OPS, type SyncOperationInput, type SyncOpResult, type SyncOpType, type SyncResponse } from "@/lib/sync/envelope";
|
import { CONFLICTING_OPS, type SyncOperationInput, type SyncOpResult, type SyncOpType, type SyncResponse } from "@/lib/sync/envelope";
|
||||||
import { OP_PAYLOAD_SCHEMAS, type ParsedOpPayload } from "@/lib/sync/ops";
|
import { OP_PAYLOAD_SCHEMAS, type ParsedOpPayload } from "@/lib/sync/ops";
|
||||||
import { endSession, pauseSession, resumeSession, startSession } from "@/server/services/field/sessions";
|
import { endSession, pauseSession, resumeSession, startSession, stopForToday, switchSegment } from "@/server/services/field/sessions";
|
||||||
|
import { addManualTimeEntry, proposeTimeCorrection } from "@/server/services/field/time-entries";
|
||||||
import { createNote } from "@/server/services/field/notes";
|
import { createNote } from "@/server/services/field/notes";
|
||||||
import { toggleChecklistItem } from "@/server/services/field/checklist";
|
import { toggleChecklistItem } from "@/server/services/field/checklist";
|
||||||
import { upsertMaterialUsage } from "@/server/services/field/materials";
|
import { upsertMaterialUsage } from "@/server/services/field/materials";
|
||||||
@@ -58,8 +59,23 @@ const FIELD_HANDLERS: Partial<Record<SyncOpType, Handler>> = {
|
|||||||
"material.upsert": h<"material.upsert">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await upsertMaterialUsage(ctx, p)).usageId) })),
|
"material.upsert": h<"material.upsert">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await upsertMaterialUsage(ctx, p)).usageId) })),
|
||||||
"photo.attach": h<"photo.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachPhoto(ctx, p)).photoId) })),
|
"photo.attach": h<"photo.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachPhoto(ctx, p)).photoId) })),
|
||||||
"voice.attach": h<"voice.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachVoiceNote(ctx, p)).voiceNoteId) })),
|
"voice.attach": h<"voice.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachVoiceNote(ctx, p)).voiceNoteId) })),
|
||||||
|
// L12 Zeiterfassung
|
||||||
|
"session.stop_day": h<"session.stop_day">(async (ctx, p) => ({ entityVersion: (await stopForToday(ctx, p)).workOrderVersion })),
|
||||||
|
"session.segment": h<"session.segment">(async (ctx, p) => ({ entityVersion: (await switchSegment(ctx, p)).workOrderVersion })),
|
||||||
|
"time.add_manual": h<"time.add_manual">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await addManualTimeEntry(ctx, p)).id) })),
|
||||||
|
"time.propose_correction": h<"time.propose_correction">(async (ctx, p) => {
|
||||||
|
await proposeTimeCorrection(ctx, p);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Message of a rejected op: blockers as JSON, the auto-switch conflict with the other order (L12). */
|
||||||
|
function errorMessage(err: ServiceError): string {
|
||||||
|
if (err.code === "blocked") return JSON.stringify(err.details ?? []);
|
||||||
|
if (err.message === "other_session_running") return `other_session_running:${JSON.stringify(err.details ?? {})}`;
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
|
||||||
class NotAvailable extends Error {}
|
class NotAvailable extends Error {}
|
||||||
|
|
||||||
/** Route a validated op to the field handler or the registered module of another lane. */
|
/** Route a validated op to the field handler or the registered module of another lane. */
|
||||||
@@ -205,12 +221,12 @@ async function applyOne(ctx: ServiceCtx, deviceId: string, op: SyncOperationInpu
|
|||||||
// a transition conflict detected inside the service (race after the pre-check)
|
// a transition conflict detected inside the service (race after the pre-check)
|
||||||
const status = err.code === "conflict" && CONFLICTING_OPS.includes(op.opType) ? "conflict" : "rejected";
|
const status = err.code === "conflict" && CONFLICTING_OPS.includes(op.opType) ? "conflict" : "rejected";
|
||||||
const details = err.code === "blocked" ? { blockers: err.details } : {};
|
const details = err.code === "blocked" ? { blockers: err.details } : {};
|
||||||
const rec = await record(ctx, op, deviceId, status, { message: err.message, ...details }, err.code);
|
const rec = await record(ctx, op, deviceId, status, { message: errorMessage(err), ...details }, err.code);
|
||||||
if (rec === "duplicate") return { ...base, status: "duplicate" };
|
if (rec === "duplicate") return { ...base, status: "duplicate" };
|
||||||
if (status === "conflict") {
|
if (status === "conflict") {
|
||||||
await emitEvent(ctx, { type: "sync.failed", entityType: "sync_operation", entityId: rec.id, data: { opType: op.opType, reason: "conflict" } });
|
await emitEvent(ctx, { type: "sync.failed", entityType: "sync_operation", entityId: rec.id, data: { opType: op.opType, reason: "conflict" } });
|
||||||
}
|
}
|
||||||
return { ...base, status, errorCode: err.code, message: err.code === "blocked" ? JSON.stringify(err.details ?? []) : err.message };
|
return { ...base, status, errorCode: err.code, message: errorMessage(err) };
|
||||||
}
|
}
|
||||||
console.error(`[sync] ${op.opType} ${op.clientOpId} failed:`, err);
|
console.error(`[sync] ${op.opType} ${op.clientOpId} failed:`, err);
|
||||||
return { ...base, status: "rejected", errorCode: "internal", message: "internal error" };
|
return { ...base, status: "rejected", errorCode: "internal", message: "internal error" };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/status";
|
import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
import type { ServiceCtx } from "@/server/services/context";
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
import { loadVisibleWorkOrder } from "@/server/services/work-orders/_shared";
|
import { loadVisibleWorkOrder } from "@/server/services/work-orders/_shared";
|
||||||
@@ -61,8 +62,15 @@ export async function transitionBlockers(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (to === "released_for_billing" && wo.status === "in_review") {
|
if (to === "released_for_billing" && wo.status === "in_review") {
|
||||||
const approved = await ctx.db.report.count({ where: { workOrderId: wo.id, type: "completion", status: "approved" } });
|
const [approved, pendingTime] = await Promise.all([
|
||||||
return approved > 0 ? [] : [{ kind: "missing_field", field: "approved_completion_report" }];
|
ctx.db.report.count({ where: { workOrderId: wo.id, type: "completion", status: "approved" } }),
|
||||||
|
// L12: manual entries / correction proposals waiting for approval block the billing release
|
||||||
|
ctx.db.timeEntry.count({ where: { workSession: { workOrderId: wo.id }, OR: [{ approvalStatus: "pending" }, { pendingChange: { not: Prisma.DbNull } }] } }),
|
||||||
|
]);
|
||||||
|
const blockers: CompletionBlocker[] = [];
|
||||||
|
if (approved === 0) blockers.push({ kind: "missing_field", field: "approved_completion_report" });
|
||||||
|
if (pendingTime > 0) blockers.push({ kind: "missing_field", field: "pending_time_entries" });
|
||||||
|
return blockers;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { PRESETS, type Preset, type WorkOrderFilter } from "@/lib/work-orders/fi
|
|||||||
import { can, type ServiceCtx } from "@/server/services/context";
|
import { can, type ServiceCtx } from "@/server/services/context";
|
||||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||||
import { buildWorkOrderWhere, presetWhere } from "@/server/services/work-orders/list";
|
import { buildWorkOrderWhere, presetWhere } from "@/server/services/work-orders/list";
|
||||||
|
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
|
||||||
|
|
||||||
export type DashboardTiles = Record<Preset, number> & { reportsToReview: number; syncConflicts: number };
|
export type DashboardTiles = Record<Preset, number> & { reportsToReview: number; syncConflicts: number; timeApprovals: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backoffice dashboard counts (spec §21). Every tile = base filter ∧ preset, within workOrderScope.
|
* Backoffice dashboard counts (spec §21). Every tile = base filter ∧ preset, within workOrderScope.
|
||||||
@@ -17,13 +18,14 @@ export async function getDashboardTiles(ctx: ServiceCtx, filter: WorkOrderFilter
|
|||||||
const counts = await Promise.all(
|
const counts = await Promise.all(
|
||||||
PRESETS.map((preset) => ctx.db.workOrder.count({ where: { AND: [base, presetWhere(preset, pc)] } })),
|
PRESETS.map((preset) => ctx.db.workOrder.count({ where: { AND: [base, presetWhere(preset, pc)] } })),
|
||||||
);
|
);
|
||||||
const [reportsToReview, syncConflicts] = await Promise.all([
|
const [reportsToReview, syncConflicts, timeApprovals] = await Promise.all([
|
||||||
ctx.db.report.count({ where: { status: { in: ["submitted", "team_approved"] }, workOrder: base } }),
|
ctx.db.report.count({ where: { status: { in: ["submitted", "team_approved"] }, workOrder: base } }),
|
||||||
can(ctx, "work_order:write")
|
can(ctx, "work_order:write")
|
||||||
? ctx.db.syncOperation.count({ where: { status: "conflict", resolvedAt: null } })
|
? ctx.db.syncOperation.count({ where: { status: "conflict", resolvedAt: null } })
|
||||||
: Promise.resolve(0),
|
: Promise.resolve(0),
|
||||||
|
countPendingTimeEntries(ctx), // L12 „Zeiten zur Freigabe" (0 without time:approve)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tiles = Object.fromEntries(PRESETS.map((p, i) => [p, counts[i]])) as Record<Preset, number>;
|
const tiles = Object.fromEntries(PRESETS.map((p, i) => [p, counts[i]])) as Record<Preset, number>;
|
||||||
return { ...tiles, reportsToReview, syncConflicts };
|
return { ...tiles, reportsToReview, syncConflicts, timeApprovals };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,14 @@ export async function getTimesTab(ctx: ServiceCtx, workOrderId: string) {
|
|||||||
orderBy: { startedAt: "desc" },
|
orderBy: { startedAt: "desc" },
|
||||||
include: { user: { select: { id: true, name: true } }, entries: { orderBy: { startedAt: "asc" } } },
|
include: { user: { select: { id: true, name: true } }, entries: { orderBy: { startedAt: "asc" } } },
|
||||||
});
|
});
|
||||||
return sessions.map((s) => {
|
// L12: only approved entries count; pending manual entries are shown separately
|
||||||
const minutes = s.entries
|
const minutesOf = (entries: typeof sessions[number]["entries"], status: "approved" | "pending") =>
|
||||||
.filter((e) => e.type !== "break")
|
Math.round(
|
||||||
.reduce((sum, e) => sum + ((e.endedAt ?? new Date()).getTime() - e.startedAt.getTime()) / 60000, 0);
|
entries
|
||||||
return { ...s, workMinutes: Math.round(minutes) };
|
.filter((e) => e.type !== "break" && e.approvalStatus === status)
|
||||||
});
|
.reduce((sum, e) => sum + ((e.endedAt ?? new Date()).getTime() - e.startedAt.getTime()) / 60000, 0),
|
||||||
|
);
|
||||||
|
return sessions.map((s) => ({ ...s, workMinutes: minutesOf(s.entries, "approved"), pendingMinutes: minutesOf(s.entries, "pending") }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPhotosTab(ctx: ServiceCtx, workOrderId: string) {
|
export async function getPhotosTab(ctx: ServiceCtx, workOrderId: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user