diff --git a/docs/craftvia/lanes/notdienst.md b/docs/craftvia/lanes/notdienst.md new file mode 100644 index 0000000..b9d5a98 --- /dev/null +++ b/docs/craftvia/lanes/notdienst.md @@ -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. diff --git a/messages/de/emergency.json b/messages/de/emergency.json new file mode 100644 index 0000000..189ad4e --- /dev/null +++ b/messages/de/emergency.json @@ -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." + } + } +} diff --git a/messages/de/nav.json b/messages/de/nav.json index 12b90ae..5a215f3 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -1,6 +1,7 @@ { "dashboard": "Dashboard", "workOrders": "Aufträge", + "emergencyReview": "Notdienst-Prüfung", "imports": "Import", "customers": "Kunden", "sites": "Objekte", diff --git a/messages/en/emergency.json b/messages/en/emergency.json new file mode 100644 index 0000000..a8f8460 --- /dev/null +++ b/messages/en/emergency.json @@ -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." + } + } +} diff --git a/messages/en/nav.json b/messages/en/nav.json index fdeec50..a78fd64 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -1,6 +1,7 @@ { "dashboard": "Dashboard", "workOrders": "Work orders", + "emergencyReview": "Emergency review", "imports": "Import", "customers": "Customers", "sites": "Sites", diff --git a/scripts/test-notdienst-flow.ts b/scripts/test-notdienst-flow.ts new file mode 100644 index 0000000..c71f4ca --- /dev/null +++ b/scripts/test-notdienst-flow.ts @@ -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 { + 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, 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) => 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); + }); diff --git a/scripts/test-notdienst-review.ts b/scripts/test-notdienst-review.ts new file mode 100644 index 0000000..d82b463 --- /dev/null +++ b/scripts/test-notdienst-review.ts @@ -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 { + 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 { + 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); + }); diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 68bcef3..34ac022 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -144,7 +144,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
    {TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => { const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; - const href = key === "sync_conflicts" ? "/work-orders/conflicts" : `/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 (
  • }) { + 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) => ; + + return ( +
    + + + {t("back")} + + + + {t("openOrder")} + + } + /> + +
    +
    + + +
    +
      + {REVIEW_STEPS.map((s, idx) => ( +
    1. +

      + {idx + 1}. {t(`steps.${s}`)} +

      + {stepState(r.progress.done[s])} +
    2. + ))} +
    + {r.emergencyReason && ( +
    +

    {t("reason")}

    +

    {r.emergencyReason}

    +
    + )} +
    + +
    + {/* 1 — Kunde */} +
    +
    + {customerProvisional && ( +

    {t("provisionalHint")}

    + )} +
    + {customerProvisional && can("customer:write") && ( +
    +

    {t("confirmCustomer.title")}

    +

    {t("confirmCustomer.hint")}

    + +
    + )} + +
    +

    {t("duplicates.title")}

    + {r.duplicates.length === 0 ? ( +

    {t("duplicates.none")}

    + ) : ( +
      + {r.duplicates.map((d) => ( +
    • + + {d.displayName} + + + {[d.customerNumber, d.city, t("duplicates.score", { score: Math.round(d.score * 100) })].filter(Boolean).join(" · ")} + +
    • + ))} +
    + )} +
    + + {customerProvisional && can("customer:write") && can("work_order:write") && ( +
    +

    {t("assignCustomer.title")}

    +

    {t("assignCustomer.hint")}

    + + + +
    + )} + + {can("customer:merge") && r.customer.status !== "merged" && ( +
    +

    {t("mergeCustomer.title")}

    +

    {t("mergeCustomer.hint")}

    + + + + +
    + )} +
    +
    + + {/* 2 — Objekt */} +
    + {r.site ? ( +
    + ) : ( + {t("noSite")} + )} +
    + {r.site && can("site:write") && ( +
    +

    {t("correctSite.title")}

    + +
    + + + + + + + + + + + + + + + + + + + + + + +