Merge lane/notdienst in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
@@ -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
@@ -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]>;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 })) };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user