Merge lane/notdienst in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 17:23:13 +02:00
co-authored by Claude Opus 5
28 changed files with 3204 additions and 6 deletions
+98
View File
@@ -0,0 +1,98 @@
# Lane L8 – Notdienst
Branch `lane/notdienst` (Basis `d5c1221` auf `feature/craftvia-mvp`, Welle 1 L1–L6 integriert). Spec §19 komplett, §39, US-010, §7.3, ARCHITEKTUR §3/§4.1/§4.6.
**Keine Schemaänderung, keine neue Migration, keine neuen npm-Abhängigkeiten.**
## 1. Umfang / erfüllte Spec-Punkte
| Spec | Umsetzung |
|---|---|
| §19.1 / US-010 Monteur legt Notdienst selbst an | `/m/emergency` (Modul `emergency`, Recht `emergency:create` + `field:execute`), jederzeit anlegbar (keine Geschäftszeitenprüfung, §19 Punkt 5 optional nicht umgesetzt) |
| §19.2 Erfassungsmaske | 3 Schritte mit Schrittanzeige: **1 Kunde** – Suche bestehender Kunden (Name/Ort/Kundennummer, Treffer zeigen nur Name · Nummer · Ort · „Vorläufig") oder „Neuer Kunde" (Firma/Name, Telefon Pflicht, E-Mail, Adresse); **2 Einsatzort** – Objekt des Kunden oder Einsatzadresse (Straße + Ort Pflicht, „Kundenadresse übernehmen"), Ansprechpartner vor Ort + Telefon Pflicht; **3 Grund** (Pflicht, Textfeld) + Sprachnotiz optional (lokal aufgenommen, nach dem Start hochgeladen und als `voice.attach` angehängt), Beginn (Default jetzt), Team (Default eigenes Team) und weitere Monteure des Teams (eigener User immer dabei). Große Felder (≥ 48 px), `type="tel"`/`inputMode="tel"` für Nummern, Status/Fehler immer mit Text + Icon. |
| §19.3 Vorläufige Datensätze | `createEmergencyOrder` in **einer** Transaktion (`inTransaction`): vorläufiger Kunde (`status provisional`, `isProvisional true`, **ohne Kundennummer**) bzw. bestehender Kunde → Ansprechpartner (wiederverwendet bei gleichem Namen + Telefon) → vorläufiges Objekt (`provisional`) bzw. bestehendes Objekt des Kunden → Auftrag über L2 `createWorkOrder` (`isEmergency`, Auftragsart `notdienst`, Nummer aus Sequenz `emergency` N-…, Priorität `urgent`, Status `in_progress`, `plannedStart` = Beginn) → Team/Teamleiter/Zuweisungen → L4 `startSession` (WorkSession + Arbeitszeit-Segment). Danach Event `emergency.created` (`startedAt`). Anschließend normale Einsatzseite `/m/orders/[id]` (Zeiten, Material, Fotos, Tätigkeiten, Bericht, Unterschrift aus L4/L5). |
| Offline (ARCHITEKTUR §4.6) | Sync-Op `emergency.create` mit `clientIds { workOrder, session, customer?, site? }`: Zod-Schema in `src/lib/sync/ops.ts`, Registry-Eintrag in `services/sync/external-ops.ts`. Idempotent doppelt: gleiche `clientOpId` → `duplicate` (L4), neue `clientOpId` mit gleicher Session-Client-ID → Replay liefert denselben Auftrag. Modul `emergency` wird in der Op geprüft (Sync-Route ist `field`-gegated). `idMap` bildet alle Client-IDs auf Server-IDs ab. Der Wizard sendet über `submitOp` (L7 kann die Outbox dahinter tauschen; die Client-IDs bleiben über Wiederholungen stabil). |
| §19.4 Benachrichtigung | Nach Einreichen des Abschlussberichts (v1) eines Notdienstauftrags → `emergency.completed` mit `number`, `technician` (Ersteller), `customer`, `startedAt` (erste Session), `endedAt` (letztes Session-Ende), `status` (i. d. R. `in_review`), `occurrenceId`. L6 versendet die Pflichtmail im Format §19.4 und In-App ans Backoffice. |
| §19.3 / §39 Backoffice-Nachbearbeitung | `/work-orders/emergency-review` (Recht `emergency:review`, Modul-Gate `emergency` + `work_orders`): Tabs „Zu prüfen" (vorläufiger Kunde/Objekt oder Status `technically_completed`/`signature_pending`/`in_review`) und „Alle Notdienste"; Karten mit Nummer, Status, Kunde/Objekt (Kennzeichnung „Vorläufig"), Monteur, Beginn, Fortschrittsbalken **„x von 5 Prüfschritten erledigt"**. Detail `/work-orders/emergency-review/[id]` mit den Prüfschritten: **1 Kunde** – bestätigen (Kundennummer wird jetzt vergeben, dann L1 `confirmProvisionalCustomer`) · bestehendem Kunden zuordnen (Kandidaten über L1 `findDuplicateCustomers` oder Kundennummer; Objekte, Ansprechpartner, Aufträge (Version +1), Dokumente werden umgehängt, vorläufiger Datensatz → `merged` + `mergedIntoId`) · Dublette zusammenführen (L1 `mergeCustomers`, `customer:merge` + Bestätigung); **2 Objekt** – korrigieren/bestätigen (L1 `updateSite`) · bestehendem Objekt zuordnen (L2 `updateWorkOrder`, ungenutztes vorläufiges Objekt → Soft Delete über L1 `deleteSite`); **3 Auftrag ergänzen** – Titel, Beschreibung, Auftragsart, Abrechnungsart (L2 `updateWorkOrder`, Versionsprüfung); **4 Bericht** – Berichtsliste mit Link zur L5-Freigabe; **5 Abrechnung** – L2 `releaseForBilling` (freigegebener Abschlussbericht), zusätzlich gesperrt solange Kunde/Objekt vorläufig (`blocked`, `master_data_open`). |
| Dashboard | Kachel „neue Notdiensteinsätze" verlinkt für Nutzer mit `emergency:review` auf `/work-orders/emergency-review` (sonst weiter auf die L2-Liste). |
| §7.3 Dubletten | Kandidaten in der Prüfung, niemals automatische Zusammenführung; Merge nur mit Bestätigung. |
Jede Mutation: Permission (Guard in der Action **und** Service) · Scope (`workOrderScope`, bei der Erfassung Team-/Zuweisungsprüfung) · Zod · `writeAuditLog` (before/after; zusätzlich Entität `emergency` je Prüfschritt) · Events über `emitEvent`. Fachdaten ausschließlich über `ctx.db`, Mehrschritt-Schreibvorgänge über `inTransaction`.
**Such-Service** `searchCustomersForEmergency(ctx, q)`: nur `emergency:create`, ab 2 Zeichen, max. 10 Treffer, nur aktive/vorläufige Kunden, Rückgabe `{ id, customerNumber, name, city, provisional }`; jeder Zugriff wird auditiert (`action "export"`, Entität `emergency_customer_search`, Suchbegriff + Treffer-IDs). `listSitesForEmergency` analog (`{ id, name, address }`, Entität `emergency_site_lookup`). Mandantentrennung über `dbForTenant`/RLS.
**Sichtbarkeit:** Ein vorläufiger Kunde ist für Monteure ausschließlich über einen sichtbaren Auftrag erreichbar – der bestehende `workOrderScope` enthält `isEmergency && createdById = userId` (ARCHITEKTUR §2), zusätzlich sehen Mitglieder/Teamleiter des gewählten Teams und Zugewiesene den Auftrag.
## 2. Dateien
**Neu (Ownership L8)**
- `src/lib/emergency/schemas.ts` – Zod `emergencyCreatePayload` (Sync-Payload = Service-Input), `emergencyOrderPatchSchema`, `REVIEW_STEPS`
- `src/server/services/emergency/create.ts` – `createEmergencyOrder(ctx, input, deps?)`
- `src/server/services/emergency/lookup.ts` – `searchCustomersForEmergency`, `listSitesForEmergency`, `emergencyTeamOptions`
- `src/server/services/emergency/completion.ts` – `buildEmergencyCompletedData`, `onCompletionReportSubmitted`
- `src/server/services/emergency/sync-ops.ts` – `applySyncOp` für `emergency.create`
- `src/server/services/emergency/review.ts` – `listEmergencyReviews`, `getEmergencyReview`, `reviewProgress`, `confirmEmergencyCustomer`, `assignEmergencyToCustomer`, `mergeEmergencyCustomer`, `correctEmergencySite`, `assignEmergencySite`, `completeEmergencyOrderData`, `releaseEmergencyForBilling`
- `src/server/actions/emergency/capture.ts` (Suche/Objekte, `moduleGuard("emergency")` + `guard("emergency:create")`), `src/server/actions/emergency/review.ts` (7 Actions, je `guard("emergency:review", …)`)
- `src/app/(field)/m/emergency/page.tsx` (Platzhalter ersetzt), `src/components/emergency/emergency-wizard.tsx`
- `src/app/(app)/work-orders/emergency-review/{layout.tsx,page.tsx,[id]/page.tsx}`, `src/components/emergency/{review-form,review-progress}.tsx`
- `messages/de/emergency.json`, `messages/en/emergency.json`
- `scripts/test-notdienst-flow.ts`, `scripts/test-notdienst-review.ts`
**Fremd-Eingriffe (Einzeiler, im Auftrag erlaubt bzw. zwingend)**
- `src/server/services/sync/external-ops.ts`: Registry-Zeile `emergency.create` aktiviert
- `src/lib/sync/ops.ts`: `"emergency.create": emergencyCreatePayload` (+ Import)
- `src/app/(app)/dashboard/page.tsx` (L2): `href` der Kachel `emergency_new` → `/work-orders/emergency-review` (bei `emergency:review`)
- `src/lib/nav.ts`: Eintrag „Notdienst-Prüfung" (`module: "emergency"`, `emergency:review`, Icon `Siren`) + Label `emergencyReview` in `messages/{de,en}/nav.json`
- `src/components/audit-trail.tsx`: Entity-Labels `emergency_customer_search`, `emergency_site_lookup`
- **`src/server/services/reports/submit.ts` (L5) – nicht in der Einzeiler-Liste, aber für Liefergegenstand 3 zwingend:** Import + ein Aufruf `await onCompletionReportSubmitted(ctx, wo.id, occurrenceId)` direkt nach `advanceOrder` für Abschlussbericht v1. Der Hook ist ein No-op für normale Aufträge und wirft nie. Alternative ohne L5-Eingriff wäre nur ein Event-Abonnement im Fundament (`emitEvent` auf `report.submitted`) – bitte beim Merge bestätigen.
## 3. Tests
`npm run gate` **grün**: prisma generate, tsc (0 Fehler), lint (0 Fehler, 2 Warnungen in fremden Dateien), build inkl. Modul-Guard-Check (29 Action-Dateien), **44/44 Testskripte grün** (davon 2 neu).
| Skript | Prüfungen | Inhalt |
|---|---|---|
| `test-notdienst-flow.ts` | 69 | Erfassung mit vorläufigem Kunden (Nummer N-, `in_progress` + Historie, Auftragsart `notdienst`, Kunde/Objekt vorläufig ohne Kundennummer, Ansprechpartner, Default-Team/-Zuweisung, laufende WorkSession, Audit, `emergency.created` ans Backoffice, Akteur ausgenommen); **Transaktion**: Fehler nach dem Auftrags-Insert → kein Kunde/Objekt/Kontakt/Auftrag/Session, Nummernkreis ohne Lücke; **Sichtbarkeit**: vorläufiger Kunde für Ersteller sichtbar, anderer Monteur und Mandant B → `not_found`; **Such-Service**: fremde Kunden auffindbar, nur minimale Felder, Audit, Mandant B findet nichts, ohne `emergency:create` → `forbidden`, Objektabfrage minimal/auditiert/mandantengetrennt; bestehender Kunde + Objekt, Kontakt-Wiederverwendung, Objekt eines anderen Kunden/fremdes Team/fremder Kollege → `invalid`, Mandant B mit A-Kunden-ID → nichts angelegt, Backoffice → `forbidden`, Pflichtfelder; **Sync-Op**: applied mit idMap, gleiche `clientOpId` → `duplicate`, Replay mit neuer `clientOpId` → derselbe Auftrag ohne Doppelanlage, Offline-Flag, ungültige Payload → `rejected invalid`, Replay durch anderen Nutzer abgelehnt, Modul deaktiviert → `forbidden`; **Abschluss**: Session beenden → Abschlussbericht → Unterschrift „Kunde abwesend" → Absenden → Auftrag `in_review`, `emergency.completed` ans Backoffice, Daten Monteur/Kunde/Beginn ≤ Ende/Status, Pflichtmail protokolliert, normaler Auftrag ohne Notdienst-Event |
| `test-notdienst-review.ts` | 49 | **Rollen**: Monteur/Teamleiter → `forbidden` (Liste, Detail, bestätigen, zuordnen, Abrechnung, Merge); **Mandant B**: Liste leer, Detail/bestätigen/zuordnen/ergänzen → `not_found`, Daten unverändert; normaler Auftrag → `not_found`; Liste + Fortschritt 0/5 + Monteur; Dublettenkandidat über Telefon/Name; Beginn/Ende; **Zuordnen**: gleicher/fremder Kunde → `invalid`, Auftrag (Version +1), Objekt, Kontakt umgehängt, Quelle `merged`, Audit, erneut → `conflict`; **Objekt**: zuordnen + vorläufiges Objekt soft-gelöscht, fremdes Objekt → `invalid`, korrigieren + bestätigen; **Bestätigen**: K-Nummer vergeben, aktiv, zweites Mal → `conflict`; **Merge** ohne Bestätigung → `invalid`, Monteur → `forbidden`, mit Bestätigung umgehängt; **Auftrag ergänzen** inkl. Validierung und Versionskonflikt; **Abrechnung**: vorläufige Stammdaten → `blocked master_data_open`, ohne freigegebenen Bericht → `blocked`, nach Freigabe → `released_for_billing`, 5/5, aus „Zu prüfen" entfernt, unter „Alle" sichtbar, Prüfschritte auditiert |
Hinweis Lane-DB: `.env` des Worktrees (nicht eingecheckt) nutzt `craftvia_notdienst` für `DATABASE_URL` **und** `RLS_DATABASE_URL`.
## 4. Stubs / Abhängigkeiten
- **Keine Stubs.** Genutzt: L1 `findDuplicateCustomers`, `confirmProvisionalCustomer`, `mergeCustomers`, `updateSite`, `deleteSite`, `customerScope`; L2 `createWorkOrder`, `updateWorkOrder`, `releaseForBilling`, `ensureDefaultOrderTypes`, `workOrderScope`/`activeTeamIds`; L4 `startSession`, `submitOp`, `uploadFieldFile`, Sync-Registry; L5 Abschlussbericht/Unterschrift (unverändert) + Hook in `submit.ts`; L6 Empfänger/Pflichtmail für `emergency.*`.
- **L7 Offline:** Der Wizard ruft `submitOp({ opType: "emergency.create", … })`. Liefert die Outbox später ein Ergebnis ohne Server-ID, zeigt der Wizard „wird übertragen, sobald Verbindung besteht". Die Kundensuche braucht Verbindung; offline bleibt „Neuer Kunde". Die Sprachnotiz wird nur online nach dem Start hochgeladen.
## 5. Bekannte Lücken / Hinweise
1. **L5-Eingriff** (siehe §2): eine Zeile + Import in `services/reports/submit.ts`.
2. **Audit „Lesezugriff":** `AuditAction` kennt kein `read`; der Suchzugriff wird als `export` protokolliert (Fundament-Vorschlag: Aktion `read`/`access`).
3. `mergeCustomers` (L1) nutzt `ctx.db.$transaction([...])` direkt und kann daher nicht innerhalb von `inTransaction` laufen; der Merge-Schritt ruft ihn eigenständig auf (L1 ist in sich atomar, bei `RLS_ENFORCED=true` gilt der Fundament-Hinweis aus §4.8).
4. `writeAuditLog` schreibt über den Owner-Client außerhalb der Transaktion; eigene Audit-Einträge der Erfassung werden deshalb erst nach dem Commit geschrieben. `createWorkOrder`/`startSession` protokollieren innerhalb – bei einem Rollback bleiben deren Audit-Zeilen stehen (bekannt aus L2).
5. `assignEmergencySite` läuft in `inTransaction`; `updateWorkOrder` emittiert sein Event dabei im Transaktionskontext (pg meldet eine Deprecation-Warnung wegen paralleler Queries im Transaktions-Client, funktional ohne Auswirkung).
6. Globale Eindeutigkeit von `WorkSession.clientId` (Schema): ein Replay mit derselben Session-Client-ID in einem anderen Mandanten scheitert mit einem internen Fehler (nicht gespeichert, keine Datenwirkung).
7. Geschäftszeiten-Hinweis (optional) nicht umgesetzt.
8. `PII_REFERENCE_FIELDS` (Fundament): keine neuen Felder – genutzt werden bestehende (`Customer.createdById`, `WorkOrder.createdById`, `WorkSession.userId`).
## 6. Screens / Routen
| Route | Rolle | Inhalt |
|---|---|---|
| `/m/emergency` | Monteur, Teamleiter (`emergency:create`) | 3-Schritt-Erfassung → „Einsatz starten" → `/m/orders/[id]` |
| `/work-orders/emergency-review` (`?filter=all`) | Backoffice, Admin (`emergency:review`) | Liste mit Fortschritt |
| `/work-orders/emergency-review/[id]` | Backoffice, Admin | Prüfschritte 1–5 |
| `POST /api/v1/sync` Op `emergency.create` | Monteur, Teamleiter | Offline-/Online-Anlage |
## 7. Smoke
HTTP-Smoke gegen den Dev-Server `:3108` (Lane-DB, Seed-Mandanten `demo`/`demo2`). Session-Cookies wurden lokal ohne Passworteingabe erzeugt (`finalizeIdentityLogin` + Auth.js `encode`). **15/16 Prüfungen grün:**
- ohne Session: `/m/emergency` → 307 Login
- Monteur: `/m/emergency` rendert Schritt 1 (bestehender/neuer Kunde); Backoffice sieht dort den Hinweis „nicht freigeschaltet"
- `POST /api/v1/sync` `emergency.create` → `applied` mit idMap; gleiche `clientOpId` → `duplicate`; fremder Origin → 403; Backoffice (ohne `emergency:create`) → abgelehnt
- Monteur: `/m/orders/[id]` des neuen Notdienstes rendert (N-Nummer, Grund)
- Backoffice: `/work-orders/emergency-review` (Fortschritt), `?filter=all`, Detail mit allen Prüfschritten → 200
- Mandant `demo2`: Detail → 404, Liste ohne Notdienste von `demo`
- Monteur/Teamleiter: Prüfseiten → 307 (nicht zugänglich)
- Navigation „Notdienst-Prüfung" wird gerendert
- ✗ **`/dashboard` → 500 im Dev-Server (vorbestehender L2-Fehler, nicht durch L8):** `dashboard/page.tsx` ruft `buttonCls()` aus der Client-Datei `components/work-orders/action-form.tsx` („use client") in einer Server-Komponente auf (Zeilen 134/137, schon auf `d5c1221` vorhanden). Die neue Kachel-Verlinkung ist deshalb nur im Code geprüft, nicht im gerenderten Dashboard. Fix-Vorschlag an L2/Architekt: `buttonCls` in eine Datei ohne „use client" verschieben.
Nicht durchgeführt: visuelle Prüfung im Browser (Responsive 375/768/1024 px) und Aufrufe der Review-Server-Actions über die UI (fachlich durch `test-notdienst-review.ts` abgedeckt). Der Smoke hinterlässt zwei Smoke-Notdienste im Mandanten `demo` der Lane-DB.
+236
View File
@@ -0,0 +1,236 @@
{
"capture": {
"title": "Notdienst",
"sub": "Einsatz in 3 Schritten anlegen und sofort starten.",
"noAccess": "Die Notdienst-Erfassung ist für Ihre Rolle nicht freigeschaltet.",
"stepOf": "Schritt {step} von 3",
"steps": {
"customer": "Kunde",
"site": "Einsatzort",
"reason": "Grund & Start"
},
"next": "Weiter",
"back": "Zurück",
"required": "Bitte die markierten Pflichtfelder ausfüllen.",
"start": "Einsatz starten",
"starting": "Einsatz wird angelegt …",
"queued": "Einsatz ist gespeichert und wird übertragen, sobald wieder Verbindung besteht.",
"customer": {
"existing": "Bestehender Kunde",
"new": "Neuer Kunde",
"search": "Kunde suchen",
"searchPlaceholder": "Name, Ort oder Kundennummer",
"searchHint": "Mindestens 2 Zeichen eingeben.",
"searching": "Suche läuft …",
"offline": "Suche gerade nicht möglich. Ohne Verbindung bitte einen neuen Kunden anlegen.",
"noHits": "Kein Kunde gefunden. Legen Sie einen neuen Kunden an.",
"provisional": "Vorläufig",
"change": "Anderen Kunden wählen",
"selectRequired": "Bitte einen Kunden auswählen oder neu anlegen.",
"newHint": "Wird als vorläufiger Kunde angelegt. Das Büro prüft ihn am nächsten Arbeitstag.",
"companyName": "Firma",
"firstName": "Vorname",
"lastName": "Nachname",
"nameHint": "Firma oder Nachname angeben.",
"phone": "Telefon",
"email": "E-Mail (optional)",
"street": "Straße",
"houseNumber": "Nr.",
"postalCode": "PLZ",
"city": "Ort"
},
"site": {
"loading": "Objekte werden geladen …",
"existing": "Objekt des Kunden",
"newAddress": "Andere Einsatzadresse",
"none": "Für diesen Kunden ist kein Objekt hinterlegt. Bitte die Einsatzadresse erfassen.",
"newHint": "Die Einsatzadresse wird als vorläufiges Objekt angelegt.",
"useCustomerAddress": "Kundenadresse übernehmen",
"name": "Bezeichnung (optional)",
"contactTitle": "Ansprechpartner vor Ort",
"contactName": "Name",
"contactPhone": "Telefon"
},
"reason": {
"label": "Grund des Notdiensteinsatzes",
"placeholder": "z. B. Wasserrohrbruch im Keller, Heizung ausgefallen",
"voice": "Sprachnotiz (optional)",
"voiceRecord": "Sprachnotiz aufnehmen",
"voiceRecording": "Aufnahme läuft · {seconds} s",
"voiceStop": "Aufnahme beenden",
"voiceDiscard": "Aufnahme verwerfen",
"voiceHint": "Wird nach dem Start am Einsatz gespeichert.",
"voiceDenied": "Kein Zugriff auf das Mikrofon. Bitte in den Geräteeinstellungen erlauben.",
"startedAt": "Beginn",
"team": "Team",
"noTeam": "Ohne Team",
"technicians": "Monteure",
"me": "Ich (immer dabei)"
},
"errors": {
"not_found": "Kunde oder Objekt nicht gefunden. Bitte Auswahl prüfen.",
"forbidden": "Dafür fehlt Ihnen die Berechtigung.",
"invalid": "Bitte die Eingaben prüfen.",
"conflict": "Der Einsatz wurde bereits angelegt. Bitte die Auftragsliste prüfen.",
"blocked": "Der Einsatz kann gerade nicht angelegt werden.",
"internal": "Einsatz konnte nicht angelegt werden. Bitte erneut versuchen.",
"network": "Keine Verbindung. Bitte erneut auf „Einsatz starten“ tippen."
}
},
"review": {
"crumb": "Aufträge · Notdienst",
"title": "Notdienst-Prüfung",
"sub": "Notdiensteinsätze mit vorläufigen Stammdaten oder zur Prüfung.",
"filterLabel": "Filter Notdiensteinsätze",
"filter": {
"open": "Zu prüfen",
"all": "Alle Notdienste"
},
"emptyOpen": "Keine Notdiensteinsätze zu prüfen.",
"emptyAll": "Noch keine Notdiensteinsätze.",
"customer": "Kunde",
"site": "Objekt",
"technician": "Monteur",
"startedAt": "Beginn",
"provisional": "Vorläufig",
"progress": "{count} von {total} Prüfschritten erledigt",
"back": "Zur Notdienst-Prüfung",
"detailSub": "Monteur: {technician} · Einsatzbeginn: {start} · Einsatzende: {end}",
"running": "läuft noch",
"openOrder": "Auftrag öffnen",
"steps": {
"customer": "Kunde klären",
"site": "Objekt klären",
"order": "Auftrag ergänzen",
"report": "Bericht freigeben",
"billing": "Abrechnung freigeben"
},
"stepDone": "Erledigt",
"stepOpen": "Offen",
"reason": "Grund des Notdiensteinsatzes",
"customerName": "Name",
"customerNumber": "Kundennummer",
"numberOnConfirm": "wird bei Bestätigung vergeben",
"status": "Status",
"customerStatus": {
"active": "Aktiv",
"inactive": "Inaktiv",
"provisional": "Vorläufig – Prüfung durch Backoffice erforderlich",
"merged": "Zusammengeführt"
},
"phone": "Telefon",
"email": "E-Mail",
"address": "Adresse",
"contact": "Ansprechpartner",
"provisionalHint": "Vorläufiger Kunde: bestätigen, einem bestehenden Kunden zuordnen oder mit einer Dublette zusammenführen.",
"saved": "Gespeichert.",
"confirmCustomer": {
"title": "Kunden bestätigen",
"hint": "Der Kunde wird aktiv und erhält eine Kundennummer.",
"submit": "Kunden bestätigen"
},
"duplicates": {
"title": "Mögliche Dubletten",
"none": "Keine ähnlichen Kunden gefunden.",
"score": "Übereinstimmung {score} %"
},
"target": {
"select": "Bestehender Kunde",
"none": "Bitte wählen",
"number": "oder Kundennummer"
},
"assignCustomer": {
"title": "Bestehendem Kunden zuordnen",
"hint": "Auftrag, Objekt und Ansprechpartner werden zum gewählten Kunden umgehängt. Der vorläufige Datensatz wird als zusammengeführt markiert.",
"submit": "Zuordnen"
},
"mergeCustomer": {
"title": "Dublette zusammenführen",
"hint": "Alle Ansprechpartner, Objekte, Aufträge und Dokumente dieses Kunden gehen auf den gewählten Kunden über. Nicht umkehrbar.",
"confirm": "Ich habe geprüft, dass es derselbe Kunde ist.",
"submit": "Zusammenführen"
},
"siteName": "Bezeichnung",
"siteStatus": {
"active": "Aktiv",
"inactive": "Inaktiv",
"provisional": "Vorläufig – Prüfung durch Backoffice erforderlich"
},
"onSiteContact": "Ansprechpartner vor Ort",
"noSite": "Kein Objekt am Auftrag.",
"street": "Straße",
"houseNumber": "Nr.",
"postalCode": "PLZ",
"city": "Ort",
"accessNotes": "Zugangshinweise",
"correctSite": {
"title": "Objekt korrigieren",
"confirm": "Objekt bestätigen (vorläufig → aktiv)",
"submit": "Objekt speichern"
},
"assignSite": {
"title": "Bestehendes Objekt zuordnen",
"hint": "Der Auftrag wird dem gewählten Objekt zugeordnet. Ein nicht mehr genutztes vorläufiges Objekt wird entfernt.",
"select": "Objekt des Kunden",
"none": "Der Kunde hat keine weiteren Objekte.",
"submit": "Objekt zuordnen"
},
"completeOrder": {
"title": "Titel",
"description": "Beschreibung",
"orderType": "Auftragsart",
"billingType": "Abrechnungsart",
"choose": "Bitte wählen",
"hint": "Erledigt, sobald Auftragsart und Abrechnungsart gesetzt sind.",
"submit": "Auftrag speichern"
},
"billingTypes": {
"fixed": "Pauschal",
"time_material": "Nach Aufwand",
"maintenance_contract": "Wartungsvertrag",
"warranty": "Gewährleistung"
},
"report": {
"none": "Noch kein Bericht eingereicht.",
"open": "Bericht prüfen",
"hint": "Freigabe und Zurückweisung erfolgen auf der Berichtsseite.",
"version": "Version {version}",
"type": {
"daily": "Tagesbericht",
"completion": "Abschlussbericht"
},
"status": {
"draft": "Entwurf",
"submitted": "Eingereicht",
"team_approved": "Vom Teamleiter geprüft",
"approved": "Freigegeben",
"rejected": "Zurückgewiesen",
"superseded": "Ersetzt"
}
},
"billing": {
"hint": "Möglich, wenn Kunde und Objekt geklärt sind und der Abschlussbericht freigegeben ist.",
"submit": "Zur Abrechnung freigeben",
"current": "Aktueller Status"
},
"errors": {
"generic": "Das hat nicht geklappt. Bitte erneut versuchen.",
"invalid": "Bitte die Eingaben prüfen.",
"not_found": "Nicht gefunden.",
"forbidden": "Dafür fehlt Ihnen die Berechtigung.",
"conflict": "Der Datensatz wurde inzwischen geändert. Bitte Seite neu laden.",
"blocked": "Noch nicht möglich: Voraussetzungen fehlen.",
"not_provisional": "Der Kunde ist nicht mehr vorläufig.",
"same_customer": "Bitte einen anderen Kunden wählen.",
"same_site": "Der Auftrag ist diesem Objekt bereits zugeordnet.",
"target_not_found": "Kunde nicht gefunden. Bitte Auswahl oder Kundennummer prüfen.",
"not_found_target": "Kunde nicht gefunden.",
"confirm_required": "Bitte die Prüfung bestätigen.",
"already_merged": "Einer der Kunden wurde bereits zusammengeführt.",
"no_site": "Der Auftrag hat kein Objekt.",
"master_data_open": "Erst Kunde und Objekt klären, dann zur Abrechnung freigeben.",
"number_allocation": "Kundennummer konnte nicht vergeben werden. Bitte erneut versuchen.",
"number_taken": "Diese Kundennummer ist bereits vergeben."
}
}
}
+1
View File
@@ -1,6 +1,7 @@
{ {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"workOrders": "Aufträge", "workOrders": "Aufträge",
"emergencyReview": "Notdienst-Prüfung",
"imports": "Import", "imports": "Import",
"customers": "Kunden", "customers": "Kunden",
"sites": "Objekte", "sites": "Objekte",
+236
View File
@@ -0,0 +1,236 @@
{
"capture": {
"title": "Emergency call-out",
"sub": "Create the call-out in 3 steps and start right away.",
"noAccess": "Emergency capture is not enabled for your role.",
"stepOf": "Step {step} of 3",
"steps": {
"customer": "Customer",
"site": "Location",
"reason": "Reason & start"
},
"next": "Next",
"back": "Back",
"required": "Please fill in the marked required fields.",
"start": "Start call-out",
"starting": "Creating call-out …",
"queued": "Call-out saved. It will be transferred once you are back online.",
"customer": {
"existing": "Existing customer",
"new": "New customer",
"search": "Search customer",
"searchPlaceholder": "Name, city or customer number",
"searchHint": "Enter at least 2 characters.",
"searching": "Searching …",
"offline": "Search is not available right now. Without a connection, please create a new customer.",
"noHits": "No customer found. Create a new customer.",
"provisional": "Provisional",
"change": "Choose another customer",
"selectRequired": "Please select a customer or create a new one.",
"newHint": "Will be created as a provisional customer. The office reviews it on the next working day.",
"companyName": "Company",
"firstName": "First name",
"lastName": "Last name",
"nameHint": "Enter company or last name.",
"phone": "Phone",
"email": "E-mail (optional)",
"street": "Street",
"houseNumber": "No.",
"postalCode": "Postcode",
"city": "City"
},
"site": {
"loading": "Loading sites …",
"existing": "Customer site",
"newAddress": "Other call-out address",
"none": "No site on file for this customer. Please enter the call-out address.",
"newHint": "The call-out address is created as a provisional site.",
"useCustomerAddress": "Use customer address",
"name": "Label (optional)",
"contactTitle": "Contact on site",
"contactName": "Name",
"contactPhone": "Phone"
},
"reason": {
"label": "Reason for the emergency call-out",
"placeholder": "e.g. burst water pipe in the basement, heating failure",
"voice": "Voice note (optional)",
"voiceRecord": "Record voice note",
"voiceRecording": "Recording · {seconds} s",
"voiceStop": "Stop recording",
"voiceDiscard": "Discard recording",
"voiceHint": "Saved to the call-out after it has started.",
"voiceDenied": "No microphone access. Please allow it in the device settings.",
"startedAt": "Start",
"team": "Team",
"noTeam": "No team",
"technicians": "Technicians",
"me": "Me (always included)"
},
"errors": {
"not_found": "Customer or site not found. Please check your selection.",
"forbidden": "You do not have permission for this.",
"invalid": "Please check your entries.",
"conflict": "The call-out has already been created. Please check the order list.",
"blocked": "The call-out cannot be created right now.",
"internal": "The call-out could not be created. Please try again.",
"network": "No connection. Please tap “Start call-out” again."
}
},
"review": {
"crumb": "Work orders · Emergency",
"title": "Emergency review",
"sub": "Emergency call-outs with provisional master data or awaiting review.",
"filterLabel": "Filter emergency call-outs",
"filter": {
"open": "To review",
"all": "All call-outs"
},
"emptyOpen": "No emergency call-outs to review.",
"emptyAll": "No emergency call-outs yet.",
"customer": "Customer",
"site": "Site",
"technician": "Technician",
"startedAt": "Start",
"provisional": "Provisional",
"progress": "{count} of {total} review steps done",
"back": "Back to emergency review",
"detailSub": "Technician: {technician} · Start: {start} · End: {end}",
"running": "still running",
"openOrder": "Open work order",
"steps": {
"customer": "Clarify customer",
"site": "Clarify site",
"order": "Complete order",
"report": "Approve report",
"billing": "Release for billing"
},
"stepDone": "Done",
"stepOpen": "Open",
"reason": "Reason for the emergency call-out",
"customerName": "Name",
"customerNumber": "Customer number",
"numberOnConfirm": "assigned on confirmation",
"status": "Status",
"customerStatus": {
"active": "Active",
"inactive": "Inactive",
"provisional": "Provisional – review by back office required",
"merged": "Merged"
},
"phone": "Phone",
"email": "E-mail",
"address": "Address",
"contact": "Contact",
"provisionalHint": "Provisional customer: confirm, assign to an existing customer or merge with a duplicate.",
"saved": "Saved.",
"confirmCustomer": {
"title": "Confirm customer",
"hint": "The customer becomes active and receives a customer number.",
"submit": "Confirm customer"
},
"duplicates": {
"title": "Possible duplicates",
"none": "No similar customers found.",
"score": "Match {score} %"
},
"target": {
"select": "Existing customer",
"none": "Please choose",
"number": "or customer number"
},
"assignCustomer": {
"title": "Assign to existing customer",
"hint": "Order, site and contacts move to the chosen customer. The provisional record is marked as merged.",
"submit": "Assign"
},
"mergeCustomer": {
"title": "Merge duplicate",
"hint": "All contacts, sites, orders and documents of this customer move to the chosen customer. Cannot be undone.",
"confirm": "I have checked that this is the same customer.",
"submit": "Merge"
},
"siteName": "Label",
"siteStatus": {
"active": "Active",
"inactive": "Inactive",
"provisional": "Provisional – review by back office required"
},
"onSiteContact": "Contact on site",
"noSite": "No site on this order.",
"street": "Street",
"houseNumber": "No.",
"postalCode": "Postcode",
"city": "City",
"accessNotes": "Access notes",
"correctSite": {
"title": "Correct site",
"confirm": "Confirm site (provisional → active)",
"submit": "Save site"
},
"assignSite": {
"title": "Assign existing site",
"hint": "The order is assigned to the chosen site. A provisional site that is no longer used is removed.",
"select": "Customer site",
"none": "The customer has no other sites.",
"submit": "Assign site"
},
"completeOrder": {
"title": "Title",
"description": "Description",
"orderType": "Order type",
"billingType": "Billing type",
"choose": "Please choose",
"hint": "Done once order type and billing type are set.",
"submit": "Save order"
},
"billingTypes": {
"fixed": "Fixed price",
"time_material": "Time and material",
"maintenance_contract": "Maintenance contract",
"warranty": "Warranty"
},
"report": {
"none": "No report submitted yet.",
"open": "Review report",
"hint": "Approval and rejection happen on the report page.",
"version": "Version {version}",
"type": {
"daily": "Daily report",
"completion": "Completion report"
},
"status": {
"draft": "Draft",
"submitted": "Submitted",
"team_approved": "Checked by team lead",
"approved": "Approved",
"rejected": "Rejected",
"superseded": "Superseded"
}
},
"billing": {
"hint": "Possible once customer and site are clarified and the completion report is approved.",
"submit": "Release for billing",
"current": "Current status"
},
"errors": {
"generic": "That did not work. Please try again.",
"invalid": "Please check your entries.",
"not_found": "Not found.",
"forbidden": "You do not have permission for this.",
"conflict": "The record has changed in the meantime. Please reload the page.",
"blocked": "Not possible yet: prerequisites are missing.",
"not_provisional": "The customer is no longer provisional.",
"same_customer": "Please choose a different customer.",
"same_site": "The order is already assigned to this site.",
"target_not_found": "Customer not found. Please check the selection or customer number.",
"not_found_target": "Customer not found.",
"confirm_required": "Please confirm the check.",
"already_merged": "One of the customers has already been merged.",
"no_site": "The order has no site.",
"master_data_open": "Clarify customer and site first, then release for billing.",
"number_allocation": "The customer number could not be assigned. Please try again.",
"number_taken": "This customer number is already taken."
}
}
}
+1
View File
@@ -1,6 +1,7 @@
{ {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"workOrders": "Work orders", "workOrders": "Work orders",
"emergencyReview": "Emergency review",
"imports": "Import", "imports": "Import",
"customers": "Customers", "customers": "Customers",
"sites": "Sites", "sites": "Sites",
+216
View File
@@ -0,0 +1,216 @@
// Lane L8 „Notdienst" — Erfassung, Transaktion, Sichtbarkeit, Such-Service, Sync-Op, Abschluss-Event.
//
// Nachweise: vorläufiger Kunde/Objekt + Auftrag N-… in_progress + WorkSession in EINER Transaktion
// (Fehler im Auftrag → kein verwaister Kunde), vorläufiger Kunde für Monteure nur über den eigenen
// Notdienstauftrag sichtbar, Such-Service mit minimalem Feldumfang + Audit + Mandantentrennung,
// Sync-Op emergency.create idempotent, Events emergency.created / emergency.completed.
//
// Lauf: npx tsx scripts/test-notdienst-flow.ts
import "dotenv/config";
import { randomUUID } from "node:crypto";
import { prisma } from "../src/server/db";
import type { SyncOperationInput } from "../src/lib/sync/envelope";
import type { EmergencyCreateInput } from "../src/lib/emergency/schemas";
import { applyOperations } from "../src/server/services/sync/apply";
import { createEmergencyOrder } from "../src/server/services/emergency/create";
import { listSitesForEmergency, searchCustomersForEmergency, emergencyTeamOptions } from "../src/server/services/emergency/lookup";
import { buildEmergencyCompletedData } from "../src/server/services/emergency/completion";
import { createWorkOrder } from "../src/server/services/work-orders/create";
import { requireVisibleWorkOrder } from "../src/server/services/work-orders/visibility";
import { getCustomer } from "../src/server/services/customers/customers";
import { endSession } from "../src/server/services/field/sessions";
import { createCompletionReport } from "../src/server/services/reports/create";
import { updateReportTexts } from "../src/server/services/reports/edit";
import { captureSignature } from "../src/server/services/reports/signature";
import { submitReport } from "../src/server/services/reports/submit";
import { cleanupTenants, createFixture, expectCode, failures, ok, rawWorkOrder } from "./lib-auftraege-fixtures";
const SLUG_A = "zz-l8-flow-a";
const SLUG_B = "zz-l8-flow-b";
export function emergencyPayload(over: Partial<EmergencyCreateInput> = {}): EmergencyCreateInput {
return {
clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() },
customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 1234567", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
onSiteContact: { name: "Lutz Meier", phone: "0171 1234567" },
reason: "Wasserrohrbruch im Keller",
...over,
};
}
function syncOp(payload: unknown, clientOpId = randomUUID()): SyncOperationInput {
return { clientOpId, opType: "emergency.create", payload: payload as Record<string, unknown>, clientCreatedAt: new Date().toISOString() };
}
async function main() {
await cleanupTenants([SLUG_A, SLUG_B]);
const A = await createFixture(SLUG_A);
const B = await createFixture(SLUG_B);
try {
console.log("\n— Erfassung mit vorläufigem Kunden —");
const p1 = emergencyPayload();
const r1 = await createEmergencyOrder(A.ctx.tech, p1);
const wo = await prisma.workOrder.findUniqueOrThrow({
where: { id: r1.workOrderId },
include: { customer: true, site: true, contact: true, orderType: true, assignees: true, workSessions: { include: { entries: true } }, statusHistory: true },
});
ok(wo.isEmergency && /^N-\d+$/.test(wo.number), `Auftrag ist Notdienst mit Nummer aus Sequenz emergency (${wo.number})`);
ok(wo.status === "in_progress" && wo.statusHistory.some((h) => h.toStatus === "in_progress"), "Status in_progress inkl. Statushistorie");
ok(wo.orderType?.key === "notdienst", "Auftragsart notdienst");
ok(wo.emergencyReason === "Wasserrohrbruch im Keller" && wo.title === "Wasserrohrbruch im Keller", "Grund als emergencyReason + Titel");
ok(wo.createdById === A.users.tech.id, "createdById = Monteur");
ok(wo.customer.status === "provisional" && wo.customer.isProvisional && wo.customer.customerNumber === null, "Kunde vorläufig, ohne Kundennummer");
ok(wo.site?.status === "provisional" && wo.site.customerId === wo.customerId && wo.site.city === "Hamburg", "Einsatzadresse als vorläufiges Objekt");
ok(wo.contact?.name === "Lutz Meier" && wo.contact.phone === "0171 1234567" && wo.site?.contactId === wo.contactId, "Ansprechpartner vor Ort + Telefon");
ok(wo.assignedTeamId === A.teamId && wo.teamLeadUserId === A.users.lead.id, "Default: eigenes Team + Teamleiter");
ok(wo.assignees.length === 1 && wo.assignees[0].userId === A.users.tech.id, "Default: eigener User zugewiesen");
const session = wo.workSessions[0];
ok(wo.workSessions.length === 1 && session.status === "running" && session.clientId === p1.clientIds.session, "WorkSession gestartet (clientId)");
ok(session.entries.length === 1 && session.entries[0].type === "work" && !session.entries[0].endedAt, "Arbeitszeit-Segment offen");
ok(r1.version === wo.version && r1.sessionId === session.id && !r1.replayed, "Ergebnis mit Version und Session-ID");
const auditRows = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entityId: { in: [r1.workOrderId, r1.customerId, r1.siteId] } }, select: { entity: true, action: true } });
ok(["emergency", "customer", "site", "work_order"].every((e) => auditRows.some((a) => a.entity === e && a.action === "create")), "Audit: emergency, customer, site, work_order angelegt");
const created = await prisma.notification.count({ where: { tenantId: A.tenantId, type: "emergency.created", userId: A.users.backoffice.id, entityId: r1.workOrderId } });
ok(created === 1, "Event emergency.created → Benachrichtigung ans Backoffice");
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, type: "emergency.created", userId: A.users.tech.id } })) === 0, "Akteur selbst nicht benachrichtigt");
console.log("\n— Transaktion —");
const failing = emergencyPayload({
customer: { mode: "new", lastName: "Rollback", phone: "040 999" },
site: { mode: "new", street: "Rollbackweg", city: "Hamburg" },
onSiteContact: { name: "Rolf Rollback", phone: "040 999" },
reason: "Rollback-Test",
});
await expectCode(
() =>
createEmergencyOrder(A.ctx.tech, failing, {
createWorkOrder: async (c, i) => {
await createWorkOrder(c, i);
throw Object.assign(new Error("simulated failure after work order insert"), { code: "invalid" });
},
}),
"invalid",
"Fehler beim Auftrag → Fehler an Aufrufer",
);
ok((await prisma.customer.count({ where: { tenantId: A.tenantId, lastName: "Rollback" } })) === 0, "kein verwaister vorläufiger Kunde");
ok((await prisma.site.count({ where: { tenantId: A.tenantId, street: "Rollbackweg" } })) === 0, "kein verwaistes Objekt");
ok((await prisma.contact.count({ where: { tenantId: A.tenantId, name: "Rolf Rollback" } })) === 0, "kein verwaister Ansprechpartner");
ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId, emergencyReason: "Rollback-Test" } })) === 0, "kein Auftrag");
ok((await prisma.workSession.count({ where: { clientId: failing.clientIds.session } })) === 0, "keine WorkSession");
const r2 = await createEmergencyOrder(A.ctx.tech, emergencyPayload({ customer: { mode: "new", companyName: "Bäckerei Sonne", phone: "040 1111" } }));
ok(Number(r2.number.slice(2)) === Number(r1.number.slice(2)) + 1, `Nummernkreis ohne Lücke nach Rollback (${r1.number} → ${r2.number})`);
console.log("\n— Sichtbarkeit vorläufiger Kunde —");
ok((await getCustomer(A.ctx.tech, r1.customerId)).id === r1.customerId, "Monteur sieht vorläufigen Kunden über eigenen Notdienstauftrag");
await expectCode(() => getCustomer(A.ctx.outsider, r1.customerId), "not_found", "anderer Monteur (fremdes Team) → not_found (Kunde)");
await expectCode(() => requireVisibleWorkOrder(A.ctx.outsider, r1.workOrderId), "not_found", "anderer Monteur → not_found (Auftrag)");
ok((await requireVisibleWorkOrder(A.ctx.lead, r1.workOrderId)).id === r1.workOrderId, "Teamleiter des gewählten Teams sieht den Auftrag");
await expectCode(() => getCustomer(B.ctx.admin, r1.customerId), "not_found", "Mandant B → not_found (Kunde)");
await expectCode(() => requireVisibleWorkOrder(B.ctx.admin, r1.workOrderId), "not_found", "Mandant B → not_found (Auftrag)");
console.log("\n— Such-Service —");
const hits = await searchCustomersForEmergency(A.ctx.outsider, SLUG_A);
ok(hits.some((h) => h.id === A.customerId), "Monteur findet fremden Kunden für den Notdienst");
ok(hits.every((h) => JSON.stringify(Object.keys(h).sort()) === JSON.stringify(["city", "customerNumber", "id", "name", "provisional"])), "nur minimale Felder (id, Nummer, Name, Ort, vorläufig)");
ok((await searchCustomersForEmergency(A.ctx.outsider, "Meier")).some((h) => h.id === r1.customerId && h.provisional), "vorläufige Kunden sind auffindbar (Dublettenvermeidung)");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency_customer_search", actorId: A.users.outsider.id } })) >= 2, "Suchzugriffe auditiert");
ok((await searchCustomersForEmergency(B.ctx.tech, SLUG_A)).length === 0, "Mandant B findet keine Kunden von A");
ok((await searchCustomersForEmergency(A.ctx.tech, "a")).length === 0, "Suchbegriff < 2 Zeichen → keine Treffer");
await expectCode(() => searchCustomersForEmergency(A.ctx.backoffice, "Kunde"), "forbidden", "ohne emergency:create → forbidden");
const sites = await listSitesForEmergency(A.ctx.outsider, A.customerId);
ok(sites.some((s) => s.id === A.siteId) && sites.every((s) => JSON.stringify(Object.keys(s).sort()) === JSON.stringify(["address", "id", "name"])), "Objekte des Kunden mit minimalen Feldern");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency_site_lookup", entityId: A.customerId } })) === 1, "Objektabfrage auditiert");
await expectCode(() => listSitesForEmergency(B.ctx.tech, A.customerId), "not_found", "Mandant B: Objekte von A → not_found");
const teamsOfTech = await emergencyTeamOptions(A.ctx.tech);
ok(teamsOfTech.length === 1 && teamsOfTech[0].id === A.teamId && teamsOfTech[0].members.some((m) => m.id === A.users.lead.id), "Teamauswahl: nur eigene Teams inkl. Mitglieder");
console.log("\n— Bestehender Kunde + Objekt, Validierung, Rechte —");
const r3 = await createEmergencyOrder(
A.ctx.outsider,
emergencyPayload({ customer: { mode: "existing", customerId: A.customerId }, site: { mode: "existing", siteId: A.siteId }, onSiteContact: { name: "Frau Kontakt", phone: "040 5555" } }),
);
const wo3 = await prisma.workOrder.findUniqueOrThrow({ where: { id: r3.workOrderId }, include: { customer: true, contact: true } });
ok(wo3.customerId === A.customerId && wo3.siteId === A.siteId && wo3.customer.status === "active", "bestehender Kunde/Objekt bleiben unverändert aktiv");
ok(wo3.contact?.customerId === A.customerId && wo3.assignedTeamId === A.otherTeamId, "Kontakt am bestehenden Kunden, eigenes Team des Monteurs");
ok((await getCustomer(A.ctx.outsider, A.customerId)).id === A.customerId, "Monteur sieht bestehenden Kunden danach über eigenen Notdienst");
const r3b = await createEmergencyOrder(
A.ctx.outsider,
emergencyPayload({ customer: { mode: "existing", customerId: A.customerId }, site: { mode: "existing", siteId: A.siteId }, onSiteContact: { name: "frau kontakt", phone: "040 5555" } }),
);
ok(r3b.contactId === r3.contactId, "gleicher Ansprechpartner (Name + Telefon) wird wiederverwendet");
await expectCode(
() => createEmergencyOrder(A.ctx.tech, emergencyPayload({ customer: { mode: "existing", customerId: A.customerId }, site: { mode: "existing", siteId: r1.siteId } })),
"invalid",
"Objekt eines anderen Kunden → invalid",
);
await expectCode(() => createEmergencyOrder(A.ctx.outsider, emergencyPayload({ teamId: A.teamId })), "invalid", "fremdes Team → invalid");
await expectCode(() => createEmergencyOrder(A.ctx.outsider, emergencyPayload({ assigneeIds: [A.users.tech.id] })), "invalid", "Kollege aus fremdem Team → invalid");
const withColleague = await createEmergencyOrder(A.ctx.tech, emergencyPayload({ assigneeIds: [A.users.lead.id] }));
ok((await prisma.workOrderAssignee.count({ where: { workOrderId: withColleague.workOrderId } })) === 2, "Kollege aus eigenem Team zugewiesen");
await expectCode(() => createEmergencyOrder(B.ctx.tech, emergencyPayload({ customer: { mode: "existing", customerId: A.customerId } })), "invalid", "Mandant B mit Kunden-ID von A → invalid (nicht gefunden)");
ok((await prisma.workOrder.count({ where: { tenantId: B.tenantId, isEmergency: true } })) === 0, "in Mandant B nichts angelegt");
await expectCode(() => createEmergencyOrder(A.ctx.backoffice, emergencyPayload()), "forbidden", "Backoffice ohne emergency:create → forbidden");
await expectCode(() => createEmergencyOrder(A.ctx.tech, emergencyPayload({ reason: " " })), "invalid", "Grund fehlt → invalid");
await expectCode(
() => createEmergencyOrder(A.ctx.tech, emergencyPayload({ customer: { mode: "new", lastName: "Ohne Telefon", phone: "" } })),
"invalid",
"vorläufiger Kunde ohne Telefon → invalid",
);
await expectCode(() => createEmergencyOrder(A.ctx.tech, emergencyPayload({ onSiteContact: { name: "X", phone: "" } })), "invalid", "Ansprechpartner ohne Telefon → invalid");
console.log("\n— Sync-Op emergency.create —");
const syncPayload = emergencyPayload({ customer: { mode: "new", lastName: "Offline", phone: "040 2222" }, offline: true });
const op = syncOp(syncPayload);
const s1 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [op] })).results[0];
const syncWoId = s1.idMap?.[syncPayload.clientIds.workOrder];
ok(s1.status === "applied" && !!syncWoId && !!s1.idMap?.[syncPayload.clientIds.session] && !!s1.idMap?.[syncPayload.clientIds.customer!], "applied mit idMap (Auftrag, Session, Kunde)");
const s2 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [op] })).results[0];
const sorted = (m?: Record<string, string>) => JSON.stringify(Object.entries(m ?? {}).sort());
ok(s2.status === "duplicate" && sorted(s2.idMap) === sorted(s1.idMap), "gleiche clientOpId → duplicate mit gespeicherter idMap");
const s3 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [syncOp(syncPayload)] })).results[0];
ok(s3.status === "applied" && s3.idMap?.[syncPayload.clientIds.workOrder] === syncWoId, "neue clientOpId, gleiche Client-IDs → derselbe Auftrag (Replay)");
ok((await prisma.customer.count({ where: { tenantId: A.tenantId, lastName: "Offline" } })) === 1, "keine Doppelanlage des Kunden");
ok((await prisma.workSession.findFirst({ where: { clientId: syncPayload.clientIds.session } }))?.startedOffline === true, "Offline-Flag an der Session");
const s4 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [syncOp({ ...syncPayload, reason: "" })] })).results[0];
ok(s4.status === "rejected" && s4.errorCode === "invalid", "ungültige Payload → rejected invalid");
const s5 = (await applyOperations(A.ctx.outsider, { deviceId: "test", operations: [syncOp(syncPayload)] })).results[0];
ok(s5.status === "rejected" && s5.errorCode === "invalid", "Replay durch anderen Nutzer → rejected (keine Übernahme)");
await prisma.tenantModule.create({ data: { tenantId: A.tenantId, moduleKey: "emergency", enabled: false } });
const s6 = (await applyOperations(A.ctx.tech, { deviceId: "test", operations: [syncOp(emergencyPayload())] })).results[0];
ok(s6.status === "rejected" && s6.errorCode === "forbidden", "Modul emergency deaktiviert → rejected forbidden");
await prisma.tenantModule.deleteMany({ where: { tenantId: A.tenantId, moduleKey: "emergency" } });
console.log("\n— Abschluss → emergency.completed —");
ok((await buildEmergencyCompletedData(A.ctx.tech, (await rawWorkOrder(A, "in_progress")).id)) === null, "normaler Auftrag → kein Notdienst-Event");
await endSession(A.ctx.tech, { workOrderId: r1.workOrderId });
const report = (await createCompletionReport(A.ctx.tech, { workOrderId: r1.workOrderId })).report;
await updateReportTexts(A.ctx.tech, { reportId: report.id, texts: { workPerformed: "Leitung abgedichtet, Wasser abgepumpt." } });
await captureSignature(A.ctx.tech, { reportId: report.id, outcome: "customer_absent", reason: "Kunde nachts nicht erreichbar", confirmationText: "Bestätigt" });
await submitReport(A.ctx.tech, { reportId: report.id });
const afterSubmit = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } });
ok(afterSubmit.status === "in_review", "Auftragsstatus nach Abschlussbericht: in_review");
const completedNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "emergency.completed", entityId: r1.workOrderId } });
ok(completedNotes.some((n) => n.userId === A.users.backoffice.id), "Event emergency.completed → Backoffice benachrichtigt");
const data = await buildEmergencyCompletedData(A.ctx.backoffice, r1.workOrderId);
ok(!!data && data.technician === `tech ${SLUG_A}` && data.customer === "Lutz Meier" && data.status === "in_review", "Daten: Monteur, Kunde, Status");
ok(!!data && new Date(data.startedAt) <= new Date(data.endedAt), "Daten: Einsatzbeginn ≤ Einsatzende");
const mails = await prisma.mailLog.findMany({ where: { tenantId: A.tenantId, template: "craftvia_emergency" } }).catch(() => null);
if (mails) ok(mails.length >= 1, "Pflicht-E-Mail craftvia_emergency protokolliert");
console.log(`\n${failures === 0 ? "✓ alle Prüfungen bestanden" : `✗ ${failures} Prüfung(en) fehlgeschlagen`}`);
} finally {
await cleanupTenants([SLUG_A, SLUG_B]);
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
process.exit(failures > 0 || process.exitCode ? 1 : 0);
});
+170
View File
@@ -0,0 +1,170 @@
// Lane L8 „Notdienst" — Backoffice-Nachbearbeitung: Liste/Detail (Fortschritt), Kunde bestätigen
// (Kundennummer), bestehendem Kunden zuordnen (alles umgehängt, vorläufig → merged), Dublette
// zusammenführen, Objekt korrigieren/zuordnen, Auftrag ergänzen, Abrechnungsfreigabe.
// Rechte: Monteur/Teamleiter können nicht reviewen; Mandant B sieht/ändert nichts von A.
//
// Lauf: npx tsx scripts/test-notdienst-review.ts
import "dotenv/config";
import { randomUUID } from "node:crypto";
import { prisma } from "../src/server/db";
import type { EmergencyCreateInput } from "../src/lib/emergency/schemas";
import { createEmergencyOrder } from "../src/server/services/emergency/create";
import {
assignEmergencySite,
assignEmergencyToCustomer,
completeEmergencyOrderData,
confirmEmergencyCustomer,
correctEmergencySite,
getEmergencyReview,
listEmergencyReviews,
mergeEmergencyCustomer,
releaseEmergencyForBilling,
} from "../src/server/services/emergency/review";
import { endSession } from "../src/server/services/field/sessions";
import { createCompletionReport } from "../src/server/services/reports/create";
import { updateReportTexts } from "../src/server/services/reports/edit";
import { captureSignature } from "../src/server/services/reports/signature";
import { submitReport } from "../src/server/services/reports/submit";
import { approveReport } from "../src/server/services/reports/approve";
import type { ServiceCtx } from "../src/server/services/context";
import { cleanupTenants, createFixture, expectCode, failures, ok } from "./lib-auftraege-fixtures";
const SLUG_A = "zz-l8-review-a";
const SLUG_B = "zz-l8-review-b";
function payload(over: Partial<EmergencyCreateInput> = {}): EmergencyCreateInput {
return {
clientIds: { workOrder: randomUUID(), session: randomUUID() },
customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 7654321", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
onSiteContact: { name: "Lutz Meier", phone: "0171 7654321" },
reason: "Heizung ausgefallen",
...over,
};
}
async function finishWithReport(ctx: ServiceCtx, workOrderId: string): Promise<string> {
await endSession(ctx, { workOrderId });
const report = (await createCompletionReport(ctx, { workOrderId })).report;
await updateReportTexts(ctx, { reportId: report.id, texts: { workPerformed: "Brenner getauscht." } });
await captureSignature(ctx, { reportId: report.id, outcome: "customer_absent", reason: "Kunde nicht vor Ort", confirmationText: "Bestätigt" });
await submitReport(ctx, { reportId: report.id });
return report.id;
}
async function main() {
await cleanupTenants([SLUG_A, SLUG_B]);
const A = await createFixture(SLUG_A);
const B = await createFixture(SLUG_B);
const office = A.ctx.backoffice;
try {
const r1 = await createEmergencyOrder(A.ctx.tech, payload());
const reportId = await finishWithReport(A.ctx.tech, r1.workOrderId);
const r2 = await createEmergencyOrder(A.ctx.tech, payload({ customer: { mode: "new", companyName: "Bäckerei Sonne", phone: "040 3333" }, onSiteContact: { name: "Frau Sonne", phone: "040 3333" }, reason: "Stromausfall Backstube" }));
const existing = await prisma.customer.create({
data: { tenantId: A.tenantId, customerNumber: "K-77001", firstName: "Lutz", lastName: "Meier", phone: "+49 171 7654321", city: "Hamburg", street: "Elbchaussee" },
});
const existingSite = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: existing.id, name: "Wohnhaus Elbchaussee", street: "Elbchaussee", houseNumber: "12", city: "Hamburg" } });
console.log("\n— Rechte & Mandantentrennung —");
await expectCode(() => listEmergencyReviews(A.ctx.tech), "forbidden", "Monteur: Liste → forbidden");
await expectCode(() => listEmergencyReviews(A.ctx.lead), "forbidden", "Teamleiter: Liste → forbidden");
await expectCode(() => getEmergencyReview(A.ctx.tech, r1.workOrderId), "forbidden", "Monteur: Detail → forbidden");
await expectCode(() => confirmEmergencyCustomer(A.ctx.tech, r2.workOrderId), "forbidden", "Monteur: Kunden bestätigen → forbidden");
await expectCode(() => assignEmergencyToCustomer(A.ctx.tech, { workOrderId: r1.workOrderId, targetCustomerId: existing.id }), "forbidden", "Monteur: zuordnen → forbidden");
await expectCode(() => releaseEmergencyForBilling(A.ctx.tech, { workOrderId: r1.workOrderId }), "forbidden", "Monteur: Abrechnung → forbidden");
ok((await listEmergencyReviews(B.ctx.backoffice, { filter: "all" })).length === 0, "Mandant B: Liste enthält keine Notdienste von A");
await expectCode(() => getEmergencyReview(B.ctx.backoffice, r1.workOrderId), "not_found", "Mandant B: Detail → not_found");
await expectCode(() => confirmEmergencyCustomer(B.ctx.backoffice, r2.workOrderId), "not_found", "Mandant B: Kunden bestätigen → not_found");
await expectCode(() => assignEmergencyToCustomer(B.ctx.backoffice, { workOrderId: r1.workOrderId, targetCustomerId: existing.id }), "not_found", "Mandant B: zuordnen → not_found");
await expectCode(() => completeEmergencyOrderData(B.ctx.backoffice, { workOrderId: r1.workOrderId, title: "B" }), "not_found", "Mandant B: Auftrag ergänzen → not_found");
ok((await prisma.customer.findUniqueOrThrow({ where: { id: r2.customerId } })).status === "provisional", "nach fremden Versuchen unverändert vorläufig");
const regular = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "A-TEST-1", title: "Normal", customerId: A.customerId, status: "in_review" } });
await expectCode(() => getEmergencyReview(office, regular.id), "not_found", "normaler Auftrag ist kein Notdienst → not_found");
console.log("\n— Liste & Fortschritt —");
const list = await listEmergencyReviews(office);
const item1 = list.find((i) => i.id === r1.workOrderId);
ok(!!item1 && !!list.find((i) => i.id === r2.workOrderId), "Liste enthält Notdienste mit vorläufigen Stammdaten");
ok(item1?.progress.total === 5 && item1.progress.count === 0 && item1.technician === `tech ${SLUG_A}`, "Fortschritt 0 von 5, Monteur angezeigt");
const detail = await getEmergencyReview(office, r1.workOrderId);
ok(detail.duplicates.some((d) => d.customerId === existing.id), "Dublettenkandidat über findDuplicateCustomers (Telefon/Name)");
ok(detail.startedAt instanceof Date && detail.endedAt instanceof Date && detail.startedAt <= detail.endedAt, "Einsatzbeginn/-ende aus Arbeitszeiten");
console.log("\n— Bestehendem Kunden zuordnen —");
await expectCode(() => assignEmergencyToCustomer(office, { workOrderId: r1.workOrderId, targetCustomerId: r1.customerId }), "invalid", "gleicher Kunde → invalid");
await expectCode(() => assignEmergencyToCustomer(office, { workOrderId: r1.workOrderId, targetCustomerId: B.customerId }), "invalid", "Kunde aus Mandant B als Ziel → invalid");
const before = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } });
const assigned = await assignEmergencyToCustomer(office, { workOrderId: r1.workOrderId, targetCustomerId: existing.id });
const woAfter = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId }, include: { site: true, contact: true } });
const src = await prisma.customer.findUniqueOrThrow({ where: { id: r1.customerId } });
ok(woAfter.customerId === existing.id && woAfter.version === before.version + 1, "Auftrag umgehängt, Version +1");
ok(woAfter.site?.customerId === existing.id && woAfter.contact?.customerId === existing.id, "Objekt und Ansprechpartner umgehängt");
ok(src.status === "merged" && src.mergedIntoId === existing.id && !src.isProvisional, "vorläufiger Kunde → merged (mergedIntoId)");
ok(assigned.moved.workOrders === 1 && assigned.moved.sites === 1, "Zähler der umgehängten Datensätze");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "customer", entityId: { in: [r1.customerId, existing.id] } } })) >= 2, "Audit für Quelle und Ziel");
await expectCode(() => assignEmergencyToCustomer(office, { workOrderId: r1.workOrderId, targetCustomerId: A.customerId }), "conflict", "erneutes Zuordnen (nicht mehr vorläufig) → conflict");
ok((await getEmergencyReview(office, r1.workOrderId)).progress.done.customer, "Prüfschritt Kunde erledigt");
console.log("\n— Objekt zuordnen / korrigieren —");
const oldSiteId = woAfter.siteId!;
const siteRes = await assignEmergencySite(office, { workOrderId: r1.workOrderId, siteId: existingSite.id });
const woSite = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } });
ok(woSite.siteId === existingSite.id && siteRes.removed, "Auftrag am bestehenden Objekt");
ok(!!(await prisma.site.findUniqueOrThrow({ where: { id: oldSiteId } })).deletedAt, "ungenutztes vorläufiges Objekt soft-gelöscht");
await expectCode(() => assignEmergencySite(office, { workOrderId: r1.workOrderId, siteId: B.siteId }), "invalid", "Objekt aus Mandant B → invalid");
const corrected = await correctEmergencySite(office, { workOrderId: r2.workOrderId, patch: { name: "Backstube", accessNotes: "Hintereingang" }, confirm: true });
ok(corrected.status === "active" && corrected.name === "Backstube" && corrected.customerId === r2.customerId, "vorläufiges Objekt korrigiert und bestätigt");
console.log("\n— Kunde bestätigen —");
const confirmed = await confirmEmergencyCustomer(office, r2.workOrderId);
const c2 = await prisma.customer.findUniqueOrThrow({ where: { id: r2.customerId } });
ok(/^K-\d+$/.test(confirmed.customerNumber ?? "") && c2.customerNumber === confirmed.customerNumber, `Kundennummer bei Bestätigung vergeben (${confirmed.customerNumber})`);
ok(c2.status === "active" && !c2.isProvisional, "Kunde aktiv");
await expectCode(() => confirmEmergencyCustomer(office, r2.workOrderId), "conflict", "zweite Bestätigung → conflict");
console.log("\n— Dublette zusammenführen —");
await expectCode(() => mergeEmergencyCustomer(office, { workOrderId: r2.workOrderId, targetCustomerId: A.customerId, confirm: false }), "invalid", "ohne Bestätigung → invalid");
await expectCode(() => mergeEmergencyCustomer(A.ctx.tech, { workOrderId: r2.workOrderId, targetCustomerId: A.customerId, confirm: true }), "forbidden", "Monteur → forbidden");
await mergeEmergencyCustomer(office, { workOrderId: r2.workOrderId, targetCustomerId: A.customerId, confirm: true });
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: r2.workOrderId } })).customerId === A.customerId, "Auftrag nach Merge am Zielkunden");
ok((await prisma.customer.findUniqueOrThrow({ where: { id: r2.customerId } })).status === "merged", "Quelle merged");
console.log("\n— Auftrag ergänzen & Abrechnung —");
const notdienst = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "notdienst" } });
await expectCode(() => completeEmergencyOrderData(office, { workOrderId: r1.workOrderId, title: "" }), "invalid", "leerer Titel → invalid");
await completeEmergencyOrderData(office, { workOrderId: r1.workOrderId, title: "Notdienst Heizung", description: "Brenner getauscht", orderTypeId: notdienst.id, billingType: "time_material" });
const woData = await prisma.workOrder.findUniqueOrThrow({ where: { id: r1.workOrderId } });
ok(woData.title === "Notdienst Heizung" && woData.billingType === "time_material" && woData.orderTypeId === notdienst.id, "Titel, Auftragsart, Abrechnungsart gespeichert");
await expectCode(() => completeEmergencyOrderData(office, { workOrderId: r1.workOrderId, title: "alt", baseVersion: 1 }), "conflict", "veraltete Version → conflict");
const r3 = await createEmergencyOrder(A.ctx.tech, payload({ customer: { mode: "new", lastName: "Offen", phone: "040 4444" } }));
await expectCode(() => releaseEmergencyForBilling(office, { workOrderId: r3.workOrderId }), "blocked", "vorläufige Stammdaten → Abrechnung blocked");
const blockedErr = await releaseEmergencyForBilling(office, { workOrderId: r3.workOrderId }).catch((e) => e);
ok((blockedErr?.details as { reason?: string })?.reason === "master_data_open", "Grund master_data_open");
await expectCode(() => releaseEmergencyForBilling(office, { workOrderId: r1.workOrderId }), "blocked", "ohne freigegebenen Abschlussbericht → blocked");
await approveReport(office, { reportId }, { dispatchPdf: async () => {} });
const released = await releaseEmergencyForBilling(office, { workOrderId: r1.workOrderId });
ok(released.status === "released_for_billing", "zur Abrechnung freigegeben");
const final = await getEmergencyReview(office, r1.workOrderId);
ok(final.progress.count === 5, "Fortschritt 5 von 5");
ok(!(await listEmergencyReviews(office)).some((i) => i.id === r1.workOrderId), "erledigter Notdienst nicht mehr in „Zu prüfen“");
ok((await listEmergencyReviews(office, { filter: "all" })).some((i) => i.id === r1.workOrderId), "unter „Alle“ weiterhin sichtbar");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "emergency", entityId: r1.workOrderId, action: "update" } })) >= 4, "Prüfschritte auditiert");
console.log(`\n${failures === 0 ? "✓ alle Prüfungen bestanden" : `✗ ${failures} Prüfung(en) fehlgeschlagen`}`);
} finally {
await cleanupTenants([SLUG_A, SLUG_B]);
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
process.exit(failures > 0 || process.exitCode ? 1 : 0);
});
+1 -1
View File
@@ -144,7 +144,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => { {TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => {
const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
const href = key === "sync_conflicts" ? "/work-orders/conflicts" : `/work-orders${toQuery({ ...filter, preset: key })}`; const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
return ( return (
<li key={key}> <li key={key}>
<Link <Link
@@ -0,0 +1,342 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ArrowLeft, ExternalLink } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { pageContext } from "@/components/work-orders/page-context";
import { Check, Dl, Empty, Field, inputCls, Section, StatusBadge } from "@/components/work-orders/ui";
import { ReviewForm } from "@/components/emergency/review-form";
import { ReviewProgressBar, StepState } from "@/components/emergency/review-progress";
import { REVIEW_STEPS } from "@/lib/emergency/schemas";
import { BILLING_TYPES } from "@/lib/work-orders/schemas";
import { formatDateTime } from "@/lib/work-orders/time";
import {
assignCustomerAction,
assignSiteAction,
completeOrderAction,
confirmCustomerAction,
correctSiteAction,
mergeCustomerAction,
releaseBillingAction,
} from "@/server/actions/emergency/review";
import { ServiceError } from "@/server/services/context";
import { formatAddress } from "@/server/services/customers/format";
import { getEmergencyReview } from "@/server/services/emergency/review";
import { listOrderTypes } from "@/server/services/work-orders/settings";
const areaCls = `${inputCls} min-h-24 py-2`;
/** `/work-orders/emergency-review/[id]` — Prüfschritte eines Notdiensteinsatzes (spec §19.3, §39). */
export default async function EmergencyReviewDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { ctx, locale, tz, can } = await pageContext();
if (!can("emergency:review")) redirect("/work-orders");
const { id } = await params;
let r;
try {
r = await getEmergencyReview(ctx, id);
} catch (err) {
if (err instanceof ServiceError && err.code === "not_found") notFound();
throw err;
}
const t = await getTranslations("emergency.review");
const tw = await getTranslations("workOrders");
const orderTypes = can("work_order:write") ? await listOrderTypes(ctx, { activeOnly: true }) : [];
const customerProvisional = r.customer.status === "provisional";
const siteProvisional = r.site?.status === "provisional";
const completion = r.reports.find((x) => x.type === "completion");
const stepState = (done: boolean) => <StepState done={done} doneLabel={t("stepDone")} openLabel={t("stepOpen")} />;
return (
<main className="flex-1 p-4 md:p-6">
<Link href="/work-orders/emergency-review" className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" aria-hidden />
{t("back")}
</Link>
<PageHead
crumb={`${t("crumb")} · ${r.number}`}
title={r.title}
sub={t("detailSub", {
technician: r.technician ?? "—",
start: r.startedAt ? formatDateTime(r.startedAt, locale, tz) : "—",
end: r.endedAt ? formatDateTime(r.endedAt, locale, tz) : t("running"),
})}
actions={
<Link href={`/work-orders/${r.id}`} className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border px-4 text-sm font-semibold hover:bg-muted">
<ExternalLink className="size-4" aria-hidden />
{t("openOrder")}
</Link>
}
/>
<Section className="mb-4">
<div className="flex flex-wrap items-center gap-4">
<StatusBadge status={r.status} label={tw(`status.${r.status}`)} />
<ReviewProgressBar className="min-w-56" count={r.progress.count} total={r.progress.total} label={t("progress", { count: r.progress.count, total: r.progress.total })} />
</div>
<ol className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
{REVIEW_STEPS.map((s, idx) => (
<li key={s} className="rounded-lg border px-3 py-2">
<p className="text-[13px] font-semibold">
{idx + 1}. {t(`steps.${s}`)}
</p>
{stepState(r.progress.done[s])}
</li>
))}
</ol>
{r.emergencyReason && (
<div className="mt-3 text-sm">
<p className="text-muted-foreground">{t("reason")}</p>
<p className="whitespace-pre-wrap">{r.emergencyReason}</p>
</div>
)}
</Section>
<div className="grid gap-4 xl:grid-cols-2">
{/* 1 — Kunde */}
<Section title={`1. ${t("steps.customer")}`} actions={stepState(r.progress.done.customer)}>
<Dl
rows={[
[t("customerName"), r.customerName],
[t("customerNumber"), r.customer.customerNumber ?? t("numberOnConfirm")],
[t("status"), t(`customerStatus.${r.customer.status}`)],
[t("phone"), r.customer.phone ?? r.customer.mobile],
[t("email"), r.customer.email],
[t("address"), formatAddress(r.customer)],
[t("contact"), r.contact ? `${r.contact.name}${r.contact.phone ? ` · ${r.contact.phone}` : ""}` : null],
]}
/>
{customerProvisional && (
<p className="mt-3 rounded-lg border border-[var(--warn)] px-3 py-2 text-sm">{t("provisionalHint")}</p>
)}
<div className="mt-4 space-y-5">
{customerProvisional && can("customer:write") && (
<div>
<h3 className="mb-1 text-sm font-semibold">{t("confirmCustomer.title")}</h3>
<p className="mb-2 text-xs text-muted-foreground">{t("confirmCustomer.hint")}</p>
<ReviewForm action={confirmCustomerAction.bind(null, r.id)} submitLabel={t("confirmCustomer.submit")} successText={t("saved")} variant="primary" />
</div>
)}
<div>
<h3 className="mb-1 text-sm font-semibold">{t("duplicates.title")}</h3>
{r.duplicates.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("duplicates.none")}</p>
) : (
<ul className="mb-2 space-y-1 text-sm">
{r.duplicates.map((d) => (
<li key={d.customerId} className="flex flex-wrap items-center gap-2">
<Link href={`/customers/${d.customerId}`} className="font-semibold text-[var(--primary)] hover:underline">
{d.displayName}
</Link>
<span className="text-xs text-muted-foreground">
{[d.customerNumber, d.city, t("duplicates.score", { score: Math.round(d.score * 100) })].filter(Boolean).join(" · ")}
</span>
</li>
))}
</ul>
)}
</div>
{customerProvisional && can("customer:write") && can("work_order:write") && (
<div>
<h3 className="mb-1 text-sm font-semibold">{t("assignCustomer.title")}</h3>
<p className="mb-2 text-xs text-muted-foreground">{t("assignCustomer.hint")}</p>
<ReviewForm action={assignCustomerAction.bind(null, r.id)} submitLabel={t("assignCustomer.submit")} successText={t("saved")} variant="outline">
<TargetFields idPrefix="assign" candidates={r.duplicates} labels={{ select: t("target.select"), none: t("target.none"), number: t("target.number") }} />
</ReviewForm>
</div>
)}
{can("customer:merge") && r.customer.status !== "merged" && (
<div>
<h3 className="mb-1 text-sm font-semibold">{t("mergeCustomer.title")}</h3>
<p className="mb-2 text-xs text-muted-foreground">{t("mergeCustomer.hint")}</p>
<ReviewForm action={mergeCustomerAction.bind(null, r.id)} submitLabel={t("mergeCustomer.submit")} successText={t("saved")} variant="danger">
<TargetFields idPrefix="merge" candidates={r.duplicates} labels={{ select: t("target.select"), none: t("target.none"), number: t("target.number") }} />
<Check id="merge-confirm" name="confirm" label={t("mergeCustomer.confirm")} />
</ReviewForm>
</div>
)}
</div>
</Section>
{/* 2 — Objekt */}
<Section title={`2. ${t("steps.site")}`} actions={stepState(r.progress.done.site)}>
{r.site ? (
<Dl
rows={[
[t("siteName"), r.site.name],
[t("address"), formatAddress(r.site)],
[t("status"), t(`siteStatus.${r.site.status}`)],
[t("onSiteContact"), [r.site.onSiteContact, r.site.phone].filter(Boolean).join(" · ")],
]}
/>
) : (
<Empty>{t("noSite")}</Empty>
)}
<div className="mt-4 space-y-5">
{r.site && can("site:write") && (
<div>
<h3 className="mb-2 text-sm font-semibold">{t("correctSite.title")}</h3>
<ReviewForm action={correctSiteAction.bind(null, r.id)} submitLabel={t("correctSite.submit")} successText={t("saved")} variant={siteProvisional ? "primary" : "outline"}>
<div className="grid gap-3 sm:grid-cols-2">
<Field label={t("siteName")} htmlFor="site-name" className="sm:col-span-2">
<input id="site-name" name="name" defaultValue={r.site.name} required className={inputCls} />
</Field>
<Field label={t("street")} htmlFor="site-street">
<input id="site-street" name="street" defaultValue={r.site.street ?? ""} className={inputCls} />
</Field>
<Field label={t("houseNumber")} htmlFor="site-no">
<input id="site-no" name="houseNumber" defaultValue={r.site.houseNumber ?? ""} className={inputCls} />
</Field>
<Field label={t("postalCode")} htmlFor="site-zip">
<input id="site-zip" name="postalCode" defaultValue={r.site.postalCode ?? ""} className={inputCls} />
</Field>
<Field label={t("city")} htmlFor="site-city">
<input id="site-city" name="city" defaultValue={r.site.city ?? ""} className={inputCls} />
</Field>
<Field label={t("onSiteContact")} htmlFor="site-contact">
<input id="site-contact" name="onSiteContact" defaultValue={r.site.onSiteContact ?? ""} className={inputCls} />
</Field>
<Field label={t("phone")} htmlFor="site-phone">
<input id="site-phone" name="phone" type="tel" defaultValue={r.site.phone ?? ""} className={inputCls} />
</Field>
<Field label={t("accessNotes")} htmlFor="site-access" className="sm:col-span-2">
<textarea id="site-access" name="accessNotes" defaultValue={r.site.accessNotes ?? ""} className={areaCls} />
</Field>
</div>
{siteProvisional && <Check id="site-confirm" name="confirm" label={t("correctSite.confirm")} defaultChecked />}
</ReviewForm>
</div>
)}
{can("work_order:write") && (
<div>
<h3 className="mb-1 text-sm font-semibold">{t("assignSite.title")}</h3>
{r.siteOptions.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("assignSite.none")}</p>
) : (
<ReviewForm action={assignSiteAction.bind(null, r.id)} submitLabel={t("assignSite.submit")} successText={t("saved")} variant="outline">
<p className="text-xs text-muted-foreground">{t("assignSite.hint")}</p>
<Field label={t("assignSite.select")} htmlFor="assign-site">
<select id="assign-site" name="siteId" required className={inputCls}>
{r.siteOptions.map((s) => (
<option key={s.id} value={s.id}>
{[s.name, formatAddress(s)].filter(Boolean).join(" · ")}
</option>
))}
</select>
</Field>
</ReviewForm>
)}
</div>
)}
</div>
</Section>
{/* 3 — Auftrag ergänzen */}
<Section title={`3. ${t("steps.order")}`} actions={stepState(r.progress.done.order)}>
{can("work_order:write") ? (
<ReviewForm action={completeOrderAction.bind(null, r.id)} submitLabel={t("completeOrder.submit")} successText={t("saved")} variant={r.progress.done.order ? "outline" : "primary"}>
<input type="hidden" name="baseVersion" value={r.version} />
<Field label={t("completeOrder.title")} htmlFor="order-title">
<input id="order-title" name="title" defaultValue={r.title} required maxLength={200} className={inputCls} />
</Field>
<Field label={t("completeOrder.description")} htmlFor="order-description">
<textarea id="order-description" name="description" defaultValue={r.description ?? ""} className={areaCls} />
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label={t("completeOrder.orderType")} htmlFor="order-type">
<select id="order-type" name="orderTypeId" defaultValue={r.orderTypeId ?? ""} className={inputCls}>
<option value="">{t("completeOrder.choose")}</option>
{orderTypes.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</Field>
<Field label={t("completeOrder.billingType")} htmlFor="order-billing">
<select id="order-billing" name="billingType" defaultValue={r.billingType ?? ""} className={inputCls}>
<option value="">{t("completeOrder.choose")}</option>
{BILLING_TYPES.map((b) => (
<option key={b} value={b}>
{t(`billingTypes.${b}`)}
</option>
))}
</select>
</Field>
</div>
<p className="text-xs text-muted-foreground">{t("completeOrder.hint")}</p>
</ReviewForm>
) : (
<Dl rows={[[t("completeOrder.orderType"), r.orderType?.name], [t("completeOrder.billingType"), r.billingType ? t(`billingTypes.${r.billingType}`) : null]]} />
)}
</Section>
{/* 4 — Bericht, 5 — Abrechnung */}
<div className="space-y-4">
<Section title={`4. ${t("steps.report")}`} actions={stepState(r.progress.done.report)}>
{r.reports.length === 0 ? (
<Empty>{t("report.none")}</Empty>
) : (
<ul className="space-y-2 text-sm">
{r.reports.map((rep) => (
<li key={rep.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2">
<span>
{t(`report.type.${rep.type}`)} · {t("report.version", { version: rep.version })} · <strong>{t(`report.status.${rep.status}`)}</strong>
</span>
<Link href={`/reports/${rep.id}`} className="inline-flex min-h-11 items-center gap-1.5 font-semibold text-[var(--primary)] hover:underline">
{t("report.open")}
</Link>
</li>
))}
</ul>
)}
{completion && completion.status !== "approved" && <p className="mt-2 text-xs text-muted-foreground">{t("report.hint")}</p>}
</Section>
<Section title={`5. ${t("steps.billing")}`} actions={stepState(r.progress.done.billing)}>
<p className="mb-3 text-sm text-muted-foreground">{t("billing.hint")}</p>
{r.status === "in_review" && can("work_order:release_billing") ? (
<ReviewForm action={releaseBillingAction.bind(null, r.id)} submitLabel={t("billing.submit")} successText={t("saved")} variant="primary">
<input type="hidden" name="baseVersion" value={r.version} />
</ReviewForm>
) : (
<p className="text-sm">
{t("billing.current")}: <StatusBadge status={r.status} label={tw(`status.${r.status}`)} />
</p>
)}
</Section>
</div>
</div>
</main>
);
}
function TargetFields({
idPrefix,
candidates,
labels,
}: {
idPrefix: string;
candidates: { customerId: string; displayName: string; customerNumber: string | null; city: string | null }[];
labels: { select: string; none: string; number: string };
}) {
return (
<div className="grid gap-3 sm:grid-cols-2">
<Field label={labels.select} htmlFor={`${idPrefix}-target`}>
<select id={`${idPrefix}-target`} name="targetCustomerId" defaultValue="" className={inputCls}>
<option value="">{labels.none}</option>
{candidates.map((c) => (
<option key={c.customerId} value={c.customerId}>
{[c.displayName, c.customerNumber, c.city].filter(Boolean).join(" · ")}
</option>
))}
</select>
</Field>
<Field label={labels.number} htmlFor={`${idPrefix}-number`}>
<input id={`${idPrefix}-number`} name="targetCustomerNumber" className={inputCls} />
</Field>
</div>
);
}
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „emergency" für die Backoffice-Nachbearbeitung (zusätzlich zum work_orders-Gate des Elternordners). */
export default async function EmergencyReviewLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("emergency");
return <>{children}</>;
}
@@ -0,0 +1,91 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ChevronRight, CircleAlert, Siren } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { pageContext } from "@/components/work-orders/page-context";
import { Empty, LinkTabs, StatusBadge } from "@/components/work-orders/ui";
import { ReviewProgressBar } from "@/components/emergency/review-progress";
import { formatDateTime } from "@/lib/work-orders/time";
import { listEmergencyReviews } from "@/server/services/emergency/review";
type SP = Record<string, string | string[] | undefined>;
/** `/work-orders/emergency-review` — Notdiensteinsätze mit vorläufigen Stammdaten oder zur Prüfung (spec §19.3). */
export default async function EmergencyReviewListPage({ searchParams }: { searchParams: Promise<SP> }) {
const { ctx, locale, tz, can } = await pageContext();
if (!can("emergency:review")) redirect("/work-orders");
const sp = await searchParams;
const filter = sp.filter === "all" ? "all" : "open";
const t = await getTranslations("emergency.review");
const tw = await getTranslations("workOrders");
const items = await listEmergencyReviews(ctx, { filter });
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
<div className="mb-4">
<LinkTabs
label={t("filterLabel")}
items={[
{ href: "/work-orders/emergency-review", label: t("filter.open"), active: filter === "open", ...(filter === "open" ? { count: items.length } : {}) },
{ href: "/work-orders/emergency-review?filter=all", label: t("filter.all"), active: filter === "all", ...(filter === "all" ? { count: items.length } : {}) },
]}
/>
</div>
{items.length === 0 ? (
<Empty>{filter === "open" ? t("emptyOpen") : t("emptyAll")}</Empty>
) : (
<ul className="grid gap-3 lg:grid-cols-2">
{items.map((i) => (
<li key={i.id}>
<Link href={`/work-orders/emergency-review/${i.id}`} className="shadow-card flex h-full flex-col gap-3 rounded-xl border border-l-4 border-l-[var(--risk)] bg-card p-4 transition-colors hover:bg-muted/40">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
<Siren className="size-3.5 text-[var(--risk)]" aria-hidden />
{i.number}
</p>
<p className="font-heading text-[15px] font-semibold break-words">{i.title}</p>
</div>
<StatusBadge status={i.status} label={tw(`status.${i.status}`)} />
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
<dt className="text-muted-foreground">{t("customer")}</dt>
<dd className="min-w-0 break-words">
{i.customerName}
{i.customer.status === "provisional" && (
<span className="ml-2 inline-flex items-center gap-1 text-xs font-semibold text-[var(--warn)]">
<CircleAlert className="size-3.5" aria-hidden />
{t("provisional")}
</span>
)}
</dd>
<dt className="text-muted-foreground">{t("site")}</dt>
<dd className="min-w-0 break-words">
{i.site?.name ?? "—"}
{i.site?.status === "provisional" && (
<span className="ml-2 inline-flex items-center gap-1 text-xs font-semibold text-[var(--warn)]">
<CircleAlert className="size-3.5" aria-hidden />
{t("provisional")}
</span>
)}
</dd>
<dt className="text-muted-foreground">{t("technician")}</dt>
<dd>{i.technician ?? "—"}</dd>
<dt className="text-muted-foreground">{t("startedAt")}</dt>
<dd>{formatDateTime(i.plannedStart ?? i.createdAt, locale, tz)}</dd>
</dl>
<div className="mt-auto flex items-center gap-3">
<ReviewProgressBar count={i.progress.count} total={i.progress.total} label={t("progress", { count: i.progress.count, total: i.progress.total })} />
<ChevronRight className="size-4.5 shrink-0 text-muted-foreground" aria-hidden />
</div>
</Link>
</li>
))}
</ul>
)}
</main>
);
}
+23 -3
View File
@@ -1,5 +1,25 @@
import { ModulePlaceholder } from "@/components/module-placeholder"; import { getTranslations } from "next-intl/server";
import { moduleGuard } from "@/server/action-guard";
import { can, ctxFromGuard } from "@/server/services/context";
import { emergencyTeamOptions } from "@/server/services/emergency/lookup";
import { EmergencyWizard } from "@/components/emergency/emergency-wizard";
export default function Page() { /** `/m/emergency` — Notdienst erfassen (spec §19.2, US-010): max. 3 steps, then straight into the call-out. */
return <ModulePlaceholder moduleKey="emergency" />; export default async function EmergencyPage() {
const ctx = ctxFromGuard(await moduleGuard("emergency")());
const t = await getTranslations("emergency.capture");
if (!can(ctx, "emergency:create") || !can(ctx, "field:execute")) {
return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
}
const teams = await emergencyTeamOptions(ctx);
return (
<main className="space-y-4 p-4">
<div>
<h1 className="text-[26px]">{t("title")}</h1>
<p className="text-[15px] text-muted-foreground">{t("sub")}</p>
</div>
<EmergencyWizard teams={teams} userId={ctx.userId} />
</main>
);
} }
+1
View File
@@ -66,6 +66,7 @@ const ENTITY_LABEL: Record<string, string> = {
import: "Auftragsimport", import: "Auftragsimport",
report: "Bericht", report: "Bericht",
emergency: "Notdienst", emergency: "Notdienst",
emergency_customer_search: "Notdienst-Kundensuche", emergency_site_lookup: "Notdienst-Objektauswahl",
signature: "Unterschrift", signature: "Unterschrift",
import_job: "Auftragsimport", import_job: "Auftragsimport",
material_usage: "Material", material_usage: "Material",
@@ -0,0 +1,618 @@
"use client";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Building2, Check, ChevronLeft, ChevronRight, LoaderCircle, MapPin, Mic, Search, Siren, Square, TriangleAlert, UserPlus, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
import { uploadFieldFile } from "@/lib/field/upload";
import type { EmergencyCreateInput } from "@/lib/emergency/schemas";
import { btnPrimary, btnSecondary, card, chip, inputClass, noticeError, noticeWarn } from "@/components/field/ui";
import { listEmergencySitesAction, searchEmergencyCustomersAction } from "@/server/actions/emergency/capture";
import type { EmergencyCustomerHit, EmergencySiteHit, EmergencyTeamOption } from "@/server/services/emergency/lookup";
type Step = 1 | 2 | 3;
type NewCustomer = { companyName: string; firstName: string; lastName: string; phone: string; email: string; street: string; houseNumber: string; postalCode: string; city: string };
type Address = { name: string; street: string; houseNumber: string; postalCode: string; city: string };
const EMPTY_CUSTOMER: NewCustomer = { companyName: "", firstName: "", lastName: "", phone: "", email: "", street: "", houseNumber: "", postalCode: "", city: "" };
const EMPTY_ADDRESS: Address = { name: "", street: "", houseNumber: "", postalCode: "", city: "" };
const MAX_VOICE_SECONDS = 300;
function localNow(): string {
const d = new Date();
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
}
function TextField({
id,
label,
value,
onChange,
type = "text",
required,
autoComplete,
inputMode,
className,
invalid,
}: {
id: string;
label: string;
value: string;
onChange: (v: string) => void;
type?: string;
required?: boolean;
autoComplete?: string;
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
className?: string;
invalid?: boolean;
}) {
return (
<div className={className}>
<label htmlFor={id} className="mb-1 block text-[14px] font-semibold">
{label}
{required && <span aria-hidden> *</span>}
</label>
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
autoComplete={autoComplete}
inputMode={inputMode}
aria-invalid={invalid || undefined}
className={cn(inputClass, invalid && "border-[var(--risk)]")}
/>
</div>
);
}
export function EmergencyWizard({ teams, userId }: { teams: EmergencyTeamOption[]; userId: string }) {
const t = useTranslations("emergency.capture");
const router = useRouter();
const [step, setStep] = useState<Step>(1);
const [showErrors, setShowErrors] = useState(false);
// step 1 — customer
const [customerMode, setCustomerMode] = useState<"existing" | "new">("existing");
const [query, setQuery] = useState("");
const [hits, setHits] = useState<EmergencyCustomerHit[]>([]);
const [searching, startSearch] = useTransition();
const [searchFailed, setSearchFailed] = useState(false);
const [selected, setSelected] = useState<EmergencyCustomerHit | null>(null);
const [newCustomer, setNewCustomer] = useState<NewCustomer>(EMPTY_CUSTOMER);
// step 2 — site
const [sites, setSites] = useState<EmergencySiteHit[] | null>(null);
const [siteMode, setSiteMode] = useState<"existing" | "new">("new");
const [siteId, setSiteId] = useState<string>("");
const [address, setAddress] = useState<Address>(EMPTY_ADDRESS);
const [contact, setContact] = useState({ name: "", phone: "" });
// step 3 — reason / start / team
const [reason, setReason] = useState("");
const [startedAt, setStartedAt] = useState("");
const [teamId, setTeamId] = useState<string>(teams[0]?.id ?? "");
const [colleagues, setColleagues] = useState<string[]>([]);
// voice note (kept locally, uploaded after the call-out exists)
const [recState, setRecState] = useState<"idle" | "recording" | "recorded">("idle");
const [recSeconds, setRecSeconds] = useState(0);
const [clip, setClip] = useState<{ blob: Blob; url: string } | null>(null);
const recorder = useRef<MediaRecorder | null>(null);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
// submit
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// stable client ids → a retry after a network error is idempotent (same session client id)
const clientIds = useRef({ workOrder: newClientId(), session: newClientId(), customer: newClientId(), site: newClientId() });
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectionSeq = useRef(0);
useEffect(
() => () => {
if (timer.current) clearInterval(timer.current);
if (searchTimer.current) clearTimeout(searchTimer.current);
recorder.current?.stream.getTracks().forEach((tr) => tr.stop());
},
[],
);
useEffect(() => () => (clip ? URL.revokeObjectURL(clip.url) : undefined), [clip]);
/** Debounced customer search (event-driven, no state updates inside effects). */
function onQueryChange(value: string) {
setQuery(value);
if (searchTimer.current) clearTimeout(searchTimer.current);
const q = value.trim();
if (q.length < 2) {
setHits([]);
setSearchFailed(false);
return;
}
searchTimer.current = setTimeout(() => {
startSearch(async () => {
const res = await searchEmergencyCustomersAction(q);
setSearchFailed(!res.ok);
setHits(res.ok ? res.items : []);
});
}, 300);
}
function resetSites() {
selectionSeq.current++;
setSites(null);
setSiteMode("new");
setSiteId("");
}
/** Select a customer and load its sites for step 2. */
function selectCustomer(hit: EmergencyCustomerHit | null) {
setSelected(hit);
resetSites();
if (!hit) return;
const seq = selectionSeq.current;
listEmergencySitesAction(hit.id).then((res) => {
if (seq !== selectionSeq.current) return;
const items = res.ok ? res.items : [];
setSites(items);
if (items.length) {
setSiteMode("existing");
setSiteId(items[0].id);
}
});
}
function switchCustomerMode(mode: "existing" | "new") {
setCustomerMode(mode);
selectCustomer(null);
}
const team = useMemo(() => teams.find((x) => x.id === teamId) ?? null, [teams, teamId]);
const step1Valid =
customerMode === "existing" ? !!selected : !!(newCustomer.companyName.trim() || newCustomer.lastName.trim()) && !!newCustomer.phone.trim();
const step2Valid =
(siteMode === "existing" ? !!siteId : !!address.street.trim() && !!address.city.trim()) && !!contact.name.trim() && !!contact.phone.trim();
const step3Valid = !!reason.trim() && !!startedAt;
function go(next: Step) {
const valid = step === 1 ? step1Valid : step === 2 ? step2Valid : step3Valid;
if (next > step && !valid) {
setShowErrors(true);
return;
}
setShowErrors(false);
setError(null);
if (next === 2 && customerMode === "new" && !contact.name && !contact.phone) {
const name = [newCustomer.firstName, newCustomer.lastName].filter((s) => s.trim()).join(" ") || newCustomer.companyName;
setContact({ name, phone: newCustomer.phone });
}
if (next === 3 && !startedAt) setStartedAt(localNow()); // default "now" when the step opens
setStep(next);
window.scrollTo({ top: 0 });
}
function useCustomerAddress() {
setAddress((a) => ({ ...a, street: newCustomer.street, houseNumber: newCustomer.houseNumber, postalCode: newCustomer.postalCode, city: newCustomer.city }));
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const types = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"];
const mimeType = typeof MediaRecorder !== "undefined" ? types.find((x) => MediaRecorder.isTypeSupported(x)) : undefined;
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) });
setRecState("recorded");
};
recorder.current = rec;
rec.start(1000);
setRecSeconds(0);
setRecState("recording");
timer.current = setInterval(() => {
setRecSeconds((s) => {
if (s + 1 >= MAX_VOICE_SECONDS) stopRecording();
return Math.min(s + 1, MAX_VOICE_SECONDS);
});
}, 1000);
} catch {
setError(t("reason.voiceDenied"));
}
}
function stopRecording() {
if (timer.current) clearInterval(timer.current);
timer.current = null;
if (recorder.current?.state === "recording") recorder.current.stop();
}
async function submit() {
if (!step3Valid) {
setShowErrors(true);
return;
}
setSubmitting(true);
setError(null);
const payload: EmergencyCreateInput = {
clientIds: clientIds.current,
customer:
customerMode === "existing" && selected
? { mode: "existing", customerId: selected.id }
: { mode: "new", ...newCustomer },
site: siteMode === "existing" ? { mode: "existing", siteId } : { mode: "new", ...address },
onSiteContact: contact,
reason,
startedAt: new Date(startedAt).toISOString(),
teamId: teamId || null,
assigneeIds: colleagues.filter((c) => c !== userId),
offline: typeof navigator !== "undefined" && !navigator.onLine,
deviceInfo: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 200) : undefined,
};
const result = await submitOp({ opType: "emergency.create", payload });
const workOrderId = result.idMap?.[clientIds.current.workOrder];
if (!isSuccess(result) || !workOrderId) {
setSubmitting(false);
const key = isSuccess(result) ? "queued" : errorKey(result);
setError(key === "queued" ? t("queued") : t(`errors.${key}`));
return;
}
if (clip) {
const type = clip.blob.type || "audio/webm";
const ext = type.includes("mp4") ? "m4a" : type.includes("ogg") ? "ogg" : "webm";
const up = await uploadFieldFile({ workOrderId, kind: "voice_note", clientId: newClientId(), file: clip.blob, fileName: `notdienst.${ext}` });
if (up.ok) {
await submitOp({
opType: "voice.attach",
payload: { workOrderId, clientId: newClientId(), documentId: up.documentId, durationSeconds: recSeconds, recordedAt: new Date().toISOString(), kind: "problem" },
});
}
}
router.push(`/m/orders/${workOrderId}`);
}
const stepLabels = [t("steps.customer"), t("steps.site"), t("steps.reason")];
const err = (cond: boolean) => showErrors && cond;
return (
<div className="space-y-4">
<ol className="grid grid-cols-3 gap-2" aria-label={t("stepOf", { step })}>
{stepLabels.map((label, i) => {
const n = (i + 1) as Step;
const state = n < step ? "done" : n === step ? "current" : "todo";
return (
<li key={label} aria-current={state === "current" ? "step" : undefined} className="flex flex-col items-center gap-1 text-center">
<span
className={cn(
"flex size-9 items-center justify-center rounded-full border-2 font-heading text-[15px] font-semibold",
state === "done" && "border-primary bg-primary text-primary-foreground",
state === "current" && "border-cta text-foreground",
state === "todo" && "border-border text-muted-foreground",
)}
>
{state === "done" ? <Check className="size-5" aria-hidden /> : n}
</span>
<span className={cn("text-[13px]", state === "current" ? "font-semibold" : "text-muted-foreground")}>{label}</span>
</li>
);
})}
</ol>
<p className="sr-only" aria-live="polite">
{t("stepOf", { step })}: {stepLabels[step - 1]}
</p>
{step === 1 && (
<section className={cn(card, "space-y-4")}>
<h2 className="text-[19px]">{t("steps.customer")}</h2>
<div className="grid grid-cols-2 gap-2" role="group" aria-label={t("steps.customer")}>
<button type="button" className={chip(customerMode === "existing")} aria-pressed={customerMode === "existing"} onClick={() => switchCustomerMode("existing")}>
<Search className="size-4.5" aria-hidden />
{t("customer.existing")}
</button>
<button type="button" className={chip(customerMode === "new")} aria-pressed={customerMode === "new"} onClick={() => switchCustomerMode("new")}>
<UserPlus className="size-4.5" aria-hidden />
{t("customer.new")}
</button>
</div>
{customerMode === "existing" &&
(selected ? (
<div className="space-y-2">
<div className="flex items-center gap-3 rounded-xl border border-primary p-3">
<Check className="size-5 shrink-0 text-primary" aria-hidden />
<div className="min-w-0 flex-1">
<p className="font-semibold">{selected.name}</p>
<p className="text-[14px] text-muted-foreground">
{[selected.customerNumber, selected.city].filter(Boolean).join(" · ")}
{selected.provisional && ` · ${t("customer.provisional")}`}
</p>
</div>
</div>
<button type="button" className={btnSecondary} onClick={() => selectCustomer(null)}>
{t("customer.change")}
</button>
</div>
) : (
<div className="space-y-3">
<div>
<label htmlFor="em-search" className="mb-1 block text-[14px] font-semibold">
{t("customer.search")}
</label>
<input
id="em-search"
type="search"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder={t("customer.searchPlaceholder")}
autoComplete="off"
className={inputClass}
/>
<p className="mt-1 text-[13px] text-muted-foreground">{t("customer.searchHint")}</p>
</div>
{searching && (
<p role="status" className="flex items-center gap-2 text-[14px]">
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
{t("customer.searching")}
</p>
)}
{searchFailed && <p className={noticeWarn}>{t("customer.offline")}</p>}
{!searching && query.trim().length >= 2 && hits.length === 0 && !searchFailed && (
<p className="text-[14px] text-muted-foreground">{t("customer.noHits")}</p>
)}
<ul className="space-y-2">
{hits.map((h) => (
<li key={h.id}>
<button type="button" onClick={() => selectCustomer(h)} className="flex min-h-14 w-full items-center gap-3 rounded-xl border bg-card px-3.5 py-2 text-left hover:bg-muted">
<Building2 className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1">
<span className="block font-semibold">{h.name}</span>
<span className="block text-[14px] text-muted-foreground">
{[h.customerNumber, h.city].filter(Boolean).join(" · ")}
{h.provisional && ` · ${t("customer.provisional")}`}
</span>
</span>
<ChevronRight className="size-5 text-muted-foreground" aria-hidden />
</button>
</li>
))}
</ul>
{err(!selected) && <p className={noticeError}>{t("customer.selectRequired")}</p>}
</div>
))}
{customerMode === "new" && (
<div className="space-y-3">
<p className={noticeWarn}>{t("customer.newHint")}</p>
<TextField id="em-company" label={t("customer.companyName")} value={newCustomer.companyName} onChange={(v) => setNewCustomer({ ...newCustomer, companyName: v })} autoComplete="organization" />
<div className="grid grid-cols-2 gap-2">
<TextField id="em-first" label={t("customer.firstName")} value={newCustomer.firstName} onChange={(v) => setNewCustomer({ ...newCustomer, firstName: v })} autoComplete="given-name" />
<TextField
id="em-last"
label={t("customer.lastName")}
value={newCustomer.lastName}
onChange={(v) => setNewCustomer({ ...newCustomer, lastName: v })}
autoComplete="family-name"
invalid={err(!newCustomer.companyName.trim() && !newCustomer.lastName.trim())}
/>
</div>
<p className="text-[13px] text-muted-foreground">{t("customer.nameHint")}</p>
<TextField id="em-phone" label={t("customer.phone")} type="tel" inputMode="tel" autoComplete="tel" required value={newCustomer.phone} onChange={(v) => setNewCustomer({ ...newCustomer, phone: v })} invalid={err(!newCustomer.phone.trim())} />
<TextField id="em-email" label={t("customer.email")} type="email" inputMode="email" autoComplete="email" value={newCustomer.email} onChange={(v) => setNewCustomer({ ...newCustomer, email: v })} />
<div className="grid grid-cols-[1fr_5.5rem] gap-2">
<TextField id="em-street" label={t("customer.street")} value={newCustomer.street} onChange={(v) => setNewCustomer({ ...newCustomer, street: v })} autoComplete="address-line1" />
<TextField id="em-no" label={t("customer.houseNumber")} value={newCustomer.houseNumber} onChange={(v) => setNewCustomer({ ...newCustomer, houseNumber: v })} />
</div>
<div className="grid grid-cols-[6.5rem_1fr] gap-2">
<TextField id="em-zip" label={t("customer.postalCode")} inputMode="numeric" autoComplete="postal-code" value={newCustomer.postalCode} onChange={(v) => setNewCustomer({ ...newCustomer, postalCode: v })} />
<TextField id="em-city" label={t("customer.city")} value={newCustomer.city} onChange={(v) => setNewCustomer({ ...newCustomer, city: v })} autoComplete="address-level2" />
</div>
{err(!step1Valid) && <p className={noticeError}>{t("required")}</p>}
</div>
)}
<button type="button" className={btnPrimary} onClick={() => go(2)}>
{t("next")}
<ChevronRight className="size-5" aria-hidden />
</button>
</section>
)}
{step === 2 && (
<section className={cn(card, "space-y-4")}>
<h2 className="text-[19px]">{t("steps.site")}</h2>
{customerMode === "existing" && sites === null && (
<p role="status" className="flex items-center gap-2 text-[14px]">
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
{t("site.loading")}
</p>
)}
{customerMode === "existing" && sites !== null && sites.length > 0 && (
<fieldset className="space-y-2">
<legend className="mb-1 text-[14px] font-semibold">{t("site.existing")}</legend>
{sites.map((s) => (
<label key={s.id} className={cn("flex min-h-14 cursor-pointer items-center gap-3 rounded-xl border px-3.5 py-2", siteMode === "existing" && siteId === s.id ? "border-primary" : "border-border")}>
<input type="radio" name="em-site" className="size-5 accent-[var(--ui-primary)]" checked={siteMode === "existing" && siteId === s.id} onChange={() => { setSiteMode("existing"); setSiteId(s.id); }} />
<span className="min-w-0 flex-1">
<span className="block font-semibold">{s.name}</span>
<span className="block text-[14px] text-muted-foreground">{s.address}</span>
</span>
</label>
))}
<label className={cn("flex min-h-14 cursor-pointer items-center gap-3 rounded-xl border px-3.5 py-2", siteMode === "new" ? "border-primary" : "border-border")}>
<input type="radio" name="em-site" className="size-5 accent-[var(--ui-primary)]" checked={siteMode === "new"} onChange={() => setSiteMode("new")} />
<MapPin className="size-5 text-muted-foreground" aria-hidden />
<span className="font-semibold">{t("site.newAddress")}</span>
</label>
</fieldset>
)}
{customerMode === "existing" && sites !== null && sites.length === 0 && <p className="text-[14px] text-muted-foreground">{t("site.none")}</p>}
{siteMode === "new" && (sites !== null || customerMode === "new") && (
<div className="space-y-3">
<p className="text-[13px] text-muted-foreground">{t("site.newHint")}</p>
{customerMode === "new" && (newCustomer.street || newCustomer.city) && (
<button type="button" className={btnSecondary} onClick={useCustomerAddress}>
<MapPin className="size-5" aria-hidden />
{t("site.useCustomerAddress")}
</button>
)}
<div className="grid grid-cols-[1fr_5.5rem] gap-2">
<TextField id="em-s-street" label={t("customer.street")} required value={address.street} onChange={(v) => setAddress({ ...address, street: v })} autoComplete="address-line1" invalid={err(!address.street.trim())} />
<TextField id="em-s-no" label={t("customer.houseNumber")} value={address.houseNumber} onChange={(v) => setAddress({ ...address, houseNumber: v })} />
</div>
<div className="grid grid-cols-[6.5rem_1fr] gap-2">
<TextField id="em-s-zip" label={t("customer.postalCode")} inputMode="numeric" autoComplete="postal-code" value={address.postalCode} onChange={(v) => setAddress({ ...address, postalCode: v })} />
<TextField id="em-s-city" label={t("customer.city")} required value={address.city} onChange={(v) => setAddress({ ...address, city: v })} autoComplete="address-level2" invalid={err(!address.city.trim())} />
</div>
<TextField id="em-s-name" label={t("site.name")} value={address.name} onChange={(v) => setAddress({ ...address, name: v })} />
</div>
)}
<fieldset className="space-y-3">
<legend className="text-[16px] font-semibold">{t("site.contactTitle")}</legend>
<TextField id="em-c-name" label={t("site.contactName")} required value={contact.name} onChange={(v) => setContact({ ...contact, name: v })} autoComplete="name" invalid={err(!contact.name.trim())} />
<TextField id="em-c-phone" label={t("site.contactPhone")} type="tel" inputMode="tel" autoComplete="tel" required value={contact.phone} onChange={(v) => setContact({ ...contact, phone: v })} invalid={err(!contact.phone.trim())} />
</fieldset>
{err(!step2Valid) && <p className={noticeError}>{t("required")}</p>}
<div className="grid grid-cols-2 gap-2">
<button type="button" className={btnSecondary} onClick={() => go(1)}>
<ChevronLeft className="size-5" aria-hidden />
{t("back")}
</button>
<button type="button" className={cn(btnPrimary, "min-h-12")} onClick={() => go(3)}>
{t("next")}
<ChevronRight className="size-5" aria-hidden />
</button>
</div>
</section>
)}
{step === 3 && (
<section className={cn(card, "space-y-4")}>
<h2 className="text-[19px]">{t("steps.reason")}</h2>
<div>
<label htmlFor="em-reason" className="mb-1 block text-[14px] font-semibold">
{t("reason.label")}
<span aria-hidden> *</span>
</label>
<textarea
id="em-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
required
rows={4}
maxLength={2000}
placeholder={t("reason.placeholder")}
aria-invalid={err(!reason.trim()) || undefined}
className={cn(inputClass, "py-3", err(!reason.trim()) && "border-[var(--risk)]")}
/>
</div>
<div className="space-y-2">
<p className="text-[14px] font-semibold">{t("reason.voice")}</p>
{recState === "idle" && (
<button type="button" className={btnSecondary} onClick={startRecording}>
<Mic className="size-5" aria-hidden />
{t("reason.voiceRecord")}
</button>
)}
{recState === "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("reason.voiceRecording", { seconds: recSeconds })}
</p>
<button type="button" className={btnSecondary} onClick={stopRecording}>
<Square className="size-5" aria-hidden />
{t("reason.voiceStop")}
</button>
</>
)}
{recState === "recorded" && clip && (
<>
<audio controls src={clip.url} className="w-full" />
<button type="button" className={btnSecondary} onClick={() => { setClip(null); setRecState("idle"); }}>
{t("reason.voiceDiscard")}
</button>
</>
)}
<p className="text-[13px] text-muted-foreground">{t("reason.voiceHint")}</p>
</div>
<div>
<label htmlFor="em-start" className="mb-1 block text-[14px] font-semibold">
{t("reason.startedAt")}
<span aria-hidden> *</span>
</label>
<input id="em-start" type="datetime-local" value={startedAt} onChange={(e) => setStartedAt(e.target.value)} required className={inputClass} />
</div>
<div>
<label htmlFor="em-team" className="mb-1 block text-[14px] font-semibold">
{t("reason.team")}
</label>
<select id="em-team" value={teamId} onChange={(e) => { setTeamId(e.target.value); setColleagues([]); }} className={inputClass}>
{teams.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
<option value="">{t("reason.noTeam")}</option>
</select>
</div>
<fieldset className="space-y-2">
<legend className="mb-1 flex items-center gap-2 text-[14px] font-semibold">
<Users className="size-4.5" aria-hidden />
{t("reason.technicians")}
</legend>
<label className="flex min-h-12 items-center gap-3 text-[15px]">
<input type="checkbox" checked disabled className="size-5 accent-[var(--ui-primary)]" />
{t("reason.me")}
</label>
{team?.members
.filter((m) => m.id !== userId)
.map((m) => (
<label key={m.id} className="flex min-h-12 cursor-pointer items-center gap-3 text-[15px]">
<input
type="checkbox"
className="size-5 accent-[var(--ui-primary)]"
checked={colleagues.includes(m.id)}
onChange={(e) => setColleagues(e.target.checked ? [...colleagues, m.id] : colleagues.filter((c) => c !== m.id))}
/>
{m.name}
</label>
))}
</fieldset>
{err(!step3Valid) && <p className={noticeError}>{t("required")}</p>}
{error && (
<p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{error}
</p>
)}
<button type="button" className={btnPrimary} onClick={submit} disabled={submitting || recState === "recording"}>
{submitting ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Siren className="size-5" aria-hidden />}
{submitting ? t("starting") : t("start")}
</button>
<button type="button" className={btnSecondary} onClick={() => go(2)} disabled={submitting}>
<ChevronLeft className="size-5" aria-hidden />
{t("back")}
</button>
</section>
)}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useActionState } from "react";
import { useTranslations } from "next-intl";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import type { ActionState } from "@/server/api/action-state";
import { buttonCls } from "@/components/work-orders/action-form";
import { cn } from "@/lib/utils";
type Action = (prev: ActionState, fd: FormData) => Promise<ActionState>;
/** Form bound to an emergency review server action; errors are translated codes (emergency.review.errors.*). */
export function ReviewForm({
action,
children,
submitLabel,
successText,
variant = "default",
className,
}: {
action: Action;
children?: React.ReactNode;
submitLabel: string;
successText?: string;
variant?: "primary" | "default" | "outline" | "danger";
className?: string;
}) {
const [state, formAction, pending] = useActionState(action, { status: "idle" } as ActionState);
const t = useTranslations("emergency.review");
let errorText = "";
if (state.status === "error") {
const reasonKey = state.reason ? `errors.${state.reason}` : "";
errorText = reasonKey && t.has(reasonKey) ? t(reasonKey) : t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.generic");
}
return (
<form action={formAction} className={cn("space-y-3", className)}>
{children}
{state.status === "error" && (
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] bg-card px-3 py-2 text-sm font-semibold text-[var(--risk)]">
<AlertCircle className="size-4 shrink-0" aria-hidden />
{errorText}
</p>
)}
{state.status === "ok" && successText && (
<p role="status" className="flex items-center gap-2 text-sm text-[var(--ok)]">
<CheckCircle2 className="size-4" aria-hidden />
{successText}
</p>
)}
<button type="submit" disabled={pending} className={buttonCls(variant)}>
{submitLabel}
</button>
</form>
);
}
@@ -0,0 +1,29 @@
import { CheckCircle2, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
/** "x von 5 Prüfschritten erledigt" — bar plus text (never colour alone). */
export function ReviewProgressBar({ count, total, label, className }: { count: number; total: number; label: string; className?: string }) {
const pct = total ? Math.round((count / total) * 100) : 0;
return (
<div className={cn("flex-1", className)}>
<p className="mb-1 text-xs font-semibold">{label}</p>
<div className="h-2 overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuemin={0} aria-valuemax={total} aria-valuenow={count} aria-label={label}>
<div className="h-full rounded-full bg-[var(--ok)]" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
export function StepState({ done, doneLabel, openLabel }: { done: boolean; doneLabel: string; openLabel: string }) {
return done ? (
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-[var(--ok)]">
<CheckCircle2 className="size-4" aria-hidden />
{doneLabel}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<Circle className="size-4" aria-hidden />
{openLabel}
</span>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { z } from "zod";
/**
* Client-safe input schemas of the emergency module (lane L8, spec §19).
* `emergencyCreatePayload` is both the sync op payload (`emergency.create`, src/lib/sync/ops.ts)
* and the input of `createEmergencyOrder`. The client ids make the op idempotent: a replay with
* the same `clientIds.session` returns the order that was already created.
*/
const id = z.string().min(1).max(64);
const clientId = z.string().uuid();
const req = (max: number) => z.string().trim().min(1).max(max);
const opt = (max: number) =>
z
.string()
.trim()
.max(max)
.nullish()
.transform((v) => (v ? v : null));
export const EMERGENCY_REASON_MAX = 2000;
const newCustomer = z.object({
mode: z.literal("new"),
companyName: opt(200),
firstName: opt(100),
lastName: opt(100),
phone: req(50),
email: z
.string()
.trim()
.max(200)
.nullish()
.transform((v) => (v ? v.toLowerCase() : null))
.pipe(z.email().nullable()),
street: opt(200),
houseNumber: opt(20),
postalCode: opt(12),
city: opt(100),
});
const existingCustomer = z.object({ mode: z.literal("existing"), customerId: id });
const newSite = z.object({
mode: z.literal("new"),
name: opt(200),
street: req(200),
houseNumber: opt(20),
postalCode: opt(12),
city: req(100),
});
const existingSite = z.object({ mode: z.literal("existing"), siteId: id });
export const emergencyCreatePayload = z
.object({
clientIds: z.object({
workOrder: clientId,
session: clientId,
customer: clientId.optional(),
site: clientId.optional(),
}),
customer: z.discriminatedUnion("mode", [existingCustomer, newCustomer]),
site: z.discriminatedUnion("mode", [existingSite, newSite]),
onSiteContact: z.object({ name: req(200), phone: req(50) }),
reason: req(EMERGENCY_REASON_MAX),
/** Einsatzbeginn (default: now) */
startedAt: z.string().datetime({ offset: true }).optional(),
/** null/undefined → the user's own team (first active team) */
teamId: id.nullish(),
/** colleagues of the chosen team; the creating user is always assigned */
assigneeIds: z.array(id).max(20).default([]),
offline: z.boolean().default(false),
deviceInfo: z.string().max(200).optional(),
})
.superRefine((v, c) => {
if (v.customer.mode === "new" && !v.customer.companyName && !v.customer.lastName) {
c.addIssue({ code: "custom", path: ["customer", "lastName"], message: "name_required" });
}
});
export type EmergencyCreateInput = z.input<typeof emergencyCreatePayload>;
export type EmergencyCreateParsed = z.output<typeof emergencyCreatePayload>;
/** Result keys of the op's idMap besides the client ids. */
export type EmergencyCreateResult = {
workOrderId: string;
number: string;
customerId: string;
siteId: string;
contactId: string;
sessionId: string;
version: number;
replayed: boolean;
};
/** Backoffice: "Auftrag ergänzen". */
export const emergencyOrderPatchSchema = z.object({
title: req(200),
description: opt(10_000),
orderTypeId: id.nullish(),
billingType: z.enum(["fixed", "time_material", "maintenance_contract", "warranty"]).nullish(),
});
/** The five review steps shown as "x von 5 Prüfschritten erledigt". */
export const REVIEW_STEPS = ["customer", "site", "order", "report", "billing"] as const;
export type ReviewStep = (typeof REVIEW_STEPS)[number];
+2
View File
@@ -12,6 +12,7 @@ import {
History, History,
Mail, Mail,
ListChecks, ListChecks,
Siren,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import type { ModuleKey } from "@/lib/modules"; import type { ModuleKey } from "@/lib/modules";
@@ -47,6 +48,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
permissions: ["work_order:read_all", "work_order:read_team"], permissions: ["work_order:read_all", "work_order:read_team"],
section: "main", section: "main",
}, },
{ href: "/work-orders/emergency-review", label: "emergencyReview", icon: Siren, module: "emergency", permissions: ["emergency:review"], section: "main" },
{ href: "/imports", label: "imports", icon: FileInput, module: "imports", permissions: ["import:write"], section: "main" }, { href: "/imports", label: "imports", icon: FileInput, module: "imports", permissions: ["import:write"], section: "main" },
{ href: "/customers", label: "customers", icon: Users, module: "customers", permissions: ["customer:read"], section: "main" }, { href: "/customers", label: "customers", icon: Users, module: "customers", permissions: ["customer:read"], section: "main" },
{ href: "/sites", label: "sites", icon: Building2, module: "sites", permissions: ["site:read"], section: "main" }, { href: "/sites", label: "sites", icon: Building2, module: "sites", permissions: ["site:read"], section: "main" },
+2 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import type { SyncOpType } from "./envelope"; import type { SyncOpType } from "./envelope";
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status"; import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
import { emergencyCreatePayload } from "@/lib/emergency/schemas";
/** /**
* Payload schemas per sync opType (ARCHITEKTUR §4.6). Client-safe: used by the mobile UI to * Payload schemas per sync opType (ARCHITEKTUR §4.6). Client-safe: used by the mobile UI to
@@ -129,7 +130,7 @@ export const OP_PAYLOAD_SCHEMAS = {
"report.save_draft": passthrough, "report.save_draft": passthrough,
"report.submit": passthrough, "report.submit": passthrough,
"signature.capture": passthrough, "signature.capture": passthrough,
"emergency.create": passthrough, "emergency.create": emergencyCreatePayload,
} satisfies Record<SyncOpType, z.ZodType>; } satisfies Record<SyncOpType, z.ZodType>;
export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>; export type OpPayload<T extends SyncOpType> = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>;
+38
View File
@@ -0,0 +1,38 @@
"use server";
import { moduleGuard } from "@/server/action-guard";
import { toActionError } from "@/server/api/action-state";
import { ctxFromGuard } from "@/server/services/context";
import {
listSitesForEmergency,
searchCustomersForEmergency,
type EmergencyCustomerHit,
type EmergencySiteHit,
} from "@/server/services/emergency/lookup";
/**
* Read lookups of the mobile emergency capture. They are server actions (not page reads) because
* the client wizard calls them interactively; both are audited in the service. The create itself
* goes through the sync op `emergency.create` (offline-capable, POST /api/v1/sync).
*/
const guard = moduleGuard("emergency");
export type LookupResult<T> = { ok: true; items: T[] } | { ok: false; code: string };
export async function searchEmergencyCustomersAction(query: string): Promise<LookupResult<EmergencyCustomerHit>> {
try {
const ctx = ctxFromGuard(await guard("emergency:create"));
return { ok: true, items: await searchCustomersForEmergency(ctx, String(query ?? "")) };
} catch (err) {
return { ok: false, code: toActionError(err).code };
}
}
export async function listEmergencySitesAction(customerId: string): Promise<LookupResult<EmergencySiteHit>> {
try {
const ctx = ctxFromGuard(await guard("emergency:create"));
return { ok: true, items: await listSitesForEmergency(ctx, String(customerId ?? "")) };
} catch (err) {
return { ok: false, code: toActionError(err).code };
}
}
+125
View File
@@ -0,0 +1,125 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
import { ctxFromGuard, type ServiceCtx } from "@/server/services/context";
import {
assignEmergencySite,
assignEmergencyToCustomer,
completeEmergencyOrderData,
confirmEmergencyCustomer,
correctEmergencySite,
mergeEmergencyCustomer,
releaseEmergencyForBilling,
} from "@/server/services/emergency/review";
/** Backoffice review of emergency call-outs (/work-orders/emergency-review/[id]). */
const guard = moduleGuard("emergency");
function done(workOrderId: string): ActionState {
revalidatePath("/work-orders/emergency-review");
revalidatePath(`/work-orders/emergency-review/${workOrderId}`);
revalidatePath(`/work-orders/${workOrderId}`);
return { status: "ok" };
}
async function run(workOrderId: string, fn: () => Promise<unknown>): Promise<ActionState> {
try {
await fn();
} catch (err) {
return toActionError(err);
}
return done(workOrderId);
}
const version = (fd: FormData) => {
const v = Number(fd.get("baseVersion"));
return Number.isInteger(v) && v > 0 ? v : undefined;
};
export async function confirmCustomerAction(workOrderId: string): Promise<ActionState> {
let ctx: ServiceCtx;
try {
ctx = ctxFromGuard(await guard("emergency:review", "customer:write"));
} catch (err) {
return toActionError(err);
}
return run(workOrderId, () => confirmEmergencyCustomer(ctx, workOrderId));
}
export async function assignCustomerAction(workOrderId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
let ctx: ServiceCtx;
try {
ctx = ctxFromGuard(await guard("emergency:review", "customer:write", "work_order:write"));
} catch (err) {
return toActionError(err);
}
return run(workOrderId, async () => assignEmergencyToCustomer(ctx, { workOrderId, targetCustomerId: await targetCustomer(ctx, fd) }));
}
/** Target from the candidate select or, as a fallback, from an entered customer number (tenant-bound lookup). */
async function targetCustomer(ctx: ServiceCtx, fd: FormData): Promise<string> {
const id = String(fd.get("targetCustomerId") ?? "").trim();
if (id) return id;
const customerNumber = String(fd.get("targetCustomerNumber") ?? "").trim();
if (!customerNumber) return "";
const c = await ctx.db.customer.findFirst({ where: { customerNumber, deletedAt: null }, select: { id: true } });
return c?.id ?? "__unknown__";
}
export async function mergeCustomerAction(workOrderId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
let ctx: ServiceCtx;
try {
ctx = ctxFromGuard(await guard("emergency:review", "customer:merge"));
} catch (err) {
return toActionError(err);
}
const confirm = fd.get("confirm") === "on" || fd.get("confirm") === "1";
return run(workOrderId, async () => mergeEmergencyCustomer(ctx, { workOrderId, targetCustomerId: await targetCustomer(ctx, fd), confirm }));
}
export async function correctSiteAction(workOrderId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
let ctx: ServiceCtx;
try {
ctx = ctxFromGuard(await guard("emergency:review", "site:write"));
} catch (err) {
return toActionError(err);
}
const values = formObject(fd, ["name", "street", "houseNumber", "postalCode", "city", "onSiteContact", "phone", "accessNotes"]);
const patch = { ...values, name: values.name ?? undefined };
const confirm = fd.get("confirm") === "on" || fd.get("confirm") === "1";
return run(workOrderId, () => correctEmergencySite(ctx, { workOrderId, patch, confirm }));
}
export async function assignSiteAction(workOrderId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
let ctx: ServiceCtx;
try {
ctx = ctxFromGuard(await guard("emergency:review", "work_order:write"));
} catch (err) {
return toActionError(err);
}
const siteId = String(fd.get("siteId") ?? "");
return run(workOrderId, () => assignEmergencySite(ctx, { workOrderId, siteId }));
}
export async function completeOrderAction(workOrderId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
let ctx: ServiceCtx;
try {
ctx = ctxFromGuard(await guard("emergency:review", "work_order:write"));
} catch (err) {
return toActionError(err);
}
const values = formObject(fd, ["title", "description", "orderTypeId", "billingType"]);
return run(workOrderId, () => completeEmergencyOrderData(ctx, { workOrderId, ...values, baseVersion: version(fd) }));
}
export async function releaseBillingAction(workOrderId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
let ctx: ServiceCtx;
try {
ctx = ctxFromGuard(await guard("emergency:review", "work_order:release_billing"));
} catch (err) {
return toActionError(err);
}
return run(workOrderId, () => releaseEmergencyForBilling(ctx, { workOrderId, baseVersion: version(fd) }));
}
@@ -0,0 +1,66 @@
import { emitEvent } from "@/server/events";
import type { ServiceCtx } from "@/server/services/context";
import { customerDisplayName } from "@/server/services/customers/format";
/**
* Spec §19.4: after the completion report of an emergency order was submitted, the backoffice is
* informed automatically (event `emergency.completed`, mandatory mail via L6).
* Data: Monteur, Kunde, Einsatzbeginn, Einsatzende, Status.
*/
export type EmergencyCompletedData = {
number: string;
technician: string | null;
customer: string;
startedAt: string;
endedAt: string;
status: string;
};
export async function buildEmergencyCompletedData(ctx: ServiceCtx, workOrderId: string): Promise<EmergencyCompletedData | null> {
const wo = await ctx.db.workOrder.findFirst({
where: { id: workOrderId, isEmergency: true, deletedAt: null },
select: {
number: true,
status: true,
createdAt: true,
plannedStart: true,
createdById: true,
customer: { select: { companyName: true, firstName: true, lastName: true } },
workSessions: { select: { startedAt: true, endedAt: true }, orderBy: { startedAt: "asc" } },
},
});
if (!wo) return null;
const creator = wo.createdById ? await ctx.db.user.findFirst({ where: { id: wo.createdById }, select: { name: true } }) : null;
const starts = wo.workSessions.map((s) => s.startedAt.getTime());
const ends = wo.workSessions.map((s) => (s.endedAt ?? new Date()).getTime());
const startedAt = starts.length ? new Date(Math.min(...starts)) : (wo.plannedStart ?? wo.createdAt);
const endedAt = ends.length ? new Date(Math.max(...ends)) : new Date();
return {
number: wo.number,
technician: creator?.name ?? null,
customer: customerDisplayName(wo.customer),
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
status: wo.status,
};
}
/**
* Hook called by the reports lane after a completion report (v1) was submitted and the order was
* advanced (→ in_review / signature_pending). No-op for regular orders; never throws.
*/
export async function onCompletionReportSubmitted(ctx: ServiceCtx, workOrderId: string, occurrenceId?: string): Promise<void> {
try {
const data = await buildEmergencyCompletedData(ctx, workOrderId);
if (!data) return;
await emitEvent(ctx, {
type: "emergency.completed",
entityType: "work_order",
entityId: workOrderId,
data: { ...data, ...(occurrenceId ? { occurrenceId } : {}) },
});
} catch (err) {
console.error(`[emergency] completion hook for ${workOrderId} failed:`, (err as Error).message);
}
}
+290
View File
@@ -0,0 +1,290 @@
import { ZodError } from "zod";
import { emergencyCreatePayload, type EmergencyCreateInput, type EmergencyCreateResult } from "@/lib/emergency/schemas";
import { writeAuditLog } from "@/server/audit";
import { emitEvent } from "@/server/events";
import { assertCan, can, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
import { startSession } from "@/server/services/field/sessions";
import { createWorkOrder as realCreateWorkOrder } from "@/server/services/work-orders/create";
import { ensureDefaultOrderTypes } from "@/server/services/work-orders/settings";
import { activeTeamIds } from "@/server/services/work-orders/visibility";
/**
* Notdienst-Erfassung (spec §19, US-010, ARCHITEKTUR §6 L8).
*
* `createEmergencyOrder` runs in ONE transaction (inTransaction):
* provisional customer (status provisional, no customer number until confirmation) or existing customer
* → provisional site (Einsatzadresse) or existing site of the customer
* → on-site contact (reused if name + phone match)
* → work order via createWorkOrder (isEmergency, order type `notdienst`, number from `emergency`, status in_progress)
* → team / assignees → WorkSession start (L4 startSession, idempotent over clientIds.session).
* Afterwards the event `emergency.created` is emitted. A replay with the same session client id returns
* the order created before (offline sync retries with a new clientOpId).
*/
export type CreateEmergencyDeps = { createWorkOrder: typeof realCreateWorkOrder };
const DEFAULT_DEPS: CreateEmergencyDeps = { createWorkOrder: realCreateWorkOrder };
function parse(raw: unknown) {
try {
return emergencyCreatePayload.parse(raw);
} catch (err) {
if (err instanceof ZodError) {
throw new ServiceError(
"invalid",
"validation_failed",
err.issues.map((i) => ({ path: i.path.join("."), code: i.message })),
);
}
throw err;
}
}
/** Client timestamp of the start; future values (clock skew) are clamped to now. */
function startTime(at: string | undefined): Date {
const now = new Date();
if (!at) return now;
const d = new Date(at);
return d.getTime() > now.getTime() + 60_000 ? now : d;
}
function firstLine(text: string, max = 120): string {
const line = text.split(/\r?\n/).find((l) => l.trim())?.trim() ?? text.trim();
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
}
async function replay(ctx: ServiceCtx, sessionClientId: string): Promise<EmergencyCreateResult | null> {
const session = await ctx.db.workSession.findFirst({
where: { clientId: sessionClientId },
select: {
id: true,
userId: true,
workOrder: { select: { id: true, number: true, customerId: true, siteId: true, contactId: true, version: true, isEmergency: true } },
},
});
if (!session) return null;
const wo = session.workOrder;
if (session.userId !== ctx.userId || !wo.isEmergency) throw new ServiceError("invalid", "client_id_already_used");
return {
workOrderId: wo.id,
number: wo.number,
customerId: wo.customerId,
siteId: wo.siteId ?? "",
contactId: wo.contactId ?? "",
sessionId: session.id,
version: wo.version,
replayed: true,
};
}
async function resolveTeam(ctx: ServiceCtx, requested: string | null | undefined) {
const own = await activeTeamIds(ctx);
let teamId: string | null = requested ?? null;
if (teamId && !own.includes(teamId) && !can(ctx, "work_order:read_all")) {
throw new ServiceError("invalid", "team_not_allowed", { field: "teamId", reason: "team_not_allowed" });
}
if (!teamId && own.length) {
const teams = await ctx.db.team.findMany({ where: { id: { in: own } }, orderBy: { name: "asc" }, select: { id: true } });
teamId = teams[0]?.id ?? null;
}
if (!teamId) return null;
const team = await ctx.db.team.findFirst({
where: { id: teamId, status: "active", deletedAt: null },
select: { id: true, leaderUserId: true },
});
if (!team) throw new ServiceError("invalid", "team_not_allowed", { field: "teamId", reason: "team_not_allowed" });
return team;
}
async function resolveAssignees(ctx: ServiceCtx, team: { id: string; leaderUserId: string | null } | null, requested: string[]) {
const others = [...new Set(requested.filter((u) => u !== ctx.userId))];
if (others.length === 0) return [ctx.userId];
if (!team) throw new ServiceError("invalid", "assignee_not_allowed", { field: "assigneeIds", reason: "assignee_not_allowed" });
const now = new Date();
const members = await ctx.db.teamMember.findMany({
where: {
teamId: team.id,
userId: { in: others },
validFrom: { lte: now },
OR: [{ validTo: null }, { validTo: { gt: now } }],
user: { status: "ACTIVE" },
},
select: { userId: true },
});
const allowed = new Set(members.map((m) => m.userId));
if (team.leaderUserId) allowed.add(team.leaderUserId);
if (others.some((u) => !allowed.has(u))) {
throw new ServiceError("invalid", "assignee_not_allowed", { field: "assigneeIds", reason: "assignee_not_allowed" });
}
return [ctx.userId, ...others];
}
export async function createEmergencyOrder(
ctx: ServiceCtx,
raw: EmergencyCreateInput,
deps: CreateEmergencyDeps = DEFAULT_DEPS,
): Promise<EmergencyCreateResult> {
assertCan(ctx, "emergency:create");
assertCan(ctx, "field:execute");
const input = parse(raw);
const prior = await replay(ctx, input.clientIds.session);
if (prior) return prior;
const startedAt = startTime(input.startedAt);
const team = await resolveTeam(ctx, input.teamId);
const assignees = await resolveAssignees(ctx, team, input.assigneeIds);
await ensureDefaultOrderTypes(ctx.db, ctx.tenantId);
const orderType = await ctx.db.orderType.findFirst({ where: { key: "notdienst", active: true }, select: { id: true } });
const audits: Parameters<typeof writeAuditLog>[0][] = [];
const audit = (entity: string, entityId: string, after: unknown) =>
audits.push({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity, entityId, after });
const result = await inTransaction(ctx, async (tx) => {
// 1. customer
let customerId: string;
if (input.customer.mode === "existing") {
// technicians may pick ANY customer of the tenant for an emergency (searchCustomersForEmergency)
const c = await tx.db.customer.findFirst({
where: { id: input.customer.customerId, deletedAt: null, status: { in: ["active", "provisional"] } },
select: { id: true },
});
if (!c) throw new ServiceError("invalid", "customer_not_found", { field: "customerId", reason: "customer_not_found" });
customerId = c.id;
} else {
const { mode: _mode, ...fields } = input.customer;
void _mode;
const c = await tx.db.customer.create({
data: {
...fields,
tenantId: ctx.tenantId,
customerNumber: null, // allocated on confirmation by the backoffice
country: "DE",
status: "provisional",
isProvisional: true,
createdById: ctx.userId,
},
});
customerId = c.id;
audit("customer", c.id, { ...c, source: "emergency" });
}
// 2. on-site contact (Ansprechpartner vor Ort) — reused when name and phone match
const contactName = input.onSiteContact.name;
const contactPhone = input.onSiteContact.phone;
const existingContact = await tx.db.contact.findFirst({
where: { customerId, deletedAt: null, name: { equals: contactName, mode: "insensitive" }, OR: [{ phone: contactPhone }, { mobile: contactPhone }] },
select: { id: true },
});
let contactId = existingContact?.id;
if (!contactId) {
const contact = await tx.db.contact.create({
data: { tenantId: ctx.tenantId, customerId, name: contactName, phone: contactPhone, preferredChannel: "phone" },
});
contactId = contact.id;
audit("contact", contact.id, { ...contact, source: "emergency" });
}
// 3. site (Objekt oder Einsatzadresse)
let siteId: string;
if (input.site.mode === "existing") {
const s = await tx.db.site.findFirst({ where: { id: input.site.siteId, customerId, deletedAt: null }, select: { id: true } });
if (!s) throw new ServiceError("invalid", "site_not_found", { field: "siteId", reason: "site_not_found" });
siteId = s.id;
} else {
const { mode: _mode, name, ...address } = input.site;
void _mode;
const label = name ?? [[address.street, address.houseNumber].filter(Boolean).join(" "), address.city].filter(Boolean).join(", ");
const s = await tx.db.site.create({
data: {
...address,
tenantId: ctx.tenantId,
customerId,
name: label.slice(0, 200),
country: "DE",
contactId,
onSiteContact: contactName,
phone: contactPhone,
status: "provisional",
},
});
siteId = s.id;
audit("site", s.id, { ...s, source: "emergency" });
}
// 4. work order (L2) — number N-…, status in_progress
const wo = await deps.createWorkOrder(tx, {
title: firstLine(input.reason),
customerId,
siteId,
contactId,
orderTypeId: orderType?.id ?? null,
priority: "urgent",
status: "in_progress",
description: input.reason,
plannedStart: startedAt,
isEmergency: true,
emergencyReason: input.reason,
numberKey: "emergency",
});
// 5. team + assignees (the creator is always assigned)
await tx.db.workOrder.update({
where: { id: wo.id },
data: {
assignedTeamId: team?.id ?? null,
teamLeadUserId: team?.leaderUserId ?? null,
assignees: { create: assignees.map((userId) => ({ tenantId: ctx.tenantId, userId })) },
},
});
// 6. work session (L4) — order already in_progress, so no further transition
const session = await startSession(tx, {
workOrderId: wo.id,
clientId: input.clientIds.session,
mode: "work",
at: startedAt.toISOString(),
offline: input.offline,
deviceInfo: input.deviceInfo,
});
return {
workOrderId: wo.id,
number: wo.number,
customerId,
siteId,
contactId,
sessionId: session.sessionId,
version: session.workOrderVersion,
replayed: false,
} satisfies EmergencyCreateResult;
});
for (const a of audits) await writeAuditLog(a);
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "create",
entity: "emergency",
entityId: result.workOrderId,
after: {
number: result.number,
customerId: result.customerId,
customerMode: input.customer.mode,
siteId: result.siteId,
siteMode: input.site.mode,
contactId: result.contactId,
teamId: team?.id ?? null,
assignees,
startedAt,
offline: input.offline,
},
});
await emitEvent(ctx, {
type: "emergency.created",
entityType: "work_order",
entityId: result.workOrderId,
data: { number: result.number, startedAt: startedAt.toISOString() },
});
return result;
}
+102
View File
@@ -0,0 +1,102 @@
import { z } from "zod";
import { writeAuditLog } from "@/server/audit";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { customerDisplayName, formatAddress } from "@/server/services/customers/format";
import { activeTeamIds } from "@/server/services/work-orders/visibility";
/**
* Slim lookups for the emergency capture (spec §19.2). A technician may search ALL customers of
* the tenant for an emergency call-out, so these services deliberately return a minimal field set
* (name, city, customer number / site address) and audit every access. Tenant isolation comes from
* ctx.db (dbForTenant + RLS).
*/
export type EmergencyCustomerHit = { id: string; customerNumber: string | null; name: string; city: string | null; provisional: boolean };
export type EmergencySiteHit = { id: string; name: string; address: string };
const querySchema = z.string().trim().min(2).max(100);
export async function searchCustomersForEmergency(ctx: ServiceCtx, rawQuery: string): Promise<EmergencyCustomerHit[]> {
assertCan(ctx, "emergency:create");
const parsed = querySchema.safeParse(rawQuery);
if (!parsed.success) return [];
const q = parsed.data;
const contains = { contains: q, mode: "insensitive" as const };
const rows = await ctx.db.customer.findMany({
where: {
deletedAt: null,
status: { in: ["active", "provisional"] },
OR: [{ customerNumber: contains }, { companyName: contains }, { firstName: contains }, { lastName: contains }, { city: contains }],
},
orderBy: [{ companyName: "asc" }, { lastName: "asc" }],
take: 10,
select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true, status: true },
});
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "export",
entity: "emergency_customer_search",
after: { query: q, resultCount: rows.length, customerIds: rows.map((r) => r.id) },
});
return rows.map((r) => ({
id: r.id,
customerNumber: r.customerNumber,
name: customerDisplayName(r),
city: r.city,
provisional: r.status === "provisional",
}));
}
export async function listSitesForEmergency(ctx: ServiceCtx, customerId: string): Promise<EmergencySiteHit[]> {
assertCan(ctx, "emergency:create");
const customer = await ctx.db.customer.findFirst({
where: { id: customerId, deletedAt: null, status: { in: ["active", "provisional"] } },
select: { id: true },
});
if (!customer) throw new ServiceError("not_found", "customer not found");
const sites = await ctx.db.site.findMany({
where: { customerId, deletedAt: null, status: { not: "inactive" } },
orderBy: { name: "asc" },
take: 50,
select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true },
});
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "export",
entity: "emergency_site_lookup",
entityId: customerId,
after: { resultCount: sites.length, siteIds: sites.map((s) => s.id) },
});
return sites.map((s) => ({ id: s.id, name: s.name, address: formatAddress(s) }));
}
export type EmergencyTeamOption = { id: string; name: string; members: { id: string; name: string }[] };
/** Teams the user may book the call-out on (own active teams) incl. current members. */
export async function emergencyTeamOptions(ctx: ServiceCtx): Promise<EmergencyTeamOption[]> {
assertCan(ctx, "emergency:create");
const ids = await activeTeamIds(ctx);
if (!ids.length) return [];
const now = new Date();
const teams = await ctx.db.team.findMany({
where: { id: { in: ids } },
orderBy: { name: "asc" },
select: {
id: true,
name: true,
leader: { select: { id: true, name: true, status: true } },
members: {
where: { validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }], user: { status: "ACTIVE" } },
select: { user: { select: { id: true, name: true } } },
},
},
});
return teams.map((t) => {
const members = new Map<string, string>();
if (t.leader && t.leader.status === "ACTIVE") members.set(t.leader.id, t.leader.name ?? "");
for (const m of t.members) members.set(m.user.id, m.user.name ?? "");
return { id: t.id, name: t.name, members: [...members].map(([id, name]) => ({ id, name })) };
});
}
+316
View File
@@ -0,0 +1,316 @@
import type { Prisma, WorkOrderStatus } from "@prisma/client";
import { ZodError } from "zod";
import { emergencyOrderPatchSchema, REVIEW_STEPS, type ReviewStep } from "@/lib/emergency/schemas";
import { writeAuditLog } from "@/server/audit";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
import { confirmProvisionalCustomer } from "@/server/services/customers/customers";
import { findDuplicateCustomers, type DuplicateCandidate } from "@/server/services/customers/duplicates";
import { customerDisplayName } from "@/server/services/customers/format";
import { mergeCustomers } from "@/server/services/customers/merge";
import { nextNumber } from "@/server/services/numbering";
import { deleteSite, updateSite, type SitePatchInput } from "@/server/services/sites/sites";
import { releaseForBilling } from "@/server/services/work-orders/release-billing";
import { updateWorkOrder } from "@/server/services/work-orders/update";
import { workOrderScope } from "@/server/services/work-orders/visibility";
/**
* Backoffice-Nachbearbeitung von Notdiensteinsätzen (spec §19.3, §39; route /work-orders/emergency-review).
* Permission `emergency:review` on every function; the individual steps additionally require the
* permission of the service they delegate to (L1 customers/sites, L2 work orders).
*/
const BILLING_DONE: WorkOrderStatus[] = ["released_for_billing", "billed"];
const REVIEW_OPEN_STATUSES: WorkOrderStatus[] = ["in_review", "technically_completed", "signature_pending"];
export type ReviewProgress = { done: Record<ReviewStep, boolean>; count: number; total: number };
type ProgressInput = {
status: WorkOrderStatus;
orderTypeId: string | null;
billingType: string | null;
customer: { status: string };
site: { status: string } | null;
reports: { type: string; status: string }[];
};
export function reviewProgress(wo: ProgressInput): ReviewProgress {
const done: Record<ReviewStep, boolean> = {
customer: wo.customer.status !== "provisional",
site: !wo.site || wo.site.status !== "provisional",
order: Boolean(wo.orderTypeId && wo.billingType),
report: wo.reports.some((r) => r.type === "completion" && r.status === "approved"),
billing: BILLING_DONE.includes(wo.status),
};
return { done, count: REVIEW_STEPS.filter((s) => done[s]).length, total: REVIEW_STEPS.length };
}
const LIST_SELECT = {
id: true,
number: true,
title: true,
status: true,
version: true,
orderTypeId: true,
billingType: true,
plannedStart: true,
createdAt: true,
createdById: true,
customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, status: true } },
site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, status: true } },
team: { select: { name: true } },
reports: { where: { status: { not: "superseded" } }, select: { id: true, type: true, status: true, version: true }, orderBy: { createdAt: "desc" } },
} satisfies Prisma.WorkOrderSelect;
export type EmergencyReviewFilter = "open" | "all";
export async function listEmergencyReviews(ctx: ServiceCtx, opts: { filter?: EmergencyReviewFilter } = {}) {
assertCan(ctx, "emergency:review");
const open: Prisma.WorkOrderWhereInput = {
OR: [{ customer: { status: "provisional" } }, { site: { status: "provisional" } }, { status: { in: REVIEW_OPEN_STATUSES } }],
status: { notIn: ["cancelled", "billed"] },
};
const rows = await ctx.db.workOrder.findMany({
where: { AND: [await workOrderScope(ctx), { isEmergency: true }, opts.filter === "all" ? {} : open] },
orderBy: { createdAt: "desc" },
take: 200,
select: LIST_SELECT,
});
const creators = await userNames(ctx, rows.map((r) => r.createdById));
return rows.map((r) => ({
...r,
customerName: customerDisplayName(r.customer),
technician: r.createdById ? (creators.get(r.createdById) ?? null) : null,
progress: reviewProgress(r),
}));
}
async function userNames(ctx: ServiceCtx, ids: (string | null)[]) {
const unique = [...new Set(ids.filter((i): i is string => !!i))];
if (!unique.length) return new Map<string, string>();
const users = await ctx.db.user.findMany({ where: { id: { in: unique } }, select: { id: true, name: true } });
return new Map(users.map((u) => [u.id, u.name ?? ""]));
}
/** Loads an emergency order in scope or throws not_found. */
async function requireEmergencyOrder(ctx: ServiceCtx, workOrderId: string) {
assertCan(ctx, "emergency:review");
const wo = await ctx.db.workOrder.findFirst({
where: { AND: [{ id: workOrderId, isEmergency: true }, await workOrderScope(ctx)] },
select: {
...LIST_SELECT,
description: true,
emergencyReason: true,
customerId: true,
siteId: true,
contact: { select: { id: true, name: true, phone: true } },
orderType: { select: { id: true, name: true } },
customer: {
select: {
id: true,
customerNumber: true,
companyName: true,
firstName: true,
lastName: true,
status: true,
street: true,
houseNumber: true,
postalCode: true,
city: true,
phone: true,
mobile: true,
email: true,
},
},
workSessions: { select: { startedAt: true, endedAt: true, userId: true }, orderBy: { startedAt: "asc" } },
},
});
if (!wo) throw new ServiceError("not_found", "emergency order not found");
return wo;
}
export async function getEmergencyReview(ctx: ServiceCtx, workOrderId: string) {
const wo = await requireEmergencyOrder(ctx, workOrderId);
const c = wo.customer;
const duplicates: DuplicateCandidate[] =
c.status === "merged"
? []
: await findDuplicateCustomers(
ctx,
{ companyName: c.companyName, firstName: c.firstName, lastName: c.lastName, street: c.street, houseNumber: c.houseNumber, postalCode: c.postalCode, city: c.city, phone: c.phone, mobile: c.mobile, email: c.email },
{ excludeId: c.id, limit: 10 },
);
const siteOptions = await ctx.db.site.findMany({
where: { customerId: wo.customerId, deletedAt: null, status: { not: "inactive" }, ...(wo.siteId ? { id: { not: wo.siteId } } : {}) },
orderBy: { name: "asc" },
take: 100,
select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, status: true },
});
const site = wo.siteId
? await ctx.db.site.findFirst({
where: { id: wo.siteId },
select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, status: true, onSiteContact: true, phone: true, accessNotes: true },
})
: null;
const names = await userNames(ctx, [wo.createdById]);
const starts = wo.workSessions.map((s) => s.startedAt.getTime());
const ends = wo.workSessions.filter((s) => s.endedAt).map((s) => s.endedAt!.getTime());
return {
...wo,
site,
customerName: customerDisplayName(c),
technician: wo.createdById ? (names.get(wo.createdById) ?? null) : null,
startedAt: starts.length ? new Date(Math.min(...starts)) : wo.plannedStart,
endedAt: ends.length && wo.workSessions.every((s) => s.endedAt) ? new Date(Math.max(...ends)) : null,
duplicates,
siteOptions,
progress: reviewProgress({ ...wo, site }),
};
}
export type EmergencyReviewDetail = Awaited<ReturnType<typeof getEmergencyReview>>;
async function auditReview(ctx: ServiceCtx, workOrderId: string, step: string, before: unknown, after: unknown) {
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "emergency", entityId: workOrderId, before, after: { step, ...(after as object) } });
}
/** Allocate the next free customer number (skips numbers entered manually). */
async function allocateCustomerNumber(ctx: ServiceCtx): Promise<string> {
for (let i = 0; i < 20; i++) {
const candidate = await nextNumber(ctx.db, ctx.tenantId, "customer");
const taken = await ctx.db.customer.findFirst({ where: { customerNumber: candidate }, select: { id: true } });
if (!taken) return candidate;
}
throw new ServiceError("conflict", "could not allocate customer number", { reason: "number_allocation" });
}
/** Step 1a — Kunden bestätigen: provisional → active, customer number is allocated now. */
export async function confirmEmergencyCustomer(ctx: ServiceCtx, workOrderId: string) {
assertCan(ctx, "customer:write");
const wo = await requireEmergencyOrder(ctx, workOrderId);
if (wo.customer.status !== "provisional") throw new ServiceError("conflict", "customer is not provisional", { reason: "not_provisional" });
const result = await inTransaction(ctx, async (tx) => {
let customerNumber = wo.customer.customerNumber;
if (!customerNumber) {
customerNumber = await allocateCustomerNumber(tx);
const before = await tx.db.customer.findFirstOrThrow({ where: { id: wo.customerId } });
const after = await tx.db.customer.update({ where: { id: wo.customerId }, data: { customerNumber } });
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: wo.customerId, before, after });
}
await confirmProvisionalCustomer(tx, wo.customerId);
return { customerId: wo.customerId, customerNumber };
});
await auditReview(ctx, workOrderId, "customer_confirmed", { customerStatus: "provisional" }, { customerStatus: "active", customerNumber: result.customerNumber });
return result;
}
/**
* Step 1b — bestehendem Kunden zuordnen: re-parent the provisional customer's sites, contacts, orders and
* documents to the existing customer; the provisional record becomes `merged` (mergedIntoId). Only for
* provisional customers; never automatic.
*/
export async function assignEmergencyToCustomer(ctx: ServiceCtx, input: { workOrderId: string; targetCustomerId: string }) {
assertCan(ctx, "customer:write");
assertCan(ctx, "work_order:write");
const wo = await requireEmergencyOrder(ctx, input.workOrderId);
const sourceId = wo.customerId;
if (wo.customer.status !== "provisional") throw new ServiceError("conflict", "customer is not provisional", { reason: "not_provisional" });
if (input.targetCustomerId === sourceId) throw new ServiceError("invalid", "same customer", { field: "targetCustomerId", reason: "same_customer" });
const target = await ctx.db.customer.findFirst({
where: { id: input.targetCustomerId, deletedAt: null, status: { in: ["active", "inactive", "provisional"] } },
select: { id: true, customerNumber: true },
});
if (!target) throw new ServiceError("invalid", "target not found", { field: "targetCustomerId", reason: "target_not_found" });
const moved = await inTransaction(ctx, async (tx) => {
const source = await tx.db.customer.findFirstOrThrow({ where: { id: sourceId } });
const [sites, contacts, workOrders, documents] = [
await tx.db.site.updateMany({ where: { customerId: sourceId }, data: { customerId: target.id } }),
await tx.db.contact.updateMany({ where: { customerId: sourceId }, data: { customerId: target.id } }),
// version bump: offline clients must not overwrite the re-parented order with stale data
await tx.db.workOrder.updateMany({ where: { customerId: sourceId }, data: { customerId: target.id, version: { increment: 1 } } }),
await tx.db.document.updateMany({ where: { customerId: sourceId }, data: { customerId: target.id } }),
];
const after = await tx.db.customer.update({ where: { id: sourceId }, data: { status: "merged", mergedIntoId: target.id, isProvisional: false } });
const counts = { sites: sites.count, contacts: contacts.count, workOrders: workOrders.count, documents: documents.count };
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: sourceId, before: source, after: { ...after, assignedTo: target.id, moved: counts } });
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: target.id, after: { assignedFrom: sourceId, moved: counts } });
return counts;
});
await auditReview(ctx, input.workOrderId, "customer_assigned", { customerId: sourceId }, { customerId: target.id, moved });
return { sourceId, targetId: target.id, moved };
}
/** Step 1c — Dublette zusammenführen (L1 merge, `customer:merge` + explicit confirmation). */
export async function mergeEmergencyCustomer(ctx: ServiceCtx, input: { workOrderId: string; targetCustomerId: string; confirm: boolean }) {
const wo = await requireEmergencyOrder(ctx, input.workOrderId);
if (!input.confirm) throw new ServiceError("invalid", "confirmation required", { field: "confirm", reason: "confirm_required" });
const result = await mergeCustomers(ctx, { sourceId: wo.customerId, targetId: input.targetCustomerId, confirm: true });
await auditReview(ctx, input.workOrderId, "customer_merged", { customerId: wo.customerId }, { customerId: input.targetCustomerId, moved: result.moved });
return result;
}
/** Step 2a — Objekt korrigieren: update the site fields; `confirm` sets a provisional site to active. */
export async function correctEmergencySite(ctx: ServiceCtx, input: { workOrderId: string; patch: SitePatchInput; confirm: boolean }) {
const wo = await requireEmergencyOrder(ctx, input.workOrderId);
if (!wo.siteId) throw new ServiceError("invalid", "order has no site", { reason: "no_site" });
const { customerId: _ignored, status: _status, ...patch } = input.patch;
void _ignored;
void _status;
const after = await updateSite(ctx, wo.siteId, { ...patch, ...(input.confirm ? { status: "active" } : {}) });
await auditReview(ctx, input.workOrderId, "site_corrected", { siteId: wo.siteId }, { siteId: wo.siteId, status: after.status });
return after;
}
/** Step 2b — Objekt zuordnen: move the order to an existing site of its customer; an unused provisional site is soft-deleted. */
export async function assignEmergencySite(ctx: ServiceCtx, input: { workOrderId: string; siteId: string }) {
const wo = await requireEmergencyOrder(ctx, input.workOrderId);
if (input.siteId === wo.siteId) throw new ServiceError("invalid", "same site", { field: "siteId", reason: "same_site" });
const oldSiteId = wo.siteId;
const result = await inTransaction(ctx, async (tx) => {
const updated = await updateWorkOrder(tx, wo.id, { siteId: input.siteId });
let removed = false;
if (oldSiteId) {
const old = await tx.db.site.findFirst({ where: { id: oldSiteId }, select: { status: true, _count: { select: { workOrders: { where: { deletedAt: null } } } } } });
if (old?.status === "provisional" && old._count.workOrders === 0) {
await deleteSite(tx, oldSiteId);
removed = true;
}
}
return { version: updated.version, removed };
});
await auditReview(ctx, input.workOrderId, "site_assigned", { siteId: oldSiteId }, { siteId: input.siteId, provisionalSiteRemoved: result.removed });
return result;
}
/** Step 3 — Auftrag ergänzen (title, description, order type, billing type) via L2 updateWorkOrder. */
export async function completeEmergencyOrderData(ctx: ServiceCtx, input: { workOrderId: string; baseVersion?: number } & Record<string, unknown>) {
const wo = await requireEmergencyOrder(ctx, input.workOrderId);
let patch;
try {
patch = emergencyOrderPatchSchema.parse(input);
} catch (err) {
if (err instanceof ZodError) throw new ServiceError("invalid", "validation_failed", err.issues.map((i) => ({ path: i.path.join(".") })));
throw err;
}
const result = await updateWorkOrder(
ctx,
wo.id,
{ title: patch.title, description: patch.description, orderTypeId: patch.orderTypeId ?? null, billingType: patch.billingType ?? null },
input.baseVersion,
);
await auditReview(ctx, input.workOrderId, "order_completed", { orderTypeId: wo.orderTypeId, billingType: wo.billingType }, { orderTypeId: patch.orderTypeId ?? null, billingType: patch.billingType ?? null });
return result;
}
/**
* Step 5 — zur Abrechnung freigeben (L2 releaseForBilling: `work_order:release_billing` + approved completion report).
* Additionally blocked while customer or site are still provisional.
*/
export async function releaseEmergencyForBilling(ctx: ServiceCtx, input: { workOrderId: string; baseVersion?: number }) {
const wo = await requireEmergencyOrder(ctx, input.workOrderId);
const progress = reviewProgress(wo);
const open = (["customer", "site"] as const).filter((s) => !progress.done[s]);
if (open.length) throw new ServiceError("blocked", "master data still provisional", { reason: "master_data_open", steps: open });
const result = await releaseForBilling(ctx, { workOrderId: wo.id, baseVersion: input.baseVersion });
await auditReview(ctx, input.workOrderId, "billing_released", { status: wo.status }, { status: result.status });
return result;
}
+26
View File
@@ -0,0 +1,26 @@
import type { SyncOperationInput } from "@/lib/sync/envelope";
import { emergencyCreatePayload } from "@/lib/emergency/schemas";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import type { ExternalOpResult } from "@/server/services/sync/external-ops";
import { createEmergencyOrder } from "./create";
/**
* Sync op `emergency.create` (ARCHITEKTUR §4.6), registered in services/sync/external-ops.ts.
* The sync route is gated by the module "field"; the emergency module is checked here.
* idMap maps every client id of the payload to the server id.
*/
export async function applySyncOp(ctx: ServiceCtx, op: SyncOperationInput): Promise<ExternalOpResult> {
if (op.opType !== "emergency.create") throw new ServiceError("invalid", `unsupported op ${op.opType}`);
const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "emergency" }, select: { enabled: true } });
if (moduleRow && !moduleRow.enabled) throw new ServiceError("forbidden", "module emergency disabled");
const parsed = emergencyCreatePayload.safeParse(op.payload);
if (!parsed.success) throw new ServiceError("invalid", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ").slice(0, 500));
const r = await createEmergencyOrder(ctx, op.payload as never);
const ids = parsed.data.clientIds;
const idMap: Record<string, string> = { [ids.workOrder]: r.workOrderId, [ids.session]: r.sessionId };
if (ids.customer) idMap[ids.customer] = r.customerId;
if (ids.site && r.siteId) idMap[ids.site] = r.siteId;
return { idMap, entityVersion: r.version };
}
+2
View File
@@ -8,6 +8,7 @@ import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/cont
import { getCompletionBlockers } from "@/server/services/work-orders/completion"; import { getCompletionBlockers } from "@/server/services/work-orders/completion";
import { transitionWorkOrder } from "@/server/services/work-orders/transition"; import { transitionWorkOrder } from "@/server/services/work-orders/transition";
import { auditReport, assertEditable, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common"; import { auditReport, assertEditable, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common";
import { onCompletionReportSubmitted } from "@/server/services/emergency/completion";
export const submitReportSchema = z.object({ export const submitReportSchema = z.object({
reportId: z.string().min(1).max(64), reportId: z.string().min(1).max(64),
@@ -88,6 +89,7 @@ export async function submitReport(ctx: ServiceCtx, raw: SubmitReportInput): Pro
if (report.type === "completion" && report.version === 1) { if (report.type === "completion" && report.version === 1) {
await advanceOrder(ctx, wo.id, wo.status as WorkOrderStatus, wo.signatureRequired, content.signature, occurrenceId); await advanceOrder(ctx, wo.id, wo.status as WorkOrderStatus, wo.signatureRequired, content.signature, occurrenceId);
await onCompletionReportSubmitted(ctx, wo.id, occurrenceId); // lane emergency (L8): event emergency.completed, no-op for regular orders
const sig = content.signature; const sig = content.signature;
if (wo.signatureRequired && sig && (sig.outcome === "refused" || sig.outcome === "customer_absent")) { if (wo.signatureRequired && sig && (sig.outcome === "refused" || sig.outcome === "customer_absent")) {
await emitEvent(ctx, { type: "work_order.signature_missing", entityType: "work_order", entityId: wo.id, data: { number: wo.number, outcome: sig.outcome, occurrenceId } }); await emitEvent(ctx, { type: "work_order.signature_missing", entityType: "work_order", entityId: wo.id, data: { number: wo.number, outcome: sig.outcome, occurrenceId } });
+1 -1
View File
@@ -26,5 +26,5 @@ export const EXTERNAL_OPS: Partial<Record<SyncOpType, () => Promise<ExternalOpHa
// lane-reports: "report.save_draft": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), // 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: "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-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), "emergency.create": () => import("@/server/services/emergency/sync-ops").then((m) => m.applySyncOp),
}; };