diff --git a/docs/craftvia/lanes/einsatz.md b/docs/craftvia/lanes/einsatz.md new file mode 100644 index 0000000..72b42da --- /dev/null +++ b/docs/craftvia/lanes/einsatz.md @@ -0,0 +1,110 @@ +# Lane L4 – Einsatz mobil (`lane/einsatz`) + +Stand: 2026-09-14 · Basis `bf44567` (`feature/craftvia-mvp`) + +## 1. Umfang / erfüllte Spec-Punkte + +| Spec | Umsetzung | +|---|---| +| §11.2 Auftragsansicht | `/m` Heute (heute geplant + laufend + pausiert), `/m/orders` Tabs Kommend · Laufend · Abzuschließen · Vergangen; große Karten mit Nummer, Kunde, Objektadresse + Routenlink, Zeitfenster, Statusgruppe (Text + Icon + Statuskante), Primärbutton | +| §12.1 Einsatzstart | `session.start` speichert Startzeit, Benutzer, Team, Auftrag, optional Standort, Gerätestatus (User-Agent), Offline-Flag; optional „Losfahren“ (Anfahrt) vor Arbeitsbeginn | +| §12.2 Arbeitszeiten | Session aus TimeEntry-Segmenten (Anfahrt → Arbeit ⇄ Pause), Start/Pause/Weiter/Ende, Dauerberechnung; Korrektur nur mit `field:correct_time`, Grund Pflicht, `corrected`-Flag, Audit before/after | +| §12.3 Tätigkeitsdokumentation | Notizen mit Art (9 große Chips) + Text; Entwurf bleibt bei Verbindungsabbruch lokal erhalten (US-006); Sprachnotiz | +| §12.4 Checklisten | Abhaken mit großem Schalter + Kommentar | +| §13.2/§13.3 Material | Planposition vollständig/teilweise/nicht verwendet mit Stepper-Menge; Begründung bei Abweichung Pflicht (UI + Server, gemeinsame Regeln); Zusatzmaterial mit Bezeichnung, Artikelnummer, Menge, Einheiten-Vorschlägen, Grund | +| §14.1/§14.3/§14.4 Fotos | Kamera (`capture="environment"`) + Galerie; Phase Vorher/Während/Nachher, Pflichtfoto (Kategorie), Checklistenpunkt, Kommentar, optional Standort; Kompression im Browser (max. 2560 px, JPEG 0.82, Vorschaubild 400 px, EXIF-Orientierung über `imageOrientation: "from-image"`), Upload-Fortschritt; Metadaten: Auftrag, Einsatz, Benutzer, Zeit, MIME (Magic Bytes), Größe, Prüfsumme, Uploadstatus; serverseitiges Vorschaubild per Job, falls der Client keines liefert | +| §15.1 Sprachnotizen | MediaRecorder (webm/opus, mp4 auf iOS), max. 5 min, Upload → VoiceNote `pending` → `dispatchJob("transcription")`; ohne registrierten Processor (L9) Status `disabled` | +| §22 Mobile Ansicht | eigene Shell ohne Sidebar, Bottom-Nav Heute · Aufträge · Notdienst · Sync · Profil, Online/Offline-Badge, Touch-Ziele ≥ 48 px, eine Primäraktion je Zustand, Kamera in 2 Taps (Karte → Foto) | +| US-005 | Dokumente am Auftrag + Objekt (nur erlaubte Sichtbarkeit, `backoffice_only` verborgen), freigegebene Berichte der Objekt-Historie; Auslieferung über autorisierte Route | +| US-006 | Einsatz starten/beenden, Fotos, Material bestätigen/ändern, Zusatzmaterial, Notizen mit lokalem Entwurf | +| ARCHITEKTUR §4.6 (Server) | `src/lib/sync/ops.ts`, `applyOperations`, `POST /api/v1/sync`, `POST /api/v1/uploads`, `GET /api/v1/field/bundle?since=` | + +Primäraktion je Zustand (`components/field/primary-action.tsx`): `assigned` → Annehmen · `accepted` → Losfahren (sekundär: Arbeit starten) · `en_route` → Arbeit starten · in Arbeit mit laufender eigener Session → Abschließen (sekundär Pause) · pausiert → Weiter · `technically_completed`/`signature_pending` → Hinweis „Bericht und Unterschrift folgen“. Abschließen beendet die eigene Session und ruft `work_order.transition → technically_completed`; offene Pflichtpunkte werden vorher als Liste angezeigt und der Button gesperrt. + +## 2. Routen / Screens + +| Route | Inhalt | +|---|---| +| `/m` | Heute | +| `/m/orders?tab=` | Auftragsliste mit Tabs | +| `/m/orders/[id]` | Kopf (Status, Zeitfenster, Primäraktion), Schnellaktionen (Foto direkt · Notiz · Material · Checkliste · Zeiten), Hinweise für Monteure, Objekt (Zugang/Parken/Sicherheit/Technik hervorgehoben, Route), Kunde/Ansprechpartner (`tel:`/`mailto:`), Auftrag, Dokumente, Objekt-Historie, Zusammenfassungen | +| `/m/orders/[id]/photos` · `/notes` · `/materials` · `/checklist` · `/time` | Unterseiten (max. 2 Ebenen unter der Liste) | +| `/m/profile` | Name, Betrieb, Team, Rolle, Sprache, Abmelden (Link ins Büro für Teamleiter/Backoffice) | +| `/m/sync` | **Platzhalter** für L7 (Verbindungsstatus, „Änderungen werden sofort übertragen“) | +| `/m/emergency` | unverändert Platzhalter von L8 (nur verschoben) | +| `POST /api/v1/sync` | Batch-Ops → `SyncResponse` | +| `POST /api/v1/uploads` | multipart `file`, `clientId`, `workOrderId`, `kind` (`photo`/`voice_note`), optional `preview` → `{ documentId, duplicate }` | +| `GET /api/v1/field/bundle?since=` | Offline-Pull | +| `GET /api/v1/field/documents/[id]?variant=preview` | autorisierte Dokument-Auslieferung (Sichtbarkeit + Scope) | + +Route-Group-Umzug: `src/app/(app)/m/**` → `src/app/(field)/m/**`. `(field)/m/layout.tsx` = Shell (Zugriffsprüfung), `(field)/m/(core)/layout.tsx` = Modul-Gate `field`, `emergency/layout.tsx` = Modul-Gate `emergency` (unverändert). Die Zugriffsprüfungen aus `(app)/layout.tsx` sind nach `src/server/app-access.ts#requireAppAccess` extrahiert und werden von beiden Shells genutzt (erlaubter Fundament-Eingriff, ARCHITEKTUR §5). + +Header-Slot für L6: im Mobile-Header (`(field)/m/layout.tsx`) ist links neben dem Online-Badge Platz für `` markiert. + +## 3. Dateien + +**Neu (Ownership L4)** +- `src/app/(field)/m/layout.tsx`, `(core)/layout.tsx` (verschoben), `(core)/page.tsx`, `(core)/orders/page.tsx`, `(core)/orders/[id]/{page.tsx,load.ts}`, `(core)/orders/[id]/{photos,notes,materials,checklist,time}/page.tsx`, `(core)/profile/page.tsx`, `sync/page.tsx` (Platzhalter L7) +- `src/app/api/v1/sync/route.ts`, `src/app/api/v1/uploads/route.ts`, `src/app/api/v1/field/bundle/route.ts`, `src/app/api/v1/field/documents/[id]/route.ts` +- `src/server/services/field/`: `common.ts`, `sessions.ts`, `time-correction.ts`, `checklist.ts`, `materials.ts`, `notes.ts`, `photos.ts`, `voice.ts`, `uploads.ts`, `documents.ts`, `queries.ts`, `page-context.ts`, `stubs/{work-order-transition,documents-store,site-history}.ts` +- `src/server/services/sync/apply.ts`, `src/server/services/sync/api-context.ts`, `src/server/services/sync/external-ops.ts` (Registry für Ops anderer Lanes) +- `src/server/actions/field/time.ts` (Zeitkorrektur, `moduleGuard("field")`) +- `src/server/jobs/processors/image-derivatives.ts` +- `src/lib/sync/ops.ts`, `src/lib/field/{client-ops,upload,image,format,material-rules}.ts` +- `src/components/field/*` (Shell-Navigation, Online-Badge, Statusbadge, Auftragskarte, Primäraktion, Foto, Sprachnotiz, Notiz, Material, Stepper, Checkliste, Zeitkorrektur, UI-Klassen) +- `messages/de/field.json`, `messages/en/field.json` +- `scripts/test-einsatz-field.ts`, `scripts/test-einsatz-sync.ts`, `scripts/lib/einsatz-fixture.ts` + +**Fundament / Fremd-Einzeiler** +- `src/server/app-access.ts` (neu, extrahiert) + `src/components/account-inactive-notice.tsx` (Hinweis „Konto deaktiviert“ aus dem Layout herausgelöst, Text unverändert) + `src/app/(app)/layout.tsx` nutzt beides +- `src/app/page.tsx`: rollenabhängige Startseite (`landingPath`: `field:execute` ohne `work_order:read_all` → `/m`, sonst `/dashboard`) +- `src/app/login/page.tsx`, `src/app/login/mfa/page.tsx`: Default-Redirect nach Login `/dashboard` → `/` (je 1 Zeile, + „bereits angemeldet“-Redirect) +- `src/server/jobs/processors/index.ts`: Registrierung `image-derivatives` (1 Zeile) +- `src/components/audit-trail.tsx`: Entity-Labels für `work_session`, `time_entry`, `checklist_item`, `material_usage`, `photo`, `voice_note`, `activity_note`, `sync_operation` (1 Zeile) + +Keine Schemaänderung, keine neue Migration, keine neuen npm-Abhängigkeiten (`sharp` ist über Next vorhanden). + +## 4. Tests + +- `scripts/test-einsatz-field.ts` – Session-Zustände (keine doppelte laufende Session, Pause-Segmente, Ende berechnet Dauer), Abschluss-Guards, Zeitkorrektur (Recht, Grund, Ende ≥ Beginn, Audit before/after), Material (Abweichung ohne Grund → invalid, Zusatzmaterial, Idempotenz), Checkliste/Notiz, Scope (Monteur ohne Zuweisung → not_found; ohne `field:execute` → forbidden), Sichtbarkeit/Bundle, Mandantentrennung. +- `scripts/test-einsatz-sync.ts` – Idempotenz (gleiche clientOpId → duplicate, keine Doppelanlage, je Mandant), Konflikt bei veralteter baseVersion (nichts überschrieben, SyncOperation `conflict`), Scope über Sync (fremder Auftrag → not_found, kein Versions-Leak), Ops fremder Lanes (rejected invalid, nicht gespeichert), Uploads (Idempotenz, Magic Bytes, Mandantentrennung beim Hochladen/Öffnen/Anhängen, `backoffice_only` verborgen), Foto/Sprachnotiz (Status `disabled`), image-derivatives-Processor inkl. Fremdmandant. + +Ergebnis: siehe Abschnitt 7 (Gate). + +## 5. Stubs & Abhängigkeiten zu anderen Lanes + +| Stub (in L4-Pfad) | Vertrag | Ersetzen durch | +|---|---|---| +| `services/field/stubs/work-order-transition.ts` | `transitionWorkOrder(ctx, { workOrderId, to, reason?, baseVersion? }) → { id, from, status, version }`; `completionBlockers(ctx, workOrderId) → CompletionBlocker[]`; wirft `ServiceError` inkl. `blocked` mit Blockern | L2 `services/work-orders/transition.ts` (Importe in `sessions.ts`, `queries.ts`, `sync/apply.ts`, Test) | +| `services/field/stubs/documents-store.ts` | `storeFile(ctx, { bytes, fileName, declaredMime, category, visibility, links, lineageId? }) → Document` (§4.3) inkl. Magic-Byte-Prüfung/Limits/SHA-256 | gemeinsames `services/documents/store.ts` (Import in `uploads.ts`) | +| `services/field/stubs/site-history.ts` | `getSiteHistory(ctx, siteId, { onlyApproved, limit? }) → SiteHistoryEntry[]` | L1 (Importe in `queries.ts`) | +| `app/(field)/m/sync/page.tsx` | Platzhalter | L7 ersetzt die Seite und `lib/field/client-ops.ts#submitOp` (Signatur stabil) | +| Sync-Ops `report.save_draft`, `report.submit`, `signature.capture` | Lazy-Import-Registry `services/sync/external-ops.ts` (auskommentierte Einzeiler) → `services/reports/sync-ops.ts#applySyncOp(ctx, op) → { idMap?, entityVersion? }` | L5 liefert das Modul und aktiviert je Op eine Zeile in `external-ops.ts`; bis dahin `rejected invalid` („not available yet“), nicht gespeichert | +| Sync-Op `emergency.create` | Registry-Einzeiler in `services/sync/external-ops.ts` → `services/emergency/sync-ops.ts#applySyncOp` | L8 | +| Transkription | Processor `transcription` in `jobs/processors/index.ts` | L9; bis dahin VoiceNote `disabled` | +| `/m/orders/[id]/{report,sign}` | noch nicht angelegt | Komponenten von L5, Einbindung durch L4 nach Merge | + +## 6. Bekannte Lücken / Hinweise an den Architekten + +0. **Registry statt berechnetem `import()`**: Ein Import mit berechnetem Pfad (`import(`../${dir}/sync-ops`)`) kann Turbopack nicht auflösen (Build-Warnung, Modul wäre nach Merge nicht ladbar). Daher explizite Registry `services/sync/external-ops.ts` – L5/L8 brauchen dort je Op einen Einzeiler. + +1. **Offline**: Ops werden sofort gesendet; ohne Verbindung wird nichts zwischengespeichert (außer Notiz-Entwurf). Outbox/Service Worker = L7. +2. **Bundle-Löschungen**: `bundle?since=` liefert nur geänderte/offene Aufträge, keine Tombstones für entzogene/abgeschlossene Aufträge (Client muss Vollabgleich ohne `since` machen). +3. **Jobs (Fundament-Bug)**: `enqueueJob` erzeugt `jobId` mit `:` – BullMQ lehnt das ab („Custom Id cannot contain :“), `dispatchJob` fällt deshalb immer auf Inline-Ausführung zurück. Fix in `src/server/jobs/queues.ts` (z. B. `-` statt `:`) nötig. +4. **`requireApiContext`** wird in `services/context.ts` erwähnt, existiert im Fundament aber nicht → lane-lokal in `services/sync/api-context.ts` (nutzt `moduleGuard`, Same-Origin-Prüfung, Fehler-Mapping). Kandidat für das Fundament. +5. **DSGVO `pii-fields.ts`** (Fundament): Personenreferenzen der Einsatzmodelle sind noch nicht eingetragen: `WorkSession.userId`, `TimeEntry.userId`, `TimeEntry.correctedById`, `ActivityNote.authorId`, `Photo.takenById`, `VoiceNote.recordedById`, `MaterialUsage.recordedById`, `ChecklistItem.checkedById`, `Document.uploadedById`, `SyncOperation.userId`/`resolvedById`. +6. **`/files/[...key]`** prüft weiterhin nur das Mandantenpräfix (TODO documents); die Mobile-App nutzt deshalb `/api/v1/field/documents/[id]` mit Sichtbarkeits- und Scope-Prüfung. +7. **Transaktionen**: Session-Operationen (Segment schließen/öffnen + Statuswechsel) laufen sequenziell ohne DB-Transaktion; bei parallelen Doppelstarts desselben Users ist eine zweite aktive Session theoretisch möglich (kein Unique-Index). Idempotenz über `clientId`/`clientOpId` deckt Wiederholungen ab. +8. **„Heute“** wird in der Server-Zeitzone berechnet, Anzeige in `Europe/Berlin` (`lib/field/format.ts`); Mandanten-Zeitzone fehlt im Modell. +9. **Passkey-Login** und Mandantenwechsel (`tenant-switch.ts`, `/select-tenant`) leiten weiter fest auf `/dashboard` – Feldrollen landen dort erst über die Startseite `/`, nicht direkt auf `/m`. +10. **HEIC** ohne Browser-Dekodierung: Upload scheitert mit Hinweis (keine serverseitige Konvertierung). + +## 7. Gate & Smoke + +**Tests:** `test-einsatz-field.ts` 48 Prüfungen, `test-einsatz-sync.ts` 38 Prüfungen – alle grün (S3/Garage-Pfad aktiv; ohne `S3_ENDPOINT` werden nur die Byte-Abrufe übersprungen). + +**Gate:** `npm run gate` grün – prisma generate, tsc, lint (0 Fehler, 2 Warnungen im Fundament-Platzhalter `handle-event.ts`), build inkl. Modul-Guard-Check (14 Action-Dateien), 24/24 Testskripte. + +**HTTP-Smoke** (Dev-Server :3104, Demo-Mandant mit Smoke-Auftrag, Session-Cookies für `monteur@`, `multi@` (Monteur ohne Zuweisung) und `admin2@` (Mandant demo2)): 32/32 Prüfungen grün – Startseite `/` → `/m` (Monteur) bzw. `/dashboard` (Admin), alle Mobile-Seiten 200 mit erwarteten Inhalten, fremder/zugriffsloser Auftrag → 404, Backoffice-Dashboard rendert weiterhin, Sync (not_found für fremd, applied+duplicate, conflict bei veralteter Version, 400 bei leerem Batch, 403 bei Cross-Origin, ohne Session → Login-Redirect), Bundle, Upload 201/200 (idempotent)/404 (fremder Mandant), Dokument-Original + Vorschau 200 bzw. 404 für fremd, `photo.attach` applied. + +**Mobile-UX 375×812** (Browser-Pane): große Karten mit Statuskante, Status als Text + Icon, eine orange Primäraktion, Schnellaktionen mit Kamera direkt (Heute → Karte → Foto = 2 Taps), Bottom-Nav 64 px, Buttons ≥ 48 px, max. 2 Ebenen unter der Liste; Klickpfad Annehmen → Losfahren über die UI geprüft. diff --git a/messages/de/field.json b/messages/de/field.json new file mode 100644 index 0000000..ba53ccf --- /dev/null +++ b/messages/de/field.json @@ -0,0 +1,298 @@ +{ + "nav": { + "label": "Einsatz-Navigation", + "today": "Heute", + "orders": "Aufträge", + "emergency": "Notdienst", + "sync": "Sync", + "profile": "Profil" + }, + "connection": { + "online": "Online", + "offline": "Offline" + }, + "noAccess": "Die Einsatz-App ist für Ihre Rolle nicht freigeschaltet.", + "today": { + "title": "Heute", + "greeting": "Hallo {name}", + "count": "{count, plural, =0 {Keine Einsätze} one {# Einsatz} other {# Einsätze}}", + "empty": "Heute stehen keine Einsätze an.", + "allOrders": "Alle Aufträge ansehen" + }, + "orders": { + "title": "Aufträge", + "tabs": { + "upcoming": "Kommend", + "running": "Laufend", + "to_complete": "Abzuschließen", + "past": "Vergangen" + }, + "empty": "Keine Aufträge in dieser Liste." + }, + "card": { + "open": "Auftrag öffnen", + "route": "Route", + "noDate": "Ohne Termin", + "emergency": "Notdienst", + "urgent": "Dringend", + "high": "Hoch" + }, + "statusGroup": { + "new": "Neu", + "planned": "Geplant", + "en_route": "Unterwegs", + "in_progress": "In Arbeit", + "documentation_incomplete": "Dokumentation unvollständig", + "in_review": "Zur Prüfung", + "ready_for_billing": "Bereit zur Abrechnung", + "billed": "Abgerechnet", + "cancelled": "Storniert" + }, + "statusDetail": { + "draft": "Entwurf", + "review_required": "Prüfung erforderlich", + "planned": "Geplant", + "assigned": "Zugewiesen", + "accepted": "Angenommen", + "en_route": "In Anfahrt", + "in_progress": "In Arbeit", + "paused": "Pausiert", + "waiting_material": "Wartet auf Material", + "daily_report_created": "Tagesbericht erstellt", + "technically_completed": "Technisch abgeschlossen", + "signature_pending": "Unterschrift ausstehend", + "in_review": "Zur Prüfung", + "released_for_billing": "Bereit zur Abrechnung", + "billed": "Abgerechnet", + "cancelled": "Storniert" + }, + "action": { + "accept": "Annehmen", + "travel": "Losfahren", + "start": "Arbeit starten", + "pause": "Pause", + "resume": "Weiter", + "complete": "Abschließen", + "confirmComplete": "Jetzt abschließen", + "confirmHint": "Zeiterfassung wird beendet und der Auftrag technisch abgeschlossen.", + "saving": "Wird gespeichert …", + "blocked": "Vor dem Abschluss noch offen:", + "reportNext": "Bericht und Unterschrift folgen im nächsten Schritt.", + "nothing": "Keine Aktion erforderlich." + }, + "blocker": { + "checklist_item": "Checkliste: {label}", + "photo_requirement": "Pflichtfoto: {label}", + "running_session": "Zeiterfassung eines Teammitglieds läuft noch", + "missing_field": "Angabe fehlt: {label}" + }, + "errors": { + "not_found": "Nicht gefunden oder nicht freigegeben.", + "forbidden": "Keine Berechtigung für diese Aktion.", + "invalid": "Eingabe prüfen.", + "conflict": "Der Auftrag wurde inzwischen geändert. Die Ansicht ist aktualisiert.", + "blocked": "Pflichtangaben fehlen noch.", + "internal": "Technischer Fehler. Bitte erneut versuchen.", + "network": "Keine Verbindung. Bitte erneut versuchen.", + "image": "Foto konnte nicht verarbeitet werden." + }, + "detail": { + "back": "Zurück", + "backToOrder": "Zum Auftrag", + "window": "Zeitfenster", + "hints": "Hinweise für Monteure", + "order": "Auftrag", + "orderType": "Auftragsart", + "externalNumber": "Kundenauftrag", + "team": "Team", + "scope": "Leistungsumfang", + "description": "Beschreibung", + "customer": "Kunde", + "contact": "Ansprechpartner", + "onSiteContact": "Vor Ort", + "call": "Anrufen", + "mail": "E-Mail", + "site": "Objekt", + "route": "Route öffnen", + "access": "Zugang", + "parking": "Parken", + "safety": "Sicherheit", + "technical": "Technik", + "documents": "Dokumente", + "noDocuments": "Keine Dokumente.", + "siteDocument": "Objekt", + "openDocument": "Öffnen", + "history": "Objekt-Historie", + "noHistory": "Keine freigegebenen Berichte am Objekt.", + "reportType": { + "daily": "Tagesbericht", + "completion": "Abschlussbericht" + }, + "checklist": "Checkliste", + "checklistProgress": "{done} von {total} erledigt", + "materials": "Material", + "materialsProgress": "{done} von {total} Positionen erfasst", + "materialsNone": "Kein Material geplant", + "photos": "Fotos", + "photosProgress": "{count} Fotos · {done} von {total} Pflichtfotos", + "photosCount": "{count} Fotos", + "notes": "Notizen", + "notesCount": "{count, plural, =0 {Noch keine Notizen} one {# Notiz} other {# Notizen}}", + "time": "Zeiten", + "myTime": "Meine Arbeitszeit: {duration}", + "quick": { + "photo": "Foto", + "note": "Notiz", + "material": "Material", + "checklist": "Checkliste", + "time": "Zeiten" + } + }, + "photos": { + "title": "Fotos", + "camera": "Kamera", + "gallery": "Galerie", + "phaseLabel": "Zeitpunkt", + "phase": { + "before": "Vorher", + "during": "Während", + "after": "Nachher" + }, + "requirement": "Pflichtfoto", + "checklistItem": "Checklistenpunkt", + "none": "Keine Zuordnung", + "comment": "Kommentar", + "location": "Standort mitsenden", + "save": "Foto speichern", + "discard": "Verwerfen", + "preview": "Vorschau des Fotos", + "compressing": "Foto wird verkleinert …", + "uploading": "Hochladen … {percent} %", + "saved": "Foto gespeichert.", + "empty": "Noch keine Fotos.", + "requirements": "Pflichtfotos", + "missing": "Fehlt", + "done": "Vorhanden" + }, + "notes": { + "title": "Notizen", + "kindLabel": "Art", + "kind": { + "work_done": "Durchgeführt", + "deviation": "Abweichung", + "problem": "Problem", + "additional_work": "Zusatzleistung", + "not_executable": "Nicht ausführbar", + "follow_up": "Folgearbeit", + "recommendation": "Empfehlung", + "customer_note": "Kundenhinweis", + "general": "Allgemein" + }, + "text": "Notiz", + "placeholder": "Kurz beschreiben, was vor Ort passiert ist …", + "save": "Notiz speichern", + "saved": "Notiz gespeichert.", + "draftKept": "Entwurf bleibt auf diesem Gerät erhalten.", + "empty": "Noch keine Notizen." + }, + "voice": { + "title": "Sprachnotiz", + "record": "Aufnahme starten", + "stop": "Aufnahme beenden", + "recording": "Aufnahme läuft · {time} von 5:00", + "save": "Sprachnotiz speichern", + "discard": "Verwerfen", + "saved": "Sprachnotiz gespeichert.", + "unsupported": "Sprachaufnahme wird auf diesem Gerät nicht unterstützt.", + "denied": "Mikrofon ist nicht freigegeben.", + "status": { + "pending": "Transkription läuft", + "done": "Transkribiert", + "failed": "Transkription fehlgeschlagen", + "disabled": "Ohne Transkription" + } + }, + "materials": { + "title": "Material", + "planned": "Geplant: {quantity} {unit}", + "usage": { + "fully_used": "Vollständig", + "partially_used": "Teilweise", + "not_used": "Nicht verwendet", + "additional": "Zusätzlich" + }, + "quantity": "Menge", + "unit": "Einheit", + "decrease": "Menge verringern", + "increase": "Menge erhöhen", + "reason": "Begründung der Abweichung", + "reasonRequired": "Bei Abweichung ist eine Begründung Pflicht.", + "save": "Speichern", + "saved": "Gespeichert.", + "recorded": "Erfasst: {quantity} {unit}", + "open": "Noch offen", + "noPlan": "Kein Material geplant.", + "additionalTitle": "Zusatzmaterial", + "additionalEmpty": "Kein Zusatzmaterial erfasst.", + "name": "Bezeichnung", + "articleNumber": "Artikelnummer (optional)", + "additionalReason": "Grund", + "add": "Zusatzmaterial speichern", + "invalid": "Angaben unvollständig oder Menge passt nicht zum Status." + }, + "checklist": { + "title": "Checkliste", + "required": "Pflicht", + "photo": "Foto nötig", + "done": "Erledigt", + "open": "Offen", + "comment": "Kommentar", + "saveComment": "Kommentar speichern", + "empty": "Für diesen Auftrag gibt es keine Checkliste." + }, + "time": { + "title": "Zeiten", + "type": { + "travel": "Anfahrt", + "work": "Arbeitszeit", + "break": "Pause", + "material_procurement": "Materialbeschaffung", + "return_travel": "Rückfahrt", + "interruption": "Unterbrechung" + }, + "session": { + "en_route": "In Anfahrt", + "running": "Läuft", + "paused": "Pausiert", + "ended": "Beendet" + }, + "running": "läuft", + "offlineStarted": "offline gestartet", + "corrected": "Korrigiert: {reason}", + "correct": "Korrigieren", + "start": "Beginn", + "end": "Ende", + "reason": "Grund der Korrektur", + "reasonHint": "Pflichtangabe, wird protokolliert.", + "save": "Korrektur speichern", + "saved": "Korrektur gespeichert.", + "empty": "Noch keine Zeiten erfasst.", + "totalWork": "Arbeitszeit gesamt: {duration}" + }, + "profile": { + "title": "Profil", + "tenant": "Betrieb", + "teams": "Team", + "noTeam": "Keinem Team zugeordnet", + "roles": "Rolle", + "language": "Sprache", + "logout": "Abmelden", + "backoffice": "Zum Büro-Bereich" + }, + "sync": { + "title": "Synchronisation", + "immediate": "Änderungen werden sofort an das Büro übertragen.", + "offlineHint": "Ohne Verbindung können Eingaben noch nicht zwischengespeichert werden – die Offline-Warteschlange folgt.", + "status": "Verbindung" + } +} diff --git a/messages/en/field.json b/messages/en/field.json new file mode 100644 index 0000000..d8a5456 --- /dev/null +++ b/messages/en/field.json @@ -0,0 +1,298 @@ +{ + "nav": { + "label": "Field navigation", + "today": "Today", + "orders": "Orders", + "emergency": "Emergency", + "sync": "Sync", + "profile": "Profile" + }, + "connection": { + "online": "Online", + "offline": "Offline" + }, + "noAccess": "The field app is not enabled for your role.", + "today": { + "title": "Today", + "greeting": "Hello {name}", + "count": "{count, plural, =0 {No jobs} one {# job} other {# jobs}}", + "empty": "No jobs scheduled for today.", + "allOrders": "View all orders" + }, + "orders": { + "title": "Orders", + "tabs": { + "upcoming": "Upcoming", + "running": "Ongoing", + "to_complete": "To complete", + "past": "Past" + }, + "empty": "No orders in this list." + }, + "card": { + "open": "Open order", + "route": "Route", + "noDate": "No date", + "emergency": "Emergency", + "urgent": "Urgent", + "high": "High" + }, + "statusGroup": { + "new": "New", + "planned": "Planned", + "en_route": "On the way", + "in_progress": "In progress", + "documentation_incomplete": "Documentation incomplete", + "in_review": "In review", + "ready_for_billing": "Ready for billing", + "billed": "Billed", + "cancelled": "Cancelled" + }, + "statusDetail": { + "draft": "Draft", + "review_required": "Review required", + "planned": "Planned", + "assigned": "Assigned", + "accepted": "Accepted", + "en_route": "On the way", + "in_progress": "In progress", + "paused": "Paused", + "waiting_material": "Waiting for material", + "daily_report_created": "Daily report created", + "technically_completed": "Technically completed", + "signature_pending": "Signature pending", + "in_review": "In review", + "released_for_billing": "Ready for billing", + "billed": "Billed", + "cancelled": "Cancelled" + }, + "action": { + "accept": "Accept", + "travel": "Leave now", + "start": "Start work", + "pause": "Pause", + "resume": "Resume", + "complete": "Complete", + "confirmComplete": "Complete now", + "confirmHint": "Time tracking stops and the order is marked technically completed.", + "saving": "Saving …", + "blocked": "Still missing before completion:", + "reportNext": "Report and signature follow in the next step.", + "nothing": "No action required." + }, + "blocker": { + "checklist_item": "Checklist: {label}", + "photo_requirement": "Required photo: {label}", + "running_session": "A team member's time tracking is still running", + "missing_field": "Missing: {label}" + }, + "errors": { + "not_found": "Not found or not shared with you.", + "forbidden": "You are not allowed to do this.", + "invalid": "Please check your input.", + "conflict": "The order was changed in the meantime. The view has been refreshed.", + "blocked": "Required information is still missing.", + "internal": "Technical error. Please try again.", + "network": "No connection. Please try again.", + "image": "The photo could not be processed." + }, + "detail": { + "back": "Back", + "backToOrder": "Back to order", + "window": "Time window", + "hints": "Notes for technicians", + "order": "Order", + "orderType": "Order type", + "externalNumber": "Customer order", + "team": "Team", + "scope": "Scope of work", + "description": "Description", + "customer": "Customer", + "contact": "Contact", + "onSiteContact": "On site", + "call": "Call", + "mail": "Email", + "site": "Site", + "route": "Open route", + "access": "Access", + "parking": "Parking", + "safety": "Safety", + "technical": "Technical", + "documents": "Documents", + "noDocuments": "No documents.", + "siteDocument": "Site", + "openDocument": "Open", + "history": "Site history", + "noHistory": "No approved reports at this site.", + "reportType": { + "daily": "Daily report", + "completion": "Completion report" + }, + "checklist": "Checklist", + "checklistProgress": "{done} of {total} done", + "materials": "Material", + "materialsProgress": "{done} of {total} items recorded", + "materialsNone": "No material planned", + "photos": "Photos", + "photosProgress": "{count} photos · {done} of {total} required photos", + "photosCount": "{count} photos", + "notes": "Notes", + "notesCount": "{count, plural, =0 {No notes yet} one {# note} other {# notes}}", + "time": "Time", + "myTime": "My working time: {duration}", + "quick": { + "photo": "Photo", + "note": "Note", + "material": "Material", + "checklist": "Checklist", + "time": "Time" + } + }, + "photos": { + "title": "Photos", + "camera": "Camera", + "gallery": "Gallery", + "phaseLabel": "When", + "phase": { + "before": "Before", + "during": "During", + "after": "After" + }, + "requirement": "Required photo", + "checklistItem": "Checklist item", + "none": "Not assigned", + "comment": "Comment", + "location": "Include location", + "save": "Save photo", + "discard": "Discard", + "preview": "Photo preview", + "compressing": "Resizing photo …", + "uploading": "Uploading … {percent} %", + "saved": "Photo saved.", + "empty": "No photos yet.", + "requirements": "Required photos", + "missing": "Missing", + "done": "Present" + }, + "notes": { + "title": "Notes", + "kindLabel": "Type", + "kind": { + "work_done": "Work done", + "deviation": "Deviation", + "problem": "Problem", + "additional_work": "Additional work", + "not_executable": "Not feasible", + "follow_up": "Follow-up work", + "recommendation": "Recommendation", + "customer_note": "Customer note", + "general": "General" + }, + "text": "Note", + "placeholder": "Briefly describe what happened on site …", + "save": "Save note", + "saved": "Note saved.", + "draftKept": "Draft is kept on this device.", + "empty": "No notes yet." + }, + "voice": { + "title": "Voice note", + "record": "Start recording", + "stop": "Stop recording", + "recording": "Recording · {time} of 5:00", + "save": "Save voice note", + "discard": "Discard", + "saved": "Voice note saved.", + "unsupported": "Voice recording is not supported on this device.", + "denied": "Microphone access is not granted.", + "status": { + "pending": "Transcription running", + "done": "Transcribed", + "failed": "Transcription failed", + "disabled": "No transcription" + } + }, + "materials": { + "title": "Material", + "planned": "Planned: {quantity} {unit}", + "usage": { + "fully_used": "Fully used", + "partially_used": "Partially used", + "not_used": "Not used", + "additional": "Additional" + }, + "quantity": "Quantity", + "unit": "Unit", + "decrease": "Decrease quantity", + "increase": "Increase quantity", + "reason": "Reason for deviation", + "reasonRequired": "A reason is required for deviations.", + "save": "Save", + "saved": "Saved.", + "recorded": "Recorded: {quantity} {unit}", + "open": "Open", + "noPlan": "No material planned.", + "additionalTitle": "Additional material", + "additionalEmpty": "No additional material recorded.", + "name": "Name", + "articleNumber": "Article number (optional)", + "additionalReason": "Reason", + "add": "Save additional material", + "invalid": "Incomplete input or quantity does not match the status." + }, + "checklist": { + "title": "Checklist", + "required": "Required", + "photo": "Photo needed", + "done": "Done", + "open": "Open", + "comment": "Comment", + "saveComment": "Save comment", + "empty": "This order has no checklist." + }, + "time": { + "title": "Time", + "type": { + "travel": "Travel", + "work": "Working time", + "break": "Break", + "material_procurement": "Material procurement", + "return_travel": "Return travel", + "interruption": "Interruption" + }, + "session": { + "en_route": "On the way", + "running": "Running", + "paused": "Paused", + "ended": "Ended" + }, + "running": "running", + "offlineStarted": "started offline", + "corrected": "Corrected: {reason}", + "correct": "Correct", + "start": "Start", + "end": "End", + "reason": "Reason for correction", + "reasonHint": "Required, will be logged.", + "save": "Save correction", + "saved": "Correction saved.", + "empty": "No time recorded yet.", + "totalWork": "Total working time: {duration}" + }, + "profile": { + "title": "Profile", + "tenant": "Company", + "teams": "Team", + "noTeam": "Not assigned to a team", + "roles": "Role", + "language": "Language", + "logout": "Sign out", + "backoffice": "Go to office area" + }, + "sync": { + "title": "Synchronisation", + "immediate": "Changes are sent to the office immediately.", + "offlineHint": "Without a connection, entries cannot be buffered yet – the offline queue will follow.", + "status": "Connection" + } +} diff --git a/scripts/lib/einsatz-fixture.ts b/scripts/lib/einsatz-fixture.ts new file mode 100644 index 0000000..9c4a6c7 --- /dev/null +++ b/scripts/lib/einsatz-fixture.ts @@ -0,0 +1,133 @@ +// Shared fixture of the lane L4 tests (scripts/test-einsatz-*.ts): two zz test tenants with +// users, customer, site, team and work orders. Not a test itself (the runner only picks up +// scripts/test-*.ts at top level). + +import { Prisma } from "@prisma/client"; +import { prisma, dbForTenant } from "../../src/server/db"; +import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac"; +import type { ServiceCtx } from "../../src/server/services/context"; + +export let failures = 0; +export const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +/** Expects `fn` to throw a ServiceError with the given code. */ +export async function expectCode(fn: () => Promise, code: string, msg: string) { + try { + await fn(); + ok(false, `${msg} — kein Fehler (erwartet ${code})`); + } catch (err) { + const actual = (err as { code?: string }).code; + ok(actual === code, `${msg}${actual === code ? "" : ` — Code ${actual ?? (err as Error).message}`}`); + } +} + +export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx { + return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set(ROLE_DEFS[role].permissions) }; +} + +async function cleanupSlug(slug: string, emailDomain: string) { + const tenant = await prisma.tenant.findUnique({ where: { slug }, select: { id: true } }); + if (tenant) { + const where = { tenantId: tenant.id }; + await prisma.auditLog.deleteMany({ where }); + await prisma.syncOperation.deleteMany({ where }); + await prisma.photo.deleteMany({ where }); + await prisma.voiceNote.deleteMany({ where }); + await prisma.document.deleteMany({ where }); + await prisma.workOrder.deleteMany({ where }); + await prisma.teamMember.deleteMany({ where }); + await prisma.team.deleteMany({ where }); + await prisma.site.deleteMany({ where }); + await prisma.contact.deleteMany({ where }); + await prisma.customer.deleteMany({ where }); + await prisma.numberSequence.deleteMany({ where }); + await prisma.userRole.deleteMany({ where: { user: { tenantId: tenant.id } } }); + await prisma.user.deleteMany({ where }); + await prisma.role.deleteMany({ where }); + await prisma.tenant.delete({ where: { id: tenant.id } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: emailDomain }, memberships: { none: {} } } }); +} + +async function user(tenantId: string, email: string, name: string) { + const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); + return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name } }); +} + +export type Fixture = Awaited>; + +export async function createFixture(prefix: string) { + const slugA = `zz-${prefix}-a`; + const slugB = `zz-${prefix}-b`; + const domain = `@zz-${prefix}.test`; + await cleanupSlug(slugA, domain); + await cleanupSlug(slugB, domain); + + const tenantA = await prisma.tenant.create({ data: { name: `L4 ${prefix} A`, slug: slugA } }); + const tenantB = await prisma.tenant.create({ data: { name: `L4 ${prefix} B`, slug: slugB } }); + + const tech = await user(tenantA.id, `tech${domain}`, "Tech A"); + const lead = await user(tenantA.id, `lead${domain}`, "Lead A"); + const outsider = await user(tenantA.id, `outsider${domain}`, "Outsider A"); + const techB = await user(tenantB.id, `techb${domain}`, "Tech B"); + + const customerA = await prisma.customer.create({ data: { tenantId: tenantA.id, companyName: "Kunde A GmbH", city: "Hamburg" } }); + const siteA = await prisma.site.create({ data: { tenantId: tenantA.id, customerId: customerA.id, name: "Halle 1", street: "Hafenstraße", houseNumber: "1", postalCode: "20457", city: "Hamburg", accessNotes: "Schlüssel beim Pförtner" } }); + const team = await prisma.team.create({ data: { tenantId: tenantA.id, name: `Team ${prefix}`, leaderUserId: lead.id } }); + + const now = new Date(); + const orderA = await prisma.workOrder.create({ + data: { + tenantId: tenantA.id, + number: `A-${prefix}-1`, + customerId: customerA.id, + siteId: siteA.id, + title: "Heizung montieren", + status: "assigned", + plannedStart: new Date(now.getTime() - 60 * 60 * 1000), + plannedEnd: new Date(now.getTime() + 60 * 60 * 1000), + teamLeadUserId: lead.id, + assignees: { create: [{ tenantId: tenantA.id, userId: tech.id }] }, + checklistItems: { create: [{ tenantId: tenantA.id, key: "safe", label: "Arbeitsbereich abgesichert", required: true }] }, + photoRequirements: { create: [{ tenantId: tenantA.id, key: "typenschild", label: "Typenschild" }] }, + materialPlans: { create: [{ tenantId: tenantA.id, name: "Kupferrohr 15 mm", plannedQuantity: new Prisma.Decimal(10), unit: "m" }] }, + }, + include: { checklistItems: true, photoRequirements: true, materialPlans: true }, + }); + + const customerB = await prisma.customer.create({ data: { tenantId: tenantB.id, companyName: "Kunde B AG" } }); + const orderB = await prisma.workOrder.create({ + data: { + tenantId: tenantB.id, + number: `A-${prefix}-B1`, + customerId: customerB.id, + title: "Wartung B", + status: "assigned", + assignees: { create: [{ tenantId: tenantB.id, userId: techB.id }] }, + }, + }); + + return { + tenantA, + tenantB, + tech, + lead, + outsider, + techB, + team, + siteA, + orderA, + orderB, + ctxTech: ctxFor(tenantA.id, tech.id, "technician"), + ctxLead: ctxFor(tenantA.id, lead.id, "team-lead"), + ctxOutsider: ctxFor(tenantA.id, outsider.id, "technician"), + ctxB: ctxFor(tenantB.id, techB.id, "technician"), + cleanup: async () => { + await cleanupSlug(slugA, domain); + await cleanupSlug(slugB, domain); + }, + }; +} diff --git a/scripts/test-einsatz-field.ts b/scripts/test-einsatz-field.ts new file mode 100644 index 0000000..da11770 --- /dev/null +++ b/scripts/test-einsatz-field.ts @@ -0,0 +1,146 @@ +// Lane L4 „Einsatz mobil" — Service-Tests: Einsatz-Sessions (Zustände, Pause-Segmente, Dauer), +// Zeitkorrektur (Recht + Grund + Audit), Material (Abweichung ohne Grund → invalid, Zusatzmaterial, +// Idempotenz), Checkliste/Notizen, Abschluss-Guards, Rollen/Scope und Mandantentrennung. +// +// Lauf: npx tsx scripts/test-einsatz-field.ts (lokale Postgres-DB aus .env) + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { closeJobQueues } from "../src/server/jobs/queues"; +import { endSession, pauseSession, resumeSession, startSession } from "../src/server/services/field/sessions"; +import { correctTimeEntry } from "../src/server/services/field/time-correction"; +import { upsertMaterialUsage } from "../src/server/services/field/materials"; +import { toggleChecklistItem } from "../src/server/services/field/checklist"; +import { createNote } from "../src/server/services/field/notes"; +import { getFieldOrderDetail, listFieldOrders, listTodayOrders, getFieldBundle } from "../src/server/services/field/queries"; +import { transitionWorkOrder } from "../src/server/services/field/stubs/work-order-transition"; +import { createFixture, expectCode, failures, ok } from "./lib/einsatz-fixture"; + +const min = (n: number) => n * 60 * 1000; + +async function main() { + const f = await createFixture("l4field"); + const wo = f.orderA.id; + try { + console.log("\n— Einsatz-Session: Zustände —"); + const t0 = Date.now() - min(120); + const at = (m: number) => new Date(t0 + min(m)).toISOString(); + + const travel = await startSession(f.ctxTech, { workOrderId: wo, mode: "travel", at: at(0), offline: true, deviceInfo: "test", latitude: 53.5, longitude: 10 }); + ok(travel.status === "en_route" && travel.workOrderStatus === "en_route", "Losfahren → Session en_route, Auftrag en_route"); + const s0 = await prisma.workSession.findUnique({ where: { id: travel.sessionId } }); + ok(s0?.startedOffline === true && s0.startLat === 53.5 && s0.deviceInfo === "test", "Start speichert Standort, Gerätestatus und Offline-Flag"); + await expectCode(() => startSession(f.ctxTech, { workOrderId: wo, mode: "travel", offline: false }), "conflict", "zweites Losfahren → conflict"); + + const work = await startSession(f.ctxTech, { workOrderId: wo, mode: "work", at: at(10), offline: false }); + ok(work.sessionId === travel.sessionId && work.status === "running" && work.workOrderStatus === "in_progress", "Arbeit starten übernimmt die Anfahrt-Session → running, Auftrag in_progress"); + await expectCode(() => startSession(f.ctxTech, { workOrderId: wo, mode: "work", offline: false }), "conflict", "keine doppelte laufende Session je User+Auftrag"); + const active = await prisma.workSession.count({ where: { workOrderId: wo, userId: f.tech.id, status: { in: ["en_route", "running", "paused"] } } }); + ok(active === 1, "genau eine aktive Session"); + + const paused = await pauseSession(f.ctxTech, { workOrderId: wo, at: at(40) }); + ok(paused.status === "paused" && paused.workOrderStatus === "paused", "Pause → Session paused, Auftrag paused"); + await expectCode(() => pauseSession(f.ctxTech, { workOrderId: wo }), "invalid", "Pause ohne laufende Session → invalid"); + const resumed = await resumeSession(f.ctxTech, { workOrderId: wo, at: at(50) }); + ok(resumed.status === "running" && resumed.workOrderStatus === "in_progress", "Weiter → running, Auftrag in_progress"); + + const entries = await prisma.timeEntry.findMany({ where: { workSessionId: travel.sessionId }, orderBy: { startedAt: "asc" } }); + ok(entries.map((e) => e.type).join(",") === "travel,work,break,work", "Zeitabschnitte als Segmente travel → work → break → work"); + ok(entries.slice(0, 3).every((e) => e.endedAt), "abgeschlossene Segmente haben ein Ende"); + + console.log("\n— Abschluss-Guards —"); + await expectCode(() => transitionWorkOrder(f.ctxTech, { workOrderId: wo, to: "technically_completed" }), "blocked", "Abschluss mit offener Pflicht-Checkliste/Pflichtfoto/laufender Session → blocked"); + + const ended = await endSession(f.ctxTech, { workOrderId: wo, at: at(80) }); + ok(ended.status === "ended", "Ende → Session ended"); + ok(ended.workSeconds === 3600 && ended.breakSeconds === 600 && ended.travelSeconds === 600 && ended.totalSeconds === 4800, `Ende berechnet Dauer (Arbeit ${ended.workSeconds}s, Pause ${ended.breakSeconds}s, Anfahrt ${ended.travelSeconds}s)`); + await expectCode(() => endSession(f.ctxTech, { workOrderId: wo }), "invalid", "Ende ohne aktive Session → invalid"); + + console.log("\n— Zeitkorrektur —"); + const entry = entries[1]; + await expectCode( + () => correctTimeEntry(f.ctxTech, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "vergessen" }), + "forbidden", + "Monteur ohne field:correct_time → forbidden", + ); + await expectCode(() => correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "" }), "invalid", "Korrektur ohne Grund → invalid"); + await expectCode(() => correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(40), endedAt: at(12), reason: "Ende vor Start" }), "invalid", "Ende vor Beginn → invalid"); + const corrected = await correctTimeEntry(f.ctxLead, { timeEntryId: entry.id, startedAt: at(12), endedAt: at(40), reason: "Start zu früh gestempelt" }); + ok(corrected.corrected && corrected.correctionReason === "Start zu früh gestempelt" && corrected.correctedById === f.lead.id, "Teamleiter korrigiert mit Grund → corrected-Flag + Grund + Bearbeiter"); + const auditRow = await prisma.auditLog.findFirst({ where: { tenantId: f.tenantA.id, entity: "time_entry", entityId: entry.id }, orderBy: { createdAt: "desc" } }); + const before = auditRow?.before as { startedAt?: string } | null; + const after = auditRow?.after as { startedAt?: string; correctionReason?: string } | null; + ok(!!auditRow && new Date(before!.startedAt!).getTime() === t0 + min(10) && new Date(after!.startedAt!).getTime() === t0 + min(12) && after?.correctionReason === "Start zu früh gestempelt", "Audit mit before/after der Korrektur"); + await expectCode(() => correctTimeEntry(f.ctxB as never, { timeEntryId: entry.id, startedAt: at(12), reason: "fremd" }), "forbidden", "Mandant B (Monteur) kann nicht korrigieren"); + const leadB = { ...f.ctxB, permissions: new Set([...f.ctxB.permissions, "field:correct_time"]) }; + await expectCode(() => correctTimeEntry(leadB, { timeEntryId: entry.id, startedAt: at(12), reason: "fremder Mandant" }), "not_found", "Mandant B mit Korrekturrecht sieht Zeiteintrag von A nicht → not_found"); + + console.log("\n— Material —"); + const plan = f.orderA.materialPlans[0]; + await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 5, unit: "m", usageStatus: "partially_used" }), "invalid", "teilweise verwendet ohne Begründung → invalid"); + await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 12, unit: "m", usageStatus: "fully_used" }), "invalid", "Mehrmenge ohne Begründung → invalid"); + await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 3, unit: "m", usageStatus: "not_used", deviationReason: "x" }), "invalid", "nicht verwendet mit Menge > 0 → invalid"); + const partial = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 5, unit: "m", usageStatus: "partially_used", deviationReason: "Leitung kürzer" }); + const full = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, materialPlanId: plan.id, quantity: 10, unit: "m", usageStatus: "fully_used" }); + ok(partial.usageId === full.usageId, "Bestätigung derselben Planposition aktualisiert statt doppelt anzulegen"); + const planUsage = await prisma.materialUsage.findUnique({ where: { id: full.usageId } }); + ok(planUsage?.usageStatus === "fully_used" && Number(planUsage.actualQuantity) === 10 && planUsage.deviationReason === null, "vollständig verwendet ohne Abweichung braucht keine Begründung"); + + await expectCode(() => upsertMaterialUsage(f.ctxTech, { workOrderId: wo, name: "Muffe", quantity: 2, unit: "Stk", usageStatus: "additional" }), "invalid", "Zusatzmaterial ohne Grund → invalid"); + const clientId = "7f1c1d2e-3a4b-4c5d-8e6f-7a8b9c0d1e2f"; + const add1 = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, clientId, name: "Muffe", quantity: 2, unit: "Stk", usageStatus: "additional", deviationReason: "Übergang fehlte" }); + const add2 = await upsertMaterialUsage(f.ctxTech, { workOrderId: wo, clientId, name: "Muffe", quantity: 3, unit: "Stk", usageStatus: "additional", deviationReason: "Übergang fehlte" }); + ok(add1.usageId === add2.usageId && (await prisma.materialUsage.count({ where: { workOrderId: wo } })) === 2, "Zusatzmaterial idempotent über clientId"); + + console.log("\n— Checkliste & Notizen —"); + const item = f.orderA.checklistItems[0]; + const toggled = await toggleChecklistItem(f.ctxTech, { workOrderId: wo, itemId: item.id, checked: true, comment: "erledigt" }); + const itemRow = await prisma.checklistItem.findUnique({ where: { id: item.id } }); + ok(toggled.checked && itemRow?.checkedById === f.tech.id && itemRow.comment === "erledigt", "Checklistenpunkt abhaken mit Kommentar"); + const noteClient = "1b2c3d4e-5f60-4718-8293-a4b5c6d7e8f9"; + const n1 = await createNote(f.ctxTech, { workOrderId: wo, clientId: noteClient, kind: "problem", text: "Ventil klemmt" }); + const n2 = await createNote(f.ctxTech, { workOrderId: wo, clientId: noteClient, kind: "problem", text: "Ventil klemmt" }); + ok(n1.noteId === n2.noteId && (await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "Notiz idempotent über clientId"); + const noteAudit = await prisma.auditLog.count({ where: { tenantId: f.tenantA.id, entity: "activity_note", entityId: n1.noteId } }); + ok(noteAudit === 1, "Notiz erzeugt genau einen Audit-Eintrag"); + + console.log("\n— Rollen/Scope (Monteur ohne Zuweisung) —"); + await expectCode(() => startSession(f.ctxOutsider, { workOrderId: wo, mode: "work", offline: false }), "not_found", "fremder Auftrag: Session starten → not_found"); + await expectCode(() => toggleChecklistItem(f.ctxOutsider, { workOrderId: wo, itemId: item.id, checked: false }), "not_found", "fremder Auftrag: Checkliste → not_found"); + await expectCode(() => createNote(f.ctxOutsider, { workOrderId: wo, kind: "general", text: "x" }), "not_found", "fremder Auftrag: Notiz → not_found"); + await expectCode(() => getFieldOrderDetail(f.ctxOutsider, wo), "not_found", "fremder Auftrag: Detail → not_found"); + ok((await listFieldOrders(f.ctxOutsider, "running")).length === 0 && (await listTodayOrders(f.ctxOutsider)).length === 0, "Monteur ohne Zuweisung sieht keine Aufträge"); + const noExecute = { ...f.ctxTech, permissions: new Set([...f.ctxTech.permissions].filter((p) => p !== "field:execute")) }; + await expectCode(() => createNote(noExecute, { workOrderId: wo, kind: "general", text: "x" }), "forbidden", "ohne field:execute → forbidden"); + + console.log("\n— Sichtbarkeit für Zugewiesene/Teamleiter —"); + const running = await listFieldOrders(f.ctxTech, "running"); + ok(running.some((o) => o.id === wo), "Monteur sieht zugewiesenen Auftrag unter „laufend\""); + ok((await listTodayOrders(f.ctxTech)).some((o) => o.id === wo), "Auftrag erscheint unter „Heute\""); + const detail = await getFieldOrderDetail(f.ctxLead, wo); + ok(detail.card.address === "Hafenstraße 1, 20457 Hamburg" && detail.site?.accessNotes === "Schlüssel beim Pförtner", "Teamleiter sieht Detail inkl. Objektadresse und Zugangshinweis"); + ok(detail.blockers.some((b) => b.kind === "photo_requirement") && !detail.blockers.some((b) => b.kind === "checklist_item"), "Blocker: nur noch Pflichtfoto offen"); + const bundle = await getFieldBundle(f.ctxTech); + ok(bundle.orders.length === 1 && bundle.orders[0].id === wo && bundle.orders[0].materialPlans.length === 1, "Bundle liefert Auftrag im Scope inkl. Materialvorgabe"); + + console.log("\n— Mandantentrennung (B gegen A) —"); + await expectCode(() => getFieldOrderDetail(f.ctxB, wo), "not_found", "B liest Auftrag von A → not_found"); + await expectCode(() => startSession(f.ctxB, { workOrderId: wo, mode: "work", offline: false }), "not_found", "B startet Session auf Auftrag von A → not_found"); + await expectCode(() => upsertMaterialUsage(f.ctxB, { workOrderId: f.orderB.id, materialPlanId: plan.id, quantity: 1, unit: "m", usageStatus: "fully_used" }), "not_found", "B nutzt Planposition von A → not_found"); + await expectCode(() => toggleChecklistItem(f.ctxB, { workOrderId: f.orderB.id, itemId: item.id, checked: false }), "not_found", "B ändert Checklistenpunkt von A → not_found"); + const itemAfter = await prisma.checklistItem.findUnique({ where: { id: item.id } }); + ok(itemAfter?.checked === true, "Checklistenpunkt von A unverändert"); + ok((await getFieldBundle(f.ctxB)).orders.every((o) => o.id !== wo), "Bundle von B enthält keine Aufträge von A"); + } finally { + await f.cleanup(); + await closeJobQueues(); + await prisma.$disconnect(); + } + console.log(`\n${failures === 0 ? "✓ alle Prüfungen grün" : `✗ ${failures} Fehler`}`); + process.exit(failures ? 1 : 0); +} + +main().catch(async (err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/test-einsatz-sync.ts b/scripts/test-einsatz-sync.ts new file mode 100644 index 0000000..04b06a3 --- /dev/null +++ b/scripts/test-einsatz-sync.ts @@ -0,0 +1,158 @@ +// Lane L4 „Einsatz mobil" — Sync- und Upload-Tests: Idempotenz (gleiche clientOpId → duplicate, +// keine Doppelanlage), Konflikt bei veralteter baseVersion (nichts überschrieben), Scope über die +// Sync-API (fremder Auftrag → not_found), Mandantentrennung bei Uploads/Dokumenten, Foto-/ +// Sprachnotiz-Anhang, Ops fremder Lanes ohne Implementierung, image-derivatives-Processor. +// +// Lauf: npx tsx scripts/test-einsatz-sync.ts (lokale Postgres-DB aus .env; S3 optional) + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import sharp from "sharp"; +import { prisma } from "../src/server/db"; +import { closeJobQueues } from "../src/server/jobs/queues"; +import { applyOperations } from "../src/server/services/sync/apply"; +import { storeFieldUpload } from "../src/server/services/field/uploads"; +import { openFieldDocument } from "../src/server/services/field/documents"; +import { process as imageDerivatives } from "../src/server/jobs/processors/image-derivatives"; +import type { SyncOperationInput, SyncOpType } from "../src/lib/sync/envelope"; +import type { ServiceCtx } from "../src/server/services/context"; +import { createFixture, expectCode, failures, ok } from "./lib/einsatz-fixture"; + +const S3 = !!process.env.S3_ENDPOINT?.trim(); + +function op(opType: SyncOpType, payload: Record, extra: Partial = {}): SyncOperationInput { + return { clientOpId: randomUUID(), opType, payload, clientCreatedAt: new Date().toISOString(), ...extra }; +} + +async function one(ctx: ServiceCtx, operation: SyncOperationInput) { + const res = await applyOperations(ctx, { deviceId: "test-device", operations: [operation] }); + return res.results[0]; +} + +async function main() { + const f = await createFixture("l4sync"); + const wo = f.orderA.id; + try { + console.log("\n— Idempotenz —"); + const noteOp = op("note.create", { workOrderId: wo, clientId: randomUUID(), kind: "work_done", text: "Heizkörper montiert" }); + const r1 = await one(f.ctxTech, noteOp); + ok(r1.status === "applied" && !!r1.idMap && Object.keys(r1.idMap).length === 1, "erste Op → applied mit idMap"); + const r2 = await one(f.ctxTech, noteOp); + ok(r2.status === "duplicate" && JSON.stringify(r2.idMap) === JSON.stringify(r1.idMap), "gleiche clientOpId → duplicate mit gespeicherter idMap"); + ok((await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "keine Doppelanlage der Notiz"); + ok((await prisma.syncOperation.count({ where: { tenantId: f.tenantA.id, clientOpId: noteOp.clientOpId } })) === 1, "SyncOperation einmal protokolliert"); + const batch = await applyOperations(f.ctxTech, { deviceId: "test-device", operations: [noteOp, noteOp] }); + ok(batch.results.every((r) => r.status === "duplicate"), "Batch mit Wiederholungen → duplicate"); + + const bad = await one(f.ctxTech, op("note.create", { workOrderId: wo, kind: "unbekannt", text: "" })); + ok(bad.status === "rejected" && bad.errorCode === "invalid", "ungültige Payload → rejected invalid"); + + console.log("\n— Konflikt (baseVersion) —"); + const before = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } }); + const stale = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }, { baseVersion: before.version + 5 })); + ok(stale.status === "conflict" && stale.entityVersion === before.version, "veraltete baseVersion → conflict mit aktueller Version"); + const afterConflict = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } }); + ok(afterConflict.status === before.status && afterConflict.version === before.version, "bei Konflikt nichts überschrieben"); + const stored = await prisma.syncOperation.findFirst({ where: { tenantId: f.tenantA.id, clientOpId: stale.clientOpId } }); + ok(stored?.status === "conflict" && stored.errorCode === "conflict", "Konflikt als SyncOperation(status=conflict) für das Backoffice gespeichert"); + const noBase = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" })); + ok(noBase.status === "rejected" && noBase.errorCode === "invalid", "konfliktbehaftete Op ohne baseVersion → invalid"); + const fresh = await one(f.ctxTech, op("work_order.transition", { workOrderId: wo, to: "accepted" }, { baseVersion: before.version })); + const accepted = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo } }); + ok(fresh.status === "applied" && fresh.entityVersion === before.version + 1 && accepted.status === "accepted", "aktuelle baseVersion → applied, Version +1"); + ok((await prisma.workOrderStatusChange.count({ where: { workOrderId: wo, toStatus: "accepted" } })) === 1, "Statuswechsel protokolliert"); + + console.log("\n— Sessions über Sync —"); + const st1 = await one(f.ctxTech, op("session.start", { workOrderId: wo, mode: "work", clientId: randomUUID() })); + const st2 = await one(f.ctxTech, op("session.start", { workOrderId: wo, mode: "work", clientId: randomUUID() })); + ok(st1.status === "applied" && st2.status === "rejected" && st2.errorCode === "conflict", "zweiter Start über Sync → rejected conflict"); + ok((await prisma.workSession.count({ where: { workOrderId: wo, status: "running" } })) === 1, "nur eine laufende Session"); + + console.log("\n— Scope über die Sync-API —"); + const foreign = await one(f.ctxOutsider, op("note.create", { workOrderId: wo, kind: "general", text: "darf nicht" })); + ok(foreign.status === "rejected" && foreign.errorCode === "not_found", "Monteur ohne Zuweisung → rejected not_found"); + const foreignTransition = await one(f.ctxOutsider, op("work_order.transition", { workOrderId: wo, to: "en_route" }, { baseVersion: accepted.version })); + ok(foreignTransition.status === "rejected" && foreignTransition.errorCode === "not_found", "fremder Auftrag bei Konfliktprüfung → not_found (kein Versions-Leak)"); + const crossTenant = await one(f.ctxB, op("note.create", { workOrderId: wo, kind: "general", text: "Mandant B" })); + ok(crossTenant.status === "rejected" && crossTenant.errorCode === "not_found", "Mandant B auf Auftrag von A → not_found"); + ok((await prisma.activityNote.count({ where: { workOrderId: wo } })) === 1, "keine fremden Notizen angelegt"); + const sameOpIdB = await one(f.ctxB, { ...noteOp, payload: { workOrderId: f.orderB.id, kind: "general", text: "B eigene Notiz" } }); + ok(sameOpIdB.status === "applied", "gleiche clientOpId in Mandant B ist unabhängig (Idempotenz je Mandant)"); + const reuseOther = await one(f.ctxLead, noteOp); + ok(reuseOther.status === "rejected" && !reuseOther.idMap, "fremder Nutzer mit gleicher clientOpId erhält kein gespeichertes Ergebnis"); + + console.log("\n— Ops fremder Lanes —"); + const report = await one(f.ctxTech, op("report.save_draft", { workOrderId: wo })); + ok(report.status === "rejected" && report.errorCode === "invalid" && /not available/.test(report.message ?? ""), "report.save_draft ohne L5 → rejected invalid mit Hinweis"); + ok((await prisma.syncOperation.count({ where: { clientOpId: report.clientOpId } })) === 0, "nicht verfügbare Op wird nicht gespeichert (später wiederholbar)"); + + console.log("\n— Uploads & Mandantentrennung —"); + const jpeg = await sharp({ create: { width: 1200, height: 800, channels: 3, background: { r: 200, g: 120, b: 40 } } }).jpeg().toBuffer(); + const thumb = await sharp(jpeg).resize(400).jpeg().toBuffer(); + const uploadClient = randomUUID(); + const up1 = await storeFieldUpload(f.ctxTech, { clientId: uploadClient, workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" }, { bytes: thumb, name: "t.jpg", type: "image/jpeg" }); + const up2 = await storeFieldUpload(f.ctxTech, { clientId: uploadClient, workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" }); + ok(!up1.duplicate && up2.duplicate && up1.documentId === up2.documentId, "Upload idempotent über clientId"); + const doc = await prisma.document.findUniqueOrThrow({ where: { id: up1.documentId } }); + ok(doc.tenantId === f.tenantA.id && doc.category === "photo" && doc.mimeType === "image/jpeg" && doc.checksum.length === 64 && !!doc.previewKey, "Dokument mit MIME, Prüfsumme und Vorschaubild"); + + const upB = await storeFieldUpload(f.ctxB, { clientId: uploadClient, workOrderId: f.orderB.id, kind: "photo" }, { bytes: jpeg, name: "foto.jpg", type: "image/jpeg" }); + ok(!upB.duplicate && upB.documentId !== up1.documentId, "gleiche clientId in Mandant B → eigenes Dokument, kein Zugriff auf A"); + await expectCode(() => storeFieldUpload(f.ctxB, { clientId: randomUUID(), workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "x.jpg", type: "image/jpeg" }), "not_found", "Mandant B lädt auf Auftrag von A hoch → not_found"); + await expectCode(() => openFieldDocument(f.ctxB, up1.documentId), "not_found", "Mandant B öffnet Dokument von A → not_found"); + await expectCode(() => openFieldDocument(f.ctxOutsider, up1.documentId), "not_found", "Monteur ohne Zuweisung öffnet Dokument → not_found"); + await expectCode( + () => storeFieldUpload(f.ctxTech, { clientId: randomUUID(), workOrderId: wo, kind: "photo" }, { bytes: Buffer.from("%PDF-1.7 fake pdf content"), name: "x.pdf", type: "application/pdf" }), + "invalid", + "PDF als Foto → invalid (Magic Bytes)", + ); + if (S3) { + const opened = await openFieldDocument(f.ctxTech, up1.documentId, "preview"); + ok(!!opened.content.stream, "zugewiesener Monteur öffnet eigenes Foto (Vorschau)"); + } else { + console.log("↷ Byte-Abruf übersprungen (kein S3_ENDPOINT)"); + } + const internal = await prisma.document.create({ + data: { tenantId: f.tenantA.id, workOrderId: wo, category: "other", fileName: "intern.pdf", storageKey: `${f.tenantA.id}/uploads/x`, mimeType: "application/pdf", fileSize: 1, checksum: "0", lineageId: randomUUID(), visibility: "backoffice_only" }, + }); + await expectCode(() => openFieldDocument(f.ctxTech, internal.id), "not_found", "Backoffice-internes Dokument bleibt für Monteur verborgen"); + + console.log("\n— Foto & Sprachnotiz anhängen —"); + const req = f.orderA.photoRequirements[0]; + const photoClient = randomUUID(); + const attach = await one(f.ctxTech, op("photo.attach", { workOrderId: wo, clientId: photoClient, documentId: up1.documentId, phase: "before", photoRequirementId: req.id, comment: "Typenschild" })); + ok(attach.status === "applied" && !!attach.idMap?.[photoClient], "photo.attach → applied"); + const photo = await prisma.photo.findFirst({ where: { documentId: up1.documentId } }); + ok(photo?.photoRequirementId === req.id && photo.phase === "before" && photo.takenById === f.tech.id, "Foto mit Phase, Pflichtfoto und Aufnehmendem"); + const attachB = await one(f.ctxB, op("photo.attach", { workOrderId: f.orderB.id, documentId: up1.documentId })); + ok(attachB.status === "rejected" && attachB.errorCode === "not_found", "Mandant B hängt Dokument von A an → not_found"); + const attachAgain = await one(f.ctxTech, op("photo.attach", { workOrderId: wo, documentId: up1.documentId })); + ok(attachAgain.status === "rejected" && attachAgain.errorCode === "invalid", "Dokument doppelt anhängen → invalid"); + + const webm = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(512, 1)]); + const voiceUp = await storeFieldUpload(f.ctxTech, { clientId: randomUUID(), workOrderId: wo, kind: "voice_note" }, { bytes: webm, name: "sprachnotiz.webm", type: "audio/webm;codecs=opus" }); + const voice = await one(f.ctxTech, op("voice.attach", { workOrderId: wo, clientId: randomUUID(), documentId: voiceUp.documentId, durationSeconds: 12 })); + const vn = await prisma.voiceNote.findFirst({ where: { documentId: voiceUp.documentId } }); + ok(voice.status === "applied" && vn?.transcriptionStatus === "disabled", "Sprachnotiz ohne Transkriptions-Processor → Status disabled statt Fehler"); + + console.log("\n— image-derivatives —"); + const noThumb = await storeFieldUpload(f.ctxTech, { clientId: randomUUID(), workOrderId: wo, kind: "photo" }, { bytes: jpeg, name: "ohne-vorschau.jpg", type: "image/jpeg" }); + await imageDerivatives({ tenantId: f.tenantA.id, entityId: noThumb.documentId, actorId: f.tech.id }); + const derived = await prisma.document.findUniqueOrThrow({ where: { id: noThumb.documentId } }); + if (S3) ok(!!derived.previewKey && derived.previewKey.startsWith(`${f.tenantA.id}/`), "Processor erzeugt mandantenpräfixiertes Vorschaubild"); + else ok(derived.previewKey === null, "ohne Byte-Speicher kein Vorschaubild (kein Fehler)"); + await imageDerivatives({ tenantId: f.tenantB.id, entityId: noThumb.documentId }); + ok((await prisma.document.findUniqueOrThrow({ where: { id: noThumb.documentId } })).previewKey === derived.previewKey, "Processor mit fremdem Mandanten ändert nichts"); + } finally { + await f.cleanup(); + await closeJobQueues(); + await prisma.$disconnect(); + } + console.log(`\n${failures === 0 ? "✓ alle Prüfungen grün" : `✗ ${failures} Fehler`}`); + process.exit(failures ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 1d7808c..f7ad927 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,10 +1,8 @@ import Link from "next/link"; -import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; -import { auth, signOut } from "@/server/auth"; -import { dbForTenant, prisma } from "@/server/db"; -import { isTokenStillValid } from "@/server/sessions"; -import { resolveMfaRequired } from "@/lib/mfa-policy"; +import { signOut } from "@/server/auth"; +import { dbForTenant } from "@/server/db"; +import { requireAppAccess } from "@/server/app-access"; import { NAV_ITEMS, visibleNavItems } from "@/lib/nav"; import { resolveTenantBranding } from "@/lib/brand"; import { Button } from "@/components/ui/button"; @@ -14,60 +12,16 @@ import { TenantSwitcher } from "@/components/tenant-switcher"; import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { CraftviaLogo } from "@/components/brand/craftvia-logo"; import { NotificationBell } from "@/components/notifications/bell"; +import { AccountInactiveNotice } from "@/components/account-inactive-notice"; export default async function AppLayout({ children, }: Readonly<{ children: React.ReactNode }>) { - const session = await auth(); - if (!session?.user) redirect("/login"); - // Angemeldet, aber (noch) kein aktiver Mandant (mehrere Mitgliedschaften ohne Slug) → - // Auswahlseite. MUSS vor jeder dbForTenant-Nutzung stehen (leerer tenantId). - if (!session.user.tenantId) redirect("/select-tenant"); - - // Kontostatus (autoritativ aus DB, nicht aus dem JWT): deaktivierte Nutzer werden - // abgemeldet, ein offener Passwortwechsel wird erzwungen. Membership-Status kommt vom - // User, die globalen Auth-Zustände (Passwortzwang, Kill-Switch, MFA) von der Identity. - const account = await dbForTenant(session.user.tenantId).user.findUnique({ - where: { id: session.user.id }, - select: { status: true }, - }); - const identity = session.user.identityId - ? await prisma.identity.findUnique({ - where: { id: session.user.identityId }, - select: { status: true, mustChangePassword: true, sessionsValidAfter: true, mfaEnrolledAt: true, uiLocale: true }, - }) - : null; - // SEC2: serverseitig entwertete Sessions (Passwort-Reset/-Wechsel) sofort abmelden. - if (identity && !isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) { - await signOut({ redirectTo: "/login" }); - } - if (!account || account.status !== "ACTIVE" || !identity || identity.status !== "ACTIVE") { - return ( -
-
- -

Konto deaktiviert

-

Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.

-
{ "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5"> - -
-
-
- ); - } - if (identity.mustChangePassword) redirect("/change-password"); - - // SEC3-a/-b: MFA-Enrollment-Gate. Verlangt der Mandant MFA und hat die Identity weder TOTP - // noch einen Passkey, wird die Einrichtung erzwungen (Pflichtseite außerhalb dieses Layouts). - if (!identity.mfaEnrolledAt) { - const settings = await dbForTenant(session.user.tenantId).tenantSettings.findFirst({ select: { securityPolicy: true } }); - if (resolveMfaRequired(settings?.securityPolicy)) { - const passkeys = session.user.identityId - ? await prisma.webAuthnCredential.count({ where: { identityId: session.user.identityId } }) - : 0; - if (passkeys === 0) redirect("/enroll-mfa"); - } - } + // Session-, Konto-, Passwort- und MFA-Prüfungen: gemeinsam mit der Mobile-Shell + // (src/server/app-access.ts). + const access = await requireAppAccess(); + if (access.kind === "inactive") return ; + const { session, identity } = access; const t = await getTranslations("nav"); const tc = await getTranslations("common"); diff --git a/src/app/(app)/m/(field)/page.tsx b/src/app/(app)/m/(field)/page.tsx deleted file mode 100644 index 1281447..0000000 --- a/src/app/(app)/m/(field)/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; - -export default function Page() { - return ; -} diff --git a/src/app/(app)/m/(field)/layout.tsx b/src/app/(field)/m/(core)/layout.tsx similarity index 100% rename from src/app/(app)/m/(field)/layout.tsx rename to src/app/(field)/m/(core)/layout.tsx diff --git a/src/app/(field)/m/(core)/orders/[id]/checklist/page.tsx b/src/app/(field)/m/(core)/orders/[id]/checklist/page.tsx new file mode 100644 index 0000000..e668c5d --- /dev/null +++ b/src/app/(field)/m/(core)/orders/[id]/checklist/page.tsx @@ -0,0 +1,31 @@ +import { getTranslations } from "next-intl/server"; +import { ChecklistItemRow } from "@/components/field/checklist-item"; +import { SubPageHeader } from "@/components/field/sub-page-header"; +import { loadOrder } from "../load"; + +/** `/m/orders/[id]/checklist` — checklist execution (Spec §12.4). */ +export default async function ChecklistPage({ params }: { params: Promise<{ id: string }> }) { + const { order } = await loadOrder(params); + const t = await getTranslations("field"); + const done = order.checklistItems.filter((i) => i.checked).length; + + return ( +
+ +
+ {order.checklistItems.length === 0 ? ( +

{t("checklist.empty")}

+ ) : ( + <> +

{t("detail.checklistProgress", { done, total: order.checklistItems.length })}

+
    + {order.checklistItems.map((item) => ( + + ))} +
+ + )} +
+
+ ); +} diff --git a/src/app/(field)/m/(core)/orders/[id]/load.ts b/src/app/(field)/m/(core)/orders/[id]/load.ts new file mode 100644 index 0000000..2704a85 --- /dev/null +++ b/src/app/(field)/m/(core)/orders/[id]/load.ts @@ -0,0 +1,16 @@ +import { notFound } from "next/navigation"; +import { ServiceError } from "@/server/services/context"; +import { fieldPageContext } from "@/server/services/field/page-context"; +import { getFieldOrderDetail } from "@/server/services/field/queries"; + +/** Shared loader of the order detail and its sub pages: scope violations render 404 (no existence leak). */ +export async function loadOrder(params: Promise<{ id: string }>) { + const { id } = await params; + const ctx = await fieldPageContext(); + try { + return { ctx, order: await getFieldOrderDetail(ctx, id) }; + } catch (err) { + if (err instanceof ServiceError && err.code === "not_found") notFound(); + throw err; + } +} diff --git a/src/app/(field)/m/(core)/orders/[id]/materials/page.tsx b/src/app/(field)/m/(core)/orders/[id]/materials/page.tsx new file mode 100644 index 0000000..6c55edb --- /dev/null +++ b/src/app/(field)/m/(core)/orders/[id]/materials/page.tsx @@ -0,0 +1,53 @@ +import { getTranslations } from "next-intl/server"; +import { AdditionalMaterialForm, MaterialPlanItem } from "@/components/field/material-forms"; +import { SubPageHeader } from "@/components/field/sub-page-header"; +import { card } from "@/components/field/ui"; +import { loadOrder } from "../load"; + +/** `/m/orders/[id]/materials` — confirm planned material, record additional material (Spec §13.2/§13.3). */ +export default async function MaterialsPage({ params }: { params: Promise<{ id: string }> }) { + const { order } = await loadOrder(params); + const t = await getTranslations("field"); + const additional = order.materialUsages.filter((u) => !u.materialPlanId); + + return ( +
+ +
+ {order.materialPlans.length === 0 ? ( +

{t("materials.noPlan")}

+ ) : ( +
    + {order.materialPlans.map((p) => { + const usage = order.materialUsages.find((u) => u.materialPlanId === p.id); + return ( + + ); + })} +
+ )} + +

{t("materials.additionalTitle")}

+ {additional.length === 0 ? ( +

{t("materials.additionalEmpty")}

+ ) : ( +
    + {additional.map((u) => ( +
  • +

    {u.name}

    +

    {t("materials.recorded", { quantity: String(Number(u.actualQuantity)).replace(".", ","), unit: u.unit })}

    + {u.deviationReason &&

    {u.deviationReason}

    } +
  • + ))} +
+ )} + +
+
+ ); +} diff --git a/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx b/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx new file mode 100644 index 0000000..37f1c87 --- /dev/null +++ b/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx @@ -0,0 +1,60 @@ +import { getLocale, getTranslations } from "next-intl/server"; +import { fmtDateTime, fmtDuration } from "@/lib/field/format"; +import { NoteForm } from "@/components/field/note-form"; +import { SubPageHeader } from "@/components/field/sub-page-header"; +import { VoiceRecorder } from "@/components/field/voice-recorder"; +import { card } from "@/components/field/ui"; +import { loadOrder } from "../load"; + +/** `/m/orders/[id]/notes` — activity notes + voice notes (Spec §12.3, §15.1). */ +export default async function NotesPage({ params }: { params: Promise<{ id: string }> }) { + const { order } = await loadOrder(params); + const t = await getTranslations("field"); + const locale = await getLocale(); + + return ( +
+ +
+
+ +
+
+

{t("voice.title")}

+ +
+ + {order.voiceNotes.length > 0 && ( +
    + {order.voiceNotes.map((v) => ( +
  • +

    + {t("voice.title")} · {fmtDateTime(v.recordedAt, locale)} + {v.durationSeconds ? ` · ${fmtDuration(v.durationSeconds)}` : ""} +

    +
  • + ))} +
+ )} + + {order.notes.length === 0 ? ( +

{t("notes.empty")}

+ ) : ( +
    + {order.notes.map((n) => ( +
  • +

    + {t(`notes.kind.${n.kind}`)} · {fmtDateTime(n.createdAt, locale)} +

    +

    {n.text}

    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/app/(field)/m/(core)/orders/[id]/page.tsx b/src/app/(field)/m/(core)/orders/[id]/page.tsx new file mode 100644 index 0000000..f8d60a5 --- /dev/null +++ b/src/app/(field)/m/(core)/orders/[id]/page.tsx @@ -0,0 +1,312 @@ +import Link from "next/link"; +import { getLocale, getTranslations } from "next-intl/server"; +import { + Building2, + ChevronLeft, + ChevronRight, + Clock, + FileText, + History, + Info, + KeyRound, + ListChecks, + Mail, + Navigation, + Package, + Phone, + ShieldAlert, + SquareParking, + StickyNote, + User, + Wrench, + type LucideIcon, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { can } from "@/server/services/context"; +import { customerDisplayName, formatAddress, mapsUrl } from "@/server/services/field/queries"; +import { fmtDate, fmtDuration, fmtWindow, secondsBetween } from "@/lib/field/format"; +import { STATUS_GROUP } from "@/lib/work-orders/status"; +import { PrimaryAction } from "@/components/field/primary-action"; +import { QuickPhotoButton } from "@/components/field/photo-capture"; +import { StatusBadge } from "@/components/field/status-badge"; +import { card, toneClasses } from "@/components/field/ui"; +import { loadOrder } from "./load"; + +function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) { + const head = ( +
+ +
+

{title}

+ {summary &&

{summary}

} +
+ {href && } +
+ ); + return ( +
+ {href ? ( + + {head} + + ) : ( + head + )} + {children &&
{children}
} +
+ ); +} + +function ContactButtons({ phone, email, labels }: { phone?: string | null; email?: string | null; labels: { call: string; mail: string } }) { + if (!phone && !email) return null; + const cls = "inline-flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border px-3 text-[15px] font-semibold text-primary"; + return ( +
+ {phone && ( + + + {labels.call} + + )} + {email && ( + + + {labels.mail} + + )} +
+ ); +} + +function Notice({ icon: Icon, label, text, tone }: { icon: LucideIcon; label: string; text: string; tone: "warn" | "risk" | "info" }) { + const toneCls = { + warn: "bg-[color-mix(in_oklch,var(--warn)_12%,transparent)]", + risk: "bg-[color-mix(in_oklch,var(--risk)_10%,transparent)]", + info: "bg-[color-mix(in_oklch,var(--info)_10%,transparent)]", + }[tone]; + return ( +
+ +

+ {label} + {text} +

+
+ ); +} + +/** `/m/orders/[id]` — order detail with status, one primary action and all field sections (Spec §22, US-005/006). */ +export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { ctx, order } = await loadOrder(params); + const t = await getTranslations("field"); + const locale = await getLocale(); + const base = `/m/orders/${order.id}`; + + const address = formatAddress(order.site) ?? formatAddress(order.customer); + const route = mapsUrl(address); + const contact = order.contact ?? order.site?.contact ?? null; + const blockers = order.blockers + .filter((b) => !(b.kind === "running_session" && b.userId === ctx.userId)) + .map((b) => t(`blocker.${b.kind}`, { label: "label" in b ? b.label : "field" in b ? b.field : "" })); + const requirementsDone = order.photoRequirements.filter((r) => r._count.photos > 0).length; + const checklistDone = order.checklistItems.filter((i) => i.checked).length; + const plannedDone = order.materialPlans.filter((p) => order.materialUsages.some((u) => u.materialPlanId === p.id)).length; + const myWorkSeconds = order.workSessions + .filter((s) => s.userId === ctx.userId) + .flatMap((s) => s.entries) + .filter((e) => e.type === "work") + .reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); + const editable = can(ctx, "field:execute"); + + const quick = [ + { href: `${base}/notes`, label: t("detail.quick.note"), icon: StickyNote }, + { href: `${base}/materials`, label: t("detail.quick.material"), icon: Package }, + { href: `${base}/checklist`, label: t("detail.quick.checklist"), icon: ListChecks }, + { href: `${base}/time`, label: t("detail.quick.time"), icon: Clock }, + ]; + + return ( +
+ + + {t("detail.back")} + + +
+
+ {order.number} + +
+

{order.title}

+

{customerDisplayName(order.customer)}

+

+ + {fmtWindow(order.plannedStart, order.plannedEnd, locale) ?? t("card.noDate")} +

+ {editable && ( +
+ +
+ )} +
+ + {editable && ( + + )} + + {order.technicianNotes && } + + {order.site && ( +
+

{order.site.name}

+ {address &&

{address}

} + {route && ( + + + {t("detail.route")} + + )} + {order.site.accessNotes && } + {order.site.parkingNotes && } + {order.site.safetyNotes && } + {order.site.technicalNotes && } + {order.site.onSiteContact && ( +

+ {t("detail.onSiteContact")}: + {order.site.onSiteContact} +

+ )} + +
+ )} + +
+

{customerDisplayName(order.customer)}

+ {!order.site && address &&

{address}

} + + {contact && ( +
+

{t("detail.contact")}

+

+ {contact.name} + {contact.role && · {contact.role}} +

+ +
+ )} +
+ +
+
+ {order.orderType && ( + <> +
{t("detail.orderType")}
+
{order.orderType.name}
+ + )} + {order.externalOrderNumber && ( + <> +
{t("detail.externalNumber")}
+
{order.externalOrderNumber}
+ + )} + {order.team && ( + <> +
{t("detail.team")}
+
{order.team.name}
+ + )} +
+ {order.description &&

{order.description}

} + {order.scope && ( +
+

{t("detail.scope")}

+

{order.scope}

+
+ )} +
+ +
+ {order.documents.length === 0 ? ( +

{t("detail.noDocuments")}

+ ) : ( + + )} +
+ + {order.site && ( +
+ {order.siteHistory.length === 0 ? ( +

{t("detail.noHistory")}

+ ) : ( +
    + {order.siteHistory.map((h) => ( +
  • + + + {t(`detail.reportType.${h.reportType}`)} · {fmtDate(h.reportDate, locale)} + + + {h.workOrderNumber} · {h.workOrderTitle} + + + {h.pdfDocumentId && ( + + {t("detail.openDocument")} + + )} +
  • + ))} +
+ )} +
+ )} + +
+
+
+
+
+
+ ); +} diff --git a/src/app/(field)/m/(core)/orders/[id]/photos/page.tsx b/src/app/(field)/m/(core)/orders/[id]/photos/page.tsx new file mode 100644 index 0000000..9bb3a06 --- /dev/null +++ b/src/app/(field)/m/(core)/orders/[id]/photos/page.tsx @@ -0,0 +1,71 @@ +import { getLocale, getTranslations } from "next-intl/server"; +import { CircleCheck, CircleDot } from "lucide-react"; +import { fmtDateTime } from "@/lib/field/format"; +import { PhotoCapture } from "@/components/field/photo-capture"; +import { SubPageHeader } from "@/components/field/sub-page-header"; +import { card } from "@/components/field/ui"; +import { loadOrder } from "../load"; + +/** `/m/orders/[id]/photos` — capture (camera/gallery) + gallery + required photos (Spec §14). */ +export default async function PhotosPage({ params }: { params: Promise<{ id: string }> }) { + const { order } = await loadOrder(params); + const t = await getTranslations("field"); + const locale = await getLocale(); + const requirementLabel = new Map(order.photoRequirements.map((r) => [r.id, r.label])); + + return ( +
+ +
+
+ ({ id: r.id, label: r.label }))} + checklistItems={order.checklistItems.map((c) => ({ id: c.id, label: c.label }))} + /> +
+ + {order.photoRequirements.length > 0 && ( +
+

{t("photos.requirements")}

+
    + {order.photoRequirements.map((r) => { + const done = r._count.photos > 0; + return ( +
  • + {done ? : } + {r.label} + {done ? t("photos.done") : t("photos.missing")} +
  • + ); + })} +
+
+ )} + + {order.photos.length === 0 ? ( +

{t("photos.empty")}

+ ) : ( +
    + {order.photos.map((p) => ( +
  • + + {/* eslint-disable-next-line @next/next/no-img-element */} + {p.comment + +
    +

    + {p.phase ? t(`photos.phase.${p.phase}`) : "—"} + {p.photoRequirementId && requirementLabel.get(p.photoRequirementId) ? ` · ${requirementLabel.get(p.photoRequirementId)}` : ""} +

    + {p.comment &&

    {p.comment}

    } +

    {fmtDateTime(p.takenAt, locale)}

    +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/app/(app)/m/(field)/orders/[id]/report/page.tsx b/src/app/(field)/m/(core)/orders/[id]/report/page.tsx similarity index 100% rename from src/app/(app)/m/(field)/orders/[id]/report/page.tsx rename to src/app/(field)/m/(core)/orders/[id]/report/page.tsx diff --git a/src/app/(app)/m/(field)/orders/[id]/sign/page.tsx b/src/app/(field)/m/(core)/orders/[id]/sign/page.tsx similarity index 100% rename from src/app/(app)/m/(field)/orders/[id]/sign/page.tsx rename to src/app/(field)/m/(core)/orders/[id]/sign/page.tsx diff --git a/src/app/(field)/m/(core)/orders/[id]/time/page.tsx b/src/app/(field)/m/(core)/orders/[id]/time/page.tsx new file mode 100644 index 0000000..e5191b1 --- /dev/null +++ b/src/app/(field)/m/(core)/orders/[id]/time/page.tsx @@ -0,0 +1,55 @@ +import { getLocale, getTranslations } from "next-intl/server"; +import { can } from "@/server/services/context"; +import { fmtDateTime, fmtDuration, fmtTime, secondsBetween } from "@/lib/field/format"; +import { SubPageHeader } from "@/components/field/sub-page-header"; +import { TimeCorrectionForm } from "@/components/field/time-correction-form"; +import { card } from "@/components/field/ui"; +import { loadOrder } from "../load"; + +/** `/m/orders/[id]/time` — sessions and time segments, corrections with reason (Spec §12.2). */ +export default async function TimePage({ params }: { params: Promise<{ id: string }> }) { + const { ctx, order } = await loadOrder(params); + const t = await getTranslations("field"); + const locale = await getLocale(); + const canCorrect = can(ctx, "field:correct_time"); + const totalWork = order.workSessions + .flatMap((s) => s.entries) + .filter((e) => e.type === "work") + .reduce((acc, e) => acc + secondsBetween(e.startedAt, e.endedAt), 0); + + return ( +
+ +
+

{t("time.totalWork", { duration: fmtDuration(totalWork) })}

+ {order.workSessions.length === 0 &&

{t("time.empty")}

} + {order.workSessions.map((s) => ( +
+
+

{s.user.name}

+ + {t(`time.session.${s.status}`)} + {s.startedOffline ? ` · ${t("time.offlineStarted")}` : ""} + +
+

{fmtDateTime(s.startedAt, locale)}

+
    + {s.entries.map((e) => ( +
  • +
    + {t(`time.type.${e.type}`)} + + {fmtTime(e.startedAt, locale)}–{e.endedAt ? fmtTime(e.endedAt, locale) : t("time.running")} · {fmtDuration(secondsBetween(e.startedAt, e.endedAt))} + +
    + {e.corrected &&

    {t("time.corrected", { reason: e.correctionReason ?? "" })}

    } + {canCorrect && } +
  • + ))} +
+
+ ))} +
+
+ ); +} diff --git a/src/app/(field)/m/(core)/orders/page.tsx b/src/app/(field)/m/(core)/orders/page.tsx new file mode 100644 index 0000000..d20248e --- /dev/null +++ b/src/app/(field)/m/(core)/orders/page.tsx @@ -0,0 +1,46 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { cn } from "@/lib/utils"; +import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; +import { listFieldOrders, ORDER_TABS, type OrderTab } from "@/server/services/field/queries"; +import { OrderCard } from "@/components/field/order-card"; +import { chip } from "@/components/field/ui"; + +/** `/m/orders` — tabs upcoming · running · to complete · past (Spec §11.2). */ +export default async function OrdersPage({ searchParams }: { searchParams: Promise<{ tab?: string }> }) { + const ctx = await fieldPageContext(); + const t = await getTranslations("field"); + if (!canUseFieldApp(ctx)) return

{t("noAccess")}

; + + const { tab: rawTab } = await searchParams; + const tab: OrderTab = (ORDER_TABS as readonly string[]).includes(rawTab ?? "") ? (rawTab as OrderTab) : "running"; + const orders = await listFieldOrders(ctx, tab); + + return ( +
+

{t("orders.title")}

+ + {orders.length === 0 ? ( +

{t("orders.empty")}

+ ) : ( +
    + {orders.map((o) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/(field)/m/(core)/page.tsx b/src/app/(field)/m/(core)/page.tsx new file mode 100644 index 0000000..0802068 --- /dev/null +++ b/src/app/(field)/m/(core)/page.tsx @@ -0,0 +1,41 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { ChevronRight } from "lucide-react"; +import { canUseFieldApp, fieldPageContext } from "@/server/services/field/page-context"; +import { listTodayOrders } from "@/server/services/field/queries"; +import { OrderCard } from "@/components/field/order-card"; +import { btnSecondary } from "@/components/field/ui"; + +/** `/m` — Heute: today's, running and paused orders (Spec §11.2). */ +export default async function TodayPage() { + const ctx = await fieldPageContext(); + const t = await getTranslations("field"); + if (!canUseFieldApp(ctx)) return

{t("noAccess")}

; + + const [orders, me] = await Promise.all([listTodayOrders(ctx), ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } })]); + + return ( +
+
+

{t("today.greeting", { name: me?.name ?? "" })}

+

{t("today.title")}

+

{t("today.count", { count: orders.length })}

+
+ {orders.length === 0 ? ( +

{t("today.empty")}

+ ) : ( +
    + {orders.map((o) => ( +
  • + +
  • + ))} +
+ )} + + {t("today.allOrders")} + + +
+ ); +} diff --git a/src/app/(field)/m/(core)/profile/page.tsx b/src/app/(field)/m/(core)/profile/page.tsx new file mode 100644 index 0000000..6531a10 --- /dev/null +++ b/src/app/(field)/m/(core)/profile/page.tsx @@ -0,0 +1,73 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { LogOut } from "lucide-react"; +import { auth, signOut } from "@/server/auth"; +import { prisma } from "@/server/db"; +import { can } from "@/server/services/context"; +import { activeTeamIds } from "@/server/services/work-orders/visibility"; +import { fieldPageContext } from "@/server/services/field/page-context"; +import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; +import { btnSecondary, card } from "@/components/field/ui"; + +/** `/m/profile` — name, team, company, language, sign out. */ +export default async function ProfilePage() { + const ctx = await fieldPageContext(); + const t = await getTranslations("field.profile"); + const session = await auth(); + const teamIds = await activeTeamIds(ctx); + const [me, teams, identity] = await Promise.all([ + ctx.db.user.findFirst({ + where: { id: ctx.userId }, + select: { name: true, email: true, tenant: { select: { name: true } }, userRoles: { select: { role: { select: { name: true } } } } }, + }), + teamIds.length ? ctx.db.team.findMany({ where: { id: { in: teamIds } }, select: { id: true, name: true }, orderBy: { name: "asc" } }) : Promise.resolve([]), + session?.user?.identityId ? prisma.identity.findUnique({ where: { id: session.user.identityId }, select: { uiLocale: true } }) : Promise.resolve(null), + ]); + + async function logout() { + "use server"; + await signOut({ redirectTo: "/login" }); + } + + const row = "flex flex-col gap-0.5 py-2.5"; + return ( +
+

{t("title")}

+
+

{me?.name}

+

{me?.email}

+
+
+
{t("tenant")}
+
{me?.tenant.name}
+
+
+
{t("teams")}
+
{teams.length ? teams.map((tm) => tm.name).join(", ") : t("noTeam")}
+
+
+
{t("roles")}
+
{me?.userRoles.map((r) => r.role.name).join(", ")}
+
+
+
{t("language")}
+
+ +
+
+
+
+ {(can(ctx, "work_order:read_all") || can(ctx, "report:approve_team")) && ( + + {t("backoffice")} + + )} +
+ +
+
+ ); +} diff --git a/src/app/(app)/m/emergency/layout.tsx b/src/app/(field)/m/emergency/layout.tsx similarity index 100% rename from src/app/(app)/m/emergency/layout.tsx rename to src/app/(field)/m/emergency/layout.tsx diff --git a/src/app/(app)/m/emergency/page.tsx b/src/app/(field)/m/emergency/page.tsx similarity index 100% rename from src/app/(app)/m/emergency/page.tsx rename to src/app/(field)/m/emergency/page.tsx diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx new file mode 100644 index 0000000..f42b53d --- /dev/null +++ b/src/app/(field)/m/layout.tsx @@ -0,0 +1,34 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { requireAppAccess } from "@/server/app-access"; +import { CraftviaLogo } from "@/components/brand/craftvia-logo"; +import { AccountInactiveNotice } from "@/components/account-inactive-notice"; +import { BottomNav } from "@/components/field/bottom-nav"; +import { OnlineBadge } from "@/components/field/online-badge"; + +/** + * Mobile shell `/m` (ARCHITEKTUR §5): same session/account/MFA checks as the backoffice + * (src/server/app-access.ts), no sidebar, bottom navigation, online/offline badge. + * Module gates live one level below: (core) → "field", emergency → "emergency". + */ +export default async function FieldShell({ children }: Readonly<{ children: React.ReactNode }>) { + const access = await requireAppAccess(); + if (access.kind === "inactive") return ; + const t = await getTranslations("field.nav"); + + return ( +
+
+ + + +
+ {/* Slot für L6: aus src/components/notifications/bell.tsx */} + +
+
+
{children}
+ +
+ ); +} diff --git a/src/app/(field)/m/sync/page.tsx b/src/app/(field)/m/sync/page.tsx new file mode 100644 index 0000000..9a81ea5 --- /dev/null +++ b/src/app/(field)/m/sync/page.tsx @@ -0,0 +1,23 @@ +import { getTranslations } from "next-intl/server"; +import { OnlineBadge } from "@/components/field/online-badge"; +import { card } from "@/components/field/ui"; + +/** + * PLACEHOLDER (lane L4) — `/m/sync` belongs to lane L7 (Offline/PWA), which replaces this page + * with the outbox status, errors and conflicts. Until then it shows the connection state and that + * ops are sent immediately (src/lib/field/client-ops.ts). + */ +export default async function SyncPlaceholderPage() { + const t = await getTranslations("field.sync"); + return ( +
+

{t("title")}

+
+

{t("status")}

+ +

{t("immediate")}

+

{t("offlineHint")}

+
+
+ ); +} diff --git a/src/app/api/v1/field/bundle/route.ts b/src/app/api/v1/field/bundle/route.ts new file mode 100644 index 0000000..0fe60db --- /dev/null +++ b/src/app/api/v1/field/bundle/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { getFieldBundle } from "@/server/services/field/queries"; +import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; + +/** GET /api/v1/field/bundle?since= — offline pull of the orders in scope (ARCHITEKTUR §4.6). */ +export async function GET(req: Request) { + return withApi(req, async () => { + const ctx = await requireApiContext("field", "field:execute"); + const raw = new URL(req.url).searchParams.get("since"); + const since = raw ? new Date(raw) : null; + if (since && Number.isNaN(since.getTime())) return apiError("invalid", 400, "invalid since"); + return NextResponse.json(await getFieldBundle(ctx, since), { headers: { "Cache-Control": "private, no-store" } }); + }); +} diff --git a/src/app/api/v1/field/documents/[id]/route.ts b/src/app/api/v1/field/documents/[id]/route.ts new file mode 100644 index 0000000..778985b --- /dev/null +++ b/src/app/api/v1/field/documents/[id]/route.ts @@ -0,0 +1,27 @@ +import { openFieldDocument } from "@/server/services/field/documents"; +import { requireApiContext, withApi } from "@/server/services/sync/api-context"; + +/** + * GET /api/v1/field/documents/[?variant=preview] — authorised document delivery for the mobile + * app (visibility + scope checked in the service). Only magic-byte-verified media types are served + * inline; everything else is a download. + */ +const INLINE = /^(image\/(jpeg|png|webp)|application\/pdf|audio\/(webm|ogg|mp4|mpeg|wav))$/; + +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + return withApi(req, async () => { + const ctx = await requireApiContext("field"); + const { id } = await params; + const variant = new URL(req.url).searchParams.get("variant") === "preview" ? "preview" : "original"; + const { content, mimeType, fileName } = await openFieldDocument(ctx, id, variant); + const safeName = fileName.replace(/["\\\r\n]/g, "_"); + const headers = new Headers({ + "Content-Type": mimeType, + "Content-Disposition": `${INLINE.test(mimeType) ? "inline" : "attachment"}; filename="${safeName}"`, + "X-Content-Type-Options": "nosniff", + "Cache-Control": "private, max-age=300", + }); + if (content.size != null) headers.set("Content-Length", String(content.size)); + return new Response(content.stream, { headers }); + }); +} diff --git a/src/app/api/v1/sync/route.ts b/src/app/api/v1/sync/route.ts new file mode 100644 index 0000000..145838a --- /dev/null +++ b/src/app/api/v1/sync/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { syncRequestSchema } from "@/lib/sync/envelope"; +import { applyOperations } from "@/server/services/sync/apply"; +import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; + +/** POST /api/v1/sync — batch of offline/online operations (ARCHITEKTUR §4.6). */ +export async function POST(req: Request) { + return withApi(req, async () => { + const ctx = await requireApiContext("field"); + const body = syncRequestSchema.safeParse(await req.json().catch(() => null)); + if (!body.success) return apiError("invalid", 400, "invalid sync request", body.error.issues.slice(0, 10)); + return NextResponse.json(await applyOperations(ctx, body.data), { headers: { "Cache-Control": "no-store" } }); + }); +} diff --git a/src/app/api/v1/uploads/route.ts b/src/app/api/v1/uploads/route.ts new file mode 100644 index 0000000..0eac1ca --- /dev/null +++ b/src/app/api/v1/uploads/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { storeFieldUpload, uploadMetaSchema } from "@/server/services/field/uploads"; +import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context"; + +/** + * POST /api/v1/uploads — multipart: file, clientId (uuid), workOrderId, kind (photo|voice_note), + * optional preview (thumbnail). Returns { documentId }; the same clientId returns the same document. + */ +const MAX_BYTES = 25 * 1024 * 1024; + +export async function POST(req: Request) { + return withApi(req, async () => { + const ctx = await requireApiContext("field", "field:execute"); + const declared = Number(req.headers.get("content-length") ?? "0"); + if (declared > MAX_BYTES + 3 * 1024 * 1024) return apiError("invalid", 413, "file too large"); + + const form = await req.formData().catch(() => null); + if (!form) return apiError("invalid", 400, "multipart body expected"); + const file = form.get("file"); + if (!(file instanceof File)) return apiError("invalid", 400, "file missing"); + if (file.size > MAX_BYTES) return apiError("invalid", 413, "file too large"); + const meta = uploadMetaSchema.safeParse({ clientId: form.get("clientId"), workOrderId: form.get("workOrderId"), kind: form.get("kind") }); + if (!meta.success) return apiError("invalid", 400, "invalid upload metadata"); + const preview = form.get("preview"); + + const result = await storeFieldUpload( + ctx, + meta.data, + { bytes: Buffer.from(await file.arrayBuffer()), name: file.name, type: file.type }, + preview instanceof File && preview.size > 0 ? { bytes: Buffer.from(await preview.arrayBuffer()), name: preview.name, type: preview.type } : null, + ); + return NextResponse.json(result, { status: result.duplicate ? 200 : 201, headers: { "Cache-Control": "no-store" } }); + }); +} diff --git a/src/app/login/mfa/page.tsx b/src/app/login/mfa/page.tsx index 98718b3..7b0a641 100644 --- a/src/app/login/mfa/page.tsx +++ b/src/app/login/mfa/page.tsx @@ -46,7 +46,7 @@ export default async function LoginMfaPage({ // MFA erfüllt → pending entwerten und aus dem Ticket die Session prägen. jar.delete(MFA_PENDING_COOKIE); try { - await signIn("login-ticket", { ticket: signLoginTicket(pending.identityId, pending.tenant), redirectTo: target }); + await signIn("login-ticket", { ticket: signLoginTicket(pending.identityId, pending.tenant), redirectTo: target === "/dashboard" ? "/" : target }); // "/" → rollenabhängige Startseite } catch (err) { if (err instanceof AuthError) redirect("/login?error=1"); throw err; // NEXT_REDIRECT eines erfolgreichen signIn muss durchpropagieren diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 1b136f3..7024f41 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -30,7 +30,7 @@ export default async function LoginPage({ // Already signed in → straight to the app const session = await auth(); - if (session?.user) redirect("/dashboard"); + if (session?.user) redirect("/"); // rollenabhängige Startseite (src/app/page.tsx) // WS5: Two-Step-Login, Schritt 1 (E-Mail + Passwort). Bei aktiver MFA wird ein // kurzlebiger, einzweckiger `mfa_pending`-Cookie gesetzt und auf /login/mfa geleitet — @@ -65,7 +65,7 @@ export default async function LoginPage({ } try { - await signIn("login-ticket", { ticket: signLoginTicket(pw.identityId, tenant), redirectTo: target }); + await signIn("login-ticket", { ticket: signLoginTicket(pw.identityId, tenant), redirectTo: target === "/dashboard" ? "/" : target }); // "/" → rollenabhängige Startseite } catch (err) { if (err instanceof AuthError) { redirect(`/login?error=1${target !== "/dashboard" ? `&callbackUrl=${encodeURIComponent(target)}` : ""}`); diff --git a/src/app/page.tsx b/src/app/page.tsx index a74cb27..2f55512 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,8 @@ import { redirect } from "next/navigation"; +import { auth } from "@/server/auth"; +import { landingPath } from "@/server/app-access"; -export default function Home() { - redirect("/dashboard"); +export default async function Home() { + // Feldrollen → /m, Backoffice → /dashboard (ARCHITEKTUR §5) + redirect(landingPath(await auth())); } diff --git a/src/components/account-inactive-notice.tsx b/src/components/account-inactive-notice.tsx new file mode 100644 index 0000000..b5e005b --- /dev/null +++ b/src/components/account-inactive-notice.tsx @@ -0,0 +1,19 @@ +import { signOut } from "@/server/auth"; +import { Button } from "@/components/ui/button"; +import { CraftviaLogo } from "@/components/brand/craftvia-logo"; + +/** Hinweis für deaktivierte Konten — gemeinsam für Backoffice- und Mobile-Shell (src/server/app-access.ts). */ +export function AccountInactiveNotice() { + return ( +
+
+ +

Konto deaktiviert

+

Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.

+
{ "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5"> + +
+
+
+ ); +} diff --git a/src/components/audit-trail.tsx b/src/components/audit-trail.tsx index 6d4fadc..d80c22f 100644 --- a/src/components/audit-trail.tsx +++ b/src/components/audit-trail.tsx @@ -77,6 +77,8 @@ const ENTITY_LABEL: Record = { tenant_mail_settings: "E-Mail-Versand", sync_operation: "Synchronisation", document: "Dokument", + // Einsatz mobil (L4) + work_session: "Einsatz-Zeiterfassung", time_entry: "Zeitabschnitt", checklist_item: "Checklistenpunkt", material_usage: "Materialverbrauch", photo: "Foto", voice_note: "Sprachnotiz", activity_note: "Tätigkeitsnotiz", sync_operation: "Sync-Vorgang", }; const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }); diff --git a/src/components/field/bottom-nav.tsx b/src/components/field/bottom-nav.tsx new file mode 100644 index 0000000..96f90f5 --- /dev/null +++ b/src/components/field/bottom-nav.tsx @@ -0,0 +1,47 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { ClipboardList, House, RefreshCw, Siren, User } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const ITEMS = [ + { href: "/m", key: "today", icon: House, exact: true }, + { href: "/m/orders", key: "orders", icon: ClipboardList, exact: false }, + { href: "/m/emergency", key: "emergency", icon: Siren, exact: false }, + { href: "/m/sync", key: "sync", icon: RefreshCw, exact: false }, + { href: "/m/profile", key: "profile", icon: User, exact: false }, +] as const; + +/** Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil. */ +export function BottomNav() { + const t = useTranslations("field.nav"); + const pathname = usePathname(); + return ( + + ); +} diff --git a/src/components/field/checklist-item.tsx b/src/components/field/checklist-item.tsx new file mode 100644 index 0000000..af19097 --- /dev/null +++ b/src/components/field/checklist-item.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Camera, Check, TriangleAlert } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { errorKey, isSuccess, submitOp } from "@/lib/field/client-ops"; +import { btnSecondary, card, inputClass, noticeError } from "./ui"; + +type Item = { id: string; label: string; required: boolean; requiresPhoto: boolean; checked: boolean; comment: string | null }; + +/** One checklist item (Spec §12.4): large toggle + optional comment. Optimistic, reverted on failure. */ +export function ChecklistItemRow({ workOrderId, item }: { workOrderId: string; item: Item }) { + const t = useTranslations("field"); + const router = useRouter(); + const [checked, setChecked] = useState(item.checked); + const [comment, setComment] = useState(item.comment ?? ""); + const [editing, setEditing] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function send(nextChecked: boolean, nextComment?: string) { + setBusy(true); + setError(null); + const previous = checked; + setChecked(nextChecked); + const result = await submitOp({ + opType: "checklist.toggle", + payload: { workOrderId, itemId: item.id, checked: nextChecked, ...(nextComment !== undefined ? { comment: nextComment } : {}) }, + }); + setBusy(false); + if (!isSuccess(result)) { + setChecked(previous); + setError(t(`errors.${errorKey(result)}`)); + return; + } + setEditing(false); + router.refresh(); + } + + return ( +
  • + + {editing ? ( +
    +