Merge lane/einsatz in feature/craftvia-mvp
Konflikte gelöst: processors/index.ts (report-pdf + image-derivatives), (app)/layout.tsx (Logo, Glocke, AccountInactiveNotice). L5-Mobilseiten report/sign nach src/app/(field)/m/(core)/orders/[id]/ verschoben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
# Lane L4 – Einsatz mobil (`lane/einsatz`)
|
||||||
|
|
||||||
|
Stand: 2026-09-14 · Basis `bf44567` (`feature/craftvia-mvp`)
|
||||||
|
|
||||||
|
## 1. Umfang / erfüllte Spec-Punkte
|
||||||
|
|
||||||
|
| Spec | Umsetzung |
|
||||||
|
|---|---|
|
||||||
|
| §11.2 Auftragsansicht | `/m` Heute (heute geplant + laufend + pausiert), `/m/orders` Tabs Kommend · Laufend · Abzuschließen · Vergangen; große Karten mit Nummer, Kunde, Objektadresse + Routenlink, Zeitfenster, Statusgruppe (Text + Icon + Statuskante), Primärbutton |
|
||||||
|
| §12.1 Einsatzstart | `session.start` speichert Startzeit, Benutzer, Team, Auftrag, optional Standort, Gerätestatus (User-Agent), Offline-Flag; optional „Losfahren“ (Anfahrt) vor Arbeitsbeginn |
|
||||||
|
| §12.2 Arbeitszeiten | Session aus TimeEntry-Segmenten (Anfahrt → Arbeit ⇄ Pause), Start/Pause/Weiter/Ende, Dauerberechnung; Korrektur nur mit `field:correct_time`, Grund Pflicht, `corrected`-Flag, Audit before/after |
|
||||||
|
| §12.3 Tätigkeitsdokumentation | Notizen mit Art (9 große Chips) + Text; Entwurf bleibt bei Verbindungsabbruch lokal erhalten (US-006); Sprachnotiz |
|
||||||
|
| §12.4 Checklisten | Abhaken mit großem Schalter + Kommentar |
|
||||||
|
| §13.2/§13.3 Material | Planposition vollständig/teilweise/nicht verwendet mit Stepper-Menge; Begründung bei Abweichung Pflicht (UI + Server, gemeinsame Regeln); Zusatzmaterial mit Bezeichnung, Artikelnummer, Menge, Einheiten-Vorschlägen, Grund |
|
||||||
|
| §14.1/§14.3/§14.4 Fotos | Kamera (`capture="environment"`) + Galerie; Phase Vorher/Während/Nachher, Pflichtfoto (Kategorie), Checklistenpunkt, Kommentar, optional Standort; Kompression im Browser (max. 2560 px, JPEG 0.82, Vorschaubild 400 px, EXIF-Orientierung über `imageOrientation: "from-image"`), Upload-Fortschritt; Metadaten: Auftrag, Einsatz, Benutzer, Zeit, MIME (Magic Bytes), Größe, Prüfsumme, Uploadstatus; serverseitiges Vorschaubild per Job, falls der Client keines liefert |
|
||||||
|
| §15.1 Sprachnotizen | MediaRecorder (webm/opus, mp4 auf iOS), max. 5 min, Upload → VoiceNote `pending` → `dispatchJob("transcription")`; ohne registrierten Processor (L9) Status `disabled` |
|
||||||
|
| §22 Mobile Ansicht | eigene Shell ohne Sidebar, Bottom-Nav Heute · Aufträge · Notdienst · Sync · Profil, Online/Offline-Badge, Touch-Ziele ≥ 48 px, eine Primäraktion je Zustand, Kamera in 2 Taps (Karte → Foto) |
|
||||||
|
| US-005 | Dokumente am Auftrag + Objekt (nur erlaubte Sichtbarkeit, `backoffice_only` verborgen), freigegebene Berichte der Objekt-Historie; Auslieferung über autorisierte Route |
|
||||||
|
| US-006 | Einsatz starten/beenden, Fotos, Material bestätigen/ändern, Zusatzmaterial, Notizen mit lokalem Entwurf |
|
||||||
|
| ARCHITEKTUR §4.6 (Server) | `src/lib/sync/ops.ts`, `applyOperations`, `POST /api/v1/sync`, `POST /api/v1/uploads`, `GET /api/v1/field/bundle?since=` |
|
||||||
|
|
||||||
|
Primäraktion je Zustand (`components/field/primary-action.tsx`): `assigned` → Annehmen · `accepted` → Losfahren (sekundär: Arbeit starten) · `en_route` → Arbeit starten · in Arbeit mit laufender eigener Session → Abschließen (sekundär Pause) · pausiert → Weiter · `technically_completed`/`signature_pending` → Hinweis „Bericht und Unterschrift folgen“. Abschließen beendet die eigene Session und ruft `work_order.transition → technically_completed`; offene Pflichtpunkte werden vorher als Liste angezeigt und der Button gesperrt.
|
||||||
|
|
||||||
|
## 2. Routen / Screens
|
||||||
|
|
||||||
|
| Route | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `/m` | Heute |
|
||||||
|
| `/m/orders?tab=` | Auftragsliste mit Tabs |
|
||||||
|
| `/m/orders/[id]` | Kopf (Status, Zeitfenster, Primäraktion), Schnellaktionen (Foto direkt · Notiz · Material · Checkliste · Zeiten), Hinweise für Monteure, Objekt (Zugang/Parken/Sicherheit/Technik hervorgehoben, Route), Kunde/Ansprechpartner (`tel:`/`mailto:`), Auftrag, Dokumente, Objekt-Historie, Zusammenfassungen |
|
||||||
|
| `/m/orders/[id]/photos` · `/notes` · `/materials` · `/checklist` · `/time` | Unterseiten (max. 2 Ebenen unter der Liste) |
|
||||||
|
| `/m/profile` | Name, Betrieb, Team, Rolle, Sprache, Abmelden (Link ins Büro für Teamleiter/Backoffice) |
|
||||||
|
| `/m/sync` | **Platzhalter** für L7 (Verbindungsstatus, „Änderungen werden sofort übertragen“) |
|
||||||
|
| `/m/emergency` | unverändert Platzhalter von L8 (nur verschoben) |
|
||||||
|
| `POST /api/v1/sync` | Batch-Ops → `SyncResponse` |
|
||||||
|
| `POST /api/v1/uploads` | multipart `file`, `clientId`, `workOrderId`, `kind` (`photo`/`voice_note`), optional `preview` → `{ documentId, duplicate }` |
|
||||||
|
| `GET /api/v1/field/bundle?since=` | Offline-Pull |
|
||||||
|
| `GET /api/v1/field/documents/[id]?variant=preview` | autorisierte Dokument-Auslieferung (Sichtbarkeit + Scope) |
|
||||||
|
|
||||||
|
Route-Group-Umzug: `src/app/(app)/m/**` → `src/app/(field)/m/**`. `(field)/m/layout.tsx` = Shell (Zugriffsprüfung), `(field)/m/(core)/layout.tsx` = Modul-Gate `field`, `emergency/layout.tsx` = Modul-Gate `emergency` (unverändert). Die Zugriffsprüfungen aus `(app)/layout.tsx` sind nach `src/server/app-access.ts#requireAppAccess` extrahiert und werden von beiden Shells genutzt (erlaubter Fundament-Eingriff, ARCHITEKTUR §5).
|
||||||
|
|
||||||
|
Header-Slot für L6: im Mobile-Header (`(field)/m/layout.tsx`) ist links neben dem Online-Badge Platz für `<NotificationBell variant="mobile" />` markiert.
|
||||||
|
|
||||||
|
## 3. Dateien
|
||||||
|
|
||||||
|
**Neu (Ownership L4)**
|
||||||
|
- `src/app/(field)/m/layout.tsx`, `(core)/layout.tsx` (verschoben), `(core)/page.tsx`, `(core)/orders/page.tsx`, `(core)/orders/[id]/{page.tsx,load.ts}`, `(core)/orders/[id]/{photos,notes,materials,checklist,time}/page.tsx`, `(core)/profile/page.tsx`, `sync/page.tsx` (Platzhalter L7)
|
||||||
|
- `src/app/api/v1/sync/route.ts`, `src/app/api/v1/uploads/route.ts`, `src/app/api/v1/field/bundle/route.ts`, `src/app/api/v1/field/documents/[id]/route.ts`
|
||||||
|
- `src/server/services/field/`: `common.ts`, `sessions.ts`, `time-correction.ts`, `checklist.ts`, `materials.ts`, `notes.ts`, `photos.ts`, `voice.ts`, `uploads.ts`, `documents.ts`, `queries.ts`, `page-context.ts`, `stubs/{work-order-transition,documents-store,site-history}.ts`
|
||||||
|
- `src/server/services/sync/apply.ts`, `src/server/services/sync/api-context.ts`, `src/server/services/sync/external-ops.ts` (Registry für Ops anderer Lanes)
|
||||||
|
- `src/server/actions/field/time.ts` (Zeitkorrektur, `moduleGuard("field")`)
|
||||||
|
- `src/server/jobs/processors/image-derivatives.ts`
|
||||||
|
- `src/lib/sync/ops.ts`, `src/lib/field/{client-ops,upload,image,format,material-rules}.ts`
|
||||||
|
- `src/components/field/*` (Shell-Navigation, Online-Badge, Statusbadge, Auftragskarte, Primäraktion, Foto, Sprachnotiz, Notiz, Material, Stepper, Checkliste, Zeitkorrektur, UI-Klassen)
|
||||||
|
- `messages/de/field.json`, `messages/en/field.json`
|
||||||
|
- `scripts/test-einsatz-field.ts`, `scripts/test-einsatz-sync.ts`, `scripts/lib/einsatz-fixture.ts`
|
||||||
|
|
||||||
|
**Fundament / Fremd-Einzeiler**
|
||||||
|
- `src/server/app-access.ts` (neu, extrahiert) + `src/components/account-inactive-notice.tsx` (Hinweis „Konto deaktiviert“ aus dem Layout herausgelöst, Text unverändert) + `src/app/(app)/layout.tsx` nutzt beides
|
||||||
|
- `src/app/page.tsx`: rollenabhängige Startseite (`landingPath`: `field:execute` ohne `work_order:read_all` → `/m`, sonst `/dashboard`)
|
||||||
|
- `src/app/login/page.tsx`, `src/app/login/mfa/page.tsx`: Default-Redirect nach Login `/dashboard` → `/` (je 1 Zeile, + „bereits angemeldet“-Redirect)
|
||||||
|
- `src/server/jobs/processors/index.ts`: Registrierung `image-derivatives` (1 Zeile)
|
||||||
|
- `src/components/audit-trail.tsx`: Entity-Labels für `work_session`, `time_entry`, `checklist_item`, `material_usage`, `photo`, `voice_note`, `activity_note`, `sync_operation` (1 Zeile)
|
||||||
|
|
||||||
|
Keine Schemaänderung, keine neue Migration, keine neuen npm-Abhängigkeiten (`sharp` ist über Next vorhanden).
|
||||||
|
|
||||||
|
## 4. Tests
|
||||||
|
|
||||||
|
- `scripts/test-einsatz-field.ts` – Session-Zustände (keine doppelte laufende Session, Pause-Segmente, Ende berechnet Dauer), Abschluss-Guards, Zeitkorrektur (Recht, Grund, Ende ≥ Beginn, Audit before/after), Material (Abweichung ohne Grund → invalid, Zusatzmaterial, Idempotenz), Checkliste/Notiz, Scope (Monteur ohne Zuweisung → not_found; ohne `field:execute` → forbidden), Sichtbarkeit/Bundle, Mandantentrennung.
|
||||||
|
- `scripts/test-einsatz-sync.ts` – Idempotenz (gleiche clientOpId → duplicate, keine Doppelanlage, je Mandant), Konflikt bei veralteter baseVersion (nichts überschrieben, SyncOperation `conflict`), Scope über Sync (fremder Auftrag → not_found, kein Versions-Leak), Ops fremder Lanes (rejected invalid, nicht gespeichert), Uploads (Idempotenz, Magic Bytes, Mandantentrennung beim Hochladen/Öffnen/Anhängen, `backoffice_only` verborgen), Foto/Sprachnotiz (Status `disabled`), image-derivatives-Processor inkl. Fremdmandant.
|
||||||
|
|
||||||
|
Ergebnis: siehe Abschnitt 7 (Gate).
|
||||||
|
|
||||||
|
## 5. Stubs & Abhängigkeiten zu anderen Lanes
|
||||||
|
|
||||||
|
| Stub (in L4-Pfad) | Vertrag | Ersetzen durch |
|
||||||
|
|---|---|---|
|
||||||
|
| `services/field/stubs/work-order-transition.ts` | `transitionWorkOrder(ctx, { workOrderId, to, reason?, baseVersion? }) → { id, from, status, version }`; `completionBlockers(ctx, workOrderId) → CompletionBlocker[]`; wirft `ServiceError` inkl. `blocked` mit Blockern | L2 `services/work-orders/transition.ts` (Importe in `sessions.ts`, `queries.ts`, `sync/apply.ts`, Test) |
|
||||||
|
| `services/field/stubs/documents-store.ts` | `storeFile(ctx, { bytes, fileName, declaredMime, category, visibility, links, lineageId? }) → Document` (§4.3) inkl. Magic-Byte-Prüfung/Limits/SHA-256 | gemeinsames `services/documents/store.ts` (Import in `uploads.ts`) |
|
||||||
|
| `services/field/stubs/site-history.ts` | `getSiteHistory(ctx, siteId, { onlyApproved, limit? }) → SiteHistoryEntry[]` | L1 (Importe in `queries.ts`) |
|
||||||
|
| `app/(field)/m/sync/page.tsx` | Platzhalter | L7 ersetzt die Seite und `lib/field/client-ops.ts#submitOp` (Signatur stabil) |
|
||||||
|
| Sync-Ops `report.save_draft`, `report.submit`, `signature.capture` | Lazy-Import-Registry `services/sync/external-ops.ts` (auskommentierte Einzeiler) → `services/reports/sync-ops.ts#applySyncOp(ctx, op) → { idMap?, entityVersion? }` | L5 liefert das Modul und aktiviert je Op eine Zeile in `external-ops.ts`; bis dahin `rejected invalid` („not available yet“), nicht gespeichert |
|
||||||
|
| Sync-Op `emergency.create` | Registry-Einzeiler in `services/sync/external-ops.ts` → `services/emergency/sync-ops.ts#applySyncOp` | L8 |
|
||||||
|
| Transkription | Processor `transcription` in `jobs/processors/index.ts` | L9; bis dahin VoiceNote `disabled` |
|
||||||
|
| `/m/orders/[id]/{report,sign}` | noch nicht angelegt | Komponenten von L5, Einbindung durch L4 nach Merge |
|
||||||
|
|
||||||
|
## 6. Bekannte Lücken / Hinweise an den Architekten
|
||||||
|
|
||||||
|
0. **Registry statt berechnetem `import()`**: Ein Import mit berechnetem Pfad (`import(`../${dir}/sync-ops`)`) kann Turbopack nicht auflösen (Build-Warnung, Modul wäre nach Merge nicht ladbar). Daher explizite Registry `services/sync/external-ops.ts` – L5/L8 brauchen dort je Op einen Einzeiler.
|
||||||
|
|
||||||
|
1. **Offline**: Ops werden sofort gesendet; ohne Verbindung wird nichts zwischengespeichert (außer Notiz-Entwurf). Outbox/Service Worker = L7.
|
||||||
|
2. **Bundle-Löschungen**: `bundle?since=` liefert nur geänderte/offene Aufträge, keine Tombstones für entzogene/abgeschlossene Aufträge (Client muss Vollabgleich ohne `since` machen).
|
||||||
|
3. **Jobs (Fundament-Bug)**: `enqueueJob` erzeugt `jobId` mit `:` – BullMQ lehnt das ab („Custom Id cannot contain :“), `dispatchJob` fällt deshalb immer auf Inline-Ausführung zurück. Fix in `src/server/jobs/queues.ts` (z. B. `-` statt `:`) nötig.
|
||||||
|
4. **`requireApiContext`** wird in `services/context.ts` erwähnt, existiert im Fundament aber nicht → lane-lokal in `services/sync/api-context.ts` (nutzt `moduleGuard`, Same-Origin-Prüfung, Fehler-Mapping). Kandidat für das Fundament.
|
||||||
|
5. **DSGVO `pii-fields.ts`** (Fundament): Personenreferenzen der Einsatzmodelle sind noch nicht eingetragen: `WorkSession.userId`, `TimeEntry.userId`, `TimeEntry.correctedById`, `ActivityNote.authorId`, `Photo.takenById`, `VoiceNote.recordedById`, `MaterialUsage.recordedById`, `ChecklistItem.checkedById`, `Document.uploadedById`, `SyncOperation.userId`/`resolvedById`.
|
||||||
|
6. **`/files/[...key]`** prüft weiterhin nur das Mandantenpräfix (TODO documents); die Mobile-App nutzt deshalb `/api/v1/field/documents/[id]` mit Sichtbarkeits- und Scope-Prüfung.
|
||||||
|
7. **Transaktionen**: Session-Operationen (Segment schließen/öffnen + Statuswechsel) laufen sequenziell ohne DB-Transaktion; bei parallelen Doppelstarts desselben Users ist eine zweite aktive Session theoretisch möglich (kein Unique-Index). Idempotenz über `clientId`/`clientOpId` deckt Wiederholungen ab.
|
||||||
|
8. **„Heute“** wird in der Server-Zeitzone berechnet, Anzeige in `Europe/Berlin` (`lib/field/format.ts`); Mandanten-Zeitzone fehlt im Modell.
|
||||||
|
9. **Passkey-Login** und Mandantenwechsel (`tenant-switch.ts`, `/select-tenant`) leiten weiter fest auf `/dashboard` – Feldrollen landen dort erst über die Startseite `/`, nicht direkt auf `/m`.
|
||||||
|
10. **HEIC** ohne Browser-Dekodierung: Upload scheitert mit Hinweis (keine serverseitige Konvertierung).
|
||||||
|
|
||||||
|
## 7. Gate & Smoke
|
||||||
|
|
||||||
|
**Tests:** `test-einsatz-field.ts` 48 Prüfungen, `test-einsatz-sync.ts` 38 Prüfungen – alle grün (S3/Garage-Pfad aktiv; ohne `S3_ENDPOINT` werden nur die Byte-Abrufe übersprungen).
|
||||||
|
|
||||||
|
**Gate:** `npm run gate` grün – prisma generate, tsc, lint (0 Fehler, 2 Warnungen im Fundament-Platzhalter `handle-event.ts`), build inkl. Modul-Guard-Check (14 Action-Dateien), 24/24 Testskripte.
|
||||||
|
|
||||||
|
**HTTP-Smoke** (Dev-Server :3104, Demo-Mandant mit Smoke-Auftrag, Session-Cookies für `monteur@`, `multi@` (Monteur ohne Zuweisung) und `admin2@` (Mandant demo2)): 32/32 Prüfungen grün – Startseite `/` → `/m` (Monteur) bzw. `/dashboard` (Admin), alle Mobile-Seiten 200 mit erwarteten Inhalten, fremder/zugriffsloser Auftrag → 404, Backoffice-Dashboard rendert weiterhin, Sync (not_found für fremd, applied+duplicate, conflict bei veralteter Version, 400 bei leerem Batch, 403 bei Cross-Origin, ohne Session → Login-Redirect), Bundle, Upload 201/200 (idempotent)/404 (fremder Mandant), Dokument-Original + Vorschau 200 bzw. 404 für fremd, `photo.attach` applied.
|
||||||
|
|
||||||
|
**Mobile-UX 375×812** (Browser-Pane): große Karten mit Statuskante, Status als Text + Icon, eine orange Primäraktion, Schnellaktionen mit Kamera direkt (Heute → Karte → Foto = 2 Taps), Bottom-Nav 64 px, Buttons ≥ 48 px, max. 2 Ebenen unter der Liste; Klickpfad Annehmen → Losfahren über die UI geprüft.
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"label": "Einsatz-Navigation",
|
||||||
|
"today": "Heute",
|
||||||
|
"orders": "Aufträge",
|
||||||
|
"emergency": "Notdienst",
|
||||||
|
"sync": "Sync",
|
||||||
|
"profile": "Profil"
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"online": "Online",
|
||||||
|
"offline": "Offline"
|
||||||
|
},
|
||||||
|
"noAccess": "Die Einsatz-App ist für Ihre Rolle nicht freigeschaltet.",
|
||||||
|
"today": {
|
||||||
|
"title": "Heute",
|
||||||
|
"greeting": "Hallo {name}",
|
||||||
|
"count": "{count, plural, =0 {Keine Einsätze} one {# Einsatz} other {# Einsätze}}",
|
||||||
|
"empty": "Heute stehen keine Einsätze an.",
|
||||||
|
"allOrders": "Alle Aufträge ansehen"
|
||||||
|
},
|
||||||
|
"orders": {
|
||||||
|
"title": "Aufträge",
|
||||||
|
"tabs": {
|
||||||
|
"upcoming": "Kommend",
|
||||||
|
"running": "Laufend",
|
||||||
|
"to_complete": "Abzuschließen",
|
||||||
|
"past": "Vergangen"
|
||||||
|
},
|
||||||
|
"empty": "Keine Aufträge in dieser Liste."
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"open": "Auftrag öffnen",
|
||||||
|
"route": "Route",
|
||||||
|
"noDate": "Ohne Termin",
|
||||||
|
"emergency": "Notdienst",
|
||||||
|
"urgent": "Dringend",
|
||||||
|
"high": "Hoch"
|
||||||
|
},
|
||||||
|
"statusGroup": {
|
||||||
|
"new": "Neu",
|
||||||
|
"planned": "Geplant",
|
||||||
|
"en_route": "Unterwegs",
|
||||||
|
"in_progress": "In Arbeit",
|
||||||
|
"documentation_incomplete": "Dokumentation unvollständig",
|
||||||
|
"in_review": "Zur Prüfung",
|
||||||
|
"ready_for_billing": "Bereit zur Abrechnung",
|
||||||
|
"billed": "Abgerechnet",
|
||||||
|
"cancelled": "Storniert"
|
||||||
|
},
|
||||||
|
"statusDetail": {
|
||||||
|
"draft": "Entwurf",
|
||||||
|
"review_required": "Prüfung erforderlich",
|
||||||
|
"planned": "Geplant",
|
||||||
|
"assigned": "Zugewiesen",
|
||||||
|
"accepted": "Angenommen",
|
||||||
|
"en_route": "In Anfahrt",
|
||||||
|
"in_progress": "In Arbeit",
|
||||||
|
"paused": "Pausiert",
|
||||||
|
"waiting_material": "Wartet auf Material",
|
||||||
|
"daily_report_created": "Tagesbericht erstellt",
|
||||||
|
"technically_completed": "Technisch abgeschlossen",
|
||||||
|
"signature_pending": "Unterschrift ausstehend",
|
||||||
|
"in_review": "Zur Prüfung",
|
||||||
|
"released_for_billing": "Bereit zur Abrechnung",
|
||||||
|
"billed": "Abgerechnet",
|
||||||
|
"cancelled": "Storniert"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"accept": "Annehmen",
|
||||||
|
"travel": "Losfahren",
|
||||||
|
"start": "Arbeit starten",
|
||||||
|
"pause": "Pause",
|
||||||
|
"resume": "Weiter",
|
||||||
|
"complete": "Abschließen",
|
||||||
|
"confirmComplete": "Jetzt abschließen",
|
||||||
|
"confirmHint": "Zeiterfassung wird beendet und der Auftrag technisch abgeschlossen.",
|
||||||
|
"saving": "Wird gespeichert …",
|
||||||
|
"blocked": "Vor dem Abschluss noch offen:",
|
||||||
|
"reportNext": "Bericht und Unterschrift folgen im nächsten Schritt.",
|
||||||
|
"nothing": "Keine Aktion erforderlich."
|
||||||
|
},
|
||||||
|
"blocker": {
|
||||||
|
"checklist_item": "Checkliste: {label}",
|
||||||
|
"photo_requirement": "Pflichtfoto: {label}",
|
||||||
|
"running_session": "Zeiterfassung eines Teammitglieds läuft noch",
|
||||||
|
"missing_field": "Angabe fehlt: {label}"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"not_found": "Nicht gefunden oder nicht freigegeben.",
|
||||||
|
"forbidden": "Keine Berechtigung für diese Aktion.",
|
||||||
|
"invalid": "Eingabe prüfen.",
|
||||||
|
"conflict": "Der Auftrag wurde inzwischen geändert. Die Ansicht ist aktualisiert.",
|
||||||
|
"blocked": "Pflichtangaben fehlen noch.",
|
||||||
|
"internal": "Technischer Fehler. Bitte erneut versuchen.",
|
||||||
|
"network": "Keine Verbindung. Bitte erneut versuchen.",
|
||||||
|
"image": "Foto konnte nicht verarbeitet werden."
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"back": "Zurück",
|
||||||
|
"backToOrder": "Zum Auftrag",
|
||||||
|
"window": "Zeitfenster",
|
||||||
|
"hints": "Hinweise für Monteure",
|
||||||
|
"order": "Auftrag",
|
||||||
|
"orderType": "Auftragsart",
|
||||||
|
"externalNumber": "Kundenauftrag",
|
||||||
|
"team": "Team",
|
||||||
|
"scope": "Leistungsumfang",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"customer": "Kunde",
|
||||||
|
"contact": "Ansprechpartner",
|
||||||
|
"onSiteContact": "Vor Ort",
|
||||||
|
"call": "Anrufen",
|
||||||
|
"mail": "E-Mail",
|
||||||
|
"site": "Objekt",
|
||||||
|
"route": "Route öffnen",
|
||||||
|
"access": "Zugang",
|
||||||
|
"parking": "Parken",
|
||||||
|
"safety": "Sicherheit",
|
||||||
|
"technical": "Technik",
|
||||||
|
"documents": "Dokumente",
|
||||||
|
"noDocuments": "Keine Dokumente.",
|
||||||
|
"siteDocument": "Objekt",
|
||||||
|
"openDocument": "Öffnen",
|
||||||
|
"history": "Objekt-Historie",
|
||||||
|
"noHistory": "Keine freigegebenen Berichte am Objekt.",
|
||||||
|
"reportType": {
|
||||||
|
"daily": "Tagesbericht",
|
||||||
|
"completion": "Abschlussbericht"
|
||||||
|
},
|
||||||
|
"checklist": "Checkliste",
|
||||||
|
"checklistProgress": "{done} von {total} erledigt",
|
||||||
|
"materials": "Material",
|
||||||
|
"materialsProgress": "{done} von {total} Positionen erfasst",
|
||||||
|
"materialsNone": "Kein Material geplant",
|
||||||
|
"photos": "Fotos",
|
||||||
|
"photosProgress": "{count} Fotos · {done} von {total} Pflichtfotos",
|
||||||
|
"photosCount": "{count} Fotos",
|
||||||
|
"notes": "Notizen",
|
||||||
|
"notesCount": "{count, plural, =0 {Noch keine Notizen} one {# Notiz} other {# Notizen}}",
|
||||||
|
"time": "Zeiten",
|
||||||
|
"myTime": "Meine Arbeitszeit: {duration}",
|
||||||
|
"quick": {
|
||||||
|
"photo": "Foto",
|
||||||
|
"note": "Notiz",
|
||||||
|
"material": "Material",
|
||||||
|
"checklist": "Checkliste",
|
||||||
|
"time": "Zeiten"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"photos": {
|
||||||
|
"title": "Fotos",
|
||||||
|
"camera": "Kamera",
|
||||||
|
"gallery": "Galerie",
|
||||||
|
"phaseLabel": "Zeitpunkt",
|
||||||
|
"phase": {
|
||||||
|
"before": "Vorher",
|
||||||
|
"during": "Während",
|
||||||
|
"after": "Nachher"
|
||||||
|
},
|
||||||
|
"requirement": "Pflichtfoto",
|
||||||
|
"checklistItem": "Checklistenpunkt",
|
||||||
|
"none": "Keine Zuordnung",
|
||||||
|
"comment": "Kommentar",
|
||||||
|
"location": "Standort mitsenden",
|
||||||
|
"save": "Foto speichern",
|
||||||
|
"discard": "Verwerfen",
|
||||||
|
"preview": "Vorschau des Fotos",
|
||||||
|
"compressing": "Foto wird verkleinert …",
|
||||||
|
"uploading": "Hochladen … {percent} %",
|
||||||
|
"saved": "Foto gespeichert.",
|
||||||
|
"empty": "Noch keine Fotos.",
|
||||||
|
"requirements": "Pflichtfotos",
|
||||||
|
"missing": "Fehlt",
|
||||||
|
"done": "Vorhanden"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"title": "Notizen",
|
||||||
|
"kindLabel": "Art",
|
||||||
|
"kind": {
|
||||||
|
"work_done": "Durchgeführt",
|
||||||
|
"deviation": "Abweichung",
|
||||||
|
"problem": "Problem",
|
||||||
|
"additional_work": "Zusatzleistung",
|
||||||
|
"not_executable": "Nicht ausführbar",
|
||||||
|
"follow_up": "Folgearbeit",
|
||||||
|
"recommendation": "Empfehlung",
|
||||||
|
"customer_note": "Kundenhinweis",
|
||||||
|
"general": "Allgemein"
|
||||||
|
},
|
||||||
|
"text": "Notiz",
|
||||||
|
"placeholder": "Kurz beschreiben, was vor Ort passiert ist …",
|
||||||
|
"save": "Notiz speichern",
|
||||||
|
"saved": "Notiz gespeichert.",
|
||||||
|
"draftKept": "Entwurf bleibt auf diesem Gerät erhalten.",
|
||||||
|
"empty": "Noch keine Notizen."
|
||||||
|
},
|
||||||
|
"voice": {
|
||||||
|
"title": "Sprachnotiz",
|
||||||
|
"record": "Aufnahme starten",
|
||||||
|
"stop": "Aufnahme beenden",
|
||||||
|
"recording": "Aufnahme läuft · {time} von 5:00",
|
||||||
|
"save": "Sprachnotiz speichern",
|
||||||
|
"discard": "Verwerfen",
|
||||||
|
"saved": "Sprachnotiz gespeichert.",
|
||||||
|
"unsupported": "Sprachaufnahme wird auf diesem Gerät nicht unterstützt.",
|
||||||
|
"denied": "Mikrofon ist nicht freigegeben.",
|
||||||
|
"status": {
|
||||||
|
"pending": "Transkription läuft",
|
||||||
|
"done": "Transkribiert",
|
||||||
|
"failed": "Transkription fehlgeschlagen",
|
||||||
|
"disabled": "Ohne Transkription"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"materials": {
|
||||||
|
"title": "Material",
|
||||||
|
"planned": "Geplant: {quantity} {unit}",
|
||||||
|
"usage": {
|
||||||
|
"fully_used": "Vollständig",
|
||||||
|
"partially_used": "Teilweise",
|
||||||
|
"not_used": "Nicht verwendet",
|
||||||
|
"additional": "Zusätzlich"
|
||||||
|
},
|
||||||
|
"quantity": "Menge",
|
||||||
|
"unit": "Einheit",
|
||||||
|
"decrease": "Menge verringern",
|
||||||
|
"increase": "Menge erhöhen",
|
||||||
|
"reason": "Begründung der Abweichung",
|
||||||
|
"reasonRequired": "Bei Abweichung ist eine Begründung Pflicht.",
|
||||||
|
"save": "Speichern",
|
||||||
|
"saved": "Gespeichert.",
|
||||||
|
"recorded": "Erfasst: {quantity} {unit}",
|
||||||
|
"open": "Noch offen",
|
||||||
|
"noPlan": "Kein Material geplant.",
|
||||||
|
"additionalTitle": "Zusatzmaterial",
|
||||||
|
"additionalEmpty": "Kein Zusatzmaterial erfasst.",
|
||||||
|
"name": "Bezeichnung",
|
||||||
|
"articleNumber": "Artikelnummer (optional)",
|
||||||
|
"additionalReason": "Grund",
|
||||||
|
"add": "Zusatzmaterial speichern",
|
||||||
|
"invalid": "Angaben unvollständig oder Menge passt nicht zum Status."
|
||||||
|
},
|
||||||
|
"checklist": {
|
||||||
|
"title": "Checkliste",
|
||||||
|
"required": "Pflicht",
|
||||||
|
"photo": "Foto nötig",
|
||||||
|
"done": "Erledigt",
|
||||||
|
"open": "Offen",
|
||||||
|
"comment": "Kommentar",
|
||||||
|
"saveComment": "Kommentar speichern",
|
||||||
|
"empty": "Für diesen Auftrag gibt es keine Checkliste."
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"title": "Zeiten",
|
||||||
|
"type": {
|
||||||
|
"travel": "Anfahrt",
|
||||||
|
"work": "Arbeitszeit",
|
||||||
|
"break": "Pause",
|
||||||
|
"material_procurement": "Materialbeschaffung",
|
||||||
|
"return_travel": "Rückfahrt",
|
||||||
|
"interruption": "Unterbrechung"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"en_route": "In Anfahrt",
|
||||||
|
"running": "Läuft",
|
||||||
|
"paused": "Pausiert",
|
||||||
|
"ended": "Beendet"
|
||||||
|
},
|
||||||
|
"running": "läuft",
|
||||||
|
"offlineStarted": "offline gestartet",
|
||||||
|
"corrected": "Korrigiert: {reason}",
|
||||||
|
"correct": "Korrigieren",
|
||||||
|
"start": "Beginn",
|
||||||
|
"end": "Ende",
|
||||||
|
"reason": "Grund der Korrektur",
|
||||||
|
"reasonHint": "Pflichtangabe, wird protokolliert.",
|
||||||
|
"save": "Korrektur speichern",
|
||||||
|
"saved": "Korrektur gespeichert.",
|
||||||
|
"empty": "Noch keine Zeiten erfasst.",
|
||||||
|
"totalWork": "Arbeitszeit gesamt: {duration}"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profil",
|
||||||
|
"tenant": "Betrieb",
|
||||||
|
"teams": "Team",
|
||||||
|
"noTeam": "Keinem Team zugeordnet",
|
||||||
|
"roles": "Rolle",
|
||||||
|
"language": "Sprache",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"backoffice": "Zum Büro-Bereich"
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"title": "Synchronisation",
|
||||||
|
"immediate": "Änderungen werden sofort an das Büro übertragen.",
|
||||||
|
"offlineHint": "Ohne Verbindung können Eingaben noch nicht zwischengespeichert werden – die Offline-Warteschlange folgt.",
|
||||||
|
"status": "Verbindung"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"label": "Field navigation",
|
||||||
|
"today": "Today",
|
||||||
|
"orders": "Orders",
|
||||||
|
"emergency": "Emergency",
|
||||||
|
"sync": "Sync",
|
||||||
|
"profile": "Profile"
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"online": "Online",
|
||||||
|
"offline": "Offline"
|
||||||
|
},
|
||||||
|
"noAccess": "The field app is not enabled for your role.",
|
||||||
|
"today": {
|
||||||
|
"title": "Today",
|
||||||
|
"greeting": "Hello {name}",
|
||||||
|
"count": "{count, plural, =0 {No jobs} one {# job} other {# jobs}}",
|
||||||
|
"empty": "No jobs scheduled for today.",
|
||||||
|
"allOrders": "View all orders"
|
||||||
|
},
|
||||||
|
"orders": {
|
||||||
|
"title": "Orders",
|
||||||
|
"tabs": {
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"running": "Ongoing",
|
||||||
|
"to_complete": "To complete",
|
||||||
|
"past": "Past"
|
||||||
|
},
|
||||||
|
"empty": "No orders in this list."
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"open": "Open order",
|
||||||
|
"route": "Route",
|
||||||
|
"noDate": "No date",
|
||||||
|
"emergency": "Emergency",
|
||||||
|
"urgent": "Urgent",
|
||||||
|
"high": "High"
|
||||||
|
},
|
||||||
|
"statusGroup": {
|
||||||
|
"new": "New",
|
||||||
|
"planned": "Planned",
|
||||||
|
"en_route": "On the way",
|
||||||
|
"in_progress": "In progress",
|
||||||
|
"documentation_incomplete": "Documentation incomplete",
|
||||||
|
"in_review": "In review",
|
||||||
|
"ready_for_billing": "Ready for billing",
|
||||||
|
"billed": "Billed",
|
||||||
|
"cancelled": "Cancelled"
|
||||||
|
},
|
||||||
|
"statusDetail": {
|
||||||
|
"draft": "Draft",
|
||||||
|
"review_required": "Review required",
|
||||||
|
"planned": "Planned",
|
||||||
|
"assigned": "Assigned",
|
||||||
|
"accepted": "Accepted",
|
||||||
|
"en_route": "On the way",
|
||||||
|
"in_progress": "In progress",
|
||||||
|
"paused": "Paused",
|
||||||
|
"waiting_material": "Waiting for material",
|
||||||
|
"daily_report_created": "Daily report created",
|
||||||
|
"technically_completed": "Technically completed",
|
||||||
|
"signature_pending": "Signature pending",
|
||||||
|
"in_review": "In review",
|
||||||
|
"released_for_billing": "Ready for billing",
|
||||||
|
"billed": "Billed",
|
||||||
|
"cancelled": "Cancelled"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"accept": "Accept",
|
||||||
|
"travel": "Leave now",
|
||||||
|
"start": "Start work",
|
||||||
|
"pause": "Pause",
|
||||||
|
"resume": "Resume",
|
||||||
|
"complete": "Complete",
|
||||||
|
"confirmComplete": "Complete now",
|
||||||
|
"confirmHint": "Time tracking stops and the order is marked technically completed.",
|
||||||
|
"saving": "Saving …",
|
||||||
|
"blocked": "Still missing before completion:",
|
||||||
|
"reportNext": "Report and signature follow in the next step.",
|
||||||
|
"nothing": "No action required."
|
||||||
|
},
|
||||||
|
"blocker": {
|
||||||
|
"checklist_item": "Checklist: {label}",
|
||||||
|
"photo_requirement": "Required photo: {label}",
|
||||||
|
"running_session": "A team member's time tracking is still running",
|
||||||
|
"missing_field": "Missing: {label}"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"not_found": "Not found or not shared with you.",
|
||||||
|
"forbidden": "You are not allowed to do this.",
|
||||||
|
"invalid": "Please check your input.",
|
||||||
|
"conflict": "The order was changed in the meantime. The view has been refreshed.",
|
||||||
|
"blocked": "Required information is still missing.",
|
||||||
|
"internal": "Technical error. Please try again.",
|
||||||
|
"network": "No connection. Please try again.",
|
||||||
|
"image": "The photo could not be processed."
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"back": "Back",
|
||||||
|
"backToOrder": "Back to order",
|
||||||
|
"window": "Time window",
|
||||||
|
"hints": "Notes for technicians",
|
||||||
|
"order": "Order",
|
||||||
|
"orderType": "Order type",
|
||||||
|
"externalNumber": "Customer order",
|
||||||
|
"team": "Team",
|
||||||
|
"scope": "Scope of work",
|
||||||
|
"description": "Description",
|
||||||
|
"customer": "Customer",
|
||||||
|
"contact": "Contact",
|
||||||
|
"onSiteContact": "On site",
|
||||||
|
"call": "Call",
|
||||||
|
"mail": "Email",
|
||||||
|
"site": "Site",
|
||||||
|
"route": "Open route",
|
||||||
|
"access": "Access",
|
||||||
|
"parking": "Parking",
|
||||||
|
"safety": "Safety",
|
||||||
|
"technical": "Technical",
|
||||||
|
"documents": "Documents",
|
||||||
|
"noDocuments": "No documents.",
|
||||||
|
"siteDocument": "Site",
|
||||||
|
"openDocument": "Open",
|
||||||
|
"history": "Site history",
|
||||||
|
"noHistory": "No approved reports at this site.",
|
||||||
|
"reportType": {
|
||||||
|
"daily": "Daily report",
|
||||||
|
"completion": "Completion report"
|
||||||
|
},
|
||||||
|
"checklist": "Checklist",
|
||||||
|
"checklistProgress": "{done} of {total} done",
|
||||||
|
"materials": "Material",
|
||||||
|
"materialsProgress": "{done} of {total} items recorded",
|
||||||
|
"materialsNone": "No material planned",
|
||||||
|
"photos": "Photos",
|
||||||
|
"photosProgress": "{count} photos · {done} of {total} required photos",
|
||||||
|
"photosCount": "{count} photos",
|
||||||
|
"notes": "Notes",
|
||||||
|
"notesCount": "{count, plural, =0 {No notes yet} one {# note} other {# notes}}",
|
||||||
|
"time": "Time",
|
||||||
|
"myTime": "My working time: {duration}",
|
||||||
|
"quick": {
|
||||||
|
"photo": "Photo",
|
||||||
|
"note": "Note",
|
||||||
|
"material": "Material",
|
||||||
|
"checklist": "Checklist",
|
||||||
|
"time": "Time"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"photos": {
|
||||||
|
"title": "Photos",
|
||||||
|
"camera": "Camera",
|
||||||
|
"gallery": "Gallery",
|
||||||
|
"phaseLabel": "When",
|
||||||
|
"phase": {
|
||||||
|
"before": "Before",
|
||||||
|
"during": "During",
|
||||||
|
"after": "After"
|
||||||
|
},
|
||||||
|
"requirement": "Required photo",
|
||||||
|
"checklistItem": "Checklist item",
|
||||||
|
"none": "Not assigned",
|
||||||
|
"comment": "Comment",
|
||||||
|
"location": "Include location",
|
||||||
|
"save": "Save photo",
|
||||||
|
"discard": "Discard",
|
||||||
|
"preview": "Photo preview",
|
||||||
|
"compressing": "Resizing photo …",
|
||||||
|
"uploading": "Uploading … {percent} %",
|
||||||
|
"saved": "Photo saved.",
|
||||||
|
"empty": "No photos yet.",
|
||||||
|
"requirements": "Required photos",
|
||||||
|
"missing": "Missing",
|
||||||
|
"done": "Present"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"title": "Notes",
|
||||||
|
"kindLabel": "Type",
|
||||||
|
"kind": {
|
||||||
|
"work_done": "Work done",
|
||||||
|
"deviation": "Deviation",
|
||||||
|
"problem": "Problem",
|
||||||
|
"additional_work": "Additional work",
|
||||||
|
"not_executable": "Not feasible",
|
||||||
|
"follow_up": "Follow-up work",
|
||||||
|
"recommendation": "Recommendation",
|
||||||
|
"customer_note": "Customer note",
|
||||||
|
"general": "General"
|
||||||
|
},
|
||||||
|
"text": "Note",
|
||||||
|
"placeholder": "Briefly describe what happened on site …",
|
||||||
|
"save": "Save note",
|
||||||
|
"saved": "Note saved.",
|
||||||
|
"draftKept": "Draft is kept on this device.",
|
||||||
|
"empty": "No notes yet."
|
||||||
|
},
|
||||||
|
"voice": {
|
||||||
|
"title": "Voice note",
|
||||||
|
"record": "Start recording",
|
||||||
|
"stop": "Stop recording",
|
||||||
|
"recording": "Recording · {time} of 5:00",
|
||||||
|
"save": "Save voice note",
|
||||||
|
"discard": "Discard",
|
||||||
|
"saved": "Voice note saved.",
|
||||||
|
"unsupported": "Voice recording is not supported on this device.",
|
||||||
|
"denied": "Microphone access is not granted.",
|
||||||
|
"status": {
|
||||||
|
"pending": "Transcription running",
|
||||||
|
"done": "Transcribed",
|
||||||
|
"failed": "Transcription failed",
|
||||||
|
"disabled": "No transcription"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"materials": {
|
||||||
|
"title": "Material",
|
||||||
|
"planned": "Planned: {quantity} {unit}",
|
||||||
|
"usage": {
|
||||||
|
"fully_used": "Fully used",
|
||||||
|
"partially_used": "Partially used",
|
||||||
|
"not_used": "Not used",
|
||||||
|
"additional": "Additional"
|
||||||
|
},
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"unit": "Unit",
|
||||||
|
"decrease": "Decrease quantity",
|
||||||
|
"increase": "Increase quantity",
|
||||||
|
"reason": "Reason for deviation",
|
||||||
|
"reasonRequired": "A reason is required for deviations.",
|
||||||
|
"save": "Save",
|
||||||
|
"saved": "Saved.",
|
||||||
|
"recorded": "Recorded: {quantity} {unit}",
|
||||||
|
"open": "Open",
|
||||||
|
"noPlan": "No material planned.",
|
||||||
|
"additionalTitle": "Additional material",
|
||||||
|
"additionalEmpty": "No additional material recorded.",
|
||||||
|
"name": "Name",
|
||||||
|
"articleNumber": "Article number (optional)",
|
||||||
|
"additionalReason": "Reason",
|
||||||
|
"add": "Save additional material",
|
||||||
|
"invalid": "Incomplete input or quantity does not match the status."
|
||||||
|
},
|
||||||
|
"checklist": {
|
||||||
|
"title": "Checklist",
|
||||||
|
"required": "Required",
|
||||||
|
"photo": "Photo needed",
|
||||||
|
"done": "Done",
|
||||||
|
"open": "Open",
|
||||||
|
"comment": "Comment",
|
||||||
|
"saveComment": "Save comment",
|
||||||
|
"empty": "This order has no checklist."
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"title": "Time",
|
||||||
|
"type": {
|
||||||
|
"travel": "Travel",
|
||||||
|
"work": "Working time",
|
||||||
|
"break": "Break",
|
||||||
|
"material_procurement": "Material procurement",
|
||||||
|
"return_travel": "Return travel",
|
||||||
|
"interruption": "Interruption"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"en_route": "On the way",
|
||||||
|
"running": "Running",
|
||||||
|
"paused": "Paused",
|
||||||
|
"ended": "Ended"
|
||||||
|
},
|
||||||
|
"running": "running",
|
||||||
|
"offlineStarted": "started offline",
|
||||||
|
"corrected": "Corrected: {reason}",
|
||||||
|
"correct": "Correct",
|
||||||
|
"start": "Start",
|
||||||
|
"end": "End",
|
||||||
|
"reason": "Reason for correction",
|
||||||
|
"reasonHint": "Required, will be logged.",
|
||||||
|
"save": "Save correction",
|
||||||
|
"saved": "Correction saved.",
|
||||||
|
"empty": "No time recorded yet.",
|
||||||
|
"totalWork": "Total working time: {duration}"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Profile",
|
||||||
|
"tenant": "Company",
|
||||||
|
"teams": "Team",
|
||||||
|
"noTeam": "Not assigned to a team",
|
||||||
|
"roles": "Role",
|
||||||
|
"language": "Language",
|
||||||
|
"logout": "Sign out",
|
||||||
|
"backoffice": "Go to office area"
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"title": "Synchronisation",
|
||||||
|
"immediate": "Changes are sent to the office immediately.",
|
||||||
|
"offlineHint": "Without a connection, entries cannot be buffered yet – the offline queue will follow.",
|
||||||
|
"status": "Connection"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Shared fixture of the lane L4 tests (scripts/test-einsatz-*.ts): two zz test tenants with
|
||||||
|
// users, customer, site, team and work orders. Not a test itself (the runner only picks up
|
||||||
|
// scripts/test-*.ts at top level).
|
||||||
|
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma, dbForTenant } from "../../src/server/db";
|
||||||
|
import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac";
|
||||||
|
import type { ServiceCtx } from "../../src/server/services/context";
|
||||||
|
|
||||||
|
export let failures = 0;
|
||||||
|
export const ok = (cond: boolean, msg: string) => {
|
||||||
|
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||||
|
if (!cond) failures++;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Expects `fn` to throw a ServiceError with the given code. */
|
||||||
|
export async function expectCode(fn: () => Promise<unknown>, code: string, msg: string) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
ok(false, `${msg} — kein Fehler (erwartet ${code})`);
|
||||||
|
} catch (err) {
|
||||||
|
const actual = (err as { code?: string }).code;
|
||||||
|
ok(actual === code, `${msg}${actual === code ? "" : ` — Code ${actual ?? (err as Error).message}`}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx {
|
||||||
|
return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set<string>(ROLE_DEFS[role].permissions) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupSlug(slug: string, emailDomain: string) {
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { slug }, select: { id: true } });
|
||||||
|
if (tenant) {
|
||||||
|
const where = { tenantId: tenant.id };
|
||||||
|
await prisma.auditLog.deleteMany({ where });
|
||||||
|
await prisma.syncOperation.deleteMany({ where });
|
||||||
|
await prisma.photo.deleteMany({ where });
|
||||||
|
await prisma.voiceNote.deleteMany({ where });
|
||||||
|
await prisma.document.deleteMany({ where });
|
||||||
|
await prisma.workOrder.deleteMany({ where });
|
||||||
|
await prisma.teamMember.deleteMany({ where });
|
||||||
|
await prisma.team.deleteMany({ where });
|
||||||
|
await prisma.site.deleteMany({ where });
|
||||||
|
await prisma.contact.deleteMany({ where });
|
||||||
|
await prisma.customer.deleteMany({ where });
|
||||||
|
await prisma.numberSequence.deleteMany({ where });
|
||||||
|
await prisma.userRole.deleteMany({ where: { user: { tenantId: tenant.id } } });
|
||||||
|
await prisma.user.deleteMany({ where });
|
||||||
|
await prisma.role.deleteMany({ where });
|
||||||
|
await prisma.tenant.delete({ where: { id: tenant.id } });
|
||||||
|
}
|
||||||
|
await prisma.identity.deleteMany({ where: { email: { endsWith: emailDomain }, memberships: { none: {} } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function user(tenantId: string, email: string, name: string) {
|
||||||
|
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
|
||||||
|
return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Fixture = Awaited<ReturnType<typeof createFixture>>;
|
||||||
|
|
||||||
|
export async function createFixture(prefix: string) {
|
||||||
|
const slugA = `zz-${prefix}-a`;
|
||||||
|
const slugB = `zz-${prefix}-b`;
|
||||||
|
const domain = `@zz-${prefix}.test`;
|
||||||
|
await cleanupSlug(slugA, domain);
|
||||||
|
await cleanupSlug(slugB, domain);
|
||||||
|
|
||||||
|
const tenantA = await prisma.tenant.create({ data: { name: `L4 ${prefix} A`, slug: slugA } });
|
||||||
|
const tenantB = await prisma.tenant.create({ data: { name: `L4 ${prefix} B`, slug: slugB } });
|
||||||
|
|
||||||
|
const tech = await user(tenantA.id, `tech${domain}`, "Tech A");
|
||||||
|
const lead = await user(tenantA.id, `lead${domain}`, "Lead A");
|
||||||
|
const outsider = await user(tenantA.id, `outsider${domain}`, "Outsider A");
|
||||||
|
const techB = await user(tenantB.id, `techb${domain}`, "Tech B");
|
||||||
|
|
||||||
|
const customerA = await prisma.customer.create({ data: { tenantId: tenantA.id, companyName: "Kunde A GmbH", city: "Hamburg" } });
|
||||||
|
const siteA = await prisma.site.create({ data: { tenantId: tenantA.id, customerId: customerA.id, name: "Halle 1", street: "Hafenstraße", houseNumber: "1", postalCode: "20457", city: "Hamburg", accessNotes: "Schlüssel beim Pförtner" } });
|
||||||
|
const team = await prisma.team.create({ data: { tenantId: tenantA.id, name: `Team ${prefix}`, leaderUserId: lead.id } });
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const orderA = await prisma.workOrder.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenantA.id,
|
||||||
|
number: `A-${prefix}-1`,
|
||||||
|
customerId: customerA.id,
|
||||||
|
siteId: siteA.id,
|
||||||
|
title: "Heizung montieren",
|
||||||
|
status: "assigned",
|
||||||
|
plannedStart: new Date(now.getTime() - 60 * 60 * 1000),
|
||||||
|
plannedEnd: new Date(now.getTime() + 60 * 60 * 1000),
|
||||||
|
teamLeadUserId: lead.id,
|
||||||
|
assignees: { create: [{ tenantId: tenantA.id, userId: tech.id }] },
|
||||||
|
checklistItems: { create: [{ tenantId: tenantA.id, key: "safe", label: "Arbeitsbereich abgesichert", required: true }] },
|
||||||
|
photoRequirements: { create: [{ tenantId: tenantA.id, key: "typenschild", label: "Typenschild" }] },
|
||||||
|
materialPlans: { create: [{ tenantId: tenantA.id, name: "Kupferrohr 15 mm", plannedQuantity: new Prisma.Decimal(10), unit: "m" }] },
|
||||||
|
},
|
||||||
|
include: { checklistItems: true, photoRequirements: true, materialPlans: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerB = await prisma.customer.create({ data: { tenantId: tenantB.id, companyName: "Kunde B AG" } });
|
||||||
|
const orderB = await prisma.workOrder.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenantB.id,
|
||||||
|
number: `A-${prefix}-B1`,
|
||||||
|
customerId: customerB.id,
|
||||||
|
title: "Wartung B",
|
||||||
|
status: "assigned",
|
||||||
|
assignees: { create: [{ tenantId: tenantB.id, userId: techB.id }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantA,
|
||||||
|
tenantB,
|
||||||
|
tech,
|
||||||
|
lead,
|
||||||
|
outsider,
|
||||||
|
techB,
|
||||||
|
team,
|
||||||
|
siteA,
|
||||||
|
orderA,
|
||||||
|
orderB,
|
||||||
|
ctxTech: ctxFor(tenantA.id, tech.id, "technician"),
|
||||||
|
ctxLead: ctxFor(tenantA.id, lead.id, "team-lead"),
|
||||||
|
ctxOutsider: ctxFor(tenantA.id, outsider.id, "technician"),
|
||||||
|
ctxB: ctxFor(tenantB.id, techB.id, "technician"),
|
||||||
|
cleanup: async () => {
|
||||||
|
await cleanupSlug(slugA, domain);
|
||||||
|
await cleanupSlug(slugB, domain);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Lane L4 „Einsatz mobil" — Service-Tests: Einsatz-Sessions (Zustände, Pause-Segmente, Dauer),
|
||||||
|
// Zeitkorrektur (Recht + Grund + Audit), Material (Abweichung ohne Grund → invalid, Zusatzmaterial,
|
||||||
|
// Idempotenz), Checkliste/Notizen, Abschluss-Guards, Rollen/Scope und Mandantentrennung.
|
||||||
|
//
|
||||||
|
// Lauf: npx tsx scripts/test-einsatz-field.ts (lokale Postgres-DB aus .env)
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import { prisma } from "../src/server/db";
|
||||||
|
import { closeJobQueues } from "../src/server/jobs/queues";
|
||||||
|
import { endSession, pauseSession, resumeSession, startSession } from "../src/server/services/field/sessions";
|
||||||
|
import { correctTimeEntry } from "../src/server/services/field/time-correction";
|
||||||
|
import { upsertMaterialUsage } from "../src/server/services/field/materials";
|
||||||
|
import { toggleChecklistItem } from "../src/server/services/field/checklist";
|
||||||
|
import { createNote } from "../src/server/services/field/notes";
|
||||||
|
import { getFieldOrderDetail, listFieldOrders, listTodayOrders, getFieldBundle } from "../src/server/services/field/queries";
|
||||||
|
import { transitionWorkOrder } from "../src/server/services/field/stubs/work-order-transition";
|
||||||
|
import { createFixture, expectCode, failures, ok } from "./lib/einsatz-fixture";
|
||||||
|
|
||||||
|
const min = (n: number) => n * 60 * 1000;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const f = await createFixture("l4field");
|
||||||
|
const wo = f.orderA.id;
|
||||||
|
try {
|
||||||
|
console.log("\n— Einsatz-Session: Zustände —");
|
||||||
|
const t0 = Date.now() - min(120);
|
||||||
|
const at = (m: number) => new Date(t0 + min(m)).toISOString();
|
||||||
|
|
||||||
|
const travel = await startSession(f.ctxTech, { workOrderId: wo, mode: "travel", at: at(0), offline: true, deviceInfo: "test", latitude: 53.5, longitude: 10 });
|
||||||
|
ok(travel.status === "en_route" && travel.workOrderStatus === "en_route", "Losfahren → Session en_route, Auftrag en_route");
|
||||||
|
const s0 = await prisma.workSession.findUnique({ where: { id: travel.sessionId } });
|
||||||
|
ok(s0?.startedOffline === true && s0.startLat === 53.5 && s0.deviceInfo === "test", "Start speichert Standort, Gerätestatus und Offline-Flag");
|
||||||
|
await expectCode(() => startSession(f.ctxTech, { workOrderId: wo, mode: "travel", offline: false }), "conflict", "zweites Losfahren → conflict");
|
||||||
|
|
||||||
|
const work = await startSession(f.ctxTech, { workOrderId: wo, mode: "work", at: at(10), offline: false });
|
||||||
|
ok(work.sessionId === travel.sessionId && work.status === "running" && work.workOrderStatus === "in_progress", "Arbeit starten übernimmt die Anfahrt-Session → running, Auftrag in_progress");
|
||||||
|
await expectCode(() => startSession(f.ctxTech, { workOrderId: wo, mode: "work", offline: false }), "conflict", "keine doppelte laufende Session je User+Auftrag");
|
||||||
|
const active = await prisma.workSession.count({ where: { workOrderId: wo, userId: f.tech.id, status: { in: ["en_route", "running", "paused"] } } });
|
||||||
|
ok(active === 1, "genau eine aktive Session");
|
||||||
|
|
||||||
|
const paused = await pauseSession(f.ctxTech, { workOrderId: wo, at: at(40) });
|
||||||
|
ok(paused.status === "paused" && paused.workOrderStatus === "paused", "Pause → Session paused, Auftrag paused");
|
||||||
|
await expectCode(() => pauseSession(f.ctxTech, { workOrderId: wo }), "invalid", "Pause ohne laufende Session → invalid");
|
||||||
|
const resumed = await resumeSession(f.ctxTech, { workOrderId: wo, at: at(50) });
|
||||||
|
ok(resumed.status === "running" && resumed.workOrderStatus === "in_progress", "Weiter → running, Auftrag in_progress");
|
||||||
|
|
||||||
|
const entries = await prisma.timeEntry.findMany({ where: { workSessionId: travel.sessionId }, orderBy: { startedAt: "asc" } });
|
||||||
|
ok(entries.map((e) => e.type).join(",") === "travel,work,break,work", "Zeitabschnitte als Segmente travel → work → break → work");
|
||||||
|
ok(entries.slice(0, 3).every((e) => e.endedAt), "abgeschlossene Segmente haben ein Ende");
|
||||||
|
|
||||||
|
console.log("\n— Abschluss-Guards —");
|
||||||
|
await expectCode(() => transitionWorkOrder(f.ctxTech, { workOrderId: wo, to: "technically_completed" }), "blocked", "Abschluss mit offener Pflicht-Checkliste/Pflichtfoto/laufender Session → blocked");
|
||||||
|
|
||||||
|
const ended = await endSession(f.ctxTech, { workOrderId: wo, at: at(80) });
|
||||||
|
ok(ended.status === "ended", "Ende → Session ended");
|
||||||
|
ok(ended.workSeconds === 3600 && ended.breakSeconds === 600 && ended.travelSeconds === 600 && ended.totalSeconds === 4800, `Ende berechnet Dauer (Arbeit ${ended.workSeconds}s, Pause ${ended.breakSeconds}s, Anfahrt ${ended.travelSeconds}s)`);
|
||||||
|
await expectCode(() => endSession(f.ctxTech, { workOrderId: wo }), "invalid", "Ende ohne aktive Session → invalid");
|
||||||
|
|
||||||
|
console.log("\n— Zeitkorrektur —");
|
||||||
|
const entry = entries[1];
|
||||||
|
await expectCode(
|
||||||
|
() => correctTimeEntry(f.ctxTech, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "vergessen" }),
|
||||||
|
"forbidden",
|
||||||
|
"Monteur ohne field:correct_time → forbidden",
|
||||||
|
);
|
||||||
|
await expectCode(() => correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "" }), "invalid", "Korrektur ohne Grund → invalid");
|
||||||
|
await expectCode(() => correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(40), endedAt: at(12), reason: "Ende vor Start" }), "invalid", "Ende vor Beginn → invalid");
|
||||||
|
const corrected = await correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "Start zu früh gestempelt" });
|
||||||
|
ok(corrected.corrected && corrected.correctionReason === "Start zu früh gestempelt" && corrected.correctedById === f.lead.id, "Teamleiter korrigiert mit Grund → corrected-Flag + Grund + Bearbeiter");
|
||||||
|
const auditRow = await prisma.auditLog.findFirst({ where: { tenantId: f.tenantA.id, entity: "time_entry", entityId: entry.id }, orderBy: { createdAt: "desc" } });
|
||||||
|
const before = auditRow?.before as { startedAt?: string } | null;
|
||||||
|
const after = auditRow?.after as { startedAt?: string; correctionReason?: string } | null;
|
||||||
|
ok(!!auditRow && new Date(before!.startedAt!).getTime() === t0 + min(10) && new Date(after!.startedAt!).getTime() === t0 + min(12) && after?.correctionReason === "Start zu früh gestempelt", "Audit mit before/after der Korrektur");
|
||||||
|
await expectCode(() => correctTimeEntry(f.ctxB as never, { timeEntryId: entry.id, startedAt: at(12), reason: "fremd" }), "forbidden", "Mandant B (Monteur) kann nicht korrigieren");
|
||||||
|
const leadB = { ...f.ctxB, permissions: new Set([...f.ctxB.permissions, "field:correct_time"]) };
|
||||||
|
await expectCode(() => correctTimeEntry(leadB, { timeEntryId: entry.id, startedAt: at(12), reason: "fremder Mandant" }), "not_found", "Mandant B mit Korrekturrecht sieht Zeiteintrag von A nicht → not_found");
|
||||||
|
|
||||||
|
console.log("\n— Material —");
|
||||||
|
const plan = f.orderA.materialPlans[0];
|
||||||
|
await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 5, unit: "m", usageStatus: "partially_used" }), "invalid", "teilweise verwendet ohne Begründung → invalid");
|
||||||
|
await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 12, unit: "m", usageStatus: "fully_used" }), "invalid", "Mehrmenge ohne Begründung → invalid");
|
||||||
|
await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 3, unit: "m", usageStatus: "not_used", deviationReason: "x" }), "invalid", "nicht verwendet mit Menge > 0 → invalid");
|
||||||
|
const partial = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 5, unit: "m", usageStatus: "partially_used", deviationReason: "Leitung kürzer" });
|
||||||
|
const full = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 10, unit: "m", usageStatus: "fully_used" });
|
||||||
|
ok(partial.usageId === full.usageId, "Bestätigung derselben Planposition aktualisiert statt doppelt anzulegen");
|
||||||
|
const planUsage = await prisma.materialUsage.findUnique({ where: { id: full.usageId } });
|
||||||
|
ok(planUsage?.usageStatus === "fully_used" && Number(planUsage.actualQuantity) === 10 && planUsage.deviationReason === null, "vollständig verwendet ohne Abweichung braucht keine Begründung");
|
||||||
|
|
||||||
|
await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, name: "Muffe", quantity: 2, unit: "Stk", usageStatus: "additional" }), "invalid", "Zusatzmaterial ohne Grund → invalid");
|
||||||
|
const clientId = "7f1c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f";
|
||||||
|
const add1 = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, clientId, name: "Muffe", quantity: 2, unit: "Stk", usageStatus: "additional", deviationReason: "Übergang fehlte" });
|
||||||
|
const add2 = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, clientId, name: "Muffe", quantity: 3, unit: "Stk", usageStatus: "additional", deviationReason: "Übergang fehlte" });
|
||||||
|
ok(add1.usageId === add2.usageId && (await prisma.materialUsage.count({ where: { workOrderId: wo } })) === 2, "Zusatzmaterial idempotent über clientId");
|
||||||
|
|
||||||
|
console.log("\n— Checkliste & Notizen —");
|
||||||
|
const item = f.orderA.checklistItems[0];
|
||||||
|
const toggled = await toggleChecklistItem(f.ctxTech, { workOrderId: wo, itemId: item.id, checked: true, comment: "erledigt" });
|
||||||
|
const itemRow = await prisma.checklistItem.findUnique({ where: { id: item.id } });
|
||||||
|
ok(toggled.checked && itemRow?.checkedById === f.tech.id && itemRow.comment === "erledigt", "Checklistenpunkt abhaken mit Kommentar");
|
||||||
|
const noteClient = "1b2c3d4e-5f60-4718-8293-a4b5c6d7e8f9";
|
||||||
|
const n1 = await createNote(f.ctxTech, { workOrderId: wo, clientId: noteClient, kind: "problem", text: "Ventil klemmt" });
|
||||||
|
const n2 = await createNote(f.ctxTech, { workOrderId: wo, clientId: noteClient, kind: "problem", text: "Ventil klemmt" });
|
||||||
|
ok(n1.noteId === n2.noteId && (await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "Notiz idempotent über clientId");
|
||||||
|
const noteAudit = await prisma.auditLog.count({ where: { tenantId: f.tenantA.id, entity: "activity_note", entityId: n1.noteId } });
|
||||||
|
ok(noteAudit === 1, "Notiz erzeugt genau einen Audit-Eintrag");
|
||||||
|
|
||||||
|
console.log("\n— Rollen/Scope (Monteur ohne Zuweisung) —");
|
||||||
|
await expectCode(() => startSession(f.ctxOutsider, { workOrderId: wo, mode: "work", offline: false }), "not_found", "fremder Auftrag: Session starten → not_found");
|
||||||
|
await expectCode(() => toggleChecklistItem(f.ctxOutsider, { workOrderId: wo, itemId: item.id, checked: false }), "not_found", "fremder Auftrag: Checkliste → not_found");
|
||||||
|
await expectCode(() => createNote(f.ctxOutsider, { workOrderId: wo, kind: "general", text: "x" }), "not_found", "fremder Auftrag: Notiz → not_found");
|
||||||
|
await expectCode(() => getFieldOrderDetail(f.ctxOutsider, wo), "not_found", "fremder Auftrag: Detail → not_found");
|
||||||
|
ok((await listFieldOrders(f.ctxOutsider, "running")).length === 0 && (await listTodayOrders(f.ctxOutsider)).length === 0, "Monteur ohne Zuweisung sieht keine Aufträge");
|
||||||
|
const noExecute = { ...f.ctxTech, permissions: new Set([...f.ctxTech.permissions].filter((p) => p !== "field:execute")) };
|
||||||
|
await expectCode(() => createNote(noExecute, { workOrderId: wo, kind: "general", text: "x" }), "forbidden", "ohne field:execute → forbidden");
|
||||||
|
|
||||||
|
console.log("\n— Sichtbarkeit für Zugewiesene/Teamleiter —");
|
||||||
|
const running = await listFieldOrders(f.ctxTech, "running");
|
||||||
|
ok(running.some((o) => o.id === wo), "Monteur sieht zugewiesenen Auftrag unter „laufend\"");
|
||||||
|
ok((await listTodayOrders(f.ctxTech)).some((o) => o.id === wo), "Auftrag erscheint unter „Heute\"");
|
||||||
|
const detail = await getFieldOrderDetail(f.ctxLead, wo);
|
||||||
|
ok(detail.card.address === "Hafenstraße 1, 20457 Hamburg" && detail.site?.accessNotes === "Schlüssel beim Pförtner", "Teamleiter sieht Detail inkl. Objektadresse und Zugangshinweis");
|
||||||
|
ok(detail.blockers.some((b) => b.kind === "photo_requirement") && !detail.blockers.some((b) => b.kind === "checklist_item"), "Blocker: nur noch Pflichtfoto offen");
|
||||||
|
const bundle = await getFieldBundle(f.ctxTech);
|
||||||
|
ok(bundle.orders.length === 1 && bundle.orders[0].id === wo && bundle.orders[0].materialPlans.length === 1, "Bundle liefert Auftrag im Scope inkl. Materialvorgabe");
|
||||||
|
|
||||||
|
console.log("\n— Mandantentrennung (B gegen A) —");
|
||||||
|
await expectCode(() => getFieldOrderDetail(f.ctxB, wo), "not_found", "B liest Auftrag von A → not_found");
|
||||||
|
await expectCode(() => startSession(f.ctxB, { workOrderId: wo, mode: "work", offline: false }), "not_found", "B startet Session auf Auftrag von A → not_found");
|
||||||
|
await expectCode(() => upsertMaterialUsage(f.ctxB, { workOrderId: f.orderB.id, materialPlanId: plan.id, quantity: 1, unit: "m", usageStatus: "fully_used" }), "not_found", "B nutzt Planposition von A → not_found");
|
||||||
|
await expectCode(() => toggleChecklistItem(f.ctxB, { workOrderId: f.orderB.id, itemId: item.id, checked: false }), "not_found", "B ändert Checklistenpunkt von A → not_found");
|
||||||
|
const itemAfter = await prisma.checklistItem.findUnique({ where: { id: item.id } });
|
||||||
|
ok(itemAfter?.checked === true, "Checklistenpunkt von A unverändert");
|
||||||
|
ok((await getFieldBundle(f.ctxB)).orders.every((o) => o.id !== wo), "Bundle von B enthält keine Aufträge von A");
|
||||||
|
} finally {
|
||||||
|
await f.cleanup();
|
||||||
|
await closeJobQueues();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
console.log(`\n${failures === 0 ? "✓ alle Prüfungen grün" : `✗ ${failures} Fehler`}`);
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Lane L4 „Einsatz mobil" — Sync- und Upload-Tests: Idempotenz (gleiche clientOpId → duplicate,
|
||||||
|
// keine Doppelanlage), Konflikt bei veralteter baseVersion (nichts überschrieben), Scope über die
|
||||||
|
// Sync-API (fremder Auftrag → not_found), Mandantentrennung bei Uploads/Dokumenten, Foto-/
|
||||||
|
// Sprachnotiz-Anhang, Ops fremder Lanes ohne Implementierung, image-derivatives-Processor.
|
||||||
|
//
|
||||||
|
// Lauf: npx tsx scripts/test-einsatz-sync.ts (lokale Postgres-DB aus .env; S3 optional)
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { prisma } from "../src/server/db";
|
||||||
|
import { closeJobQueues } from "../src/server/jobs/queues";
|
||||||
|
import { applyOperations } from "../src/server/services/sync/apply";
|
||||||
|
import { storeFieldUpload } from "../src/server/services/field/uploads";
|
||||||
|
import { openFieldDocument } from "../src/server/services/field/documents";
|
||||||
|
import { process as imageDerivatives } from "../src/server/jobs/processors/image-derivatives";
|
||||||
|
import type { SyncOperationInput, SyncOpType } from "../src/lib/sync/envelope";
|
||||||
|
import type { ServiceCtx } from "../src/server/services/context";
|
||||||
|
import { createFixture, expectCode, failures, ok } from "./lib/einsatz-fixture";
|
||||||
|
|
||||||
|
const S3 = !!process.env.S3_ENDPOINT?.trim();
|
||||||
|
|
||||||
|
function op(opType: SyncOpType, payload: Record<string, unknown>, extra: Partial<SyncOperationInput> = {}): SyncOperationInput {
|
||||||
|
return { clientOpId: randomUUID(), opType, payload, clientCreatedAt: new Date().toISOString(), ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function one(ctx: ServiceCtx, operation: SyncOperationInput) {
|
||||||
|
const res = await applyOperations(ctx, { deviceId: "test-device", operations: [operation] });
|
||||||
|
return res.results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const f = await createFixture("l4sync");
|
||||||
|
const wo = f.orderA.id;
|
||||||
|
try {
|
||||||
|
console.log("\n— Idempotenz —");
|
||||||
|
const noteOp = op("note.create", { workOrderId: wo, clientId: randomUUID(), kind: "work_done", text: "Heizkörper montiert" });
|
||||||
|
const r1 = await one(f.ctxTech, noteOp);
|
||||||
|
ok(r1.status === "applied" && !!r1.idMap && Object.keys(r1.idMap).length === 1, "erste Op → applied mit idMap");
|
||||||
|
const r2 = await one(f.ctxTech, noteOp);
|
||||||
|
ok(r2.status === "duplicate" && JSON.stringify(r2.idMap) === JSON.stringify(r1.idMap), "gleiche clientOpId → duplicate mit gespeicherter idMap");
|
||||||
|
ok((await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "keine Doppelanlage der Notiz");
|
||||||
|
ok((await prisma.syncOperation.count({ where: { tenantId: f.tenantA.id, clientOpId: noteOp.clientOpId } })) === 1, "SyncOperation einmal protokolliert");
|
||||||
|
const batch = await applyOperations(f.ctxTech, { deviceId: "test-device", operations: [noteOp, noteOp] });
|
||||||
|
ok(batch.results.every((r) => r.status === "duplicate"), "Batch mit Wiederholungen → duplicate");
|
||||||
|
|
||||||
|
const bad = await one(f.ctxTech, op("note.create", { workOrderId: wo, kind: "unbekannt", text: "" }));
|
||||||
|
ok(bad.status === "rejected" && bad.errorCode === "invalid", "ungültige Payload → rejected invalid");
|
||||||
|
|
||||||
|
console.log("\n— Konflikt (baseVersion) —");
|
||||||
|
const before = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } });
|
||||||
|
const stale = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }, { baseVersion: before.version + 5 }));
|
||||||
|
ok(stale.status === "conflict" && stale.entityVersion === before.version, "veraltete baseVersion → conflict mit aktueller Version");
|
||||||
|
const afterConflict = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } });
|
||||||
|
ok(afterConflict.status === before.status && afterConflict.version === before.version, "bei Konflikt nichts überschrieben");
|
||||||
|
const stored = await prisma.syncOperation.findFirst({ where: { tenantId: f.tenantA.id, clientOpId: stale.clientOpId } });
|
||||||
|
ok(stored?.status === "conflict" && stored.errorCode === "conflict", "Konflikt als SyncOperation(status=conflict) für das Backoffice gespeichert");
|
||||||
|
const noBase = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }));
|
||||||
|
ok(noBase.status === "rejected" && noBase.errorCode === "invalid", "konfliktbehaftete Op ohne baseVersion → invalid");
|
||||||
|
const fresh = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }, { baseVersion: before.version }));
|
||||||
|
const accepted = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } });
|
||||||
|
ok(fresh.status === "applied" && fresh.entityVersion === before.version + 1 && accepted.status === "accepted", "aktuelle baseVersion → applied, Version +1");
|
||||||
|
ok((await prisma.workOrderStatusChange.count({ where: { workOrderId: wo, toStatus: "accepted" } })) === 1, "Statuswechsel protokolliert");
|
||||||
|
|
||||||
|
console.log("\n— Sessions über Sync —");
|
||||||
|
const st1 = await one(f.ctxTech, op("session.start", { workOrderId: wo, mode: "work", clientId: randomUUID() }));
|
||||||
|
const st2 = await one(f.ctxTech, op("session.start", { workOrderId: wo, mode: "work", clientId: randomUUID() }));
|
||||||
|
ok(st1.status === "applied" && st2.status === "rejected" && st2.errorCode === "conflict", "zweiter Start über Sync → rejected conflict");
|
||||||
|
ok((await prisma.workSession.count({ where: { workOrderId: wo, status: "running" } })) === 1, "nur eine laufende Session");
|
||||||
|
|
||||||
|
console.log("\n— Scope über die Sync-API —");
|
||||||
|
const foreign = await one(f.ctxOutsider, op("note.create", { workOrderId: wo, kind: "general", text: "darf nicht" }));
|
||||||
|
ok(foreign.status === "rejected" && foreign.errorCode === "not_found", "Monteur ohne Zuweisung → rejected not_found");
|
||||||
|
const foreignTransition = await one(f.ctxOutsider, op("work_order.transition", { workOrderId: wo, to: "en_route" }, { baseVersion: accepted.version }));
|
||||||
|
ok(foreignTransition.status === "rejected" && foreignTransition.errorCode === "not_found", "fremder Auftrag bei Konfliktprüfung → not_found (kein Versions-Leak)");
|
||||||
|
const crossTenant = await one(f.ctxB, op("note.create", { workOrderId: wo, kind: "general", text: "Mandant B" }));
|
||||||
|
ok(crossTenant.status === "rejected" && crossTenant.errorCode === "not_found", "Mandant B auf Auftrag von A → not_found");
|
||||||
|
ok((await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "keine fremden Notizen angelegt");
|
||||||
|
const sameOpIdB = await one(f.ctxB, { ...noteOp, payload: { workOrderId: f.orderB.id, kind: "general", text: "B eigene Notiz" } });
|
||||||
|
ok(sameOpIdB.status === "applied", "gleiche clientOpId in Mandant B ist unabhängig (Idempotenz je Mandant)");
|
||||||
|
const reuseOther = await one(f.ctxLead, noteOp);
|
||||||
|
ok(reuseOther.status === "rejected" && !reuseOther.idMap, "fremder Nutzer mit gleicher clientOpId erhält kein gespeichertes Ergebnis");
|
||||||
|
|
||||||
|
console.log("\n— Ops fremder Lanes —");
|
||||||
|
const report = await one(f.ctxTech, op("report.save_draft", { workOrderId: wo }));
|
||||||
|
ok(report.status === "rejected" && report.errorCode === "invalid" && /not available/.test(report.message ?? ""), "report.save_draft ohne L5 → rejected invalid mit Hinweis");
|
||||||
|
ok((await prisma.syncOperation.count({ where: { clientOpId: report.clientOpId } })) === 0, "nicht verfügbare Op wird nicht gespeichert (später wiederholbar)");
|
||||||
|
|
||||||
|
console.log("\n— Uploads & Mandantentrennung —");
|
||||||
|
const jpeg = await sharp({ create: { width: 1200, height: 800, channels: 3, background: { r: 200, g: 120, b: 40 } } }).jpeg().toBuffer();
|
||||||
|
const thumb = await sharp(jpeg).resize(400).jpeg().toBuffer();
|
||||||
|
const uploadClient = randomUUID();
|
||||||
|
const up1 = await storeFieldUpload(f.ctxTech, { clientId: uploadClient, workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" }, { bytes: thumb, name: "t.jpg", type: "image/jpeg" });
|
||||||
|
const up2 = await storeFieldUpload(f.ctxTech, { clientId: uploadClient, workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" });
|
||||||
|
ok(!up1.duplicate && up2.duplicate && up1.documentId === up2.documentId, "Upload idempotent über clientId");
|
||||||
|
const doc = await prisma.document.findUniqueOrThrow({ where: { id: up1.documentId } });
|
||||||
|
ok(doc.tenantId === f.tenantA.id && doc.category === "photo" && doc.mimeType === "image/jpeg" && doc.checksum.length === 64 && !!doc.previewKey, "Dokument mit MIME, Prüfsumme und Vorschaubild");
|
||||||
|
|
||||||
|
const upB = await storeFieldUpload(f.ctxB, { clientId: uploadClient, workOrderId: f.orderB.id, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" });
|
||||||
|
ok(!upB.duplicate && upB.documentId !== up1.documentId, "gleiche clientId in Mandant B → eigenes Dokument, kein Zugriff auf A");
|
||||||
|
await expectCode(() => storeFieldUpload(f.ctxB, { clientId: randomUUID(), workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "x.jpg", type: "image/jpeg" }), "not_found", "Mandant B lädt auf Auftrag von A hoch → not_found");
|
||||||
|
await expectCode(() => openFieldDocument(f.ctxB, up1.documentId), "not_found", "Mandant B öffnet Dokument von A → not_found");
|
||||||
|
await expectCode(() => openFieldDocument(f.ctxOutsider, up1.documentId), "not_found", "Monteur ohne Zuweisung öffnet Dokument → not_found");
|
||||||
|
await expectCode(
|
||||||
|
() => storeFieldUpload(f.ctxTech, { clientId: randomUUID(), workOrderId: wo, kind: "photo" }, { bytes: Buffer.from("%PDF-1.7 fake pdf content"), name: "x.pdf", type: "application/pdf" }),
|
||||||
|
"invalid",
|
||||||
|
"PDF als Foto → invalid (Magic Bytes)",
|
||||||
|
);
|
||||||
|
if (S3) {
|
||||||
|
const opened = await openFieldDocument(f.ctxTech, up1.documentId, "preview");
|
||||||
|
ok(!!opened.content.stream, "zugewiesener Monteur öffnet eigenes Foto (Vorschau)");
|
||||||
|
} else {
|
||||||
|
console.log("↷ Byte-Abruf übersprungen (kein S3_ENDPOINT)");
|
||||||
|
}
|
||||||
|
const internal = await prisma.document.create({
|
||||||
|
data: { tenantId: f.tenantA.id, workOrderId: wo, category: "other", fileName: "intern.pdf", storageKey: `${f.tenantA.id}/uploads/x`, mimeType: "application/pdf", fileSize: 1, checksum: "0", lineageId: randomUUID(), visibility: "backoffice_only" },
|
||||||
|
});
|
||||||
|
await expectCode(() => openFieldDocument(f.ctxTech, internal.id), "not_found", "Backoffice-internes Dokument bleibt für Monteur verborgen");
|
||||||
|
|
||||||
|
console.log("\n— Foto & Sprachnotiz anhängen —");
|
||||||
|
const req = f.orderA.photoRequirements[0];
|
||||||
|
const photoClient = randomUUID();
|
||||||
|
const attach = await one(f.ctxTech, op("photo.attach", { workOrderId: wo, clientId: photoClient, documentId: up1.documentId, phase: "before", photoRequirementId: req.id, comment: "Typenschild" }));
|
||||||
|
ok(attach.status === "applied" && !!attach.idMap?.[photoClient], "photo.attach → applied");
|
||||||
|
const photo = await prisma.photo.findFirst({ where: { documentId: up1.documentId } });
|
||||||
|
ok(photo?.photoRequirementId === req.id && photo.phase === "before" && photo.takenById === f.tech.id, "Foto mit Phase, Pflichtfoto und Aufnehmendem");
|
||||||
|
const attachB = await one(f.ctxB, op("photo.attach", { workOrderId: f.orderB.id, documentId: up1.documentId }));
|
||||||
|
ok(attachB.status === "rejected" && attachB.errorCode === "not_found", "Mandant B hängt Dokument von A an → not_found");
|
||||||
|
const attachAgain = await one(f.ctxTech, op("photo.attach", { workOrderId: wo, documentId: up1.documentId }));
|
||||||
|
ok(attachAgain.status === "rejected" && attachAgain.errorCode === "invalid", "Dokument doppelt anhängen → invalid");
|
||||||
|
|
||||||
|
const webm = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(512, 1)]);
|
||||||
|
const voiceUp = await storeFieldUpload(f.ctxTech, { clientId: randomUUID(), workOrderId: wo, kind: "voice_note" }, { bytes: webm, name: "sprachnotiz.webm", type: "audio/webm;codecs=opus" });
|
||||||
|
const voice = await one(f.ctxTech, op("voice.attach", { workOrderId: wo, clientId: randomUUID(), documentId: voiceUp.documentId, durationSeconds: 12 }));
|
||||||
|
const vn = await prisma.voiceNote.findFirst({ where: { documentId: voiceUp.documentId } });
|
||||||
|
ok(voice.status === "applied" && vn?.transcriptionStatus === "disabled", "Sprachnotiz ohne Transkriptions-Processor → Status disabled statt Fehler");
|
||||||
|
|
||||||
|
console.log("\n— image-derivatives —");
|
||||||
|
const noThumb = await storeFieldUpload(f.ctxTech, { clientId: randomUUID(), workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "ohne-vorschau.jpg", type: "image/jpeg" });
|
||||||
|
await imageDerivatives({ tenantId: f.tenantA.id, entityId: noThumb.documentId, actorId: f.tech.id });
|
||||||
|
const derived = await prisma.document.findUniqueOrThrow({ where: { id: noThumb.documentId } });
|
||||||
|
if (S3) ok(!!derived.previewKey && derived.previewKey.startsWith(`${f.tenantA.id}/`), "Processor erzeugt mandantenpräfixiertes Vorschaubild");
|
||||||
|
else ok(derived.previewKey === null, "ohne Byte-Speicher kein Vorschaubild (kein Fehler)");
|
||||||
|
await imageDerivatives({ tenantId: f.tenantB.id, entityId: noThumb.documentId });
|
||||||
|
ok((await prisma.document.findUniqueOrThrow({ where: { id: noThumb.documentId } })).previewKey === derived.previewKey, "Processor mit fremdem Mandanten ändert nichts");
|
||||||
|
} finally {
|
||||||
|
await f.cleanup();
|
||||||
|
await closeJobQueues();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
console.log(`\n${failures === 0 ? "✓ alle Prüfungen grün" : `✗ ${failures} Fehler`}`);
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { auth, signOut } from "@/server/auth";
|
import { signOut } from "@/server/auth";
|
||||||
import { dbForTenant, prisma } from "@/server/db";
|
import { dbForTenant } from "@/server/db";
|
||||||
import { isTokenStillValid } from "@/server/sessions";
|
import { requireAppAccess } from "@/server/app-access";
|
||||||
import { resolveMfaRequired } from "@/lib/mfa-policy";
|
|
||||||
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
|
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
|
||||||
import { resolveTenantBranding } from "@/lib/brand";
|
import { resolveTenantBranding } from "@/lib/brand";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -14,60 +12,16 @@ import { TenantSwitcher } from "@/components/tenant-switcher";
|
|||||||
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
|
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
|
||||||
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||||
import { NotificationBell } from "@/components/notifications/bell";
|
import { NotificationBell } from "@/components/notifications/bell";
|
||||||
|
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
||||||
|
|
||||||
export default async function AppLayout({
|
export default async function AppLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
const session = await auth();
|
// Session-, Konto-, Passwort- und MFA-Prüfungen: gemeinsam mit der Mobile-Shell
|
||||||
if (!session?.user) redirect("/login");
|
// (src/server/app-access.ts).
|
||||||
// Angemeldet, aber (noch) kein aktiver Mandant (mehrere Mitgliedschaften ohne Slug) →
|
const access = await requireAppAccess();
|
||||||
// Auswahlseite. MUSS vor jeder dbForTenant-Nutzung stehen (leerer tenantId).
|
if (access.kind === "inactive") return <AccountInactiveNotice />;
|
||||||
if (!session.user.tenantId) redirect("/select-tenant");
|
const { session, identity } = access;
|
||||||
|
|
||||||
// Kontostatus (autoritativ aus DB, nicht aus dem JWT): deaktivierte Nutzer werden
|
|
||||||
// abgemeldet, ein offener Passwortwechsel wird erzwungen. Membership-Status kommt vom
|
|
||||||
// User, die globalen Auth-Zustände (Passwortzwang, Kill-Switch, MFA) von der Identity.
|
|
||||||
const account = await dbForTenant(session.user.tenantId).user.findUnique({
|
|
||||||
where: { id: session.user.id },
|
|
||||||
select: { status: true },
|
|
||||||
});
|
|
||||||
const identity = session.user.identityId
|
|
||||||
? await prisma.identity.findUnique({
|
|
||||||
where: { id: session.user.identityId },
|
|
||||||
select: { status: true, mustChangePassword: true, sessionsValidAfter: true, mfaEnrolledAt: true, uiLocale: true },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
// SEC2: serverseitig entwertete Sessions (Passwort-Reset/-Wechsel) sofort abmelden.
|
|
||||||
if (identity && !isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
|
|
||||||
await signOut({ redirectTo: "/login" });
|
|
||||||
}
|
|
||||||
if (!account || account.status !== "ACTIVE" || !identity || identity.status !== "ACTIVE") {
|
|
||||||
return (
|
|
||||||
<main className="flex min-h-screen flex-1 items-center justify-center p-6">
|
|
||||||
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
|
|
||||||
<CraftviaLogo variant="horizontal" height={34} className="mx-auto" />
|
|
||||||
<p className="mt-5 font-heading text-lg font-semibold">Konto deaktiviert</p>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.</p>
|
|
||||||
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5">
|
|
||||||
<Button type="submit" variant="outline" className="w-full">Abmelden</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (identity.mustChangePassword) redirect("/change-password");
|
|
||||||
|
|
||||||
// SEC3-a/-b: MFA-Enrollment-Gate. Verlangt der Mandant MFA und hat die Identity weder TOTP
|
|
||||||
// noch einen Passkey, wird die Einrichtung erzwungen (Pflichtseite außerhalb dieses Layouts).
|
|
||||||
if (!identity.mfaEnrolledAt) {
|
|
||||||
const settings = await dbForTenant(session.user.tenantId).tenantSettings.findFirst({ select: { securityPolicy: true } });
|
|
||||||
if (resolveMfaRequired(settings?.securityPolicy)) {
|
|
||||||
const passkeys = session.user.identityId
|
|
||||||
? await prisma.webAuthnCredential.count({ where: { identityId: session.user.identityId } })
|
|
||||||
: 0;
|
|
||||||
if (passkeys === 0) redirect("/enroll-mfa");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = await getTranslations("nav");
|
const t = await getTranslations("nav");
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { ModulePlaceholder } from "@/components/module-placeholder";
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <ModulePlaceholder moduleKey="field" />;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ChecklistItemRow } from "@/components/field/checklist-item";
|
||||||
|
import { SubPageHeader } from "@/components/field/sub-page-header";
|
||||||
|
import { loadOrder } from "../load";
|
||||||
|
|
||||||
|
/** `/m/orders/[id]/checklist` — checklist execution (Spec §12.4). */
|
||||||
|
export default async function ChecklistPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { order } = await loadOrder(params);
|
||||||
|
const t = await getTranslations("field");
|
||||||
|
const done = order.checklistItems.filter((i) => i.checked).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="space-y-4 pb-4">
|
||||||
|
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("checklist.title")} />
|
||||||
|
<div className="space-y-3 px-4">
|
||||||
|
{order.checklistItems.length === 0 ? (
|
||||||
|
<p className="text-[15px] text-muted-foreground">{t("checklist.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-[15px] font-semibold">{t("detail.checklistProgress", { done, total: order.checklistItems.length })}</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{order.checklistItems.map((item) => (
|
||||||
|
<ChecklistItemRow key={item.id} workOrderId={order.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
import { fieldPageContext } from "@/server/services/field/page-context";
|
||||||
|
import { getFieldOrderDetail } from "@/server/services/field/queries";
|
||||||
|
|
||||||
|
/** Shared loader of the order detail and its sub pages: scope violations render 404 (no existence leak). */
|
||||||
|
export async function loadOrder(params: Promise<{ id: string }>) {
|
||||||
|
const { id } = await params;
|
||||||
|
const ctx = await fieldPageContext();
|
||||||
|
try {
|
||||||
|
return { ctx, order: await getFieldOrderDetail(ctx, id) };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceError && err.code === "not_found") notFound();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { AdditionalMaterialForm, MaterialPlanItem } from "@/components/field/material-forms";
|
||||||
|
import { SubPageHeader } from "@/components/field/sub-page-header";
|
||||||
|
import { card } from "@/components/field/ui";
|
||||||
|
import { loadOrder } from "../load";
|
||||||
|
|
||||||
|
/** `/m/orders/[id]/materials` — confirm planned material, record additional material (Spec §13.2/§13.3). */
|
||||||
|
export default async function MaterialsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { order } = await loadOrder(params);
|
||||||
|
const t = await getTranslations("field");
|
||||||
|
const additional = order.materialUsages.filter((u) => !u.materialPlanId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="space-y-4 pb-4">
|
||||||
|
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("materials.title")} />
|
||||||
|
<div className="space-y-4 px-4">
|
||||||
|
{order.materialPlans.length === 0 ? (
|
||||||
|
<p className="text-[15px] text-muted-foreground">{t("materials.noPlan")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{order.materialPlans.map((p) => {
|
||||||
|
const usage = order.materialUsages.find((u) => u.materialPlanId === p.id);
|
||||||
|
return (
|
||||||
|
<MaterialPlanItem
|
||||||
|
key={p.id}
|
||||||
|
workOrderId={order.id}
|
||||||
|
plan={{ id: p.id, name: p.name, articleNumber: p.articleNumber, plannedQuantity: Number(p.plannedQuantity), unit: p.unit, notes: p.notes }}
|
||||||
|
usage={usage ? { usageStatus: usage.usageStatus, quantity: Number(usage.actualQuantity), unit: usage.unit, deviationReason: usage.deviationReason } : null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="pt-2 text-[20px]">{t("materials.additionalTitle")}</h2>
|
||||||
|
{additional.length === 0 ? (
|
||||||
|
<p className="text-[15px] text-muted-foreground">{t("materials.additionalEmpty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{additional.map((u) => (
|
||||||
|
<li key={u.id} className={card}>
|
||||||
|
<p className="text-[16px] font-semibold">{u.name}</p>
|
||||||
|
<p className="text-[14px]">{t("materials.recorded", { quantity: String(Number(u.actualQuantity)).replace(".", ","), unit: u.unit })}</p>
|
||||||
|
{u.deviationReason && <p className="text-[14px] text-muted-foreground">{u.deviationReason}</p>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<AdditionalMaterialForm workOrderId={order.id} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
import { fmtDateTime, fmtDuration } from "@/lib/field/format";
|
||||||
|
import { NoteForm } from "@/components/field/note-form";
|
||||||
|
import { SubPageHeader } from "@/components/field/sub-page-header";
|
||||||
|
import { VoiceRecorder } from "@/components/field/voice-recorder";
|
||||||
|
import { card } from "@/components/field/ui";
|
||||||
|
import { loadOrder } from "../load";
|
||||||
|
|
||||||
|
/** `/m/orders/[id]/notes` — activity notes + voice notes (Spec §12.3, §15.1). */
|
||||||
|
export default async function NotesPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { order } = await loadOrder(params);
|
||||||
|
const t = await getTranslations("field");
|
||||||
|
const locale = await getLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="space-y-4 pb-4">
|
||||||
|
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("notes.title")} />
|
||||||
|
<div className="space-y-4 px-4">
|
||||||
|
<section className={card}>
|
||||||
|
<NoteForm workOrderId={order.id} />
|
||||||
|
</section>
|
||||||
|
<section className={card}>
|
||||||
|
<h2 className="mb-3 text-[17px]">{t("voice.title")}</h2>
|
||||||
|
<VoiceRecorder workOrderId={order.id} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{order.voiceNotes.length > 0 && (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{order.voiceNotes.map((v) => (
|
||||||
|
<li key={v.id} className={card}>
|
||||||
|
<p className="text-[13px] text-muted-foreground">
|
||||||
|
{t("voice.title")} · {fmtDateTime(v.recordedAt, locale)}
|
||||||
|
{v.durationSeconds ? ` · ${fmtDuration(v.durationSeconds)}` : ""}
|
||||||
|
</p>
|
||||||
|
<audio controls preload="none" src={`/api/v1/field/documents/${v.documentId}`} className="mt-2 w-full" />
|
||||||
|
<p className="mt-2 text-[13px] font-semibold">{t(`voice.status.${v.transcriptionStatus}`)}</p>
|
||||||
|
{v.transcript && <p className="mt-1 whitespace-pre-line text-[15px]">{v.transcript}</p>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.notes.length === 0 ? (
|
||||||
|
<p className="text-[15px] text-muted-foreground">{t("notes.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{order.notes.map((n) => (
|
||||||
|
<li key={n.id} className={card}>
|
||||||
|
<p className="text-[13px] text-muted-foreground">
|
||||||
|
<span className="font-semibold text-foreground">{t(`notes.kind.${n.kind}`)}</span> · {fmtDateTime(n.createdAt, locale)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 whitespace-pre-line text-[15px]">{n.text}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
History,
|
||||||
|
Info,
|
||||||
|
KeyRound,
|
||||||
|
ListChecks,
|
||||||
|
Mail,
|
||||||
|
Navigation,
|
||||||
|
Package,
|
||||||
|
Phone,
|
||||||
|
ShieldAlert,
|
||||||
|
SquareParking,
|
||||||
|
StickyNote,
|
||||||
|
User,
|
||||||
|
Wrench,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { can } from "@/server/services/context";
|
||||||
|
import { customerDisplayName, formatAddress, mapsUrl } from "@/server/services/field/queries";
|
||||||
|
import { fmtDate, fmtDuration, fmtWindow, secondsBetween } from "@/lib/field/format";
|
||||||
|
import { STATUS_GROUP } from "@/lib/work-orders/status";
|
||||||
|
import { PrimaryAction } from "@/components/field/primary-action";
|
||||||
|
import { QuickPhotoButton } from "@/components/field/photo-capture";
|
||||||
|
import { StatusBadge } from "@/components/field/status-badge";
|
||||||
|
import { card, toneClasses } from "@/components/field/ui";
|
||||||
|
import { loadOrder } from "./load";
|
||||||
|
|
||||||
|
function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) {
|
||||||
|
const head = (
|
||||||
|
<div className="flex min-h-12 items-center gap-2.5">
|
||||||
|
<Icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-[17px]">{title}</h2>
|
||||||
|
{summary && <p className="text-[14px] text-muted-foreground">{summary}</p>}
|
||||||
|
</div>
|
||||||
|
{href && <ChevronRight className="size-5 text-muted-foreground" aria-hidden />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section className={card}>
|
||||||
|
{href ? (
|
||||||
|
<Link href={href} className="block">
|
||||||
|
{head}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
head
|
||||||
|
)}
|
||||||
|
{children && <div className="mt-2 space-y-2 text-[15px]">{children}</div>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContactButtons({ phone, email, labels }: { phone?: string | null; email?: string | null; labels: { call: string; mail: string } }) {
|
||||||
|
if (!phone && !email) return null;
|
||||||
|
const cls = "inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border px-3 text-[15px] font-semibold text-primary";
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{phone && (
|
||||||
|
<a href={`tel:${phone.replace(/[^\d+]/g, "")}`} className={cls}>
|
||||||
|
<Phone className="size-4.5" aria-hidden />
|
||||||
|
{labels.call}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{email && (
|
||||||
|
<a href={`mailto:${email}`} className={cls}>
|
||||||
|
<Mail className="size-4.5" aria-hidden />
|
||||||
|
{labels.mail}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Notice({ icon: Icon, label, text, tone }: { icon: LucideIcon; label: string; text: string; tone: "warn" | "risk" | "info" }) {
|
||||||
|
const toneCls = {
|
||||||
|
warn: "bg-[color-mix(in_oklch,var(--warn)_12%,transparent)]",
|
||||||
|
risk: "bg-[color-mix(in_oklch,var(--risk)_10%,transparent)]",
|
||||||
|
info: "bg-[color-mix(in_oklch,var(--info)_10%,transparent)]",
|
||||||
|
}[tone];
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-start gap-2.5 rounded-xl px-3.5 py-3", toneCls)}>
|
||||||
|
<Icon className="mt-0.5 size-5 shrink-0" aria-hidden />
|
||||||
|
<p>
|
||||||
|
<span className="block text-[13px] font-bold uppercase tracking-wide">{label}</span>
|
||||||
|
<span className="whitespace-pre-line">{text}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/m/orders/[id]` — order detail with status, one primary action and all field sections (Spec §22, US-005/006). */
|
||||||
|
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { ctx, order } = await loadOrder(params);
|
||||||
|
const t = await getTranslations("field");
|
||||||
|
const locale = await getLocale();
|
||||||
|
const base = `/m/orders/${order.id}`;
|
||||||
|
|
||||||
|
const address = formatAddress(order.site) ?? formatAddress(order.customer);
|
||||||
|
const route = mapsUrl(address);
|
||||||
|
const contact = order.contact ?? order.site?.contact ?? null;
|
||||||
|
const blockers = order.blockers
|
||||||
|
.filter((b) => !(b.kind === "running_session" && b.userId === ctx.userId))
|
||||||
|
.map((b) => t(`blocker.${b.kind}`, { label: "label" in b ? b.label : "field" in b ? b.field : "" }));
|
||||||
|
const requirementsDone = order.photoRequirements.filter((r) => r._count.photos > 0).length;
|
||||||
|
const checklistDone = order.checklistItems.filter((i) => i.checked).length;
|
||||||
|
const plannedDone = order.materialPlans.filter((p) => order.materialUsages.some((u) => u.materialPlanId === p.id)).length;
|
||||||
|
const myWorkSeconds = order.workSessions
|
||||||
|
.filter((s) => s.userId === ctx.userId)
|
||||||
|
.flatMap((s) => s.entries)
|
||||||
|
.filter((e) => e.type === "work")
|
||||||
|
.reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
|
||||||
|
const editable = can(ctx, "field:execute");
|
||||||
|
|
||||||
|
const quick = [
|
||||||
|
{ href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote },
|
||||||
|
{ href: `${base}/materials`, label: t("detail.quick.material"), icon: Package },
|
||||||
|
{ href: `${base}/checklist`, label: t("detail.quick.checklist"), icon: ListChecks },
|
||||||
|
{ href: `${base}/time`, label: t("detail.quick.time"), icon: Clock },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="space-y-3 p-4">
|
||||||
|
<Link href="/m/orders" 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("detail.back")}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<section className={cn(card, "border-l-4", toneClasses(STATUS_GROUP[order.status]).edge)}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="font-mono text-[14px] font-semibold text-muted-foreground">{order.number}</span>
|
||||||
|
<StatusBadge status={order.status} large />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-2 text-[22px] leading-snug">{order.title}</h1>
|
||||||
|
<p className="text-[16px] font-semibold">{customerDisplayName(order.customer)}</p>
|
||||||
|
<p className="mt-2 flex items-center gap-2 text-[15px]">
|
||||||
|
<Clock className="size-4.5 text-muted-foreground" aria-hidden />
|
||||||
|
{fmtWindow(order.plannedStart, order.plannedEnd, locale) ?? t("card.noDate")}
|
||||||
|
</p>
|
||||||
|
{editable && (
|
||||||
|
<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} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<nav aria-label={t("detail.order")} className="grid grid-cols-5 gap-2">
|
||||||
|
<QuickPhotoButton workOrderId={order.id} label={t("detail.quick.photo")} />
|
||||||
|
{quick.map((q) => (
|
||||||
|
<Link key={q.href} href={q.href} className="flex min-h-18 flex-col items-center justify-center gap-1 rounded-xl border bg-card px-1 text-[13px] font-semibold text-primary">
|
||||||
|
<q.icon className="size-6" aria-hidden />
|
||||||
|
{q.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.technicianNotes && <Notice icon={Info} label={t("detail.hints")} text={order.technicianNotes} tone="info" />}
|
||||||
|
|
||||||
|
{order.site && (
|
||||||
|
<Section title={t("detail.site")} icon={Building2}>
|
||||||
|
<p className="font-semibold">{order.site.name}</p>
|
||||||
|
{address && <p>{address}</p>}
|
||||||
|
{route && (
|
||||||
|
<a href={route} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-xl border px-3 text-[15px] font-semibold text-primary">
|
||||||
|
<Navigation className="size-4.5" aria-hidden />
|
||||||
|
{t("detail.route")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{order.site.accessNotes && <Notice icon={KeyRound} label={t("detail.access")} text={order.site.accessNotes} tone="warn" />}
|
||||||
|
{order.site.parkingNotes && <Notice icon={SquareParking} label={t("detail.parking")} text={order.site.parkingNotes} tone="info" />}
|
||||||
|
{order.site.safetyNotes && <Notice icon={ShieldAlert} label={t("detail.safety")} text={order.site.safetyNotes} tone="risk" />}
|
||||||
|
{order.site.technicalNotes && <Notice icon={Wrench} label={t("detail.technical")} text={order.site.technicalNotes} tone="info" />}
|
||||||
|
{order.site.onSiteContact && (
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">{t("detail.onSiteContact")}: </span>
|
||||||
|
{order.site.onSiteContact}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ContactButtons phone={order.site.phone} labels={{ call: t("detail.call"), mail: t("detail.mail") }} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title={t("detail.customer")} icon={User}>
|
||||||
|
<p className="font-semibold">{customerDisplayName(order.customer)}</p>
|
||||||
|
{!order.site && address && <p>{address}</p>}
|
||||||
|
<ContactButtons phone={order.customer.mobile ?? order.customer.phone} email={order.customer.email} labels={{ call: t("detail.call"), mail: t("detail.mail") }} />
|
||||||
|
{contact && (
|
||||||
|
<div className="mt-2 border-t pt-2">
|
||||||
|
<p className="text-[13px] text-muted-foreground">{t("detail.contact")}</p>
|
||||||
|
<p className="font-semibold">
|
||||||
|
{contact.name}
|
||||||
|
{contact.role && <span className="font-normal text-muted-foreground"> · {contact.role}</span>}
|
||||||
|
</p>
|
||||||
|
<ContactButtons phone={contact.mobile ?? contact.phone} email={contact.email} labels={{ call: t("detail.call"), mail: t("detail.mail") }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t("detail.order")} icon={FileText}>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||||
|
{order.orderType && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t("detail.orderType")}</dt>
|
||||||
|
<dd>{order.orderType.name}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{order.externalOrderNumber && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t("detail.externalNumber")}</dt>
|
||||||
|
<dd>{order.externalOrderNumber}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{order.team && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t("detail.team")}</dt>
|
||||||
|
<dd>{order.team.name}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
{order.description && <p className="whitespace-pre-line">{order.description}</p>}
|
||||||
|
{order.scope && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[13px] font-semibold text-muted-foreground">{t("detail.scope")}</p>
|
||||||
|
<p className="whitespace-pre-line">{order.scope}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t("detail.documents")} icon={FileText}>
|
||||||
|
{order.documents.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t("detail.noDocuments")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{order.documents.map((d) => (
|
||||||
|
<li key={d.id}>
|
||||||
|
<a href={`/api/v1/field/documents/${d.id}`} target="_blank" rel="noopener" className="flex min-h-12 items-center gap-2 py-1.5">
|
||||||
|
<FileText className="size-4.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="flex-1">
|
||||||
|
{d.title ?? d.fileName}
|
||||||
|
{d.source === "site" && <span className="ml-1.5 text-[12.5px] text-muted-foreground">({t("detail.siteDocument")})</span>}
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] font-semibold text-primary">{t("detail.openDocument")}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{order.site && (
|
||||||
|
<Section title={t("detail.history")} icon={History}>
|
||||||
|
{order.siteHistory.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t("detail.noHistory")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{order.siteHistory.map((h) => (
|
||||||
|
<li key={h.reportId} className="flex min-h-12 items-center gap-2 py-1.5">
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block font-semibold">
|
||||||
|
{t(`detail.reportType.${h.reportType}`)} · {fmtDate(h.reportDate, locale)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-muted-foreground">
|
||||||
|
{h.workOrderNumber} · {h.workOrderTitle}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{h.pdfDocumentId && (
|
||||||
|
<a href={`/api/v1/field/documents/${h.pdfDocumentId}`} target="_blank" rel="noopener" className="inline-flex min-h-12 items-center px-2 text-[13px] font-semibold text-primary">
|
||||||
|
{t("detail.openDocument")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title={t("detail.checklist")}
|
||||||
|
icon={ListChecks}
|
||||||
|
href={`${base}/checklist`}
|
||||||
|
summary={order.checklistItems.length ? t("detail.checklistProgress", { done: checklistDone, total: order.checklistItems.length }) : t("checklist.empty")}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
title={t("detail.materials")}
|
||||||
|
icon={Package}
|
||||||
|
href={`${base}/materials`}
|
||||||
|
summary={order.materialPlans.length ? t("detail.materialsProgress", { done: plannedDone, total: order.materialPlans.length }) : t("detail.materialsNone")}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
title={t("detail.photos")}
|
||||||
|
icon={FileText}
|
||||||
|
href={`${base}/photos`}
|
||||||
|
summary={
|
||||||
|
order.photoRequirements.length
|
||||||
|
? t("detail.photosProgress", { count: order.photos.length, done: requirementsDone, total: order.photoRequirements.length })
|
||||||
|
: t("detail.photosCount", { count: order.photos.length })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Section title={t("detail.notes")} icon={StickyNote} href={`${base}/notes`} summary={t("detail.notesCount", { count: order.notes.length + order.voiceNotes.length })} />
|
||||||
|
<Section title={t("detail.time")} icon={Clock} href={`${base}/time`} summary={t("detail.myTime", { duration: fmtDuration(myWorkSeconds) })} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
import { CircleCheck, CircleDot } from "lucide-react";
|
||||||
|
import { fmtDateTime } from "@/lib/field/format";
|
||||||
|
import { PhotoCapture } from "@/components/field/photo-capture";
|
||||||
|
import { SubPageHeader } from "@/components/field/sub-page-header";
|
||||||
|
import { card } from "@/components/field/ui";
|
||||||
|
import { loadOrder } from "../load";
|
||||||
|
|
||||||
|
/** `/m/orders/[id]/photos` — capture (camera/gallery) + gallery + required photos (Spec §14). */
|
||||||
|
export default async function PhotosPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { order } = await loadOrder(params);
|
||||||
|
const t = await getTranslations("field");
|
||||||
|
const locale = await getLocale();
|
||||||
|
const requirementLabel = new Map(order.photoRequirements.map((r) => [r.id, r.label]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="space-y-4 pb-4">
|
||||||
|
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("photos.title")} />
|
||||||
|
<div className="space-y-4 px-4">
|
||||||
|
<section className={card}>
|
||||||
|
<PhotoCapture
|
||||||
|
workOrderId={order.id}
|
||||||
|
requirements={order.photoRequirements.map((r) => ({ id: r.id, label: r.label }))}
|
||||||
|
checklistItems={order.checklistItems.map((c) => ({ id: c.id, label: c.label }))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{order.photoRequirements.length > 0 && (
|
||||||
|
<section className={card}>
|
||||||
|
<h2 className="mb-2 text-[17px]">{t("photos.requirements")}</h2>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{order.photoRequirements.map((r) => {
|
||||||
|
const done = r._count.photos > 0;
|
||||||
|
return (
|
||||||
|
<li key={r.id} className="flex min-h-10 items-center gap-2 text-[15px]">
|
||||||
|
{done ? <CircleCheck className="size-5 text-[var(--ok)]" aria-hidden /> : <CircleDot className="size-5 text-[var(--warn)]" aria-hidden />}
|
||||||
|
<span className="flex-1">{r.label}</span>
|
||||||
|
<span className="text-[13px] font-semibold">{done ? t("photos.done") : t("photos.missing")}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{order.photos.length === 0 ? (
|
||||||
|
<p className="text-[15px] text-muted-foreground">{t("photos.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid grid-cols-2 gap-3">
|
||||||
|
{order.photos.map((p) => (
|
||||||
|
<li key={p.id} className="overflow-hidden rounded-xl border bg-card">
|
||||||
|
<a href={`/api/v1/field/documents/${p.documentId}`} target="_blank" rel="noopener">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={`/api/v1/field/documents/${p.documentId}?variant=preview`} alt={p.comment ?? t("photos.preview")} loading="lazy" className="aspect-square w-full bg-muted object-cover" />
|
||||||
|
</a>
|
||||||
|
<div className="space-y-0.5 p-2 text-[12.5px]">
|
||||||
|
<p className="font-semibold">
|
||||||
|
{p.phase ? t(`photos.phase.${p.phase}`) : "—"}
|
||||||
|
{p.photoRequirementId && requirementLabel.get(p.photoRequirementId) ? ` · ${requirementLabel.get(p.photoRequirementId)}` : ""}
|
||||||
|
</p>
|
||||||
|
{p.comment && <p className="line-clamp-2">{p.comment}</p>}
|
||||||
|
<p className="text-muted-foreground">{fmtDateTime(p.takenAt, locale)}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
import { can } from "@/server/services/context";
|
||||||
|
import { fmtDateTime, fmtDuration, fmtTime, secondsBetween } from "@/lib/field/format";
|
||||||
|
import { SubPageHeader } from "@/components/field/sub-page-header";
|
||||||
|
import { TimeCorrectionForm } from "@/components/field/time-correction-form";
|
||||||
|
import { card } from "@/components/field/ui";
|
||||||
|
import { loadOrder } from "../load";
|
||||||
|
|
||||||
|
/** `/m/orders/[id]/time` — sessions and time segments, corrections with reason (Spec §12.2). */
|
||||||
|
export default async function TimePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { ctx, order } = await loadOrder(params);
|
||||||
|
const t = await getTranslations("field");
|
||||||
|
const locale = await getLocale();
|
||||||
|
const canCorrect = can(ctx, "field:correct_time");
|
||||||
|
const totalWork = order.workSessions
|
||||||
|
.flatMap((s) => s.entries)
|
||||||
|
.filter((e) => e.type === "work")
|
||||||
|
.reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="space-y-4 pb-4">
|
||||||
|
<SubPageHeader workOrderId={order.id} number={order.number} title={order.title} section={t("time.title")} />
|
||||||
|
<div className="space-y-3 px-4">
|
||||||
|
<p className="text-[15px] font-semibold">{t("time.totalWork", { duration: fmtDuration(totalWork) })}</p>
|
||||||
|
{order.workSessions.length === 0 && <p className="text-[15px] text-muted-foreground">{t("time.empty")}</p>}
|
||||||
|
{order.workSessions.map((s) => (
|
||||||
|
<section key={s.id} className={card}>
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
|
<h2 className="text-[17px]">{s.user.name}</h2>
|
||||||
|
<span className="text-[13px] font-semibold">
|
||||||
|
{t(`time.session.${s.status}`)}
|
||||||
|
{s.startedOffline ? ` · ${t("time.offlineStarted")}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[13px] text-muted-foreground">{fmtDateTime(s.startedAt, locale)}</p>
|
||||||
|
<ul className="mt-2 divide-y">
|
||||||
|
{s.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>
|
||||||
|
{fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{e.corrected && <p className="text-[13px] text-[var(--warn)]">{t("time.corrected", { reason: e.correctionReason ?? "" })}</p>}
|
||||||
|
{canCorrect && <TimeCorrectionForm workOrderId={order.id} entry={{ id: e.id, startedAt: e.startedAt.toISOString(), endedAt: e.endedAt?.toISOString() ?? null }} />}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
|
||||||
|
import { listFieldOrders, ORDER_TABS, type OrderTab } from "@/server/services/field/queries";
|
||||||
|
import { OrderCard } from "@/components/field/order-card";
|
||||||
|
import { chip } from "@/components/field/ui";
|
||||||
|
|
||||||
|
/** `/m/orders` — tabs upcoming · running · to complete · past (Spec §11.2). */
|
||||||
|
export default async function OrdersPage({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
|
||||||
|
const ctx = await fieldPageContext();
|
||||||
|
const t = await getTranslations("field");
|
||||||
|
if (!canUseFieldApp(ctx)) return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
|
||||||
|
|
||||||
|
const { tab: rawTab } = await searchParams;
|
||||||
|
const tab: OrderTab = (ORDER_TABS as readonly string[]).includes(rawTab ?? "") ? (rawTab as OrderTab) : "running";
|
||||||
|
const orders = await listFieldOrders(ctx, tab);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="space-y-4 p-4">
|
||||||
|
<h1 className="text-[26px]">{t("orders.title")}</h1>
|
||||||
|
<nav aria-label={t("orders.title")} className="-mx-4 overflow-x-auto px-4">
|
||||||
|
<ul className="flex gap-2">
|
||||||
|
{ORDER_TABS.map((key) => (
|
||||||
|
<li key={key} className="shrink-0">
|
||||||
|
<Link href={`/m/orders?tab=${key}`} aria-current={tab === key ? "page" : undefined} className={cn(chip(tab === key), "px-4")}>
|
||||||
|
{t(`orders.tabs.${key}`)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("orders.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{orders.map((o) => (
|
||||||
|
<li key={o.id}>
|
||||||
|
<OrderCard order={o} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context";
|
||||||
|
import { listTodayOrders } from "@/server/services/field/queries";
|
||||||
|
import { OrderCard } from "@/components/field/order-card";
|
||||||
|
import { btnSecondary } from "@/components/field/ui";
|
||||||
|
|
||||||
|
/** `/m` — Heute: today's, running and paused orders (Spec §11.2). */
|
||||||
|
export default async function TodayPage() {
|
||||||
|
const ctx = await fieldPageContext();
|
||||||
|
const t = await getTranslations("field");
|
||||||
|
if (!canUseFieldApp(ctx)) return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
|
||||||
|
|
||||||
|
const [orders, me] = await Promise.all([listTodayOrders(ctx), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } })]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[14px] text-muted-foreground">{t("today.greeting", { name: me?.name ?? "" })}</p>
|
||||||
|
<h1 className="text-[26px]">{t("today.title")}</h1>
|
||||||
|
<p className="text-[15px] font-semibold">{t("today.count", { count: orders.length })}</p>
|
||||||
|
</div>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className="rounded-xl border bg-card p-5 text-[15px] text-muted-foreground">{t("today.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{orders.map((o) => (
|
||||||
|
<li key={o.id}>
|
||||||
|
<OrderCard order={o} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<Link href="/m/orders" className={btnSecondary}>
|
||||||
|
{t("today.allOrders")}
|
||||||
|
<ChevronRight className="size-5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
import { auth, signOut } from "@/server/auth";
|
||||||
|
import { prisma } from "@/server/db";
|
||||||
|
import { can } from "@/server/services/context";
|
||||||
|
import { activeTeamIds } from "@/server/services/work-orders/visibility";
|
||||||
|
import { fieldPageContext } from "@/server/services/field/page-context";
|
||||||
|
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
|
||||||
|
import { btnSecondary, card } from "@/components/field/ui";
|
||||||
|
|
||||||
|
/** `/m/profile` — name, team, company, language, sign out. */
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
const ctx = await fieldPageContext();
|
||||||
|
const t = await getTranslations("field.profile");
|
||||||
|
const session = await auth();
|
||||||
|
const teamIds = await activeTeamIds(ctx);
|
||||||
|
const [me, teams, identity] = await Promise.all([
|
||||||
|
ctx.db.user.findFirst({
|
||||||
|
where: { id: ctx.userId },
|
||||||
|
select: { name: true, email: true, tenant: { select: { name: true } }, userRoles: { select: { role: { select: { name: true } } } } },
|
||||||
|
}),
|
||||||
|
teamIds.length ? ctx.db.team.findMany({ where: { id: { in: teamIds } }, select: { id: true, name: true }, orderBy: { name: "asc" } }) : Promise.resolve([]),
|
||||||
|
session?.user?.identityId ? prisma.identity.findUnique({ where: { id: session.user.identityId }, select: { uiLocale: true } }) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
"use server";
|
||||||
|
await signOut({ redirectTo: "/login" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = "flex flex-col gap-0.5 py-2.5";
|
||||||
|
return (
|
||||||
|
<main className="space-y-4 p-4">
|
||||||
|
<h1 className="text-[26px]">{t("title")}</h1>
|
||||||
|
<section className={card}>
|
||||||
|
<p className="text-[20px] font-semibold">{me?.name}</p>
|
||||||
|
<p className="text-[14px] text-muted-foreground">{me?.email}</p>
|
||||||
|
<dl className="mt-2 divide-y text-[15px]">
|
||||||
|
<div className={row}>
|
||||||
|
<dt className="text-[13px] text-muted-foreground">{t("tenant")}</dt>
|
||||||
|
<dd>{me?.tenant.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div className={row}>
|
||||||
|
<dt className="text-[13px] text-muted-foreground">{t("teams")}</dt>
|
||||||
|
<dd>{teams.length ? teams.map((tm) => tm.name).join(", ") : t("noTeam")}</dd>
|
||||||
|
</div>
|
||||||
|
<div className={row}>
|
||||||
|
<dt className="text-[13px] text-muted-foreground">{t("roles")}</dt>
|
||||||
|
<dd>{me?.userRoles.map((r) => r.role.name).join(", ")}</dd>
|
||||||
|
</div>
|
||||||
|
<div className={row}>
|
||||||
|
<dt className="text-[13px] text-muted-foreground">{t("language")}</dt>
|
||||||
|
<dd className="pt-1">
|
||||||
|
<UiLocaleSwitcher current={identity?.uiLocale ?? "de"} />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && (
|
||||||
|
<Link href="/dashboard" className={btnSecondary}>
|
||||||
|
{t("backoffice")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<form action={logout}>
|
||||||
|
<button type="submit" className={btnSecondary}>
|
||||||
|
<LogOut className="size-5" aria-hidden />
|
||||||
|
{t("logout")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { requireAppAccess } from "@/server/app-access";
|
||||||
|
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||||
|
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
||||||
|
import { BottomNav } from "@/components/field/bottom-nav";
|
||||||
|
import { OnlineBadge } from "@/components/field/online-badge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile shell `/m` (ARCHITEKTUR §5): same session/account/MFA checks as the backoffice
|
||||||
|
* (src/server/app-access.ts), no sidebar, bottom navigation, online/offline badge.
|
||||||
|
* Module gates live one level below: (core) → "field", emergency → "emergency".
|
||||||
|
*/
|
||||||
|
export default async function FieldShell({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
const access = await requireAppAccess();
|
||||||
|
if (access.kind === "inactive") return <AccountInactiveNotice />;
|
||||||
|
const t = await getTranslations("field.nav");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-1 flex-col bg-background">
|
||||||
|
<header className="sticky top-0 z-20 flex h-14 items-center justify-between gap-3 border-b bg-card px-4 pt-[env(safe-area-inset-top)]">
|
||||||
|
<Link href="/m" aria-label={t("today")} className="flex min-h-12 items-center">
|
||||||
|
<CraftviaLogo variant="horizontal" height={26} />
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Slot für L6: <NotificationBell variant="mobile" /> aus src/components/notifications/bell.tsx */}
|
||||||
|
<OnlineBadge />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="mx-auto w-full max-w-xl flex-1 pb-28">{children}</div>
|
||||||
|
<BottomNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { OnlineBadge } from "@/components/field/online-badge";
|
||||||
|
import { card } from "@/components/field/ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLACEHOLDER (lane L4) — `/m/sync` belongs to lane L7 (Offline/PWA), which replaces this page
|
||||||
|
* with the outbox status, errors and conflicts. Until then it shows the connection state and that
|
||||||
|
* ops are sent immediately (src/lib/field/client-ops.ts).
|
||||||
|
*/
|
||||||
|
export default async function SyncPlaceholderPage() {
|
||||||
|
const t = await getTranslations("field.sync");
|
||||||
|
return (
|
||||||
|
<main className="space-y-4 p-4">
|
||||||
|
<h1 className="text-[26px]">{t("title")}</h1>
|
||||||
|
<section className={`${card} space-y-3`}>
|
||||||
|
<p className="text-[13px] font-semibold text-muted-foreground">{t("status")}</p>
|
||||||
|
<OnlineBadge large />
|
||||||
|
<p className="text-[15px]">{t("immediate")}</p>
|
||||||
|
<p className="text-[14px] text-muted-foreground">{t("offlineHint")}</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getFieldBundle } from "@/server/services/field/queries";
|
||||||
|
import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context";
|
||||||
|
|
||||||
|
/** GET /api/v1/field/bundle?since=<ISO> — offline pull of the orders in scope (ARCHITEKTUR §4.6). */
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
return withApi(req, async () => {
|
||||||
|
const ctx = await requireApiContext("field", "field:execute");
|
||||||
|
const raw = new URL(req.url).searchParams.get("since");
|
||||||
|
const since = raw ? new Date(raw) : null;
|
||||||
|
if (since && Number.isNaN(since.getTime())) return apiError("invalid", 400, "invalid since");
|
||||||
|
return NextResponse.json(await getFieldBundle(ctx, since), { headers: { "Cache-Control": "private, no-store" } });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { openFieldDocument } from "@/server/services/field/documents";
|
||||||
|
import { requireApiContext, withApi } from "@/server/services/sync/api-context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/field/documents/<id>[?variant=preview] — authorised document delivery for the mobile
|
||||||
|
* app (visibility + scope checked in the service). Only magic-byte-verified media types are served
|
||||||
|
* inline; everything else is a download.
|
||||||
|
*/
|
||||||
|
const INLINE = /^(image\/(jpeg|png|webp)|application\/pdf|audio\/(webm|ogg|mp4|mpeg|wav))$/;
|
||||||
|
|
||||||
|
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
return withApi(req, async () => {
|
||||||
|
const ctx = await requireApiContext("field");
|
||||||
|
const { id } = await params;
|
||||||
|
const variant = new URL(req.url).searchParams.get("variant") === "preview" ? "preview" : "original";
|
||||||
|
const { content, mimeType, fileName } = await openFieldDocument(ctx, id, variant);
|
||||||
|
const safeName = fileName.replace(/["\\\r\n]/g, "_");
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Type": mimeType,
|
||||||
|
"Content-Disposition": `${INLINE.test(mimeType) ? "inline" : "attachment"}; filename="${safeName}"`,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Cache-Control": "private, max-age=300",
|
||||||
|
});
|
||||||
|
if (content.size != null) headers.set("Content-Length", String(content.size));
|
||||||
|
return new Response(content.stream, { headers });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { syncRequestSchema } from "@/lib/sync/envelope";
|
||||||
|
import { applyOperations } from "@/server/services/sync/apply";
|
||||||
|
import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context";
|
||||||
|
|
||||||
|
/** POST /api/v1/sync — batch of offline/online operations (ARCHITEKTUR §4.6). */
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
return withApi(req, async () => {
|
||||||
|
const ctx = await requireApiContext("field");
|
||||||
|
const body = syncRequestSchema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!body.success) return apiError("invalid", 400, "invalid sync request", body.error.issues.slice(0, 10));
|
||||||
|
return NextResponse.json(await applyOperations(ctx, body.data), { headers: { "Cache-Control": "no-store" } });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { storeFieldUpload, uploadMetaSchema } from "@/server/services/field/uploads";
|
||||||
|
import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/uploads — multipart: file, clientId (uuid), workOrderId, kind (photo|voice_note),
|
||||||
|
* optional preview (thumbnail). Returns { documentId }; the same clientId returns the same document.
|
||||||
|
*/
|
||||||
|
const MAX_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
return withApi(req, async () => {
|
||||||
|
const ctx = await requireApiContext("field", "field:execute");
|
||||||
|
const declared = Number(req.headers.get("content-length") ?? "0");
|
||||||
|
if (declared > MAX_BYTES + 3 * 1024 * 1024) return apiError("invalid", 413, "file too large");
|
||||||
|
|
||||||
|
const form = await req.formData().catch(() => null);
|
||||||
|
if (!form) return apiError("invalid", 400, "multipart body expected");
|
||||||
|
const file = form.get("file");
|
||||||
|
if (!(file instanceof File)) return apiError("invalid", 400, "file missing");
|
||||||
|
if (file.size > MAX_BYTES) return apiError("invalid", 413, "file too large");
|
||||||
|
const meta = uploadMetaSchema.safeParse({ clientId: form.get("clientId"), workOrderId: form.get("workOrderId"), kind: form.get("kind") });
|
||||||
|
if (!meta.success) return apiError("invalid", 400, "invalid upload metadata");
|
||||||
|
const preview = form.get("preview");
|
||||||
|
|
||||||
|
const result = await storeFieldUpload(
|
||||||
|
ctx,
|
||||||
|
meta.data,
|
||||||
|
{ bytes: Buffer.from(await file.arrayBuffer()), name: file.name, type: file.type },
|
||||||
|
preview instanceof File && preview.size > 0 ? { bytes: Buffer.from(await preview.arrayBuffer()), name: preview.name, type: preview.type } : null,
|
||||||
|
);
|
||||||
|
return NextResponse.json(result, { status: result.duplicate ? 200 : 201, headers: { "Cache-Control": "no-store" } });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ export default async function LoginMfaPage({
|
|||||||
// MFA erfüllt → pending entwerten und aus dem Ticket die Session prägen.
|
// MFA erfüllt → pending entwerten und aus dem Ticket die Session prägen.
|
||||||
jar.delete(MFA_PENDING_COOKIE);
|
jar.delete(MFA_PENDING_COOKIE);
|
||||||
try {
|
try {
|
||||||
await signIn("login-ticket", { ticket: signLoginTicket(pending.identityId, pending.tenant), redirectTo: target });
|
await signIn("login-ticket", { ticket: signLoginTicket(pending.identityId, pending.tenant), redirectTo: target === "/dashboard" ? "/" : target }); // "/" → rollenabhängige Startseite
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AuthError) redirect("/login?error=1");
|
if (err instanceof AuthError) redirect("/login?error=1");
|
||||||
throw err; // NEXT_REDIRECT eines erfolgreichen signIn muss durchpropagieren
|
throw err; // NEXT_REDIRECT eines erfolgreichen signIn muss durchpropagieren
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default async function LoginPage({
|
|||||||
|
|
||||||
// Already signed in → straight to the app
|
// Already signed in → straight to the app
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (session?.user) redirect("/dashboard");
|
if (session?.user) redirect("/"); // rollenabhängige Startseite (src/app/page.tsx)
|
||||||
|
|
||||||
// WS5: Two-Step-Login, Schritt 1 (E-Mail + Passwort). Bei aktiver MFA wird ein
|
// WS5: Two-Step-Login, Schritt 1 (E-Mail + Passwort). Bei aktiver MFA wird ein
|
||||||
// kurzlebiger, einzweckiger `mfa_pending`-Cookie gesetzt und auf /login/mfa geleitet —
|
// kurzlebiger, einzweckiger `mfa_pending`-Cookie gesetzt und auf /login/mfa geleitet —
|
||||||
@@ -65,7 +65,7 @@ export default async function LoginPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await signIn("login-ticket", { ticket: signLoginTicket(pw.identityId, tenant), redirectTo: target });
|
await signIn("login-ticket", { ticket: signLoginTicket(pw.identityId, tenant), redirectTo: target === "/dashboard" ? "/" : target }); // "/" → rollenabhängige Startseite
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AuthError) {
|
if (err instanceof AuthError) {
|
||||||
redirect(`/login?error=1${target !== "/dashboard" ? `&callbackUrl=${encodeURIComponent(target)}` : ""}`);
|
redirect(`/login?error=1${target !== "/dashboard" ? `&callbackUrl=${encodeURIComponent(target)}` : ""}`);
|
||||||
|
|||||||
+5
-2
@@ -1,5 +1,8 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/server/auth";
|
||||||
|
import { landingPath } from "@/server/app-access";
|
||||||
|
|
||||||
export default function Home() {
|
export default async function Home() {
|
||||||
redirect("/dashboard");
|
// Feldrollen → /m, Backoffice → /dashboard (ARCHITEKTUR §5)
|
||||||
|
redirect(landingPath(await auth()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { signOut } from "@/server/auth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||||
|
|
||||||
|
/** Hinweis für deaktivierte Konten — gemeinsam für Backoffice- und Mobile-Shell (src/server/app-access.ts). */
|
||||||
|
export function AccountInactiveNotice() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen flex-1 items-center justify-center p-6">
|
||||||
|
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
|
||||||
|
<CraftviaLogo variant="horizontal" height={34} className="mx-auto" />
|
||||||
|
<p className="mt-5 font-heading text-lg font-semibold">Konto deaktiviert</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.</p>
|
||||||
|
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5">
|
||||||
|
<Button type="submit" variant="outline" className="w-full">Abmelden</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,6 +77,8 @@ const ENTITY_LABEL: Record<string, string> = {
|
|||||||
tenant_mail_settings: "E-Mail-Versand",
|
tenant_mail_settings: "E-Mail-Versand",
|
||||||
sync_operation: "Synchronisation",
|
sync_operation: "Synchronisation",
|
||||||
document: "Dokument",
|
document: "Dokument",
|
||||||
|
// Einsatz mobil (L4)
|
||||||
|
work_session: "Einsatz-Zeiterfassung", time_entry: "Zeitabschnitt", checklist_item: "Checklistenpunkt", material_usage: "Materialverbrauch", photo: "Foto", voice_note: "Sprachnotiz", activity_note: "Tätigkeitsnotiz", sync_operation: "Sync-Vorgang",
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ClipboardList, House, RefreshCw, Siren, User } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ITEMS = [
|
||||||
|
{ href: "/m", key: "today", icon: House, exact: true },
|
||||||
|
{ href: "/m/orders", key: "orders", icon: ClipboardList, exact: false },
|
||||||
|
{ href: "/m/emergency", key: "emergency", icon: Siren, exact: false },
|
||||||
|
{ href: "/m/sync", key: "sync", icon: RefreshCw, exact: false },
|
||||||
|
{ href: "/m/profile", key: "profile", icon: User, exact: false },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil. */
|
||||||
|
export function BottomNav() {
|
||||||
|
const t = useTranslations("field.nav");
|
||||||
|
const pathname = usePathname();
|
||||||
|
return (
|
||||||
|
<nav aria-label={t("label")} className="fixed inset-x-0 bottom-0 z-30 border-t bg-card pb-[env(safe-area-inset-bottom)]">
|
||||||
|
<ul className="mx-auto grid max-w-xl grid-cols-5">
|
||||||
|
{ITEMS.map((item) => {
|
||||||
|
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-16 flex-col items-center justify-center gap-1 text-[12px] font-semibold",
|
||||||
|
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")}>
|
||||||
|
<item.icon className="size-5.5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
{t(item.key)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Camera, Check, TriangleAlert } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { errorKey, isSuccess, submitOp } from "@/lib/field/client-ops";
|
||||||
|
import { btnSecondary, card, inputClass, noticeError } from "./ui";
|
||||||
|
|
||||||
|
type Item = { id: string; label: string; required: boolean; requiresPhoto: boolean; checked: boolean; comment: string | null };
|
||||||
|
|
||||||
|
/** One checklist item (Spec §12.4): large toggle + optional comment. Optimistic, reverted on failure. */
|
||||||
|
export function ChecklistItemRow({ workOrderId, item }: { workOrderId: string; item: Item }) {
|
||||||
|
const t = useTranslations("field");
|
||||||
|
const router = useRouter();
|
||||||
|
const [checked, setChecked] = useState(item.checked);
|
||||||
|
const [comment, setComment] = useState(item.comment ?? "");
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function send(nextChecked: boolean, nextComment?: string) {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const previous = checked;
|
||||||
|
setChecked(nextChecked);
|
||||||
|
const result = await submitOp({
|
||||||
|
opType: "checklist.toggle",
|
||||||
|
payload: { workOrderId, itemId: item.id, checked: nextChecked, ...(nextComment !== undefined ? { comment: nextComment } : {}) },
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (!isSuccess(result)) {
|
||||||
|
setChecked(previous);
|
||||||
|
setError(t(`errors.${errorKey(result)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={cn(card, "space-y-3")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => send(!checked)}
|
||||||
|
className="flex min-h-14 w-full items-center gap-3 text-left"
|
||||||
|
>
|
||||||
|
<span className={cn("grid size-9 shrink-0 place-items-center rounded-lg border-2", checked ? "border-[var(--ok)] bg-[var(--ok)] text-white" : "border-input bg-card")}>
|
||||||
|
{checked && <Check className="size-6" aria-hidden />}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-[16px] font-semibold leading-snug">{item.label}</span>
|
||||||
|
<span className="mt-1 flex flex-wrap gap-1.5 text-[12.5px]">
|
||||||
|
<span className={cn("font-semibold", checked ? "text-[var(--ok)]" : "text-muted-foreground")}>{checked ? t("checklist.done") : t("checklist.open")}</span>
|
||||||
|
{item.required && <span className="rounded-full bg-muted px-2 font-semibold">{t("checklist.required")}</span>}
|
||||||
|
{item.requiresPhoto && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 font-semibold">
|
||||||
|
<Camera className="size-3.5" aria-hidden />
|
||||||
|
{t("checklist.photo")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{editing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea rows={2} maxLength={2000} aria-label={t("checklist.comment")} className={cn(inputClass, "py-3")} value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||||
|
<button type="button" className={btnSecondary} disabled={busy} onClick={() => send(checked, comment)}>
|
||||||
|
{t("checklist.saveComment")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => setEditing(true)} className="min-h-12 w-full rounded-xl px-1 text-left text-[14px] text-muted-foreground">
|
||||||
|
{comment ? comment : `${t("checklist.comment")} …`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className={noticeError} role="alert">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CircleCheck, TriangleAlert } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { UNIT_SUGGESTIONS, type MaterialUsageStatus } from "@/lib/sync/ops";
|
||||||
|
import { materialDeviates, validateMaterialUsage } from "@/lib/field/material-rules";
|
||||||
|
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
||||||
|
import { Stepper } from "./stepper";
|
||||||
|
import { btnPrimary, card, chip, inputClass, noticeError, noticeOk } from "./ui";
|
||||||
|
|
||||||
|
type PlanItem = { id: string; name: string; articleNumber: string | null; plannedQuantity: number; unit: string; notes: string | null };
|
||||||
|
type Usage = { usageStatus: MaterialUsageStatus; quantity: number; unit: string; deviationReason: string | null } | null;
|
||||||
|
|
||||||
|
const PLAN_STATUSES: MaterialUsageStatus[] = ["fully_used", "partially_used", "not_used"];
|
||||||
|
const fmtQty = (n: number) => String(n).replace(".", ",");
|
||||||
|
|
||||||
|
function Feedback({ error, saved, savedLabel }: { error: string | null; saved: boolean; savedLabel: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && (
|
||||||
|
<p className={noticeError} role="alert">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saved && !error && (
|
||||||
|
<p className={noticeOk} role="status">
|
||||||
|
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{savedLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm a planned material position: fully / partially / not used, quantity, reason on deviation. */
|
||||||
|
export function MaterialPlanItem({ workOrderId, plan, usage }: { workOrderId: string; plan: PlanItem; usage: Usage }) {
|
||||||
|
const t = useTranslations("field");
|
||||||
|
const router = useRouter();
|
||||||
|
const [status, setStatus] = useState<MaterialUsageStatus>(usage?.usageStatus ?? "fully_used");
|
||||||
|
const [quantity, setQuantity] = useState<number>(usage?.quantity ?? plan.plannedQuantity);
|
||||||
|
const [reason, setReason] = useState(usage?.deviationReason ?? "");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const deviates = materialDeviates({ usageStatus: status, quantity }, plan.plannedQuantity);
|
||||||
|
|
||||||
|
function choose(s: MaterialUsageStatus) {
|
||||||
|
setStatus(s);
|
||||||
|
setSaved(false);
|
||||||
|
if (s === "fully_used") setQuantity(plan.plannedQuantity);
|
||||||
|
if (s === "not_used") setQuantity(0);
|
||||||
|
if (s === "partially_used" && (quantity <= 0 || quantity >= plan.plannedQuantity)) setQuantity(Math.max(0, Math.round((plan.plannedQuantity / 2) * 1000) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setError(null);
|
||||||
|
const problem = validateMaterialUsage({ usageStatus: status, quantity, deviationReason: reason }, plan.plannedQuantity);
|
||||||
|
if (problem) {
|
||||||
|
setError(problem.startsWith("reason") ? t("materials.reasonRequired") : t("materials.invalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
const result = await submitOp({
|
||||||
|
opType: "material.upsert",
|
||||||
|
payload: { workOrderId, materialPlanId: plan.id, quantity, unit: plan.unit, usageStatus: status, deviationReason: deviates ? reason.trim() : null },
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (!isSuccess(result)) {
|
||||||
|
setError(t(`errors.${errorKey(result)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaved(true);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={cn(card, "space-y-3")}>
|
||||||
|
<div>
|
||||||
|
<p className="text-[16px] font-semibold">{plan.name}</p>
|
||||||
|
{plan.articleNumber && <p className="font-mono text-[13px] text-muted-foreground">{plan.articleNumber}</p>}
|
||||||
|
<p className="text-[14px] text-muted-foreground">{t("materials.planned", { quantity: fmtQty(plan.plannedQuantity), unit: plan.unit })}</p>
|
||||||
|
{plan.notes && <p className="mt-1 text-[14px]">{plan.notes}</p>}
|
||||||
|
<p className="mt-1 text-[13px] font-semibold">
|
||||||
|
{usage ? t("materials.recorded", { quantity: fmtQty(usage.quantity), unit: usage.unit }) : t("materials.open")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{PLAN_STATUSES.map((s) => (
|
||||||
|
<button key={s} type="button" aria-pressed={status === s} className={cn(chip(status === s), "px-2 text-[13px]")} onClick={() => choose(s)}>
|
||||||
|
{t(`materials.usage.${s}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{status !== "not_used" && (
|
||||||
|
<Stepper
|
||||||
|
value={quantity}
|
||||||
|
onChange={(v) => {
|
||||||
|
setQuantity(v);
|
||||||
|
setSaved(false);
|
||||||
|
}}
|
||||||
|
label={`${t("materials.quantity")} (${plan.unit})`}
|
||||||
|
decreaseLabel={t("materials.decrease")}
|
||||||
|
increaseLabel={t("materials.increase")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deviates && (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("materials.reason")}</span>
|
||||||
|
<textarea rows={2} maxLength={2000} className={cn(inputClass, "py-3")} value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<Feedback error={error} saved={saved} savedLabel={t("materials.saved")} />
|
||||||
|
<button type="button" className={btnPrimary} disabled={busy} onClick={save}>
|
||||||
|
{busy ? t("action.saving") : t("materials.save")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Additional (unplanned) material: name, optional article number, quantity, unit suggestions, reason. */
|
||||||
|
export function AdditionalMaterialForm({ workOrderId }: { workOrderId: string }) {
|
||||||
|
const t = useTranslations("field");
|
||||||
|
const router = useRouter();
|
||||||
|
const [clientId, setClientId] = useState(() => newClientId());
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [articleNumber, setArticleNumber] = useState("");
|
||||||
|
const [quantity, setQuantity] = useState(1);
|
||||||
|
const [unit, setUnit] = useState<string>(UNIT_SUGGESTIONS[0]);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
const problem = validateMaterialUsage({ usageStatus: "additional", quantity, name, deviationReason: reason }, null) ?? (unit.trim() ? null : "unit");
|
||||||
|
if (problem) {
|
||||||
|
setError(t("materials.invalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
const result = await submitOp({
|
||||||
|
opType: "material.upsert",
|
||||||
|
payload: { workOrderId, clientId, name: name.trim(), articleNumber: articleNumber.trim() || null, quantity, unit: unit.trim(), usageStatus: "additional", deviationReason: reason.trim() },
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (!isSuccess(result)) {
|
||||||
|
setError(t(`errors.${errorKey(result)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaved(true);
|
||||||
|
setClientId(newClientId());
|
||||||
|
setName("");
|
||||||
|
setArticleNumber("");
|
||||||
|
setQuantity(1);
|
||||||
|
setReason("");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className={cn(card, "space-y-3")}>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("materials.name")}</span>
|
||||||
|
<input className={inputClass} required maxLength={200} value={name} onChange={(e) => { setName(e.target.value); setSaved(false); }} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("materials.articleNumber")}</span>
|
||||||
|
<input className={inputClass} maxLength={100} value={articleNumber} onChange={(e) => setArticleNumber(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<Stepper value={quantity} onChange={setQuantity} label={t("materials.quantity")} decreaseLabel={t("materials.decrease")} increaseLabel={t("materials.increase")} />
|
||||||
|
<fieldset>
|
||||||
|
<legend className="mb-2 text-[14px] font-semibold">{t("materials.unit")}</legend>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{UNIT_SUGGESTIONS.slice(0, 7).map((u) => (
|
||||||
|
<button key={u} type="button" aria-pressed={unit === u} className={cn(chip(unit === u), "min-w-12")} onClick={() => setUnit(u)}>
|
||||||
|
{u}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input className={cn(inputClass, "mt-2")} list="field-unit-suggestions" maxLength={20} aria-label={t("materials.unit")} value={unit} onChange={(e) => setUnit(e.target.value)} />
|
||||||
|
<datalist id="field-unit-suggestions">
|
||||||
|
{UNIT_SUGGESTIONS.map((u) => (
|
||||||
|
<option key={u} value={u} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</fieldset>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("materials.additionalReason")}</span>
|
||||||
|
<textarea rows={2} required maxLength={2000} className={cn(inputClass, "py-3")} value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<Feedback error={error} saved={saved} savedLabel={t("materials.saved")} />
|
||||||
|
<button type="submit" className={btnPrimary} disabled={busy}>
|
||||||
|
{busy ? t("action.saving") : t("materials.add")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CircleCheck, TriangleAlert } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { NOTE_KINDS, type NoteKind } from "@/lib/sync/ops";
|
||||||
|
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
||||||
|
import { btnPrimary, chip, inputClass, noticeError, noticeOk } from "./ui";
|
||||||
|
|
||||||
|
type Draft = { kind: NoteKind; text: string; clientId: string };
|
||||||
|
|
||||||
|
const draftKey = (workOrderId: string) => `craftvia.field.noteDraft.${workOrderId}`;
|
||||||
|
|
||||||
|
function readDraft(workOrderId: string): Draft | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(draftKey(workOrderId));
|
||||||
|
return raw ? (JSON.parse(raw) as Draft) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity note (Spec §12.3): kind as large chips + text. The draft (incl. its clientId, so a retry
|
||||||
|
* stays idempotent) is kept in localStorage until the server confirmed it (US-006).
|
||||||
|
*/
|
||||||
|
export function NoteForm({ workOrderId }: { workOrderId: string }) {
|
||||||
|
const t = useTranslations("field");
|
||||||
|
const router = useRouter();
|
||||||
|
const [draft, setDraft] = useState<Draft>({ kind: "work_done", text: "", clientId: "" });
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = readDraft(workOrderId);
|
||||||
|
// restore an unsent draft after reload / connection loss (client-only storage)
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
if (stored) setDraft(stored);
|
||||||
|
}, [workOrderId]);
|
||||||
|
|
||||||
|
function update(next: Partial<Draft>) {
|
||||||
|
const value = { ...draft, ...next, clientId: draft.clientId || newClientId() };
|
||||||
|
setDraft(value);
|
||||||
|
setSaved(false);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(draftKey(workOrderId), JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// storage unavailable (private mode) — the form still works
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!draft.text.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const clientId = draft.clientId || newClientId();
|
||||||
|
const result = await submitOp({ opType: "note.create", payload: { workOrderId, clientId, kind: draft.kind, text: draft.text.trim() } });
|
||||||
|
setBusy(false);
|
||||||
|
if (!isSuccess(result)) {
|
||||||
|
setError(`${t(`errors.${errorKey(result)}`)} ${t("notes.draftKept")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(draftKey(workOrderId));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
setDraft({ kind: draft.kind, text: "", clientId: "" });
|
||||||
|
setSaved(true);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<fieldset>
|
||||||
|
<legend className="mb-2 text-[14px] font-semibold">{t("notes.kindLabel")}</legend>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{NOTE_KINDS.map((k) => (
|
||||||
|
<button key={k} type="button" aria-pressed={draft.kind === k} className={chip(draft.kind === k)} onClick={() => update({ kind: k })}>
|
||||||
|
{t(`notes.kind.${k}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("notes.text")}</span>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
maxLength={10000}
|
||||||
|
required
|
||||||
|
className={cn(inputClass, "py-3")}
|
||||||
|
placeholder={t("notes.placeholder")}
|
||||||
|
value={draft.text}
|
||||||
|
onChange={(e) => update({ text: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p className={noticeError} role="alert">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saved && (
|
||||||
|
<p className={noticeOk} role="status">
|
||||||
|
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{t("notes.saved")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" className={btnPrimary} disabled={busy || !draft.text.trim()}>
|
||||||
|
{busy ? t("action.saving") : t("notes.save")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Wifi, WifiOff } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function subscribe(cb: () => void) {
|
||||||
|
window.addEventListener("online", cb);
|
||||||
|
window.addEventListener("offline", cb);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", cb);
|
||||||
|
window.removeEventListener("offline", cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOnline(): boolean {
|
||||||
|
return useSyncExternalStore(subscribe, () => navigator.onLine, () => true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Online/offline indicator (navigator.onLine) — text + icon, never colour alone. */
|
||||||
|
export function OnlineBadge({ large = false }: { large?: boolean }) {
|
||||||
|
const t = useTranslations("field.connection");
|
||||||
|
const online = useOnline();
|
||||||
|
const Icon = online ? Wifi : WifiOff;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full font-semibold",
|
||||||
|
large ? "px-3.5 py-2 text-[15px]" : "px-2.5 py-1 text-[12.5px]",
|
||||||
|
online
|
||||||
|
? "bg-[color-mix(in_oklch,var(--ok)_12%,transparent)] text-[var(--ok)]"
|
||||||
|
: "bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={large ? "size-5" : "size-4"} aria-hidden />
|
||||||
|
{online ? t("online") : t("offline")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { ChevronRight, Clock, MapPin, Navigation, Siren } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { fmtWindow } from "@/lib/field/format";
|
||||||
|
import type { OrderCard as OrderCardData } from "@/server/services/field/queries";
|
||||||
|
import { StatusBadge } from "./status-badge";
|
||||||
|
import { btnPrimary, toneClasses } from "./ui";
|
||||||
|
|
||||||
|
/** Large order card (Spec §22): number, customer, site address with map link, time window, status, primary button. */
|
||||||
|
export function OrderCard({ order }: { order: OrderCardData }) {
|
||||||
|
const t = useTranslations("field.card");
|
||||||
|
const locale = useLocale();
|
||||||
|
const window = fmtWindow(order.plannedStart, order.plannedEnd, locale);
|
||||||
|
return (
|
||||||
|
<article className={cn("rounded-xl border border-l-4 bg-card p-4 shadow-card", toneClasses(order.statusGroup).edge)}>
|
||||||
|
<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>
|
||||||
|
<StatusBadge status={order.status} />
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
{(order.isEmergency || order.priority === "urgent" || order.priority === "high") && (
|
||||||
|
<p className="mt-1.5 inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--risk)]">
|
||||||
|
<Siren className="size-4" aria-hidden />
|
||||||
|
{order.isEmergency ? t("emergency") : order.priority === "urgent" ? t("urgent") : t("high")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<dl className="mt-3 space-y-2 text-[15px]">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Clock className="mt-0.5 size-4.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<dd>{window ?? t("noDate")}</dd>
|
||||||
|
</div>
|
||||||
|
{order.address && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<MapPin className="mt-0.5 size-4.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<dd className="flex-1">
|
||||||
|
{order.siteName && <span className="block text-[13px] text-muted-foreground">{order.siteName}</span>}
|
||||||
|
{order.address}
|
||||||
|
</dd>
|
||||||
|
{order.mapsUrl && (
|
||||||
|
<a
|
||||||
|
href={order.mapsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex min-h-12 min-w-12 items-center justify-center gap-1 rounded-xl border px-3 text-[13px] font-semibold text-primary"
|
||||||
|
>
|
||||||
|
<Navigation className="size-4" aria-hidden />
|
||||||
|
{t("route")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
<Link href={`/m/orders/${order.id}`} className={cn(btnPrimary, "mt-4")}>
|
||||||
|
{t("open")}
|
||||||
|
<ChevronRight className="size-5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Camera, CircleCheck, Image as ImageIcon, LoaderCircle, TriangleAlert, Upload } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PHOTO_PHASES, type PhotoPhase } from "@/lib/sync/ops";
|
||||||
|
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
||||||
|
import { compressImage, currentPosition } from "@/lib/field/image";
|
||||||
|
import { uploadFieldFile } from "@/lib/field/upload";
|
||||||
|
import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk } from "./ui";
|
||||||
|
|
||||||
|
type Option = { id: string; label: string };
|
||||||
|
type Meta = { phase: PhotoPhase | null; photoRequirementId: string | null; checklistItemId: string | null; comment: string; withLocation: boolean };
|
||||||
|
type Progress = { stage: "idle" } | { stage: "compressing" } | { stage: "uploading"; percent: number } | { stage: "attaching" };
|
||||||
|
|
||||||
|
const DIRECT_UPLOAD = /^image\/(jpeg|png|webp)$/;
|
||||||
|
|
||||||
|
/** Compress → upload (with progress) → photo.attach. Keeps the uploaded documentId for retries. */
|
||||||
|
function usePhotoSave(workOrderId: string) {
|
||||||
|
const t = useTranslations("field");
|
||||||
|
const router = useRouter();
|
||||||
|
const [progress, setProgress] = useState<Progress>({ stage: "idle" });
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const uploaded = useRef<{ file: File; documentId: string } | null>(null);
|
||||||
|
|
||||||
|
async function save(file: File, meta: Meta): Promise<boolean> {
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
let documentId = uploaded.current?.file === file ? uploaded.current.documentId : null;
|
||||||
|
if (!documentId) {
|
||||||
|
setProgress({ stage: "compressing" });
|
||||||
|
let image: Blob = file;
|
||||||
|
let thumbnail: Blob | null = null;
|
||||||
|
try {
|
||||||
|
const out = await compressImage(file);
|
||||||
|
image = out.image;
|
||||||
|
thumbnail = out.thumbnail;
|
||||||
|
} catch {
|
||||||
|
if (!DIRECT_UPLOAD.test(file.type)) {
|
||||||
|
setProgress({ stage: "idle" });
|
||||||
|
setError(t("errors.image"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setProgress({ stage: "uploading", percent: 0 });
|
||||||
|
const name = `${(file.name || "foto").replace(/\.[^.]+$/, "")}.jpg`;
|
||||||
|
const up = await uploadFieldFile({
|
||||||
|
workOrderId,
|
||||||
|
kind: "photo",
|
||||||
|
clientId: newClientId(),
|
||||||
|
file: image,
|
||||||
|
fileName: thumbnail ? name : file.name || name,
|
||||||
|
preview: thumbnail,
|
||||||
|
onProgress: (percent) => setProgress({ stage: "uploading", percent }),
|
||||||
|
});
|
||||||
|
if (!up.ok) {
|
||||||
|
setProgress({ stage: "idle" });
|
||||||
|
setError(t(`errors.${up.error}`));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
documentId = up.documentId;
|
||||||
|
uploaded.current = { file, documentId };
|
||||||
|
}
|
||||||
|
setProgress({ stage: "attaching" });
|
||||||
|
const pos = meta.withLocation ? await currentPosition() : null;
|
||||||
|
const result = await submitOp({
|
||||||
|
opType: "photo.attach",
|
||||||
|
payload: {
|
||||||
|
workOrderId,
|
||||||
|
clientId: newClientId(),
|
||||||
|
documentId,
|
||||||
|
phase: meta.phase,
|
||||||
|
photoRequirementId: meta.photoRequirementId,
|
||||||
|
checklistItemId: meta.checklistItemId,
|
||||||
|
comment: meta.comment.trim() || null,
|
||||||
|
takenAt: new Date(Math.min(file.lastModified || Date.now(), Date.now())).toISOString(),
|
||||||
|
...(pos ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setProgress({ stage: "idle" });
|
||||||
|
if (!isSuccess(result)) {
|
||||||
|
setError(t(`errors.${errorKey(result)}`));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
uploaded.current = null;
|
||||||
|
setSaved(true);
|
||||||
|
router.refresh();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label =
|
||||||
|
progress.stage === "compressing"
|
||||||
|
? t("photos.compressing")
|
||||||
|
: progress.stage === "uploading"
|
||||||
|
? t("photos.uploading", { percent: progress.percent })
|
||||||
|
: progress.stage === "attaching"
|
||||||
|
? t("action.saving")
|
||||||
|
: null;
|
||||||
|
return { save, progress, label, error, saved, busy: progress.stage !== "idle" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressBar({ progress, label }: { progress: Progress; label: string | null }) {
|
||||||
|
if (!label) return null;
|
||||||
|
const percent = progress.stage === "uploading" ? progress.percent : progress.stage === "attaching" ? 100 : 5;
|
||||||
|
return (
|
||||||
|
<div role="status" aria-live="polite" className="space-y-1.5">
|
||||||
|
<p className="flex items-center gap-2 text-[14px] font-semibold">
|
||||||
|
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<div className="h-2.5 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="h-full rounded-full bg-primary transition-[width]" style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quick camera button on the order detail: one tap opens the camera, the photo is saved as "during". */
|
||||||
|
export function QuickPhotoButton({ workOrderId, label }: { workOrderId: string; label: string }) {
|
||||||
|
const { save, progress, label: progressLabel, error, busy } = usePhotoSave(workOrderId);
|
||||||
|
const t = useTranslations("field.photos");
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="contents">
|
||||||
|
<label className={cn("flex min-h-18 cursor-pointer flex-col items-center justify-center gap-1 rounded-xl bg-cta px-2 text-[13px] font-semibold text-cta-foreground", busy && "opacity-60")}>
|
||||||
|
{busy ? <LoaderCircle className="size-6 animate-spin" aria-hidden /> : <Camera className="size-6" aria-hidden />}
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="sr-only"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (file) setDone(await save(file, { phase: "during", photoRequirementId: null, checklistItemId: null, comment: "", withLocation: false }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{(busy || error || done) && (
|
||||||
|
<div className="col-span-full">
|
||||||
|
<ProgressBar progress={progress} label={progressLabel} />
|
||||||
|
{error && (
|
||||||
|
<p className={noticeError} role="alert">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{done && !busy && !error && (
|
||||||
|
<p className={noticeOk} role="status">
|
||||||
|
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{t("saved")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full photo capture: camera or gallery, phase, required photo, checklist item, comment, optional location. */
|
||||||
|
export function PhotoCapture({ workOrderId, requirements, checklistItems }: { workOrderId: string; requirements: Option[]; checklistItems: Option[] }) {
|
||||||
|
const t = useTranslations("field.photos");
|
||||||
|
const { save, progress, label, error, saved, busy } = usePhotoSave(workOrderId);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [meta, setMeta] = useState<Meta>({ phase: "during", photoRequirementId: null, checklistItemId: null, comment: "", withLocation: false });
|
||||||
|
|
||||||
|
const showFile = (f: File | null) => {
|
||||||
|
setPreviewUrl((old) => {
|
||||||
|
if (old) URL.revokeObjectURL(old);
|
||||||
|
return f ? URL.createObjectURL(f) : null;
|
||||||
|
});
|
||||||
|
setFile(f);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (f) showFile(f);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
<label className={cn(btnPrimary, "cursor-pointer")}>
|
||||||
|
<Camera className="size-5" aria-hidden />
|
||||||
|
{t("camera")}
|
||||||
|
<input type="file" accept="image/*" capture="environment" className="sr-only" onChange={pick} />
|
||||||
|
</label>
|
||||||
|
<label className={cn(btnSecondary, "min-h-14 cursor-pointer")}>
|
||||||
|
<ImageIcon className="size-5" aria-hidden />
|
||||||
|
{t("gallery")}
|
||||||
|
<input type="file" accept="image/*" className="sr-only" onChange={pick} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{saved && (
|
||||||
|
<p className={noticeOk} role="status">
|
||||||
|
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{t("saved")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (await save(file, meta)) {
|
||||||
|
showFile(null);
|
||||||
|
setMeta((m) => ({ ...m, comment: "", photoRequirementId: null, checklistItemId: null }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{previewUrl && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={previewUrl} alt={t("preview")} className="max-h-72 w-full rounded-xl bg-muted object-contain" />
|
||||||
|
)}
|
||||||
|
<fieldset>
|
||||||
|
<legend className="mb-2 text-[14px] font-semibold">{t("phaseLabel")}</legend>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{PHOTO_PHASES.map((p) => (
|
||||||
|
<button key={p} type="button" aria-pressed={meta.phase === p} className={chip(meta.phase === p)} onClick={() => setMeta({ ...meta, phase: p })}>
|
||||||
|
{t(`phase.${p}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{requirements.length > 0 && (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("requirement")}</span>
|
||||||
|
<select className={inputClass} value={meta.photoRequirementId ?? ""} onChange={(e) => setMeta({ ...meta, photoRequirementId: e.target.value || null })}>
|
||||||
|
<option value="">{t("none")}</option>
|
||||||
|
{requirements.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{checklistItems.length > 0 && (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("checklistItem")}</span>
|
||||||
|
<select className={inputClass} value={meta.checklistItemId ?? ""} onChange={(e) => setMeta({ ...meta, checklistItemId: e.target.value || null })}>
|
||||||
|
<option value="">{t("none")}</option>
|
||||||
|
{checklistItems.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("comment")}</span>
|
||||||
|
<textarea rows={2} maxLength={2000} className={cn(inputClass, "py-3")} value={meta.comment} onChange={(e) => setMeta({ ...meta, comment: e.target.value })} />
|
||||||
|
</label>
|
||||||
|
<label className="flex min-h-12 items-center gap-3 text-[15px]">
|
||||||
|
<input type="checkbox" className="size-6 accent-[var(--primary)]" checked={meta.withLocation} onChange={(e) => setMeta({ ...meta, withLocation: e.target.checked })} />
|
||||||
|
{t("location")}
|
||||||
|
</label>
|
||||||
|
<ProgressBar progress={progress} label={label} />
|
||||||
|
{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}>
|
||||||
|
<Upload className="size-5" aria-hidden />
|
||||||
|
{t("save")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={btnSecondary} disabled={busy} onClick={() => showFile(null)}>
|
||||||
|
{t("discard")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CircleCheck, LoaderCircle, Pause, Play, TriangleAlert, Truck, Wrench, Handshake } from "lucide-react";
|
||||||
|
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
||||||
|
import { currentPosition } from "@/lib/field/image";
|
||||||
|
import { btnPrimary, btnSecondary, noticeError, noticeWarn } from "./ui";
|
||||||
|
|
||||||
|
type SessionState = "en_route" | "running" | "paused" | null;
|
||||||
|
type ActionKey = "accept" | "travel" | "start" | "pause" | "resume" | "complete";
|
||||||
|
|
||||||
|
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 } {
|
||||||
|
if (status === "assigned") return { primary: "accept", secondary: null };
|
||||||
|
if (status === "accepted") return { primary: "travel", secondary: "start" };
|
||||||
|
if (status === "en_route") return { primary: "start", secondary: null };
|
||||||
|
if (WORKING.includes(status)) {
|
||||||
|
if (mySession === "running") return { primary: "complete", secondary: "pause" };
|
||||||
|
if (mySession === "paused") return { primary: "resume", secondary: "complete" };
|
||||||
|
return { primary: "start", secondary: status === "in_progress" ? "complete" : null };
|
||||||
|
}
|
||||||
|
return { primary: null, secondary: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS = { accept: Handshake, travel: Truck, start: Wrench, pause: Pause, resume: Play, complete: CircleCheck } 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({
|
||||||
|
workOrderId,
|
||||||
|
status,
|
||||||
|
version,
|
||||||
|
mySession,
|
||||||
|
blockers,
|
||||||
|
}: {
|
||||||
|
workOrderId: string;
|
||||||
|
status: WorkOrderStatus;
|
||||||
|
version: number;
|
||||||
|
mySession: SessionState;
|
||||||
|
/** translated completion blockers (without the user's own session, which is ended on completion) */
|
||||||
|
blockers: string[];
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("field");
|
||||||
|
const router = useRouter();
|
||||||
|
const [busy, setBusy] = useState<ActionKey | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const { primary, secondary } = resolveActions(status, mySession);
|
||||||
|
|
||||||
|
async function run(action: ActionKey) {
|
||||||
|
if (action === "complete" && !confirm) {
|
||||||
|
setConfirm(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(action);
|
||||||
|
setError(null);
|
||||||
|
const at = new Date().toISOString();
|
||||||
|
let result;
|
||||||
|
switch (action) {
|
||||||
|
case "accept":
|
||||||
|
result = await submitOp({ opType: "work_order.transition", baseVersion: version, payload: { workOrderId, to: "accepted" } });
|
||||||
|
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":
|
||||||
|
result = await submitOp({ opType: "session.pause", payload: { workOrderId, at } });
|
||||||
|
break;
|
||||||
|
case "resume":
|
||||||
|
result = await submitOp({ opType: "session.resume", payload: { workOrderId, at } });
|
||||||
|
break;
|
||||||
|
case "complete": {
|
||||||
|
let base = version;
|
||||||
|
if (mySession) {
|
||||||
|
const ended = await submitOp({ opType: "session.end", payload: { workOrderId, at } });
|
||||||
|
if (!isSuccess(ended)) {
|
||||||
|
result = ended;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
base = ended.entityVersion ?? version;
|
||||||
|
}
|
||||||
|
result = await submitOp({ opType: "work_order.transition", baseVersion: base, payload: { workOrderId, to: "technically_completed" } });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBusy(null);
|
||||||
|
setConfirm(false);
|
||||||
|
if (!isSuccess(result)) setError(t(`errors.${errorKey(result)}`));
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!primary) {
|
||||||
|
return <p className="text-[15px] text-muted-foreground">{["technically_completed", "signature_pending"].includes(status) ? t("action.reportNext") : t("action.nothing")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completeBlocked = blockers.length > 0;
|
||||||
|
const renderButton = (action: ActionKey, variant: "primary" | "secondary") => {
|
||||||
|
const Icon = busy === action ? LoaderCircle : ICONS[action];
|
||||||
|
const disabled = busy !== null || (action === "complete" && completeBlocked);
|
||||||
|
return (
|
||||||
|
<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 />
|
||||||
|
{busy === action ? t("action.saving") : action === "complete" && confirm ? t("action.confirmComplete") : t(`action.${action}`)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{renderButton(primary, "primary")}
|
||||||
|
{secondary && renderButton(secondary, "secondary")}
|
||||||
|
{confirm && (
|
||||||
|
<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(false)}>
|
||||||
|
{t("photos.discard")}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{completeBlocked && (primary === "complete" || secondary === "complete") && (
|
||||||
|
<div className={noticeWarn} role="note">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{t("action.blocked")}</p>
|
||||||
|
<ul className="mt-1 list-disc pl-4">
|
||||||
|
{blockers.map((b) => (
|
||||||
|
<li key={b}>{b}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className={noticeError} role="alert">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Ban, BadgeCheck, CalendarClock, CircleDot, FileSearch, Receipt, TriangleAlert, Truck, Wrench, type LucideIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { STATUS_GROUP, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { toneClasses } from "./ui";
|
||||||
|
|
||||||
|
const ICONS: Record<StatusGroup, LucideIcon> = {
|
||||||
|
new: CircleDot,
|
||||||
|
planned: CalendarClock,
|
||||||
|
en_route: Truck,
|
||||||
|
in_progress: Wrench,
|
||||||
|
documentation_incomplete: TriangleAlert,
|
||||||
|
in_review: FileSearch,
|
||||||
|
ready_for_billing: BadgeCheck,
|
||||||
|
billed: Receipt,
|
||||||
|
cancelled: Ban,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Status group (Brandbook §12.3) as text + icon; the detailed status is appended when it differs. */
|
||||||
|
export function StatusBadge({ status, large = false }: { status: WorkOrderStatus; large?: boolean }) {
|
||||||
|
const t = useTranslations("field");
|
||||||
|
const group = STATUS_GROUP[status];
|
||||||
|
const Icon = ICONS[group];
|
||||||
|
const groupLabel = t(`statusGroup.${group}`);
|
||||||
|
const detail = t(`statusDetail.${status}`);
|
||||||
|
return (
|
||||||
|
<span className={cn("inline-flex items-center gap-1.5 rounded-full font-semibold", large ? "px-3 py-1.5 text-[14px]" : "px-2.5 py-1 text-[12.5px]", toneClasses(group).badge)}>
|
||||||
|
<Icon className={large ? "size-4.5" : "size-4"} aria-hidden />
|
||||||
|
{groupLabel}
|
||||||
|
{detail !== groupLabel && <span className="font-normal">· {detail}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Minus, Plus } from "lucide-react";
|
||||||
|
import { inputClass } from "./ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Quantity stepper with large +/− buttons (Spec §22: material with few inputs). */
|
||||||
|
export function Stepper({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
decreaseLabel,
|
||||||
|
increaseLabel,
|
||||||
|
step = 1,
|
||||||
|
min = 0,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
label: string;
|
||||||
|
decreaseLabel: string;
|
||||||
|
increaseLabel: string;
|
||||||
|
step?: number;
|
||||||
|
min?: number;
|
||||||
|
}) {
|
||||||
|
const round = (n: number) => Math.round(n * 1000) / 1000;
|
||||||
|
const btn = "grid size-14 shrink-0 place-items-center rounded-xl border bg-card text-primary hover:bg-muted disabled:opacity-40";
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" className={btn} aria-label={decreaseLabel} disabled={value <= min} onClick={() => onChange(Math.max(min, round(value - step)))}>
|
||||||
|
<Minus className="size-6" aria-hidden />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(inputClass, "min-h-14 text-center text-lg font-semibold")}
|
||||||
|
value={String(value).replace(".", ",")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = Number(e.target.value.replace(",", "."));
|
||||||
|
if (Number.isFinite(n) && n >= min) onChange(round(n));
|
||||||
|
else if (e.target.value.trim() === "") onChange(min);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" className={btn} aria-label={increaseLabel} onClick={() => onChange(round(value + step))}>
|
||||||
|
<Plus className="size-6" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
|
||||||
|
/** Header of the order sub pages (max. two navigation levels below the order list). */
|
||||||
|
export function SubPageHeader({ workOrderId, number, title, section }: { workOrderId: string; number: string; title: string; section: string }) {
|
||||||
|
const t = useTranslations("field.detail");
|
||||||
|
return (
|
||||||
|
<div className="px-4 pt-3">
|
||||||
|
<Link href={`/m/orders/${workOrderId}`} 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("backToOrder")}
|
||||||
|
</Link>
|
||||||
|
<p className="mt-1 font-mono text-[13px] text-muted-foreground">
|
||||||
|
{number} · {title}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-[24px]">{section}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { correctTime } from "@/server/actions/field/time";
|
||||||
|
import { btnPrimary, btnSecondary, inputClass, noticeError } from "./ui";
|
||||||
|
|
||||||
|
/** ISO → value of <input type="datetime-local"> in the device time zone. */
|
||||||
|
function toLocalInput(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual correction of a time segment (Spec §12.2) — only rendered for users with field:correct_time. */
|
||||||
|
export function TimeCorrectionForm({ workOrderId, entry }: { workOrderId: string; entry: { id: 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 [reason, setReason] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(btnSecondary, "min-h-12")}
|
||||||
|
onClick={() => {
|
||||||
|
// inputs are prefilled only on the client (device time zone), never during SSR
|
||||||
|
setStart(toLocalInput(entry.startedAt));
|
||||||
|
setEnd(entry.endedAt ? toLocalInput(entry.endedAt) : "");
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("time.correct")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (reason.trim().length < 3) {
|
||||||
|
setError(t("time.reasonHint"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
const result = await correctTime({
|
||||||
|
workOrderId,
|
||||||
|
timeEntryId: entry.id,
|
||||||
|
startedAt: new Date(start).toISOString(),
|
||||||
|
endedAt: end ? new Date(end).toISOString() : null,
|
||||||
|
reason: reason.trim(),
|
||||||
|
}).catch(() => ({ ok: false as const, error: "failed" as const }));
|
||||||
|
setBusy(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(t(`errors.${result.error === "failed" ? "internal" : result.error}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
setReason("");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="space-y-3 rounded-xl bg-muted p-3">
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<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" className={inputClass} value={end} min={start} onChange={(e) => setEnd(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-[14px] font-semibold">{t("time.reason")}</span>
|
||||||
|
<textarea rows={2} required minLength={3} maxLength={1000} className={cn(inputClass, "py-3")} value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
<span className="mt-1 block text-[13px] text-muted-foreground">{t("time.reasonHint")}</span>
|
||||||
|
</label>
|
||||||
|
{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("time.save")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={btnSecondary} disabled={busy} onClick={() => setOpen(false)}>
|
||||||
|
{t("photos.discard")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { StatusGroup } from "@/lib/work-orders/status";
|
||||||
|
import { STATUS_GROUP_TONE } from "@/lib/work-orders/status";
|
||||||
|
|
||||||
|
/** Shared class names of the mobile field UI (touch targets ≥ 48 px, colours only via tokens). */
|
||||||
|
|
||||||
|
export const btnPrimary =
|
||||||
|
"inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-cta px-5 font-heading text-base font-semibold text-cta-foreground transition-opacity hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||||
|
|
||||||
|
export const btnSecondary =
|
||||||
|
"inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 font-heading text-[15px] font-semibold text-primary transition-colors hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||||
|
|
||||||
|
export function chip(active: boolean) {
|
||||||
|
return cn(
|
||||||
|
"inline-flex min-h-12 items-center justify-center gap-1.5 rounded-xl border px-3.5 text-[14px] font-semibold transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||||
|
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-muted",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const card = "rounded-xl border bg-card p-4 shadow-card";
|
||||||
|
|
||||||
|
export const inputClass =
|
||||||
|
"min-h-12 w-full rounded-xl border border-input bg-card px-3.5 text-base outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||||
|
|
||||||
|
const TONE_CLASSES = {
|
||||||
|
neutral: { badge: "bg-muted text-muted-foreground", edge: "border-l-[var(--brand-stahlgrau)]" },
|
||||||
|
info: { badge: "bg-[color-mix(in_oklch,var(--info)_12%,transparent)] text-[var(--info)]", edge: "border-l-[var(--info)]" },
|
||||||
|
accent: { badge: "bg-[color-mix(in_oklch,var(--ui-accent)_14%,transparent)] text-foreground", edge: "border-l-[var(--ui-accent)]" },
|
||||||
|
warning: { badge: "bg-[color-mix(in_oklch,var(--warn)_14%,transparent)] text-[var(--warn)]", edge: "border-l-[var(--warn)]" },
|
||||||
|
success: { badge: "bg-[color-mix(in_oklch,var(--ok)_12%,transparent)] text-[var(--ok)]", edge: "border-l-[var(--ok)]" },
|
||||||
|
danger: { badge: "bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]", edge: "border-l-[var(--risk)]" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function toneClasses(group: StatusGroup) {
|
||||||
|
return TONE_CLASSES[STATUS_GROUP_TONE[group]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const noticeError = "flex items-start gap-2 rounded-xl bg-[color-mix(in_oklch,var(--risk)_10%,transparent)] px-3.5 py-3 text-[14px] text-[var(--risk)]";
|
||||||
|
export const noticeOk = "flex items-start gap-2 rounded-xl bg-[color-mix(in_oklch,var(--ok)_10%,transparent)] px-3.5 py-3 text-[14px] text-[var(--ok)]";
|
||||||
|
export const noticeWarn = "flex items-start gap-2 rounded-xl bg-[color-mix(in_oklch,var(--warn)_12%,transparent)] px-3.5 py-3 text-[14px] text-foreground";
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CircleCheck, LoaderCircle, Mic, Square, TriangleAlert, Upload } from "lucide-react";
|
||||||
|
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
||||||
|
import { uploadFieldFile } from "@/lib/field/upload";
|
||||||
|
import { btnPrimary, btnSecondary, noticeError, noticeOk } from "./ui";
|
||||||
|
|
||||||
|
export const MAX_RECORDING_SECONDS = 300;
|
||||||
|
|
||||||
|
function pickMimeType(): string | undefined {
|
||||||
|
if (typeof MediaRecorder === "undefined") return undefined;
|
||||||
|
for (const type of ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]) {
|
||||||
|
if (MediaRecorder.isTypeSupported(type)) return type;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionFor = (mime: string) => (mime.includes("mp4") ? "m4a" : mime.includes("ogg") ? "ogg" : "webm");
|
||||||
|
const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
/** Voice note (Spec §15.1): MediaRecorder (webm/opus, mp4 on iOS), max. 5 minutes, then upload + voice.attach. */
|
||||||
|
export function VoiceRecorder({ workOrderId }: { workOrderId: string }) {
|
||||||
|
const t = useTranslations("field");
|
||||||
|
const router = useRouter();
|
||||||
|
const [state, setState] = useState<"idle" | "recording" | "recorded" | "saving">("idle");
|
||||||
|
const [seconds, setSeconds] = useState(0);
|
||||||
|
const [clip, setClip] = useState<{ blob: Blob; url: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [percent, setPercent] = useState(0);
|
||||||
|
const recorder = useRef<MediaRecorder | null>(null);
|
||||||
|
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (timer.current) clearInterval(timer.current);
|
||||||
|
recorder.current?.stream.getTracks().forEach((tr) => tr.stop());
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
useEffect(() => () => (clip ? URL.revokeObjectURL(clip.url) : undefined), [clip]);
|
||||||
|
|
||||||
|
const supported = typeof window === "undefined" || (typeof MediaRecorder !== "undefined" && !!navigator.mediaDevices?.getUserMedia);
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (timer.current) clearInterval(timer.current);
|
||||||
|
timer.current = null;
|
||||||
|
if (recorder.current?.state === "recording") recorder.current.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const mimeType = pickMimeType();
|
||||||
|
const rec = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
|
||||||
|
const chunks: BlobPart[] = [];
|
||||||
|
rec.ondataavailable = (e) => e.data.size > 0 && chunks.push(e.data);
|
||||||
|
rec.onstop = () => {
|
||||||
|
stream.getTracks().forEach((tr) => tr.stop());
|
||||||
|
const blob = new Blob(chunks, { type: rec.mimeType || mimeType || "audio/webm" });
|
||||||
|
setClip({ blob, url: URL.createObjectURL(blob) });
|
||||||
|
setState("recorded");
|
||||||
|
};
|
||||||
|
recorder.current = rec;
|
||||||
|
rec.start(1000);
|
||||||
|
setSeconds(0);
|
||||||
|
setState("recording");
|
||||||
|
timer.current = setInterval(() => {
|
||||||
|
setSeconds((s) => {
|
||||||
|
if (s + 1 >= MAX_RECORDING_SECONDS) stop();
|
||||||
|
return Math.min(s + 1, MAX_RECORDING_SECONDS);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
} catch {
|
||||||
|
setError(t("voice.denied"));
|
||||||
|
setState("idle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!clip) return;
|
||||||
|
setState("saving");
|
||||||
|
setError(null);
|
||||||
|
const type = clip.blob.type || "audio/webm";
|
||||||
|
const up = await uploadFieldFile({ workOrderId, kind: "voice_note", clientId: newClientId(), file: clip.blob, fileName: `sprachnotiz.${extensionFor(type)}`, onProgress: setPercent });
|
||||||
|
if (!up.ok) {
|
||||||
|
setError(t(`errors.${up.error}`));
|
||||||
|
setState("recorded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await submitOp({
|
||||||
|
opType: "voice.attach",
|
||||||
|
payload: { workOrderId, clientId: newClientId(), documentId: up.documentId, durationSeconds: Math.min(seconds, MAX_RECORDING_SECONDS), recordedAt: new Date().toISOString() },
|
||||||
|
});
|
||||||
|
if (!isSuccess(result)) {
|
||||||
|
setError(t(`errors.${errorKey(result)}`));
|
||||||
|
setState("recorded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClip(null);
|
||||||
|
setSaved(true);
|
||||||
|
setState("idle");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supported) return <p className="text-[14px] text-muted-foreground">{t("voice.unsupported")}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{state === "idle" && (
|
||||||
|
<button type="button" className={btnSecondary} onClick={start}>
|
||||||
|
<Mic className="size-5" aria-hidden />
|
||||||
|
{t("voice.record")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{state === "recording" && (
|
||||||
|
<>
|
||||||
|
<p role="status" aria-live="polite" className="flex items-center gap-2 text-[15px] font-semibold text-[var(--risk)]">
|
||||||
|
<span className="size-3 animate-pulse rounded-full bg-[var(--risk)]" aria-hidden />
|
||||||
|
{t("voice.recording", { time: clock(seconds) })}
|
||||||
|
</p>
|
||||||
|
<button type="button" className={btnPrimary} onClick={stop}>
|
||||||
|
<Square className="size-5" aria-hidden />
|
||||||
|
{t("voice.stop")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(state === "recorded" || state === "saving") && clip && (
|
||||||
|
<>
|
||||||
|
<audio controls src={clip.url} className="w-full" />
|
||||||
|
{state === "saving" && (
|
||||||
|
<p role="status" className="flex items-center gap-2 text-[14px] font-semibold">
|
||||||
|
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
|
||||||
|
{t("photos.uploading", { percent })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="button" className={btnPrimary} onClick={save} disabled={state === "saving"}>
|
||||||
|
<Upload className="size-5" aria-hidden />
|
||||||
|
{t("voice.save")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={btnSecondary} onClick={() => { setClip(null); setState("idle"); }} disabled={state === "saving"}>
|
||||||
|
{t("voice.discard")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className={noticeError} role="alert">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{saved && (
|
||||||
|
<p className={noticeOk} role="status">
|
||||||
|
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||||
|
{t("voice.saved")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { SyncOpResult, SyncOpType, SyncResponse } from "@/lib/sync/envelope";
|
||||||
|
import type { OpPayload } from "@/lib/sync/ops";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client wrapper for mobile mutations (ARCHITEKTUR §4.6). Today every op is sent to
|
||||||
|
* POST /api/v1/sync immediately. Lane L7 replaces the implementation with the IndexedDB outbox —
|
||||||
|
* keep the signature `submitOp(op) → Promise<SyncOpResult>` stable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ClientOp<T extends SyncOpType = SyncOpType> = {
|
||||||
|
opType: T;
|
||||||
|
payload: OpPayload<T>;
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
baseVersion?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** RFC 4122 v4 id; falls back to getRandomValues outside secure contexts. */
|
||||||
|
export function newClientId(): string {
|
||||||
|
const c = globalThis.crypto;
|
||||||
|
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
||||||
|
const b = new Uint8Array(16);
|
||||||
|
c.getRandomValues(b);
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40;
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80;
|
||||||
|
const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
||||||
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_KEY = "craftvia.field.deviceId";
|
||||||
|
|
||||||
|
export function deviceId(): string {
|
||||||
|
try {
|
||||||
|
let id = localStorage.getItem(DEVICE_KEY);
|
||||||
|
if (!id) {
|
||||||
|
id = newClientId();
|
||||||
|
localStorage.setItem(DEVICE_KEY, id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
} catch {
|
||||||
|
return "unknown-device";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorCodeForStatus(status: number): SyncOpResult["errorCode"] {
|
||||||
|
if (status === 400 || status === 413) return "invalid";
|
||||||
|
if (status === 401 || status === 403) return "forbidden";
|
||||||
|
if (status === 404) return "not_found";
|
||||||
|
if (status === 409) return "conflict";
|
||||||
|
return "internal";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitOp<T extends SyncOpType>(op: ClientOp<T>): Promise<SyncOpResult> {
|
||||||
|
const clientOpId = newClientId();
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/sync", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ deviceId: deviceId(), operations: [{ ...op, clientOpId, clientCreatedAt: new Date().toISOString() }] }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return { clientOpId, status: "rejected", errorCode: errorCodeForStatus(res.status), message: `HTTP ${res.status}` };
|
||||||
|
const body = (await res.json()) as SyncResponse;
|
||||||
|
return body.results[0] ?? { clientOpId, status: "rejected", errorCode: "internal" };
|
||||||
|
} catch {
|
||||||
|
return { clientOpId, status: "rejected", errorCode: "internal", message: "network" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** i18n key (messages field.errors.*) for a failed op result. */
|
||||||
|
export function errorKey(result: SyncOpResult): "not_found" | "forbidden" | "invalid" | "conflict" | "blocked" | "internal" | "network" {
|
||||||
|
if (result.message === "network") return "network";
|
||||||
|
return result.errorCode ?? "internal";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuccess(result: SyncOpResult): boolean {
|
||||||
|
return result.status === "applied" || (result.status === "duplicate" && !result.errorCode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/** Date/time formatting of the mobile app with an explicit time zone (identical on server and client). */
|
||||||
|
|
||||||
|
export const FIELD_TIME_ZONE = "Europe/Berlin";
|
||||||
|
|
||||||
|
const tag = (locale: string) => (locale === "en" ? "en-GB" : "de-DE");
|
||||||
|
|
||||||
|
export function fmtTime(d: Date | string, locale: string): string {
|
||||||
|
return new Intl.DateTimeFormat(tag(locale), { hour: "2-digit", minute: "2-digit", timeZone: FIELD_TIME_ZONE }).format(new Date(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDate(d: Date | string, locale: string): string {
|
||||||
|
return new Intl.DateTimeFormat(tag(locale), { weekday: "short", day: "2-digit", month: "2-digit", timeZone: FIELD_TIME_ZONE }).format(new Date(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDateTime(d: Date | string, locale: string): string {
|
||||||
|
return `${fmtDate(d, locale)} ${fmtTime(d, locale)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayKey(d: Date): string {
|
||||||
|
return new Intl.DateTimeFormat("en-CA", { timeZone: FIELD_TIME_ZONE }).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Mo., 14.09. · 08:00–12:00" or "Mo., 14.09. 08:00 – Di., 15.09. 12:00"; null without start. */
|
||||||
|
export function fmtWindow(start: Date | string | null, end: Date | string | null, locale: string): string | null {
|
||||||
|
if (!start) return null;
|
||||||
|
const s = new Date(start);
|
||||||
|
if (!end) return `${fmtDate(s, locale)} · ${fmtTime(s, locale)}`;
|
||||||
|
const e = new Date(end);
|
||||||
|
if (dayKey(s) === dayKey(e)) return `${fmtDate(s, locale)} · ${fmtTime(s, locale)}–${fmtTime(e, locale)}`;
|
||||||
|
return `${fmtDateTime(s, locale)} – ${fmtDateTime(e, locale)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDuration(seconds: number): string {
|
||||||
|
const total = Math.max(0, Math.round(seconds / 60));
|
||||||
|
const h = Math.floor(total / 60);
|
||||||
|
const m = total % 60;
|
||||||
|
return h > 0 ? `${h} h ${String(m).padStart(2, "0")} min` : `${m} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function secondsBetween(start: Date | string, end: Date | string | null, now = new Date()): number {
|
||||||
|
return Math.max(0, Math.round(((end ? new Date(end) : now).getTime() - new Date(start).getTime()) / 1000));
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* In-browser photo compression before upload (ARCHITEKTUR §4.3, Spec §14.4):
|
||||||
|
* longest edge max 2560 px, JPEG quality 0.82, plus a 400 px thumbnail.
|
||||||
|
* EXIF orientation: decoded with `imageOrientation: "from-image"` (createImageBitmap) — modern
|
||||||
|
* browsers also apply it for <img> decoding, which is the fallback path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MAX_EDGE = 2560;
|
||||||
|
export const JPEG_QUALITY = 0.82;
|
||||||
|
export const THUMB_EDGE = 400;
|
||||||
|
|
||||||
|
type Drawable = { source: CanvasImageSource; width: number; height: number; close: () => void };
|
||||||
|
|
||||||
|
async function decode(file: Blob): Promise<Drawable> {
|
||||||
|
if (typeof createImageBitmap === "function") {
|
||||||
|
try {
|
||||||
|
const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
|
||||||
|
return { source: bmp, width: bmp.width, height: bmp.height, close: () => bmp.close() };
|
||||||
|
} catch {
|
||||||
|
// fall through to <img> decoding (e.g. older Safari)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
try {
|
||||||
|
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const el = new Image();
|
||||||
|
el.onload = () => resolve(el);
|
||||||
|
el.onerror = () => reject(new Error("decode failed"));
|
||||||
|
el.src = url;
|
||||||
|
});
|
||||||
|
return { source: img, width: img.naturalWidth, height: img.naturalHeight, close: () => URL.revokeObjectURL(url) };
|
||||||
|
} catch (err) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(d: Drawable, maxEdge: number, quality: number): Promise<Blob> {
|
||||||
|
const scale = Math.min(1, maxEdge / Math.max(d.width, d.height));
|
||||||
|
const w = Math.max(1, Math.round(d.width * scale));
|
||||||
|
const h = Math.max(1, Math.round(d.height * scale));
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const g = canvas.getContext("2d");
|
||||||
|
if (!g) return Promise.reject(new Error("canvas unavailable"));
|
||||||
|
g.imageSmoothingQuality = "high";
|
||||||
|
g.drawImage(d.source, 0, 0, w, h);
|
||||||
|
return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("encode failed"))), "image/jpeg", quality));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compressImage(file: Blob): Promise<{ image: Blob; thumbnail: Blob; width: number; height: number }> {
|
||||||
|
const d = await decode(file);
|
||||||
|
try {
|
||||||
|
const image = await render(d, MAX_EDGE, JPEG_QUALITY);
|
||||||
|
const thumbnail = await render(d, THUMB_EDGE, 0.75);
|
||||||
|
const scale = Math.min(1, MAX_EDGE / Math.max(d.width, d.height));
|
||||||
|
return { image, thumbnail, width: Math.round(d.width * scale), height: Math.round(d.height * scale) };
|
||||||
|
} finally {
|
||||||
|
d.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional GPS position (never blocks longer than `timeoutMs`, null when denied/unavailable). */
|
||||||
|
export function currentPosition(timeoutMs = 4000): Promise<{ latitude: number; longitude: number } | null> {
|
||||||
|
if (typeof navigator === "undefined" || !navigator.geolocation) return Promise.resolve(null);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => resolve(null), timeoutMs + 500);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(p) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ latitude: p.coords.latitude, longitude: p.coords.longitude });
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(null);
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: false, timeout: timeoutMs, maximumAge: 120_000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { MaterialUsageStatus } from "@/lib/sync/ops";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material confirmation rules (Spec §13.2/§13.3), shared by UI (early feedback) and server
|
||||||
|
* (authoritative, services/field/materials.ts).
|
||||||
|
* Planned position: fully_used (≥ planned) | partially_used (0 < q < planned) | not_used (q = 0);
|
||||||
|
* any deviation from the planned quantity requires a reason.
|
||||||
|
* Additional material: usageStatus additional, name, quantity > 0 and reason are mandatory.
|
||||||
|
* Returns null when valid, otherwise a short technical reason.
|
||||||
|
*/
|
||||||
|
export function validateMaterialUsage(
|
||||||
|
input: { usageStatus: MaterialUsageStatus; quantity: number; name?: string | null; deviationReason?: string | null },
|
||||||
|
plannedQuantity: number | null,
|
||||||
|
): string | null {
|
||||||
|
const reason = input.deviationReason?.trim();
|
||||||
|
if (!Number.isFinite(input.quantity) || input.quantity < 0) return "invalid quantity";
|
||||||
|
if (plannedQuantity === null) {
|
||||||
|
if (input.usageStatus !== "additional") return "additional material requires usageStatus additional";
|
||||||
|
if (!input.name?.trim()) return "name required";
|
||||||
|
if (input.quantity <= 0) return "quantity must be positive";
|
||||||
|
if (!reason) return "reason required for additional material";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (input.usageStatus === "additional") return "planned position cannot be additional";
|
||||||
|
if (input.usageStatus === "not_used" && input.quantity !== 0) return "not used requires quantity 0";
|
||||||
|
if (input.usageStatus === "partially_used" && (input.quantity <= 0 || input.quantity >= plannedQuantity)) return "partial quantity must be between 0 and planned";
|
||||||
|
if (input.usageStatus === "fully_used" && input.quantity < plannedQuantity) return "fully used requires at least the planned quantity";
|
||||||
|
if (materialDeviates(input, plannedQuantity) && !reason) return "reason required for deviation";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function materialDeviates(input: { usageStatus: MaterialUsageStatus; quantity: number }, plannedQuantity: number | null): boolean {
|
||||||
|
if (plannedQuantity === null) return true;
|
||||||
|
return input.usageStatus !== "fully_used" || input.quantity !== plannedQuantity;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Binary upload to POST /api/v1/uploads with progress (XHR — fetch has no upload progress).
|
||||||
|
* Idempotent over `clientId`: retrying the same clientId returns the same documentId.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type UploadResult = { ok: true; documentId: string } | { ok: false; error: "network" | "invalid" | "forbidden" | "not_found" | "internal" };
|
||||||
|
|
||||||
|
export function uploadFieldFile(opts: {
|
||||||
|
workOrderId: string;
|
||||||
|
kind: "photo" | "voice_note";
|
||||||
|
clientId: string;
|
||||||
|
file: Blob;
|
||||||
|
fileName: string;
|
||||||
|
preview?: Blob | null;
|
||||||
|
onProgress?: (percent: number) => void;
|
||||||
|
}): Promise<UploadResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("clientId", opts.clientId);
|
||||||
|
form.append("workOrderId", opts.workOrderId);
|
||||||
|
form.append("kind", opts.kind);
|
||||||
|
form.append("file", opts.file, opts.fileName);
|
||||||
|
if (opts.preview) form.append("preview", opts.preview, `thumb-${opts.fileName}`);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", "/api/v1/uploads");
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable) opts.onProgress?.(Math.round((e.loaded / e.total) * 100));
|
||||||
|
};
|
||||||
|
xhr.onerror = () => resolve({ ok: false, error: "network" });
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status === 200 || xhr.status === 201) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(xhr.responseText) as { documentId: string };
|
||||||
|
opts.onProgress?.(100);
|
||||||
|
resolve({ ok: true, documentId: body.documentId });
|
||||||
|
} catch {
|
||||||
|
resolve({ ok: false, error: "internal" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const error = xhr.status === 400 || xhr.status === 413 ? "invalid" : xhr.status === 403 || xhr.status === 401 ? "forbidden" : xhr.status === 404 ? "not_found" : "internal";
|
||||||
|
resolve({ ok: false, error });
|
||||||
|
};
|
||||||
|
xhr.send(form);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { SyncOpType } from "./envelope";
|
||||||
|
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload schemas per sync opType (ARCHITEKTUR §4.6). Client-safe: used by the mobile UI to
|
||||||
|
* build ops and by src/server/services/sync/apply.ts to validate them.
|
||||||
|
* `clientId` fields are device-generated ids (uuid) that make additive creates idempotent
|
||||||
|
* and are mapped to server ids in SyncOpResult.idMap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const id = z.string().min(1).max(64);
|
||||||
|
const clientId = z.string().uuid();
|
||||||
|
const isoDate = z.string().datetime({ offset: true });
|
||||||
|
const lat = z.number().min(-90).max(90);
|
||||||
|
const lng = z.number().min(-180).max(180);
|
||||||
|
const quantity = z.number().min(0).max(1_000_000);
|
||||||
|
|
||||||
|
export const NOTE_KINDS = [
|
||||||
|
"work_done",
|
||||||
|
"deviation",
|
||||||
|
"problem",
|
||||||
|
"additional_work",
|
||||||
|
"not_executable",
|
||||||
|
"follow_up",
|
||||||
|
"recommendation",
|
||||||
|
"customer_note",
|
||||||
|
"general",
|
||||||
|
] as const;
|
||||||
|
export type NoteKind = (typeof NOTE_KINDS)[number];
|
||||||
|
|
||||||
|
export const PHOTO_PHASES = ["before", "during", "after"] as const;
|
||||||
|
export type PhotoPhase = (typeof PHOTO_PHASES)[number];
|
||||||
|
|
||||||
|
export const MATERIAL_USAGE_STATUSES = ["fully_used", "partially_used", "not_used", "additional"] as const;
|
||||||
|
export type MaterialUsageStatus = (typeof MATERIAL_USAGE_STATUSES)[number];
|
||||||
|
|
||||||
|
/** Unit suggestions for the material stepper (free text stays allowed). */
|
||||||
|
export const UNIT_SUGGESTIONS = ["Stk", "m", "m²", "m³", "kg", "l", "Pkg", "Rolle", "Satz", "h"] as const;
|
||||||
|
|
||||||
|
export const sessionStartPayload = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
clientId: clientId.optional(),
|
||||||
|
/** travel = "Losfahren" (in Anfahrt), work = "Arbeit starten" */
|
||||||
|
mode: z.enum(["travel", "work"]).default("work"),
|
||||||
|
at: isoDate.optional(),
|
||||||
|
latitude: lat.optional(),
|
||||||
|
longitude: lng.optional(),
|
||||||
|
offline: z.boolean().default(false),
|
||||||
|
deviceInfo: z.string().max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessionControlPayload = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
at: isoDate.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const workOrderTransitionPayload = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
to: z.enum(WORK_ORDER_STATUSES),
|
||||||
|
reason: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const noteCreatePayload = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
clientId: clientId.optional(),
|
||||||
|
kind: z.enum(NOTE_KINDS).default("general"),
|
||||||
|
text: z.string().trim().min(1).max(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const checklistTogglePayload = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
itemId: id,
|
||||||
|
checked: z.boolean(),
|
||||||
|
comment: z.string().max(2000).nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const materialUpsertPayload = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
clientId: clientId.optional(),
|
||||||
|
materialPlanId: id.nullish(),
|
||||||
|
name: z.string().trim().max(200).optional(),
|
||||||
|
articleNumber: z.string().trim().max(100).nullish(),
|
||||||
|
quantity,
|
||||||
|
unit: z.string().trim().min(1).max(20),
|
||||||
|
usageStatus: z.enum(MATERIAL_USAGE_STATUSES),
|
||||||
|
deviationReason: z.string().trim().max(2000).nullish(),
|
||||||
|
notes: z.string().trim().max(2000).nullish(),
|
||||||
|
photoId: id.nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const photoAttachPayload = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
clientId: clientId.optional(),
|
||||||
|
documentId: id,
|
||||||
|
phase: z.enum(PHOTO_PHASES).nullish(),
|
||||||
|
photoRequirementId: id.nullish(),
|
||||||
|
checklistItemId: id.nullish(),
|
||||||
|
comment: z.string().trim().max(2000).nullish(),
|
||||||
|
takenAt: isoDate.optional(),
|
||||||
|
latitude: lat.optional(),
|
||||||
|
longitude: lng.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const voiceAttachPayload = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
clientId: clientId.optional(),
|
||||||
|
documentId: id,
|
||||||
|
durationSeconds: z.number().int().min(0).max(300).optional(),
|
||||||
|
recordedAt: isoDate.optional(),
|
||||||
|
/** optional note kind the transcript is filed under */
|
||||||
|
kind: z.enum(NOTE_KINDS).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Schemas of ops owned by other lanes are validated there (reports: L5, emergency: L8). */
|
||||||
|
const passthrough = z.record(z.string(), z.unknown());
|
||||||
|
|
||||||
|
export const OP_PAYLOAD_SCHEMAS = {
|
||||||
|
"session.start": sessionStartPayload,
|
||||||
|
"session.pause": sessionControlPayload,
|
||||||
|
"session.resume": sessionControlPayload,
|
||||||
|
"session.end": sessionControlPayload,
|
||||||
|
"work_order.transition": workOrderTransitionPayload,
|
||||||
|
"note.create": noteCreatePayload,
|
||||||
|
"checklist.toggle": checklistTogglePayload,
|
||||||
|
"material.upsert": materialUpsertPayload,
|
||||||
|
"photo.attach": photoAttachPayload,
|
||||||
|
"voice.attach": voiceAttachPayload,
|
||||||
|
"report.save_draft": passthrough,
|
||||||
|
"report.submit": passthrough,
|
||||||
|
"signature.capture": passthrough,
|
||||||
|
"emergency.create": passthrough,
|
||||||
|
} satisfies Record<SyncOpType, z.ZodType>;
|
||||||
|
|
||||||
|
export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
|
||||||
|
export type ParsedOpPayload<T extends SyncOpType> = z.output<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ctxFromGuard, ServiceError } from "@/server/services/context";
|
||||||
|
import { correctTimeEntry } from "@/server/services/field/time-correction";
|
||||||
|
|
||||||
|
const guard = moduleGuard("field");
|
||||||
|
|
||||||
|
export type TimeCorrectionResult = { ok: true } | { ok: false; error: "invalid" | "forbidden" | "not_found" | "failed" };
|
||||||
|
|
||||||
|
/** Manual time correction (Spec §12.2) — thin adapter over services/field/time-correction. */
|
||||||
|
export async function correctTime(input: {
|
||||||
|
workOrderId: string;
|
||||||
|
timeEntryId: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
reason: string;
|
||||||
|
}): Promise<TimeCorrectionResult> {
|
||||||
|
const g = await guard("field:correct_time");
|
||||||
|
try {
|
||||||
|
await correctTimeEntry(ctxFromGuard(g), {
|
||||||
|
timeEntryId: input.timeEntryId,
|
||||||
|
startedAt: input.startedAt,
|
||||||
|
endedAt: input.endedAt,
|
||||||
|
reason: input.reason,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceError && (err.code === "invalid" || err.code === "forbidden" || err.code === "not_found")) return { ok: false, error: err.code };
|
||||||
|
console.error("[field] time correction failed:", err);
|
||||||
|
return { ok: false, error: "failed" };
|
||||||
|
}
|
||||||
|
revalidatePath(`/m/orders/${input.workOrderId}/time`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import { auth, signOut } from "@/server/auth";
|
||||||
|
import { dbForTenant, prisma } from "@/server/db";
|
||||||
|
import { isTokenStillValid } from "@/server/sessions";
|
||||||
|
import { resolveMfaRequired } from "@/lib/mfa-policy";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Zugriffsprüfung der angemeldeten Mandanten-Shells (Backoffice `(app)/layout.tsx`
|
||||||
|
* und Mobile `(field)/m/layout.tsx`). Aus dem Backoffice-Layout extrahiert (ARCHITEKTUR §5),
|
||||||
|
* damit beide Shells identisch prüfen:
|
||||||
|
* Session → aktiver Mandant → Kill-Switch → Konto-/Identity-Status → Passwortzwang → MFA-Gate.
|
||||||
|
* Redirects werden hier ausgelöst; ein deaktiviertes Konto wird als `inactive` gemeldet,
|
||||||
|
* die aufrufende Shell rendert dafür `AccountInactiveNotice`.
|
||||||
|
*/
|
||||||
|
export type AppAccess =
|
||||||
|
| { kind: "inactive" }
|
||||||
|
| { kind: "ok"; session: Session; identity: { uiLocale: string } };
|
||||||
|
|
||||||
|
export async function requireAppAccess(): Promise<AppAccess> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
// Angemeldet, aber (noch) kein aktiver Mandant (mehrere Mitgliedschaften ohne Slug) →
|
||||||
|
// Auswahlseite. MUSS vor jeder dbForTenant-Nutzung stehen (leerer tenantId).
|
||||||
|
if (!session.user.tenantId) redirect("/select-tenant");
|
||||||
|
|
||||||
|
// Kontostatus (autoritativ aus DB, nicht aus dem JWT): deaktivierte Nutzer werden
|
||||||
|
// abgemeldet, ein offener Passwortwechsel wird erzwungen. Membership-Status kommt vom
|
||||||
|
// User, die globalen Auth-Zustände (Passwortzwang, Kill-Switch, MFA) von der Identity.
|
||||||
|
const account = await dbForTenant(session.user.tenantId).user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
const identity = session.user.identityId
|
||||||
|
? await prisma.identity.findUnique({
|
||||||
|
where: { id: session.user.identityId },
|
||||||
|
select: { status: true, mustChangePassword: true, sessionsValidAfter: true, mfaEnrolledAt: true, uiLocale: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
// SEC2: serverseitig entwertete Sessions (Passwort-Reset/-Wechsel) sofort abmelden.
|
||||||
|
if (identity && !isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
|
||||||
|
await signOut({ redirectTo: "/login" });
|
||||||
|
}
|
||||||
|
if (!account || account.status !== "ACTIVE" || !identity || identity.status !== "ACTIVE") {
|
||||||
|
return { kind: "inactive" };
|
||||||
|
}
|
||||||
|
if (identity.mustChangePassword) redirect("/change-password");
|
||||||
|
|
||||||
|
// SEC3-a/-b: MFA-Enrollment-Gate. Verlangt der Mandant MFA und hat die Identity weder TOTP
|
||||||
|
// noch einen Passkey, wird die Einrichtung erzwungen (Pflichtseite außerhalb der Shells).
|
||||||
|
if (!identity.mfaEnrolledAt) {
|
||||||
|
const settings = await dbForTenant(session.user.tenantId).tenantSettings.findFirst({ select: { securityPolicy: true } });
|
||||||
|
if (resolveMfaRequired(settings?.securityPolicy)) {
|
||||||
|
const passkeys = session.user.identityId
|
||||||
|
? await prisma.webAuthnCredential.count({ where: { identityId: session.user.identityId } })
|
||||||
|
: 0;
|
||||||
|
if (passkeys === 0) redirect("/enroll-mfa");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "ok", session, identity: { uiLocale: identity.uiLocale } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startseite nach dem Login (ARCHITEKTUR §5): Feldrollen (Einsatz ausführen, keine
|
||||||
|
* Gesamtsicht auf Aufträge) landen in der Mobile-App `/m`, alle anderen im Backoffice.
|
||||||
|
* Reiner UX-Komfort — die Zielseiten prüfen Rechte selbst.
|
||||||
|
*/
|
||||||
|
export function landingPath(session: Session | null): string {
|
||||||
|
const perms = session?.user?.permissions ?? [];
|
||||||
|
return perms.includes("field:execute") && !perms.includes("work_order:read_all") ? "/m" : "/dashboard";
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { dbForTenant } from "@/server/db";
|
||||||
|
import { storage } from "@/server/storage/adapter";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import type { JobPayload } from "../queues";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue "image-derivatives" (lane L4): creates the 400 px JPEG thumbnail (Document.previewKey)
|
||||||
|
* for photos whose client did not send one. EXIF orientation is applied (sharp.rotate()).
|
||||||
|
* Idempotent: documents that already have a preview are skipped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const THUMBNAIL_SIZE = 400;
|
||||||
|
|
||||||
|
async function streamToBuffer(stream: ReadableStream<Uint8Array>): Promise<Buffer> {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
const reader = stream.getReader();
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (value) chunks.push(value);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createThumbnail(bytes: Buffer): Promise<Buffer> {
|
||||||
|
const sharp = (await import("sharp")).default;
|
||||||
|
return sharp(bytes).rotate().resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, { fit: "inside", withoutEnlargement: true }).jpeg({ quality: 75 }).toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function process(payload: JobPayload): Promise<void> {
|
||||||
|
const db = dbForTenant(payload.tenantId);
|
||||||
|
const doc = await db.document.findFirst({ where: { id: payload.entityId, category: "photo", deletedAt: null } });
|
||||||
|
if (!doc || doc.previewKey) return;
|
||||||
|
const content = await storage.get(doc.storageKey);
|
||||||
|
if (!content) return; // stub storage or object missing — nothing to derive
|
||||||
|
const thumb = await createThumbnail(await streamToBuffer(content.stream));
|
||||||
|
const stored = await storage.put({ tenantId: payload.tenantId, filename: `thumb-${doc.fileName.replace(/\.[^.]+$/, "")}.jpg`, contentType: "image/jpeg", bytes: thumb });
|
||||||
|
await db.document.update({ where: { id: doc.id }, data: { previewKey: stored.storageKey } });
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: payload.tenantId,
|
||||||
|
actorId: payload.actorId ?? undefined,
|
||||||
|
action: "update",
|
||||||
|
entity: "document",
|
||||||
|
entityId: doc.id,
|
||||||
|
before: { previewKey: null },
|
||||||
|
after: { previewKey: stored.storageKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
|
|||||||
"import-extraction": () => import("./import-extraction").then((m) => m.process),
|
"import-extraction": () => import("./import-extraction").then((m) => m.process),
|
||||||
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
|
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
|
||||||
"report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle
|
"report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle
|
||||||
// lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
"image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Inline fallback when no Redis is available (dev/demo). */
|
/** Inline fallback when no Redis is available (dev/demo). */
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import type { ParsedOpPayload } from "@/lib/sync/ops";
|
||||||
|
import { audit, requireFieldOrder } from "./common";
|
||||||
|
|
||||||
|
/** Checklist execution (Spec §12.4): toggle an item of a visible work order, optional comment. */
|
||||||
|
export async function toggleChecklistItem(ctx: ServiceCtx, input: ParsedOpPayload<"checklist.toggle">) {
|
||||||
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
|
const item = await ctx.db.checklistItem.findFirst({ where: { id: input.itemId, workOrderId: wo.id } });
|
||||||
|
if (!item) throw new ServiceError("not_found", "checklist item not found");
|
||||||
|
|
||||||
|
const updated = await ctx.db.checklistItem.update({
|
||||||
|
where: { id: item.id },
|
||||||
|
data: {
|
||||||
|
checked: input.checked,
|
||||||
|
checkedById: input.checked ? ctx.userId : null,
|
||||||
|
checkedAt: input.checked ? new Date() : null,
|
||||||
|
...(input.comment !== undefined ? { comment: input.comment?.trim() || null } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await audit(ctx, "update", "checklist_item", item.id, { checked: item.checked, comment: item.comment }, { checked: updated.checked, comment: updated.comment });
|
||||||
|
return { itemId: updated.id, checked: updated.checked };
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||||
|
import { FIELD_EDITABLE, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
|
||||||
|
/** Shared helpers of the field services (lane L4). */
|
||||||
|
|
||||||
|
export type FieldOrder = {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
status: WorkOrderStatus;
|
||||||
|
version: number;
|
||||||
|
siteId: string | null;
|
||||||
|
customerId: string;
|
||||||
|
assignedTeamId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELD_ORDER_SELECT = {
|
||||||
|
id: true,
|
||||||
|
number: true,
|
||||||
|
status: true,
|
||||||
|
version: true,
|
||||||
|
siteId: true,
|
||||||
|
customerId: true,
|
||||||
|
assignedTeamId: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a work order for a field mutation: requires `field:execute`, the order must be in the
|
||||||
|
* user's visibility scope (otherwise not_found — existence is never revealed) and, when
|
||||||
|
* `editable`, in a status that allows field documentation.
|
||||||
|
*/
|
||||||
|
export async function requireFieldOrder(ctx: ServiceCtx, workOrderId: string, opts: { editable?: boolean } = {}): Promise<FieldOrder> {
|
||||||
|
assertCan(ctx, "field:execute");
|
||||||
|
const wo = (await requireVisibleWorkOrder(ctx, workOrderId, FIELD_ORDER_SELECT)) as FieldOrder;
|
||||||
|
if (opts.editable && !FIELD_EDITABLE.includes(wo.status)) {
|
||||||
|
throw new ServiceError("invalid", `work order status ${wo.status} does not allow field documentation`);
|
||||||
|
}
|
||||||
|
return wo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client timestamp of an operation; future values (clock skew) are clamped to now. */
|
||||||
|
export function opTime(at?: string | null): Date {
|
||||||
|
const now = new Date();
|
||||||
|
if (!at) return now;
|
||||||
|
const d = new Date(at);
|
||||||
|
if (Number.isNaN(d.getTime())) throw new ServiceError("invalid", "invalid timestamp");
|
||||||
|
return d.getTime() > now.getTime() + 60_000 ? now : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function audit(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
action: "create" | "update" | "delete",
|
||||||
|
entity: string,
|
||||||
|
entityId: string,
|
||||||
|
before?: unknown,
|
||||||
|
after?: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action, entity, entityId, before, after });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prisma unique-constraint violation (used for idempotent creates under races). */
|
||||||
|
export function isUniqueViolation(err: unknown): boolean {
|
||||||
|
return (err as { code?: string })?.code === "P2002";
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { storage, type StoredContent } from "@/server/storage/adapter";
|
||||||
|
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { allowedDocumentVisibility, customerScope, requireVisibleWorkOrder, siteScope } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorised document access for the mobile app (US-005): document visibility level
|
||||||
|
* (backoffice-internal stays hidden) + work order / site / customer scope. Approved report PDFs
|
||||||
|
* of other orders are readable when their site is visible (site history, Spec §8.3).
|
||||||
|
*/
|
||||||
|
export async function openFieldDocument(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
documentId: string,
|
||||||
|
variant: "original" | "preview" = "original",
|
||||||
|
): Promise<{ content: StoredContent; mimeType: string; fileName: string }> {
|
||||||
|
assertCan(ctx, "document:read");
|
||||||
|
const doc = await ctx.db.document.findFirst({
|
||||||
|
where: { id: documentId, deletedAt: null, uploadStatus: "uploaded", visibility: { in: allowedDocumentVisibility(ctx) } },
|
||||||
|
});
|
||||||
|
if (!doc) throw new ServiceError("not_found", "document not found");
|
||||||
|
|
||||||
|
if (!can(ctx, "work_order:read_all")) {
|
||||||
|
let allowed = false;
|
||||||
|
if (doc.workOrderId) {
|
||||||
|
allowed = await requireVisibleWorkOrder(ctx, doc.workOrderId, { id: true }).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!allowed && !doc.workOrderId && doc.siteId) {
|
||||||
|
allowed = !!(await ctx.db.site.findFirst({ where: { AND: [{ id: doc.siteId }, await siteScope(ctx)] }, select: { id: true } }));
|
||||||
|
}
|
||||||
|
if (!allowed && !doc.workOrderId && !doc.siteId && doc.customerId) {
|
||||||
|
allowed = !!(await ctx.db.customer.findFirst({ where: { AND: [{ id: doc.customerId }, await customerScope(ctx)] }, select: { id: true } }));
|
||||||
|
}
|
||||||
|
if (!allowed) {
|
||||||
|
// approved report PDF of an earlier order at a visible site
|
||||||
|
const report = await ctx.db.report.findFirst({
|
||||||
|
where: { pdfDocumentId: doc.id, status: "approved", workOrder: { site: await siteScope(ctx) } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
allowed = !!report;
|
||||||
|
}
|
||||||
|
if (!allowed) throw new ServiceError("not_found", "document not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = variant === "preview" && doc.previewKey ? doc.previewKey : doc.storageKey;
|
||||||
|
if (!key.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "document not available");
|
||||||
|
const content = await storage.get(key);
|
||||||
|
if (!content) throw new ServiceError("not_found", "document not available");
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
mimeType: variant === "preview" && doc.previewKey ? content.contentType ?? "image/jpeg" : doc.mimeType,
|
||||||
|
fileName: doc.fileName,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import type { ParsedOpPayload } from "@/lib/sync/ops";
|
||||||
|
import { validateMaterialUsage } from "@/lib/field/material-rules";
|
||||||
|
import { audit, requireFieldOrder } from "./common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material documentation (Spec §13.2/§13.3).
|
||||||
|
* Validation rules: src/lib/field/material-rules.ts (shared with the UI).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Input = ParsedOpPayload<"material.upsert">;
|
||||||
|
|
||||||
|
export async function upsertMaterialUsage(ctx: ServiceCtx, input: Input) {
|
||||||
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
|
const plan = input.materialPlanId ? await ctx.db.materialPlan.findFirst({ where: { id: input.materialPlanId, workOrderId: wo.id } }) : null;
|
||||||
|
if (input.materialPlanId && !plan) throw new ServiceError("not_found", "material plan not found");
|
||||||
|
|
||||||
|
const problem = validateMaterialUsage(input, plan ? Number(plan.plannedQuantity) : null);
|
||||||
|
if (problem) throw new ServiceError("invalid", problem);
|
||||||
|
|
||||||
|
const existing =
|
||||||
|
(input.clientId ? await ctx.db.materialUsage.findFirst({ where: { clientId: input.clientId } }) : null) ??
|
||||||
|
(plan ? await ctx.db.materialUsage.findFirst({ where: { workOrderId: wo.id, materialPlanId: plan.id } }) : null);
|
||||||
|
if (existing && existing.workOrderId !== wo.id) throw new ServiceError("invalid", "clientId already used");
|
||||||
|
|
||||||
|
const activeSession = await ctx.db.workSession.findFirst({
|
||||||
|
where: { workOrderId: wo.id, userId: ctx.userId, status: { in: ["running", "paused", "en_route"] } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (input.photoId) {
|
||||||
|
const photo = await ctx.db.photo.findFirst({ where: { id: input.photoId, workOrderId: wo.id }, select: { id: true } });
|
||||||
|
if (!photo) throw new ServiceError("not_found", "photo not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name: plan?.name ?? input.name!.trim(),
|
||||||
|
articleNumber: plan?.articleNumber ?? input.articleNumber ?? null,
|
||||||
|
actualQuantity: new Prisma.Decimal(input.quantity),
|
||||||
|
unit: input.unit,
|
||||||
|
usageStatus: input.usageStatus,
|
||||||
|
deviationReason: input.deviationReason?.trim() || null,
|
||||||
|
notes: input.notes?.trim() || null,
|
||||||
|
photoId: input.photoId ?? null,
|
||||||
|
recordedById: ctx.userId,
|
||||||
|
};
|
||||||
|
const snapshot = (u: { actualQuantity: Prisma.Decimal; usageStatus: string; deviationReason: string | null; unit: string; name: string }) => ({
|
||||||
|
name: u.name,
|
||||||
|
quantity: u.actualQuantity.toString(),
|
||||||
|
unit: u.unit,
|
||||||
|
usageStatus: u.usageStatus,
|
||||||
|
deviationReason: u.deviationReason,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const updated = await ctx.db.materialUsage.update({ where: { id: existing.id }, data });
|
||||||
|
await audit(ctx, "update", "material_usage", updated.id, snapshot(existing), snapshot(updated));
|
||||||
|
return { usageId: updated.id };
|
||||||
|
}
|
||||||
|
const created = await ctx.db.materialUsage.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
workOrderId: wo.id,
|
||||||
|
materialPlanId: plan?.id ?? null,
|
||||||
|
workSessionId: activeSession?.id ?? null,
|
||||||
|
clientId: input.clientId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await audit(ctx, "create", "material_usage", created.id, null, { workOrderId: wo.id, materialPlanId: plan?.id ?? null, ...snapshot(created) });
|
||||||
|
return { usageId: created.id };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import type { ParsedOpPayload } from "@/lib/sync/ops";
|
||||||
|
import { audit, isUniqueViolation, requireFieldOrder } from "./common";
|
||||||
|
|
||||||
|
/** Activity notes (Spec §12.3): structured kind + text. Idempotent over the device clientId. */
|
||||||
|
export async function createNote(ctx: ServiceCtx, input: ParsedOpPayload<"note.create">) {
|
||||||
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
|
|
||||||
|
if (input.clientId) {
|
||||||
|
const replay = await ctx.db.activityNote.findFirst({ where: { clientId: input.clientId } });
|
||||||
|
if (replay) {
|
||||||
|
if (replay.workOrderId !== wo.id) throw new ServiceError("invalid", "clientId already used");
|
||||||
|
return { noteId: replay.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const note = await ctx.db.activityNote.create({
|
||||||
|
data: { tenantId: ctx.tenantId, workOrderId: wo.id, authorId: ctx.userId, kind: input.kind, text: input.text, clientId: input.clientId ?? null },
|
||||||
|
});
|
||||||
|
await audit(ctx, "create", "activity_note", note.id, null, { workOrderId: wo.id, kind: note.kind, text: note.text });
|
||||||
|
return { noteId: note.id };
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) throw new ServiceError("conflict", "clientId already used");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { can, ctxFromGuard, type ServiceCtx } from "@/server/services/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service context for the mobile pages (server components). Uses the same DB-authoritative guard
|
||||||
|
* as mutations (account status, permissions, module "field"); page-level reads then go through
|
||||||
|
* the visibility scopes of the services.
|
||||||
|
*/
|
||||||
|
export async function fieldPageContext(): Promise<ServiceCtx> {
|
||||||
|
return ctxFromGuard(await moduleGuard("field")());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUseFieldApp(ctx: ServiceCtx): boolean {
|
||||||
|
return can(ctx, "work_order:read_team") || can(ctx, "work_order:read_all");
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import type { ParsedOpPayload } from "@/lib/sync/ops";
|
||||||
|
import { audit, isUniqueViolation, opTime, requireFieldOrder } from "./common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Photo documentation (Spec §14.1/§14.3). The binary is uploaded first (POST /api/v1/uploads →
|
||||||
|
* Document category photo); this attaches it to the work order with phase, photo requirement
|
||||||
|
* (Pflichtfoto/Kategorie), checklist item, comment and optional location.
|
||||||
|
*/
|
||||||
|
export async function attachPhoto(ctx: ServiceCtx, input: ParsedOpPayload<"photo.attach">) {
|
||||||
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
|
|
||||||
|
if (input.clientId) {
|
||||||
|
const replay = await ctx.db.photo.findFirst({ where: { clientId: input.clientId } });
|
||||||
|
if (replay) {
|
||||||
|
if (replay.workOrderId !== wo.id || replay.documentId !== input.documentId) throw new ServiceError("invalid", "clientId already used");
|
||||||
|
return { photoId: replay.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await ctx.db.document.findFirst({
|
||||||
|
where: { id: input.documentId, workOrderId: wo.id, category: "photo", deletedAt: null, uploadStatus: "uploaded" },
|
||||||
|
select: { id: true, uploadedById: true },
|
||||||
|
});
|
||||||
|
if (!doc) throw new ServiceError("not_found", "uploaded photo not found");
|
||||||
|
if (doc.uploadedById !== ctx.userId) throw new ServiceError("forbidden", "photo was uploaded by another user");
|
||||||
|
if (await ctx.db.photo.findFirst({ where: { documentId: doc.id }, select: { id: true } })) {
|
||||||
|
throw new ServiceError("invalid", "document is already attached");
|
||||||
|
}
|
||||||
|
if (input.photoRequirementId && !(await ctx.db.photoRequirement.findFirst({ where: { id: input.photoRequirementId, workOrderId: wo.id }, select: { id: true } }))) {
|
||||||
|
throw new ServiceError("not_found", "photo requirement not found");
|
||||||
|
}
|
||||||
|
if (input.checklistItemId && !(await ctx.db.checklistItem.findFirst({ where: { id: input.checklistItemId, workOrderId: wo.id }, select: { id: true } }))) {
|
||||||
|
throw new ServiceError("not_found", "checklist item not found");
|
||||||
|
}
|
||||||
|
const session = await ctx.db.workSession.findFirst({
|
||||||
|
where: { workOrderId: wo.id, userId: ctx.userId, status: { in: ["running", "paused", "en_route"] } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const photo = await ctx.db.photo.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
workOrderId: wo.id,
|
||||||
|
workSessionId: session?.id ?? null,
|
||||||
|
documentId: doc.id,
|
||||||
|
checklistItemId: input.checklistItemId ?? null,
|
||||||
|
photoRequirementId: input.photoRequirementId ?? null,
|
||||||
|
phase: input.phase ?? null,
|
||||||
|
comment: input.comment?.trim() || null,
|
||||||
|
takenAt: opTime(input.takenAt),
|
||||||
|
latitude: input.latitude ?? null,
|
||||||
|
longitude: input.longitude ?? null,
|
||||||
|
takenById: ctx.userId,
|
||||||
|
clientId: input.clientId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await audit(ctx, "create", "photo", photo.id, null, {
|
||||||
|
workOrderId: wo.id,
|
||||||
|
documentId: doc.id,
|
||||||
|
phase: photo.phase,
|
||||||
|
photoRequirementId: photo.photoRequirementId,
|
||||||
|
checklistItemId: photo.checklistItemId,
|
||||||
|
});
|
||||||
|
return { photoId: photo.id };
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) throw new ServiceError("conflict", "photo already attached");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { allowedDocumentVisibility, requireVisibleWorkOrder, workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
|
import { STATUS_GROUP, type CompletionBlocker, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { ACTIVE_SESSION_STATUSES } from "./sessions";
|
||||||
|
// TODO(merge L2/L1): replace with the lane implementations
|
||||||
|
import { completionBlockers } from "./stubs/work-order-transition";
|
||||||
|
import { getSiteHistory, type SiteHistoryEntry } from "./stubs/site-history";
|
||||||
|
|
||||||
|
/** Read models of the mobile app (Spec §11.2, §22, US-005). All reads go through workOrderScope. */
|
||||||
|
|
||||||
|
export const ORDER_TABS = ["upcoming", "running", "to_complete", "past"] as const;
|
||||||
|
export type OrderTab = (typeof ORDER_TABS)[number];
|
||||||
|
|
||||||
|
const TAB_STATUSES: Record<OrderTab, WorkOrderStatus[]> = {
|
||||||
|
upcoming: ["planned", "assigned", "accepted"],
|
||||||
|
running: ["en_route", "in_progress", "paused", "waiting_material", "daily_report_created"],
|
||||||
|
to_complete: ["technically_completed", "signature_pending"],
|
||||||
|
past: ["in_review", "released_for_billing", "billed", "cancelled"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Categories shown in the photo/voice sections instead of the document list. */
|
||||||
|
const MEDIA_CATEGORIES = ["photo", "voice_note", "signature"] as const;
|
||||||
|
|
||||||
|
export type OrderCard = {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
status: WorkOrderStatus;
|
||||||
|
statusGroup: StatusGroup;
|
||||||
|
priority: string;
|
||||||
|
isEmergency: boolean;
|
||||||
|
customerName: string;
|
||||||
|
siteName: string | null;
|
||||||
|
address: string | null;
|
||||||
|
mapsUrl: string | null;
|
||||||
|
plannedStart: Date | null;
|
||||||
|
plannedEnd: Date | null;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CARD_SELECT = {
|
||||||
|
id: true,
|
||||||
|
number: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
priority: true,
|
||||||
|
isEmergency: true,
|
||||||
|
plannedStart: true,
|
||||||
|
plannedEnd: true,
|
||||||
|
version: true,
|
||||||
|
customer: { select: { companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, city: true } },
|
||||||
|
site: { select: { name: true, street: true, houseNumber: true, postalCode: true, city: true } },
|
||||||
|
} satisfies Prisma.WorkOrderSelect;
|
||||||
|
|
||||||
|
type Addressable = { street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null };
|
||||||
|
|
||||||
|
export function formatAddress(a: Addressable | null | undefined): string | null {
|
||||||
|
if (!a) return null;
|
||||||
|
const line1 = [a.street, a.houseNumber].filter(Boolean).join(" ");
|
||||||
|
const line2 = [a.postalCode, a.city].filter(Boolean).join(" ");
|
||||||
|
const s = [line1, line2].filter(Boolean).join(", ");
|
||||||
|
return s || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapsUrl(address: string | null): string | null {
|
||||||
|
return address ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customerDisplayName(c: { companyName: string | null; firstName: string | null; lastName: string | null }): string {
|
||||||
|
return c.companyName?.trim() || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCard(wo: Prisma.WorkOrderGetPayload<{ select: typeof CARD_SELECT }>): OrderCard {
|
||||||
|
const address = formatAddress(wo.site) ?? formatAddress(wo.customer);
|
||||||
|
return {
|
||||||
|
id: wo.id,
|
||||||
|
number: wo.number,
|
||||||
|
title: wo.title,
|
||||||
|
status: wo.status,
|
||||||
|
statusGroup: STATUS_GROUP[wo.status],
|
||||||
|
priority: wo.priority,
|
||||||
|
isEmergency: wo.isEmergency,
|
||||||
|
customerName: customerDisplayName(wo.customer),
|
||||||
|
siteName: wo.site?.name ?? null,
|
||||||
|
address,
|
||||||
|
mapsUrl: mapsUrl(address),
|
||||||
|
plannedStart: wo.plannedStart,
|
||||||
|
plannedEnd: wo.plannedEnd,
|
||||||
|
version: wo.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFieldOrders(ctx: ServiceCtx, tab: OrderTab): Promise<OrderCard[]> {
|
||||||
|
const scope = await workOrderScope(ctx);
|
||||||
|
const rows = await ctx.db.workOrder.findMany({
|
||||||
|
where: { AND: [scope, { status: { in: TAB_STATUSES[tab] } }] },
|
||||||
|
orderBy: tab === "past" ? [{ updatedAt: "desc" }] : [{ plannedStart: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }],
|
||||||
|
take: tab === "past" ? 50 : 200,
|
||||||
|
select: CARD_SELECT,
|
||||||
|
});
|
||||||
|
return rows.map(toCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Heute": orders planned for today (not yet done) plus all running and paused ones. */
|
||||||
|
export async function listTodayOrders(ctx: ServiceCtx, now = new Date()): Promise<OrderCard[]> {
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
const scope = await workOrderScope(ctx);
|
||||||
|
const rows = await ctx.db.workOrder.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
scope,
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ status: { in: TAB_STATUSES.running } },
|
||||||
|
{
|
||||||
|
status: { in: [...TAB_STATUSES.upcoming, ...TAB_STATUSES.to_complete] },
|
||||||
|
plannedStart: { lt: end },
|
||||||
|
OR: [{ plannedEnd: { gte: start } }, { plannedEnd: null, plannedStart: { gte: start } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [{ plannedStart: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }],
|
||||||
|
take: 100,
|
||||||
|
select: CARD_SELECT,
|
||||||
|
});
|
||||||
|
return rows.map(toCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DETAIL_SELECT = {
|
||||||
|
...CARD_SELECT,
|
||||||
|
externalOrderNumber: true,
|
||||||
|
description: true,
|
||||||
|
scope: true,
|
||||||
|
technicianNotes: true,
|
||||||
|
signatureRequired: true,
|
||||||
|
siteId: true,
|
||||||
|
orderType: { select: { name: true } },
|
||||||
|
customer: {
|
||||||
|
select: { id: true, companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, city: true, phone: true, mobile: true, email: true },
|
||||||
|
},
|
||||||
|
contact: { select: { name: true, role: true, phone: true, mobile: true, email: true } },
|
||||||
|
site: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
street: true,
|
||||||
|
houseNumber: true,
|
||||||
|
postalCode: true,
|
||||||
|
city: true,
|
||||||
|
phone: true,
|
||||||
|
onSiteContact: true,
|
||||||
|
accessNotes: true,
|
||||||
|
parkingNotes: true,
|
||||||
|
safetyNotes: true,
|
||||||
|
technicalNotes: true,
|
||||||
|
contact: { select: { name: true, role: true, phone: true, mobile: true, email: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team: { select: { name: true } },
|
||||||
|
checklistItems: { orderBy: { sortOrder: "asc" }, select: { id: true, label: true, required: true, requiresPhoto: true, checked: true, checkedAt: true, comment: true } },
|
||||||
|
photoRequirements: { orderBy: { sortOrder: "asc" }, select: { id: true, key: true, label: true, _count: { select: { photos: true } } } },
|
||||||
|
materialPlans: { orderBy: { sortOrder: "asc" }, select: { id: true, name: true, articleNumber: true, plannedQuantity: true, unit: true, notes: true } },
|
||||||
|
materialUsages: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { id: true, materialPlanId: true, name: true, articleNumber: true, actualQuantity: true, unit: true, usageStatus: true, deviationReason: true, notes: true, clientId: true },
|
||||||
|
},
|
||||||
|
notes: { where: { deletedAt: null }, orderBy: { createdAt: "desc" }, take: 100, select: { id: true, kind: true, text: true, createdAt: true, authorId: true } },
|
||||||
|
photos: {
|
||||||
|
orderBy: { takenAt: "desc" },
|
||||||
|
select: { id: true, documentId: true, phase: true, comment: true, takenAt: true, photoRequirementId: true, checklistItemId: true, takenById: true },
|
||||||
|
},
|
||||||
|
voiceNotes: { orderBy: { recordedAt: "desc" }, select: { id: true, documentId: true, durationSeconds: true, transcript: true, transcriptionStatus: true, recordedAt: true } },
|
||||||
|
workSessions: {
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
userId: true,
|
||||||
|
status: true,
|
||||||
|
startedAt: true,
|
||||||
|
endedAt: true,
|
||||||
|
startedOffline: true,
|
||||||
|
user: { select: { name: true } },
|
||||||
|
entries: { orderBy: { startedAt: "asc" }, select: { id: true, type: true, startedAt: true, endedAt: true, corrected: true, correctionReason: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.WorkOrderSelect;
|
||||||
|
|
||||||
|
export type FieldOrderDetail = Prisma.WorkOrderGetPayload<{ select: typeof DETAIL_SELECT }> & {
|
||||||
|
card: OrderCard;
|
||||||
|
documents: Array<{ id: string; title: string | null; fileName: string; category: string; mimeType: string; fileSize: number; createdAt: Date; source: "order" | "site" }>;
|
||||||
|
siteHistory: SiteHistoryEntry[];
|
||||||
|
blockers: CompletionBlocker[];
|
||||||
|
mySession: { id: string; status: "en_route" | "running" | "paused" | "ended" } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getFieldOrderDetail(ctx: ServiceCtx, workOrderId: string): Promise<FieldOrderDetail> {
|
||||||
|
const wo = (await requireVisibleWorkOrder(ctx, workOrderId, DETAIL_SELECT)) as unknown as Prisma.WorkOrderGetPayload<{ select: typeof DETAIL_SELECT }>;
|
||||||
|
const visibility = allowedDocumentVisibility(ctx);
|
||||||
|
const docWhere = { deletedAt: null, uploadStatus: "uploaded" as const, visibility: { in: visibility }, category: { notIn: [...MEDIA_CATEGORIES] } };
|
||||||
|
const docSelect = { id: true, title: true, fileName: true, category: true, mimeType: true, fileSize: true, createdAt: true, lineageId: true, version: true } as const;
|
||||||
|
|
||||||
|
const [orderDocs, siteDocs, siteHistory, blockers] = await Promise.all([
|
||||||
|
ctx.db.document.findMany({ where: { ...docWhere, workOrderId: wo.id }, orderBy: { createdAt: "desc" }, select: docSelect }),
|
||||||
|
wo.siteId ? ctx.db.document.findMany({ where: { ...docWhere, siteId: wo.siteId, workOrderId: null }, orderBy: { createdAt: "desc" }, select: docSelect }) : Promise.resolve([]),
|
||||||
|
wo.siteId ? getSiteHistory(ctx, wo.siteId, { onlyApproved: true }).catch((err) => (err instanceof ServiceError ? [] : Promise.reject(err))) : Promise.resolve([]),
|
||||||
|
completionBlockers(ctx, wo.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// only the newest version per lineage
|
||||||
|
const latest = <T extends { lineageId: string; version: number }>(docs: T[]) =>
|
||||||
|
docs.filter((d) => !docs.some((o) => o.lineageId === d.lineageId && o.version > d.version));
|
||||||
|
|
||||||
|
const mine = wo.workSessions.find((s) => s.userId === ctx.userId && ACTIVE_SESSION_STATUSES.includes(s.status));
|
||||||
|
return {
|
||||||
|
...wo,
|
||||||
|
card: toCard(wo),
|
||||||
|
documents: [
|
||||||
|
...latest(orderDocs).map((d) => ({ ...d, source: "order" as const })),
|
||||||
|
...latest(siteDocs).map((d) => ({ ...d, source: "site" as const })),
|
||||||
|
],
|
||||||
|
siteHistory,
|
||||||
|
blockers,
|
||||||
|
mySession: mine ? { id: mine.id, status: mine.status } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline pull bundle (ARCHITEKTUR §4.6): open orders in scope (optionally only those changed
|
||||||
|
* since `since`) with customer, site, contacts, checklist, material plan, photo requirements,
|
||||||
|
* document metadata and the approved reports at the site. Blobs are fetched separately.
|
||||||
|
*/
|
||||||
|
export async function getFieldBundle(ctx: ServiceCtx, since?: Date | null) {
|
||||||
|
const scope = await workOrderScope(ctx);
|
||||||
|
const statuses: WorkOrderStatus[] = [...TAB_STATUSES.upcoming, ...TAB_STATUSES.running, ...TAB_STATUSES.to_complete];
|
||||||
|
const serverTime = new Date();
|
||||||
|
const orders = await ctx.db.workOrder.findMany({
|
||||||
|
where: { AND: [scope, { status: { in: statuses } }, since ? { updatedAt: { gt: since } } : {}] },
|
||||||
|
orderBy: [{ plannedStart: { sort: "asc", nulls: "last" } }],
|
||||||
|
take: 200,
|
||||||
|
select: {
|
||||||
|
...CARD_SELECT,
|
||||||
|
externalOrderNumber: true,
|
||||||
|
description: true,
|
||||||
|
scope: true,
|
||||||
|
technicianNotes: true,
|
||||||
|
signatureRequired: true,
|
||||||
|
updatedAt: true,
|
||||||
|
orderType: DETAIL_SELECT.orderType,
|
||||||
|
customer: DETAIL_SELECT.customer,
|
||||||
|
contact: DETAIL_SELECT.contact,
|
||||||
|
site: DETAIL_SELECT.site,
|
||||||
|
checklistItems: DETAIL_SELECT.checklistItems,
|
||||||
|
photoRequirements: DETAIL_SELECT.photoRequirements,
|
||||||
|
materialPlans: DETAIL_SELECT.materialPlans,
|
||||||
|
materialUsages: DETAIL_SELECT.materialUsages,
|
||||||
|
documents: {
|
||||||
|
where: { deletedAt: null, uploadStatus: "uploaded", visibility: { in: allowedDocumentVisibility(ctx) }, category: { notIn: [...MEDIA_CATEGORIES] } },
|
||||||
|
select: { id: true, title: true, fileName: true, category: true, mimeType: true, fileSize: true, checksum: true, version: true, lineageId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const siteIds = [...new Set(orders.map((o) => o.site?.id).filter((id): id is string => !!id))];
|
||||||
|
const histories = Object.fromEntries(
|
||||||
|
await Promise.all(siteIds.map(async (id) => [id, await getSiteHistory(ctx, id, { onlyApproved: true, limit: 5 }).catch(() => [])] as const)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
serverTime: serverTime.toISOString(),
|
||||||
|
since: since?.toISOString() ?? null,
|
||||||
|
orders: orders.map((o) => ({ ...o, statusGroup: STATUS_GROUP[o.status], siteHistory: o.site ? histories[o.site.id] ?? [] : [] })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import type { TimeEntryType, WorkSessionStatus } from "@prisma/client";
|
||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { canTransition, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import type { ParsedOpPayload } from "@/lib/sync/ops";
|
||||||
|
import { audit, opTime, requireFieldOrder, type FieldOrder } from "./common";
|
||||||
|
// TODO(merge L2): replace with "@/server/services/work-orders/transition"
|
||||||
|
import { transitionWorkOrder } from "./stubs/work-order-transition";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* transitionWorkOrder (never a direct status update).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ACTIVE_SESSION_STATUSES: WorkSessionStatus[] = ["en_route", "running", "paused"];
|
||||||
|
|
||||||
|
export type SessionResult = {
|
||||||
|
sessionId: string;
|
||||||
|
status: WorkSessionStatus;
|
||||||
|
workOrderStatus: WorkOrderStatus;
|
||||||
|
workOrderVersion: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionEndResult = SessionResult & {
|
||||||
|
workSeconds: number;
|
||||||
|
breakSeconds: number;
|
||||||
|
travelSeconds: number;
|
||||||
|
totalSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function activeSession(ctx: ServiceCtx, workOrderId: string) {
|
||||||
|
return ctx.db.workSession.findFirst({
|
||||||
|
where: { workOrderId, userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES } },
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Other users' sessions that are still working on the order. */
|
||||||
|
function othersRunning(ctx: ServiceCtx, workOrderId: string) {
|
||||||
|
return ctx.db.workSession.count({ where: { workOrderId, userId: { not: ctx.userId }, status: { in: ["running", "en_route"] } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeOpenEntries(ctx: ServiceCtx, sessionId: string, at: Date) {
|
||||||
|
const open = await ctx.db.timeEntry.findMany({ where: { workSessionId: sessionId, endedAt: null } });
|
||||||
|
for (const e of open) {
|
||||||
|
await ctx.db.timeEntry.update({ where: { id: e.id }, data: { endedAt: at < e.startedAt ? e.startedAt : at } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEntry(ctx: ServiceCtx, sessionId: string, type: TimeEntryType, at: Date) {
|
||||||
|
return ctx.db.timeEntry.create({ data: { tenantId: ctx.tenantId, workSessionId: sessionId, userId: ctx.userId, type, startedAt: at } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transition the order when the status machine allows it; returns the (new) status + version. */
|
||||||
|
async function moveOrder(ctx: ServiceCtx, wo: FieldOrder, to: WorkOrderStatus): Promise<{ status: WorkOrderStatus; version: number }> {
|
||||||
|
if (wo.status === to || !canTransition(wo.status, to)) return { status: wo.status, version: wo.version };
|
||||||
|
const r = await transitionWorkOrder(ctx, { workOrderId: wo.id, to });
|
||||||
|
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 });
|
||||||
|
const at = opTime(input.at);
|
||||||
|
|
||||||
|
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") {
|
||||||
|
if (existing) throw new ServiceError("conflict", "a session is already active for this work order");
|
||||||
|
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"
|
||||||
|
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> {
|
||||||
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
|
const at = opTime(input.at);
|
||||||
|
const existing = await activeSession(ctx, wo.id);
|
||||||
|
if (!existing || existing.status !== "running") throw new ServiceError("invalid", "no running session to pause");
|
||||||
|
await closeOpenEntries(ctx, existing.id, at);
|
||||||
|
await openEntry(ctx, existing.id, "break", at);
|
||||||
|
const session = await ctx.db.workSession.update({ where: { id: existing.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: "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> {
|
||||||
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
|
const at = opTime(input.at);
|
||||||
|
const existing = await activeSession(ctx, wo.id);
|
||||||
|
if (!existing || existing.status !== "paused") throw new ServiceError("invalid", "no paused session to resume");
|
||||||
|
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" } });
|
||||||
|
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> {
|
||||||
|
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);
|
||||||
|
const endedAt = at < existing.startedAt ? existing.startedAt : at;
|
||||||
|
const session = await ctx.db.workSession.update({ where: { id: existing.id }, data: { status: "ended", endedAt } });
|
||||||
|
const entries = await ctx.db.timeEntry.findMany({ where: { workSessionId: session.id } });
|
||||||
|
const sum = (types: TimeEntryType[]) =>
|
||||||
|
Math.round(entries.filter((e) => types.includes(e.type)).reduce((acc, e) => acc + ((e.endedAt ?? endedAt).getTime() - e.startedAt.getTime()), 0) / 1000);
|
||||||
|
const workSeconds = sum(["work", "material_procurement", "interruption"]);
|
||||||
|
const breakSeconds = sum(["break"]);
|
||||||
|
const travelSeconds = sum(["travel", "return_travel"]);
|
||||||
|
await audit(ctx, "update", "work_session", session.id, { status: existing.status }, { status: "ended", endedAt, workSeconds, breakSeconds, travelSeconds });
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
status: session.status,
|
||||||
|
workOrderStatus: wo.status,
|
||||||
|
workOrderVersion: wo.version,
|
||||||
|
workSeconds,
|
||||||
|
breakSeconds,
|
||||||
|
travelSeconds,
|
||||||
|
totalSeconds: Math.round((endedAt.getTime() - session.startedAt.getTime()) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import type { Document, DocumentCategory, DocumentVisibility } from "@prisma/client";
|
||||||
|
import { storage } from "@/server/storage/adapter";
|
||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STUB (lane L4) — stands in for `src/server/services/documents/store.ts#storeFile` (ARCHITEKTUR §4.3),
|
||||||
|
* which does not exist on the base commit. Same signature; validates allowlist, size limit per
|
||||||
|
* kind, magic bytes, normalises the file name, computes SHA-256 and stores via the storage adapter.
|
||||||
|
* On merge of the documents contract: delete this file and import the shared implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StoreFileInput = {
|
||||||
|
bytes: Buffer;
|
||||||
|
fileName: string;
|
||||||
|
declaredMime: string;
|
||||||
|
category: DocumentCategory;
|
||||||
|
visibility: DocumentVisibility;
|
||||||
|
links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null };
|
||||||
|
lineageId?: string;
|
||||||
|
title?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
type Kind = "image" | "pdf" | "audio";
|
||||||
|
const LIMITS: Record<Kind, number> = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB };
|
||||||
|
|
||||||
|
/** Detects the real MIME type from magic bytes (null = not allowed). */
|
||||||
|
export function sniffMime(bytes: Buffer): { mime: string; kind: Kind } | null {
|
||||||
|
const b = bytes;
|
||||||
|
const at = (offset: number, sig: number[]) => sig.every((v, i) => b[offset + i] === v);
|
||||||
|
const ascii = (offset: number, s: string) => b.length >= offset + s.length && b.toString("latin1", offset, offset + s.length) === s;
|
||||||
|
if (b.length < 12) return null;
|
||||||
|
if (at(0, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", kind: "image" };
|
||||||
|
if (at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { mime: "image/png", kind: "image" };
|
||||||
|
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return { mime: "image/webp", kind: "image" };
|
||||||
|
if (ascii(0, "%PDF-")) return { mime: "application/pdf", kind: "pdf" };
|
||||||
|
if (at(0, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "audio/webm", kind: "audio" };
|
||||||
|
if (ascii(0, "OggS")) return { mime: "audio/ogg", kind: "audio" };
|
||||||
|
if (ascii(0, "RIFF") && ascii(8, "WAVE")) return { mime: "audio/wav", kind: "audio" };
|
||||||
|
if (ascii(4, "ftyp")) {
|
||||||
|
const brand = b.toString("latin1", 8, 12);
|
||||||
|
if (/^(heic|heix|mif1|msf1)$/.test(brand)) return { mime: "image/heic", kind: "image" };
|
||||||
|
return { mime: "audio/mp4", kind: "audio" }; // M4A / MP4 audio from iOS MediaRecorder
|
||||||
|
}
|
||||||
|
if (ascii(0, "ID3") || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)) return { mime: "audio/mpeg", kind: "audio" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFileName(name: string): string {
|
||||||
|
const base = name.split(/[\\/]/).pop() ?? "datei";
|
||||||
|
return base.normalize("NFC").replace(/[\u0000-\u001f<>:"|?*]+/g, "_").trim().slice(0, 180) || "datei";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise<Document> {
|
||||||
|
const sniffed = sniffMime(input.bytes);
|
||||||
|
if (!sniffed) throw new ServiceError("invalid", "file type not allowed");
|
||||||
|
if (input.bytes.byteLength > LIMITS[sniffed.kind]) throw new ServiceError("invalid", "file too large");
|
||||||
|
const declaredBase = input.declaredMime.split(";")[0].trim().toLowerCase();
|
||||||
|
// declared type must at least belong to the same family (image/*, audio/*, video/webm|mp4 for audio containers, pdf)
|
||||||
|
const family = declaredBase.split("/")[0];
|
||||||
|
const familyOk =
|
||||||
|
sniffed.kind === "pdf" ? declaredBase === "application/pdf" : sniffed.kind === "image" ? family === "image" : family === "audio" || declaredBase === "video/webm" || declaredBase === "video/mp4";
|
||||||
|
if (!familyOk) throw new ServiceError("invalid", "declared MIME type does not match content");
|
||||||
|
|
||||||
|
const fileName = normalizeFileName(input.fileName);
|
||||||
|
const checksum = createHash("sha256").update(input.bytes).digest("hex");
|
||||||
|
const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: sniffed.mime, bytes: input.bytes });
|
||||||
|
|
||||||
|
let version = 1;
|
||||||
|
const lineageId = input.lineageId ?? randomUUID();
|
||||||
|
if (input.lineageId) {
|
||||||
|
const last = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } });
|
||||||
|
if (last) version = last.version + 1;
|
||||||
|
}
|
||||||
|
return ctx.db.document.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
customerId: input.links.customerId ?? null,
|
||||||
|
siteId: input.links.siteId ?? null,
|
||||||
|
workOrderId: input.links.workOrderId ?? null,
|
||||||
|
category: input.category,
|
||||||
|
title: input.title ?? null,
|
||||||
|
fileName,
|
||||||
|
storageKey: stored.storageKey,
|
||||||
|
mimeType: sniffed.mime,
|
||||||
|
fileSize: input.bytes.byteLength,
|
||||||
|
checksum,
|
||||||
|
version,
|
||||||
|
lineageId,
|
||||||
|
visibility: input.visibility,
|
||||||
|
uploadStatus: "uploaded",
|
||||||
|
uploadedById: ctx.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { siteScope } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STUB (lane L4) — stands in for L1 `src/server/services/sites/history.ts#getSiteHistory`
|
||||||
|
* (Spec §8.3) until lane "Stammdaten" is merged. Contract used by the mobile order detail:
|
||||||
|
* getSiteHistory(ctx, siteId, { onlyApproved }) → SiteHistoryEntry[] (newest first)
|
||||||
|
* Read-only; the site must be in the user's site scope (otherwise not_found).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SiteHistoryEntry = {
|
||||||
|
reportId: string;
|
||||||
|
reportType: "daily" | "completion";
|
||||||
|
reportDate: Date;
|
||||||
|
approvedAt: Date | null;
|
||||||
|
pdfDocumentId: string | null;
|
||||||
|
workOrderId: string;
|
||||||
|
workOrderNumber: string;
|
||||||
|
workOrderTitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSiteHistory(ctx: ServiceCtx, siteId: string, opts: { onlyApproved: boolean; limit?: number }): Promise<SiteHistoryEntry[]> {
|
||||||
|
const site = await ctx.db.site.findFirst({ where: { AND: [{ id: siteId }, await siteScope(ctx)] }, select: { id: true } });
|
||||||
|
if (!site) throw new ServiceError("not_found", "site not found");
|
||||||
|
const reports = await ctx.db.report.findMany({
|
||||||
|
where: {
|
||||||
|
workOrder: { siteId, deletedAt: null },
|
||||||
|
...(opts.onlyApproved ? { status: "approved" as const } : { status: { not: "superseded" as const } }),
|
||||||
|
},
|
||||||
|
orderBy: [{ reportDate: "desc" }, { createdAt: "desc" }],
|
||||||
|
take: opts.limit ?? 20,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
reportDate: true,
|
||||||
|
approvedAt: true,
|
||||||
|
pdfDocumentId: true,
|
||||||
|
workOrder: { select: { id: true, number: true, title: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reports.map((r) => ({
|
||||||
|
reportId: r.id,
|
||||||
|
reportType: r.type,
|
||||||
|
reportDate: r.reportDate,
|
||||||
|
approvedAt: r.approvedAt,
|
||||||
|
pdfDocumentId: r.pdfDocumentId,
|
||||||
|
workOrderId: r.workOrder.id,
|
||||||
|
workOrderNumber: r.workOrder.number,
|
||||||
|
workOrderTitle: r.workOrder.title,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { emitEvent } from "@/server/events";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||||
|
import type { EventType } from "@/lib/events";
|
||||||
|
import {
|
||||||
|
canTransition,
|
||||||
|
requiredPermission,
|
||||||
|
type CompletionBlocker,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
} from "@/lib/work-orders/status";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STUB (lane L4) — stands in for L2 `src/server/services/work-orders/transition.ts#transitionWorkOrder`
|
||||||
|
* until lane "Aufträge" is merged. Same contract as ARCHITEKTUR §3:
|
||||||
|
* transitionWorkOrder(ctx, { workOrderId, to, reason?, baseVersion? }) → { id, from, status, version }
|
||||||
|
* throws ServiceError not_found | forbidden | invalid | conflict | blocked (details: CompletionBlocker[]).
|
||||||
|
* On merge: delete this file and point the imports in services/field + services/sync to L2's module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TransitionInput = {
|
||||||
|
workOrderId: string;
|
||||||
|
to: WorkOrderStatus;
|
||||||
|
reason?: string | null;
|
||||||
|
/** optimistic concurrency: must match WorkOrder.version when given */
|
||||||
|
baseVersion?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TransitionResult = { id: string; from: WorkOrderStatus; status: WorkOrderStatus; version: number };
|
||||||
|
|
||||||
|
const EVENT_FOR: Partial<Record<WorkOrderStatus, EventType>> = {
|
||||||
|
in_progress: "work_order.started",
|
||||||
|
daily_report_created: "work_order.daily_report_created",
|
||||||
|
technically_completed: "work_order.technically_completed",
|
||||||
|
released_for_billing: "work_order.released_for_billing",
|
||||||
|
cancelled: "work_order.cancelled",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Completion guards (ARCHITEKTUR §3): required checklist items, photo requirements, running sessions. */
|
||||||
|
export async function completionBlockers(ctx: ServiceCtx, workOrderId: string): Promise<CompletionBlocker[]> {
|
||||||
|
const [items, requirements, sessions] = await Promise.all([
|
||||||
|
ctx.db.checklistItem.findMany({ where: { workOrderId, required: true, checked: false }, select: { id: true, label: true }, orderBy: { sortOrder: "asc" } }),
|
||||||
|
ctx.db.photoRequirement.findMany({ where: { workOrderId, photos: { none: {} } }, select: { id: true, label: true }, orderBy: { sortOrder: "asc" } }),
|
||||||
|
ctx.db.workSession.findMany({ where: { workOrderId, status: { in: ["en_route", "running", "paused"] } }, select: { id: true, userId: true } }),
|
||||||
|
]);
|
||||||
|
return [
|
||||||
|
...items.map((i): CompletionBlocker => ({ kind: "checklist_item", itemId: i.id, label: i.label })),
|
||||||
|
...requirements.map((r): CompletionBlocker => ({ kind: "photo_requirement", requirementId: r.id, label: r.label })),
|
||||||
|
...sessions.map((s): CompletionBlocker => ({ kind: "running_session", sessionId: s.id, userId: s.userId })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transitionWorkOrder(ctx: ServiceCtx, input: TransitionInput): Promise<TransitionResult> {
|
||||||
|
const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, number: true, status: true, version: true });
|
||||||
|
const from = wo.status as WorkOrderStatus;
|
||||||
|
if (!canTransition(from, input.to)) throw new ServiceError("invalid", `transition ${from} → ${input.to} not allowed`);
|
||||||
|
|
||||||
|
const permission = requiredPermission(from, input.to);
|
||||||
|
if (permission === "report:approve_team") {
|
||||||
|
if (!can(ctx, "report:approve_team") && !can(ctx, "report:approve")) throw new ServiceError("forbidden", "missing permission report:approve_team");
|
||||||
|
} else {
|
||||||
|
assertCan(ctx, permission);
|
||||||
|
}
|
||||||
|
if (input.baseVersion !== undefined && input.baseVersion !== wo.version) {
|
||||||
|
throw new ServiceError("conflict", "work order was changed in the meantime", { currentVersion: wo.version });
|
||||||
|
}
|
||||||
|
if (input.to === "technically_completed") {
|
||||||
|
const blockers = await completionBlockers(ctx, wo.id);
|
||||||
|
if (blockers.length) throw new ServiceError("blocked", "completion requirements missing", blockers);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await ctx.db.workOrder.updateMany({
|
||||||
|
where: { id: wo.id, version: wo.version },
|
||||||
|
data: { status: input.to, version: { increment: 1 } },
|
||||||
|
});
|
||||||
|
if (updated.count !== 1) throw new ServiceError("conflict", "work order was changed in the meantime");
|
||||||
|
|
||||||
|
await ctx.db.workOrderStatusChange.create({
|
||||||
|
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: from, toStatus: input.to, actorId: ctx.userId, reason: input.reason ?? null },
|
||||||
|
});
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: "update",
|
||||||
|
entity: "work_order",
|
||||||
|
entityId: wo.id,
|
||||||
|
before: { status: from, version: wo.version },
|
||||||
|
after: { status: input.to, version: wo.version + 1, reason: input.reason ?? null },
|
||||||
|
});
|
||||||
|
await emitEvent(ctx, {
|
||||||
|
type: EVENT_FOR[input.to] ?? "work_order.changed",
|
||||||
|
entityType: "work_order",
|
||||||
|
entityId: wo.id,
|
||||||
|
data: { number: wo.number, from, to: input.to },
|
||||||
|
});
|
||||||
|
return { id: wo.id, from, status: input.to, version: wo.version + 1 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||||
|
import { audit } from "./common";
|
||||||
|
|
||||||
|
/** Manual time corrections (Spec §12.2): only with `field:correct_time`, reason mandatory, always audited. */
|
||||||
|
|
||||||
|
export const TIME_ENTRY_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const;
|
||||||
|
|
||||||
|
export const timeCorrectionSchema = z
|
||||||
|
.object({
|
||||||
|
timeEntryId: z.string().min(1).max(64),
|
||||||
|
startedAt: z.coerce.date(),
|
||||||
|
endedAt: z.coerce.date().nullish(),
|
||||||
|
type: z.enum(TIME_ENTRY_TYPES).optional(),
|
||||||
|
reason: z.string().trim().min(3).max(1000),
|
||||||
|
})
|
||||||
|
.refine((v) => !v.endedAt || v.endedAt.getTime() >= v.startedAt.getTime(), { message: "end before start", path: ["endedAt"] });
|
||||||
|
|
||||||
|
export type TimeCorrectionInput = z.input<typeof timeCorrectionSchema>;
|
||||||
|
|
||||||
|
export async function correctTimeEntry(ctx: ServiceCtx, raw: TimeCorrectionInput) {
|
||||||
|
assertCan(ctx, "field:correct_time");
|
||||||
|
const parsed = timeCorrectionSchema.safeParse(raw);
|
||||||
|
if (!parsed.success) throw new ServiceError("invalid", "invalid time 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");
|
||||||
|
await requireVisibleWorkOrder(ctx, entry.workSession.workOrderId, { id: true });
|
||||||
|
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 updated = await ctx.db.timeEntry.update({
|
||||||
|
where: { id: entry.id },
|
||||||
|
data: {
|
||||||
|
startedAt: input.startedAt,
|
||||||
|
endedAt: input.endedAt === undefined ? entry.endedAt : input.endedAt,
|
||||||
|
type: input.type ?? entry.type,
|
||||||
|
corrected: true,
|
||||||
|
correctionReason: input.reason,
|
||||||
|
correctedById: ctx.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await audit(ctx, "update", "time_entry", entry.id, before, {
|
||||||
|
type: updated.type,
|
||||||
|
startedAt: updated.startedAt,
|
||||||
|
endedAt: updated.endedAt,
|
||||||
|
corrected: true,
|
||||||
|
correctionReason: input.reason,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { storage } from "@/server/storage/adapter";
|
||||||
|
import { dispatchJob } from "@/server/jobs/dispatch";
|
||||||
|
import { JOB_QUEUES } from "@/server/jobs/queues";
|
||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { audit, isUniqueViolation, requireFieldOrder } from "./common";
|
||||||
|
// TODO(merge documents contract §4.3): replace with "@/server/services/documents/store"
|
||||||
|
import { sniffMime, storeFile } from "./stubs/documents-store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binary uploads of the mobile app (ARCHITEKTUR §4.6: POST /api/v1/uploads → documentId).
|
||||||
|
* Idempotent per tenant over the device `clientId` (stored as the document lineage
|
||||||
|
* `upload:<tenantId>:<clientId>`, so the same clientId in another tenant never collides).
|
||||||
|
* The client sends a compressed image plus an optional 400 px thumbnail; without a thumbnail
|
||||||
|
* the image-derivatives job creates one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const uploadMetaSchema = z.object({
|
||||||
|
clientId: z.string().uuid(),
|
||||||
|
workOrderId: z.string().min(1).max(64),
|
||||||
|
kind: z.enum(["photo", "voice_note"]),
|
||||||
|
});
|
||||||
|
export type UploadMeta = z.infer<typeof uploadMetaSchema>;
|
||||||
|
|
||||||
|
export type UploadFile = { bytes: Buffer; name: string; type: string };
|
||||||
|
|
||||||
|
const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
export function uploadLineageId(tenantId: string, clientId: string): string {
|
||||||
|
return `upload:${tenantId}:${clientId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function storeFieldUpload(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
meta: UploadMeta,
|
||||||
|
file: UploadFile,
|
||||||
|
preview?: UploadFile | null,
|
||||||
|
): Promise<{ documentId: string; duplicate: boolean }> {
|
||||||
|
const wo = await requireFieldOrder(ctx, meta.workOrderId, { editable: true });
|
||||||
|
const lineageId = uploadLineageId(ctx.tenantId, meta.clientId);
|
||||||
|
|
||||||
|
const replay = async () => {
|
||||||
|
const existing = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "asc" }, select: { id: true, workOrderId: true, uploadedById: true } });
|
||||||
|
if (!existing) return null;
|
||||||
|
if (existing.workOrderId !== wo.id || existing.uploadedById !== ctx.userId) throw new ServiceError("invalid", "clientId already used");
|
||||||
|
return { documentId: existing.id, duplicate: true };
|
||||||
|
};
|
||||||
|
const prior = await replay();
|
||||||
|
if (prior) return prior;
|
||||||
|
|
||||||
|
const sniffed = sniffMime(file.bytes);
|
||||||
|
const expectedKind = meta.kind === "photo" ? "image" : "audio";
|
||||||
|
if (!sniffed || sniffed.kind !== expectedKind) throw new ServiceError("invalid", `file is not a valid ${expectedKind}`);
|
||||||
|
|
||||||
|
let doc;
|
||||||
|
try {
|
||||||
|
doc = await storeFile(ctx, {
|
||||||
|
bytes: file.bytes,
|
||||||
|
fileName: file.name || (meta.kind === "photo" ? "foto.jpg" : "sprachnotiz.webm"),
|
||||||
|
declaredMime: file.type || sniffed.mime,
|
||||||
|
category: meta.kind,
|
||||||
|
visibility: "team",
|
||||||
|
links: { workOrderId: wo.id, siteId: wo.siteId, customerId: wo.customerId },
|
||||||
|
lineageId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) {
|
||||||
|
const raced = await replay();
|
||||||
|
if (raced) return raced;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.kind === "photo") {
|
||||||
|
const previewMime = preview ? sniffMime(preview.bytes) : null;
|
||||||
|
if (preview && previewMime?.kind === "image" && preview.bytes.byteLength <= MAX_PREVIEW_BYTES) {
|
||||||
|
const stored = await storage.put({ tenantId: ctx.tenantId, filename: `thumb-${doc.fileName}`, contentType: previewMime.mime, bytes: preview.bytes });
|
||||||
|
doc = await ctx.db.document.update({ where: { id: doc.id }, data: { previewKey: stored.storageKey } });
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await dispatchJob(JOB_QUEUES.imageDerivatives, { tenantId: ctx.tenantId, entityId: doc.id, actorId: ctx.userId });
|
||||||
|
} catch (err) {
|
||||||
|
// thumbnails are optional — the original stays usable
|
||||||
|
console.error("[field] image-derivatives dispatch failed:", (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await audit(ctx, "create", "document", doc.id, null, {
|
||||||
|
workOrderId: wo.id,
|
||||||
|
category: doc.category,
|
||||||
|
fileName: doc.fileName,
|
||||||
|
mimeType: doc.mimeType,
|
||||||
|
fileSize: doc.fileSize,
|
||||||
|
checksum: doc.checksum,
|
||||||
|
});
|
||||||
|
return { documentId: doc.id, duplicate: false };
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { dispatchJob } from "@/server/jobs/dispatch";
|
||||||
|
import { JOB_QUEUES } from "@/server/jobs/queues";
|
||||||
|
import { PROCESSORS } from "@/server/jobs/processors";
|
||||||
|
import type { ParsedOpPayload } from "@/lib/sync/ops";
|
||||||
|
import { audit, isUniqueViolation, opTime, requireFieldOrder } from "./common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice notes (Spec §15.1): attach an uploaded audio Document as VoiceNote (status pending) and
|
||||||
|
* queue the transcription job. The processor belongs to lane Lotse (L9); while none is registered
|
||||||
|
* the note is marked `disabled` (graceful degradation, no error for the technician).
|
||||||
|
*/
|
||||||
|
export async function attachVoiceNote(ctx: ServiceCtx, input: ParsedOpPayload<"voice.attach">) {
|
||||||
|
const wo = await requireFieldOrder(ctx, input.workOrderId, { editable: true });
|
||||||
|
|
||||||
|
if (input.clientId) {
|
||||||
|
const replay = await ctx.db.voiceNote.findFirst({ where: { clientId: input.clientId } });
|
||||||
|
if (replay) {
|
||||||
|
if (replay.workOrderId !== wo.id || replay.documentId !== input.documentId) throw new ServiceError("invalid", "clientId already used");
|
||||||
|
return { voiceNoteId: replay.id, transcriptionStatus: replay.transcriptionStatus };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await ctx.db.document.findFirst({
|
||||||
|
where: { id: input.documentId, workOrderId: wo.id, category: "voice_note", deletedAt: null, uploadStatus: "uploaded" },
|
||||||
|
select: { id: true, uploadedById: true },
|
||||||
|
});
|
||||||
|
if (!doc) throw new ServiceError("not_found", "uploaded audio not found");
|
||||||
|
if (doc.uploadedById !== ctx.userId) throw new ServiceError("forbidden", "audio was uploaded by another user");
|
||||||
|
if (await ctx.db.voiceNote.findFirst({ where: { documentId: doc.id }, select: { id: true } })) {
|
||||||
|
throw new ServiceError("invalid", "document is already attached");
|
||||||
|
}
|
||||||
|
const session = await ctx.db.workSession.findFirst({
|
||||||
|
where: { workOrderId: wo.id, userId: ctx.userId, status: { in: ["running", "paused", "en_route"] } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let voice;
|
||||||
|
try {
|
||||||
|
voice = await ctx.db.voiceNote.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
workOrderId: wo.id,
|
||||||
|
workSessionId: session?.id ?? null,
|
||||||
|
documentId: doc.id,
|
||||||
|
durationSeconds: input.durationSeconds ?? null,
|
||||||
|
transcriptionStatus: "pending",
|
||||||
|
recordedById: ctx.userId,
|
||||||
|
recordedAt: opTime(input.recordedAt),
|
||||||
|
clientId: input.clientId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) throw new ServiceError("conflict", "voice note already attached");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await audit(ctx, "create", "voice_note", voice.id, null, { workOrderId: wo.id, documentId: doc.id, durationSeconds: voice.durationSeconds });
|
||||||
|
|
||||||
|
let status = voice.transcriptionStatus;
|
||||||
|
if (!PROCESSORS[JOB_QUEUES.transcription]) {
|
||||||
|
status = "disabled";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await dispatchJob(JOB_QUEUES.transcription, { tenantId: ctx.tenantId, entityId: voice.id, actorId: ctx.userId });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[field] transcription dispatch failed:", (err as Error).message);
|
||||||
|
status = "failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status !== voice.transcriptionStatus) {
|
||||||
|
await ctx.db.voiceNote.update({ where: { id: voice.id }, data: { transcriptionStatus: status } });
|
||||||
|
}
|
||||||
|
return { voiceNoteId: voice.id, transcriptionStatus: status };
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ForbiddenError, type Permission } from "@/server/rbac";
|
||||||
|
import { ModuleDisabledError } from "@/server/modules";
|
||||||
|
import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import type { ModuleKey } from "@/lib/modules";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context for /api/v1 route handlers (lane L4: sync, uploads, field). Reuses moduleGuard, so
|
||||||
|
* route handlers get exactly the same DB-authoritative checks as server actions (session, account
|
||||||
|
* status, kill switch, password change, permissions, module enabled).
|
||||||
|
* NOTE for the architect: a shared `requireApiContext` is referenced in services/context.ts but not
|
||||||
|
* provided by the foundation — this is the lane-local implementation.
|
||||||
|
*/
|
||||||
|
export async function requireApiContext(moduleKey: ModuleKey, ...permissions: Permission[]): Promise<ServiceCtx> {
|
||||||
|
return ctxFromGuard(await moduleGuard(moduleKey)(...permissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_FOR: Record<ServiceError["code"], number> = {
|
||||||
|
not_found: 404,
|
||||||
|
forbidden: 403,
|
||||||
|
invalid: 400,
|
||||||
|
conflict: 409,
|
||||||
|
blocked: 422,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function apiError(code: string, status: number, message?: string, details?: unknown) {
|
||||||
|
return NextResponse.json({ error: code, ...(message ? { message } : {}), ...(details !== undefined ? { details } : {}) }, { status, headers: { "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps a handler: same-origin check for mutations + uniform error mapping (no internals leaked). */
|
||||||
|
export async function withApi(req: Request, fn: () => Promise<Response>): Promise<Response> {
|
||||||
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||||
|
const origin = req.headers.get("origin");
|
||||||
|
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
|
||||||
|
if (origin && host) {
|
||||||
|
let originHost: string | null = null;
|
||||||
|
try {
|
||||||
|
originHost = new URL(origin).host;
|
||||||
|
} catch {
|
||||||
|
originHost = null;
|
||||||
|
}
|
||||||
|
if (originHost !== host) return apiError("forbidden", 403, "cross-origin request");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceError) return apiError(err.code, STATUS_FOR[err.code], err.message, err.code === "blocked" ? err.details : undefined);
|
||||||
|
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return apiError("forbidden", 403);
|
||||||
|
const msg = err instanceof Error ? err.message : "";
|
||||||
|
if (/Nicht angemeldet|nicht mehr gueltig/.test(msg)) return apiError("unauthorized", 401);
|
||||||
|
if (/Konto ist nicht aktiv|Passwortwechsel erforderlich/.test(msg)) return apiError("forbidden", 403);
|
||||||
|
console.error("[api/v1] unhandled error:", err);
|
||||||
|
return apiError("internal", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { emitEvent } from "@/server/events";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||||
|
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 { endSession, pauseSession, resumeSession, startSession } from "@/server/services/field/sessions";
|
||||||
|
import { createNote } from "@/server/services/field/notes";
|
||||||
|
import { toggleChecklistItem } from "@/server/services/field/checklist";
|
||||||
|
import { upsertMaterialUsage } from "@/server/services/field/materials";
|
||||||
|
import { attachPhoto } from "@/server/services/field/photos";
|
||||||
|
import { attachVoiceNote } from "@/server/services/field/voice";
|
||||||
|
// TODO(merge L2): replace with "@/server/services/work-orders/transition"
|
||||||
|
import { transitionWorkOrder } from "@/server/services/field/stubs/work-order-transition";
|
||||||
|
import { EXTERNAL_OP_OWNERS, EXTERNAL_OPS } from "./external-ops";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server side of the operation-based sync (ARCHITEKTUR §4.6). Online and offline clients use the
|
||||||
|
* same path. Per op:
|
||||||
|
* 1. idempotency: SyncOperation(tenantId, clientOpId) already stored → `duplicate` (stored result)
|
||||||
|
* 2. payload validation (src/lib/sync/ops.ts) → `rejected invalid`
|
||||||
|
* 3. conflict check for CONFLICTING_OPS: WorkOrder.version ≠ baseVersion → `conflict`, nothing
|
||||||
|
* written, SyncOperation(status=conflict) for the backoffice list, event `sync.failed`
|
||||||
|
* 4. dispatch to the domain services (the same ones the UI would use)
|
||||||
|
* Deterministic outcomes are stored; transient failures (internal errors, ops whose lane is not
|
||||||
|
* deployed yet) are NOT stored so the device can retry with the same clientOpId.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SyncRequest = { deviceId: string; operations: SyncOperationInput[] };
|
||||||
|
|
||||||
|
type HandlerResult = { idMap?: Record<string, string>; entityVersion?: number };
|
||||||
|
type Handler = (ctx: ServiceCtx, payload: unknown, op: SyncOperationInput) => Promise<HandlerResult>;
|
||||||
|
|
||||||
|
const idMap = (clientId: string | undefined, serverId: string) => (clientId ? { [clientId]: serverId } : undefined);
|
||||||
|
|
||||||
|
function h<T extends SyncOpType>(fn: (ctx: ServiceCtx, payload: ParsedOpPayload<T>, op: SyncOperationInput) => Promise<HandlerResult>): Handler {
|
||||||
|
return (ctx, payload, op) => fn(ctx, payload as ParsedOpPayload<T>, op);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_HANDLERS: Partial<Record<SyncOpType, Handler>> = {
|
||||||
|
"session.start": h<"session.start">(async (ctx, p) => {
|
||||||
|
const r = await startSession(ctx, p);
|
||||||
|
return { idMap: idMap(p.clientId, r.sessionId), entityVersion: r.workOrderVersion };
|
||||||
|
}),
|
||||||
|
"session.pause": h<"session.pause">(async (ctx, p) => ({ entityVersion: (await pauseSession(ctx, p)).workOrderVersion })),
|
||||||
|
"session.resume": h<"session.resume">(async (ctx, p) => ({ entityVersion: (await resumeSession(ctx, p)).workOrderVersion })),
|
||||||
|
"session.end": h<"session.end">(async (ctx, p) => ({ entityVersion: (await endSession(ctx, p)).workOrderVersion })),
|
||||||
|
"work_order.transition": h<"work_order.transition">(async (ctx, p, op) => {
|
||||||
|
const r = await transitionWorkOrder(ctx, { workOrderId: p.workOrderId, to: p.to, reason: p.reason, baseVersion: op.baseVersion });
|
||||||
|
return { entityVersion: r.version };
|
||||||
|
}),
|
||||||
|
"note.create": h<"note.create">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await createNote(ctx, p)).noteId) })),
|
||||||
|
"checklist.toggle": h<"checklist.toggle">(async (ctx, p) => {
|
||||||
|
await toggleChecklistItem(ctx, p);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
"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) })),
|
||||||
|
"voice.attach": h<"voice.attach">(async (ctx, p) => ({ idMap: idMap(p.clientId, (await attachVoiceNote(ctx, p)).voiceNoteId) })),
|
||||||
|
};
|
||||||
|
|
||||||
|
class NotAvailable extends Error {}
|
||||||
|
|
||||||
|
function workOrderIdOf(op: SyncOperationInput): string | undefined {
|
||||||
|
const fromPayload = (op.payload as { workOrderId?: unknown }).workOrderId;
|
||||||
|
if (typeof fromPayload === "string") return fromPayload;
|
||||||
|
return op.entityType === "work_order" ? op.entityId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function record(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
op: SyncOperationInput,
|
||||||
|
deviceId: string,
|
||||||
|
status: "applied" | "conflict" | "rejected",
|
||||||
|
result: Record<string, unknown>,
|
||||||
|
errorCode?: string,
|
||||||
|
): Promise<{ id: string } | "duplicate"> {
|
||||||
|
try {
|
||||||
|
return await ctx.db.syncOperation.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
userId: ctx.userId,
|
||||||
|
clientOpId: op.clientOpId,
|
||||||
|
opType: op.opType,
|
||||||
|
entityType: op.entityType ?? (workOrderIdOf(op) ? "work_order" : null),
|
||||||
|
entityId: op.entityId ?? workOrderIdOf(op) ?? null,
|
||||||
|
baseVersion: op.baseVersion ?? null,
|
||||||
|
payload: op.payload as Prisma.InputJsonValue,
|
||||||
|
status,
|
||||||
|
result: { ...result, deviceId } as Prisma.InputJsonValue,
|
||||||
|
errorCode: errorCode ?? null,
|
||||||
|
clientCreatedAt: new Date(op.clientCreatedAt),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: string }).code === "P2002") return "duplicate";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyOne(ctx: ServiceCtx, deviceId: string, op: SyncOperationInput): Promise<SyncOpResult> {
|
||||||
|
const base = { clientOpId: op.clientOpId };
|
||||||
|
|
||||||
|
// 1. idempotency
|
||||||
|
const prior = await ctx.db.syncOperation.findFirst({ where: { clientOpId: op.clientOpId } });
|
||||||
|
if (prior) {
|
||||||
|
if (prior.userId !== ctx.userId) return { ...base, status: "rejected", errorCode: "invalid", message: "clientOpId already used" };
|
||||||
|
const stored = (prior.result ?? {}) as HandlerResult & { message?: string };
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "duplicate",
|
||||||
|
idMap: stored.idMap,
|
||||||
|
entityVersion: stored.entityVersion,
|
||||||
|
errorCode: (prior.errorCode as SyncOpResult["errorCode"]) ?? undefined,
|
||||||
|
message: prior.status === "applied" ? undefined : `original status: ${prior.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. payload
|
||||||
|
const parsed = OP_PAYLOAD_SCHEMAS[op.opType].safeParse(op.payload);
|
||||||
|
if (!parsed.success) {
|
||||||
|
const message = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ").slice(0, 500);
|
||||||
|
const rec = await record(ctx, op, deviceId, "rejected", { message }, "invalid");
|
||||||
|
return rec === "duplicate" ? { ...base, status: "duplicate" } : { ...base, status: "rejected", errorCode: "invalid", message };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. conflict check
|
||||||
|
if (CONFLICTING_OPS.includes(op.opType)) {
|
||||||
|
const workOrderId = workOrderIdOf(op);
|
||||||
|
if (!workOrderId || op.baseVersion === undefined) throw new ServiceError("invalid", "workOrderId and baseVersion are required");
|
||||||
|
const wo = await requireVisibleWorkOrder(ctx, workOrderId, { id: true, number: true, version: true });
|
||||||
|
if (wo.version !== op.baseVersion) {
|
||||||
|
const rec = await record(ctx, op, deviceId, "conflict", { currentVersion: wo.version, message: "work order was changed in the meantime" }, "conflict");
|
||||||
|
if (rec === "duplicate") return { ...base, status: "duplicate" };
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: "create",
|
||||||
|
entity: "sync_operation",
|
||||||
|
entityId: rec.id,
|
||||||
|
after: { opType: op.opType, workOrderId, status: "conflict", baseVersion: op.baseVersion, currentVersion: wo.version },
|
||||||
|
});
|
||||||
|
await emitEvent(ctx, { type: "sync.failed", entityType: "sync_operation", entityId: rec.id, data: { opType: op.opType, number: wo.number, reason: "conflict" } });
|
||||||
|
return { ...base, status: "conflict", entityVersion: wo.version, errorCode: "conflict", message: "work order was changed in the meantime" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. dispatch
|
||||||
|
let handler = FIELD_HANDLERS[op.opType];
|
||||||
|
if (!handler) {
|
||||||
|
const load = EXTERNAL_OPS[op.opType];
|
||||||
|
const external = load ? await load() : null;
|
||||||
|
if (!external) throw new NotAvailable(`operation ${op.opType} is not available yet (lane ${EXTERNAL_OP_OWNERS[op.opType] ?? "unknown"})`);
|
||||||
|
handler = (c, _payload, o) => external(c, o);
|
||||||
|
}
|
||||||
|
const result = await handler(ctx, parsed.data, op);
|
||||||
|
const rec = await record(ctx, op, deviceId, "applied", { ...result });
|
||||||
|
if (rec === "duplicate") return { ...base, status: "duplicate", ...result };
|
||||||
|
return { ...base, status: "applied", ...result };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotAvailable) return { ...base, status: "rejected", errorCode: "invalid", message: err.message };
|
||||||
|
if (err instanceof ServiceError) {
|
||||||
|
// 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 details = err.code === "blocked" ? { blockers: err.details } : {};
|
||||||
|
const rec = await record(ctx, op, deviceId, status, { message: err.message, ...details }, err.code);
|
||||||
|
if (rec === "duplicate") return { ...base, status: "duplicate" };
|
||||||
|
if (status === "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 };
|
||||||
|
}
|
||||||
|
console.error(`[sync] ${op.opType} ${op.clientOpId} failed:`, err);
|
||||||
|
return { ...base, status: "rejected", errorCode: "internal", message: "internal error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyOperations(ctx: ServiceCtx, request: SyncRequest): Promise<SyncResponse> {
|
||||||
|
const results: SyncOpResult[] = [];
|
||||||
|
// sequential on purpose: ops of one device depend on each other (start → pause → end)
|
||||||
|
for (const op of request.operations) results.push(await applyOne(ctx, request.deviceId, op));
|
||||||
|
return { results, serverTime: new Date().toISOString() };
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { SyncOperationInput, SyncOpType } from "@/lib/sync/envelope";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of sync ops implemented by other lanes (reports: L5, emergency: L8). Each lane adds
|
||||||
|
* exactly ONE line with a lazy import of its module, e.g.
|
||||||
|
* "report.submit": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp),
|
||||||
|
* Contract of `applySyncOp(ctx, op)`: validate `op.payload`, check permissions/scope, throw
|
||||||
|
* ServiceError for deterministic failures and return `{ idMap?, entityVersion? }`.
|
||||||
|
* Unregistered ops are answered with `rejected invalid` and are NOT stored, so devices can retry
|
||||||
|
* them after the lane is deployed. (A computed `import()` path is not resolvable by Turbopack,
|
||||||
|
* hence the explicit registry.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ExternalOpResult = { idMap?: Record<string, string>; entityVersion?: number };
|
||||||
|
export type ExternalOpHandler = (ctx: ServiceCtx, op: SyncOperationInput) => Promise<ExternalOpResult>;
|
||||||
|
|
||||||
|
export const EXTERNAL_OP_OWNERS: Partial<Record<SyncOpType, string>> = {
|
||||||
|
"report.save_draft": "reports (L5)",
|
||||||
|
"report.submit": "reports (L5)",
|
||||||
|
"signature.capture": "reports (L5)",
|
||||||
|
"emergency.create": "emergency (L8)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EXTERNAL_OPS: Partial<Record<SyncOpType, () => Promise<ExternalOpHandler>>> = {
|
||||||
|
// lane-reports: "report.save_draft": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp),
|
||||||
|
// lane-reports: "report.submit": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp),
|
||||||
|
// lane-reports: "signature.capture": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp),
|
||||||
|
// lane-emergency: "emergency.create": () => import("@/server/services/emergency/sync-ops").then((m) => m.applySyncOp),
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user