diff --git a/docs/craftvia/lanes/offline.md b/docs/craftvia/lanes/offline.md new file mode 100644 index 0000000..6b0ed20 --- /dev/null +++ b/docs/craftvia/lanes/offline.md @@ -0,0 +1,98 @@ +# Lane L7 – Offline & PWA (`lane/offline`) + +Stand: 2026-09-14 · Basis `d5c1221` (`feature/craftvia-mvp`) + +## 1. Umfang / erfüllte Spec-Punkte + +| Spec | Umsetzung | +|---|---| +| §3.2 PWA | Service Worker `public/sw.js` (statisch, kein Build-Plugin), Manifest `start_url /m`, `scope /`, `display standalone`, Icons 192/512/maskable vorhanden, iOS-Meta (`appleWebApp`, apple-touch-icon) steht bereits im Root-Layout; Installationshinweis im Profil (`beforeinstallprompt`-Button, sonst iOS-/Browser-Anleitung) | +| §22 Offline-Hinweis, Sync-Status | Sync-Badge im Mobile-Header (Zahl offen, Warnsymbol bei Fehler/Konflikt/abgelaufener Anmeldung, Text + Icon); Offline-Hinweis in Offline-Ansicht und Sync-Seite; automatische Zwischenspeicherung des Notizentwurfs in IndexedDB | +| §23.2 Offline verfügbare Daten | Vorab-Download nach Login, alle 5 min und nach jedem erfolgreichen Sync: heute + 3 Tage + laufende Aufträge (Kunde, Objekt, Kontakte, Checkliste, Material, Pflichtfotos, Dokument-Metadaten, Objekt-Historie); Dokumente der Kategorien technische Zeichnung, Grundriss, Schaltplan, Montageanleitung, Sicherheitsunterlage ≤ 25 MB im Dokument-Cache (LRU 300 MB); `navigator.storage.persist()` + Speicheranzeige | +| §23.3 Offline erfassbar | Zeiten (Start/Losfahren/Pause/Weiter/Ende), Status „Annehmen“, Notizen, Checkliste, Material (L4-Formulare), Fotos und Sprachnotizen über die Upload-Warteschlange; Bericht/Unterschrift laufen ebenfalls über `submitOp` (Ops von L5), sobald L5 sie offline nutzt | +| §23.4 Synchronisation | Outbox je Op: lokale ID (`clientOpId`), Server-IDs (idMap), Zeitstempel, Benutzer/Mandant, `baseVersion`, Status queued/sending/applied/conflict/rejected, Versuche, letzter Fehler; Auslöser: online-Event, 30 s, Sichtbarkeitswechsel, „Jetzt synchronisieren“, Background Sync (Bonus) | +| §23.5 Konflikte | nichts überschrieben (Serverprüfung L4), Konflikt lokal gelistet mit Klartext „Auftrag wurde im Büro geändert – Ihre Statusänderung wurde nicht übernommen. Das Büro prüft den Vorgang.“, nicht verwerfbar (nur ausblendbar); unabhängige Ops laufen weiter | +| US-012 | alle Kriterien: Daten offline, lokale Speicherung, Foto-Warteschlange, sichtbarer Status, Übertragung bei Verbindung, keine stille Überschreibung | +| ARCHITEKTUR §4.6 | identischer Pfad online/offline: Outbox → `POST /api/v1/sync` (Batch ≤ 50) → `applyOperations`; Uploads vorher über `POST /api/v1/uploads` | +| `OFFLINE_MAX_DAYS` (Default 7) | älteres Bundle → Hinweis „veraltet“ (Sync-Seite, Offline-Ansicht); alte Ops werden trotzdem gesendet, Server speichert `SyncOperation.clientCreatedAt` (E2E geprüft) | + +### Kernregeln der Outbox (`src/lib/offline/outbox-core.ts`, reine Funktionen) +- Reihenfolge je Auftrag strikt; eine noch ausstehende Op (Backoff, Upload fehlt) blockiert nur Folge-Ops **desselben** Auftrags. +- Endgültige Ergebnisse (applied/conflict/rejected) blockieren nichts. +- Blob-Referenz `documentId: "blob:"` → Upload zuerst → Payload umgeschrieben; Upload endgültig abgelehnt → Op `rejected` („Datei wurde nicht angenommen“). +- idMap wird auf wartende Ops angewendet (eigenes `clientId`-Feld bleibt, Idempotenz). +- Verkettete `baseVersion`: Eine Statusänderung hinter einer eigenen, noch offenen Session-/Status-Op desselben Auftrags übernimmt die `entityVersion` aus deren Server-Ergebnis (sonst würde die eigene Kette immer kollidieren). +- Transiente Fehler (Netz, 5xx, `internal`) → exponentielles Backoff 2 s … 5 min mit Jitter; 401 → Pass stoppt, Hinweis „neu anmelden“, nichts geht verloren. +- „Erneut versuchen“ bei `rejected` erzeugt eine neue `clientOpId` (der Server hat das alte Ergebnis gespeichert). +- Optimistische Ansicht = Server-Snapshot + eigene offene Ops (Snapshot bleibt unverändert → abgelehnte Ops verschwinden automatisch aus der Ansicht). + +### Service Worker (`public/sw.js`) +- `/_next/static/**` cache-first, versionierter Cache (`craftvia-static-`, alte beim Aktivieren gelöscht, max. 600 Einträge). +- Navigationen unter `/m/**` network-first; offline: `/m`, `/m/orders`, `/m/orders/[/…]` → `/m/offline?from=…` (rendert aus IndexedDB), andere Seiten → zuletzt gecachte Seite; `/m/offline` + referenzierte Assets werden bei Installation und nach jedem Shell-Mount vorgeladen. +- Dokumente (`/api/v1/field/documents/`, `/files/`) cache-first nur, wenn vom Vorab-Download im Dokument-Cache abgelegt; LRU-Zeitstempel beim Zugriff. +- Nie behandelt/gecacht: Nicht-GET, `/api/v1/sync`, `/api/v1/uploads`, `/api/v1/field/bundle`, `/api/auth`, `/api/platform-auth`, `/login`, `/logout`, `/select-tenant`, `/platform`, fremde Origins. +- Update-Flow: neue SW-Version wartet → Hinweis „Neue Version verfügbar – neu laden“ → `SKIP_WAITING` → Reload. CSP: `worker-src 'self'` passt, keine Änderung an `next.config.ts` nötig. +- Im Dev-Server nur opt-in (`localStorage.setItem("craftvia.sw","1")`), sonst würden HMR-Chunks gecacht. + +### Datentrennung +IndexedDB `craftvia-offline` (Stores `outbox`, `blobs`, `bundle`, `meta`), jeder Datensatz mit `tenantId`, `userId`, Index `ctxKey = tenantId:userId`; jede Leseoperation filtert danach. Logout (Profil) löscht die Daten des Kontexts sowie Seiten-/Dokument-Cache – bei nicht übertragenen Einträgen vorher Warnung mit „Erst synchronisieren“ / „Trotzdem abmelden“. Beim Start eines anderen Kontexts (Mandantenwechsel, anderer Nutzer) werden Seiten-/Dokument-Cache geleert und fremde Kontexte ohne offene Einträge gelöscht; Kontexte mit offenen Einträgen bleiben getrennt erhalten, bis dieser Nutzer sich wieder anmeldet. + +## 2. Routen / Screens + +| Route | Inhalt | +|---|---| +| `/m/sync` | Verbindung, letzte Synchronisation, letzter Vorab-Download, Veraltet-Hinweis, offene Ops/Uploads mit Größe und Upload-Fortschritt, Warteschlange (Versuche, nächster Versuch), Fehler & Konflikte im Klartext (erneut versuchen, verwerfen mit Bestätigung nur für `rejected`, Konflikthinweis ausblenden), Speicher (`storage.estimate`, persistiert ja/nein, Anzahl Offline-Aufträge), „Für offline speichern“, „Offline-Ansicht öffnen“, „Lokale Daten zurücksetzen“ (Bestätigung + Warnung bei offenen Einträgen) | +| `/m/offline` | Offline-Ansicht aus IndexedDB: Auftragsliste (Status, Zeitfenster, Adresse, „n Änderungen warten“, Konflikt-/Fehlerhinweis) und Auftragsdetail (Zeitaktionen Annehmen/Losfahren/Arbeit starten/Pause/Weiter/Zeiterfassung beenden, Hinweise, Objekt mit Zugang/Parken/Sicherheit/Technik, Kunde + Anrufen, Leistungsumfang, Checkliste, Foto aufnehmen, Pflichtfotos, Notiz mit Entwurf, Materialfortschritt, Dokumente mit „offline verfügbar“, Objekt-Historie) | +| Mobile-Header | Sync-Badge (Link auf `/m/sync`), Update-Hinweis | +| `/m/profile` | Installationshinweis, Abmelden mit Offline-Schutz | + +Browser-Check 375×812 (Dev-Server :3107, `monteur@demo.example`): `/m/sync` und `/m/offline` gerendert, Badge „Synchron“, Sync und Bundle-Pull liefen automatisch; Touch-Ziele ≥ 48 px (Badge 44 px im Header). + +## 3. Dateien + +**Neu (Ownership L7)** +- `public/sw.js` +- `src/lib/offline/`: `types.ts`, `outbox-core.ts`, `bundle-core.ts`, `sync-engine.ts`, `memory-store.ts`, `db.ts` (IndexedDB), `outbox.ts` (Browser-Outbox, Sync-Loop, `submitOp`, `queueBlob`), `prefetch.ts` (Bundle-Pull, Dokument-Cache, Speicher), `read.ts`, `drafts.ts`, `doc-cache.ts`, `ids.ts` +- `src/components/offline/`: `offline-runtime.tsx` (Server-Wrapper: Session-Kontext + `OFFLINE_MAX_DAYS`), `offline-runtime-client.tsx` (SW-Registrierung/Update, Sync-Loop), `sync-badge.tsx`, `sync-panel.tsx`, `offline-view.tsx`, `logout-form.tsx`, `install-hint.tsx`, `hooks.ts` (`useOfflineState`, `useOfflineDraft`), `format.ts` +- `src/app/(field)/m/sync/{layout,page}.tsx` (Platzhalter ersetzt, Modul-Gate `field`), `src/app/(field)/m/offline/{layout,page}.tsx` +- `messages/de/offline.json`, `messages/en/offline.json` +- `scripts/test-offline-core.ts`, `scripts/test-offline-sync-e2e.ts` + +**Eingriffe in L4-Dateien (vom Auftrag vorgesehen, minimal)** +- `src/lib/field/client-ops.ts`: `submitOp` delegiert an die Outbox (Signatur unverändert), `newClientId`/`deviceId` nach `lib/offline/ids.ts` verschoben und re-exportiert, zusätzlich `isQueued`, `queueBlob` exportiert +- `src/app/(field)/m/layout.tsx`: `` im Header (Import + 1 Zeile) +- `src/components/field/photo-capture.tsx`, `voice-recorder.tsx`: `uploadFieldFile` → `queueBlob` (Upload durch die Outbox vor `photo.attach`/`voice.attach`), kein `router.refresh()` bei lokal gespeicherten Einträgen +- `src/components/field/note-form.tsx`: Entwurf in IndexedDB (`useOfflineDraft`) statt localStorage +- `src/app/(field)/m/(core)/profile/page.tsx`: ``, `` statt Formular +- `public/site.webmanifest`: `start_url` `/dashboard` → `/m` + +Keine Schemaänderung, keine Migration, keine neuen npm-Abhängigkeiten, keine Fundament-Dateien geändert, keine Fremd-Einzeiler in `nav.ts`/`processors/index.ts`/Audit-Labels. + +## 4. Tests + +- `scripts/test-offline-core.ts` – **67 Prüfungen**, ohne Infrastruktur: Reihenfolge je Auftrag, Batch ≤ 50 (120 Ops → 3 Batches), Retry/Backoff (Netz, `internal`, 401, endgültige Fehler, „erneut versuchen“ mit neuer `clientOpId`), Konflikt stoppt keine unabhängigen Ops, Duplicate eines Konflikts, Blob-Upload vor Op inkl. Payload-Umschreibung/Freigabe/abgelehntem Upload/Upload-Backoff, idMap, verkettete `baseVersion`, Mandanten-/User-Trennung der lokalen Stores (Outbox, Blobs, Bundle, Entwürfe, `clearContext`, Sync-Pass sendet nur eigene Ops), optimistische Ansicht, Vorab-Auswahl heute + 3 Tage + laufend, Veraltet/`OFFLINE_MAX_DAYS`, Dokumentauswahl ≤ 25 MB, LRU, Service-Worker-Regeln (Konstanten synchron, nur GET, Ausschlüsse, Fallback, Update-Flow). +- `scripts/test-offline-sync-e2e.ts` – **38 Prüfungen** gegen die Lane-DB mit echten Services (`applyOperations`, `storeFieldUpload`, `getFieldBundle`): 20 Ops offline (10 Tage alt) → 1 Upload → **ein Batch → 20× applied**, `clientCreatedAt` protokolliert, Datensätze korrekt; **Wiederholung → 20× duplicate**, keine Doppelanlagen; Konflikt durch Büroänderung stoppt unabhängige Notiz nicht; **Mandantentrennung** (B: kein Auftrag von A im Bundle, Notiz/Statusänderung/Foto-Upload auf A → not_found/abgelehnt, A unverändert); **Rollen/Scope** (Monteur ohne Zuweisung: nicht im Bundle, Ops → not_found endgültig, keine Session angelegt). + +Manueller Browser-Test (Chrome DevTools, Produktions-Build oder Dev mit `craftvia.sw=1`) – Ablauf: als Monteur `/m` öffnen (SW installiert, Bundle geladen) → DevTools › Network › Offline → Auftrag öffnen (Weiterleitung auf `/m/offline?from=/m/orders/`) → „Arbeit starten“ (Status „In Arbeit“, Badge „1 offen“) → Foto aufnehmen („Wartet auf Übertragung“) → Notiz speichern → Online → Badge wird innerhalb weniger Sekunden zu „Synchron“, `/m/sync` „Alles übertragen“, Backoffice zeigt Session/Foto/Notiz. In dieser Lane automatisiert geprüft: Rendering beider Seiten, automatischer Sync + Pull, 105 Logikprüfungen; das DevTools-Offline-Umschalten selbst ist über das Browser-Tool nicht steuerbar und wurde nicht ausgeführt. + +HTTP-Smoke (:3107, Seed-Nutzer): `/m/sync`, `/m/offline`, `/m/profile`, `/m` → 200 mit erwarteten Inhalten; `/sw.js` 200 `application/javascript`; `/site.webmanifest` 200 mit `start_url /m`; `/api/v1/field/bundle` 200 (Monteur, `admin2@` Mandant demo2); anonym `/m/sync` → 307 Login, anonym `POST /api/v1/sync` → 401. + +## 5. Stubs & Abhängigkeiten + +Keine Stubs. Genutzt: L4 `/api/v1/sync`, `/api/v1/uploads`, `/api/v1/field/bundle`, `/api/v1/field/documents/[id]`, `resolveActions`/`StatusBadge`/UI-Klassen aus `components/field`. L5 (Bericht/Unterschrift) kann `useOfflineDraft("report::")` aus `components/offline/hooks.ts` für den Berichtsentwurf nutzen – noch nicht eingebunden (L5-Dateien). + +## 6. Bekannte Lücken / Hinweise an den Architekten + +1. **`src/proxy.ts` (Fundament):** `/sw.js` liegt hinter dem Session-Gate. Registrierung/Update funktionieren nur angemeldet (bei abgelaufener Session schlägt der Update-Check still fehl, der alte SW bleibt). Empfehlung: `sw.js` in die Matcher-Ausnahme aufnehmen; zusätzlich in `next.config.ts` für `/sw.js` `Cache-Control: no-cache` setzen (heute greift Next-Default `max-age=0`, `updateViaCache: "none"` ist gesetzt). +2. **Abschließen offline** ist bewusst gesperrt (Hinweis): Pflichtangaben-Prüfung braucht Serverdaten. Arbeitsende (`session.end`) geht offline. +3. **Material in der Offline-Ansicht** nur als Fortschritt; Erfassung über die L4-Seite (Formulare laufen bereits über die Outbox, aber die Seite selbst ist ein Server Component und offline nur als gecachte Seite erreichbar, falls vorher besucht). +4. **Bundle ohne Notizen/Fotos/Sessions**: Die Offline-Ansicht zeigt nur eigene lokale Einträge; die eigene Session wird aus dem Auftragsstatus abgeleitet (bei Teamaufträgen mit mehreren Monteuren ungenau). Erweiterung von `getFieldBundle` (L4) um `mySession` empfohlen. +5. **Verkettete baseVersion**: Eine Büroänderung zwischen einer eigenen additiven Session-Op und der folgenden Statusänderung desselben Auftrags wird nicht als Konflikt erkannt, weil Session-Ops laut §4.6 konfliktfrei sind. +6. **Mandantenwechsel im Backoffice** (`tenant-switcher.tsx`, Fundament) löscht lokale Daten nicht direkt; das geschieht beim nächsten Öffnen der Mobile-Shell (Kontextwechsel → Caches geleert, fremde Kontexte ohne offene Einträge gelöscht). +7. **Background Sync** nur Chromium; Safari/iOS synchronisieren bei geöffneter App (online-Event, 30 s, Sichtbarkeit). +8. Veraltete Keys `field.sync.immediate/offlineHint/status` in `messages/*/field.json` (L4) werden nicht mehr genutzt. +9. Wurde ein Foto erfasst, aber nie angehängt (Formular verworfen), wird der lokale Blob nach 24 h gelöscht. + +## 7. Gate + +`npm run gate` grün: prisma generate, tsc, lint (0 Fehler, 2 bestehende Warnungen außerhalb L7), build inkl. Modul-Guard-Check (27 Action-Dateien), **44/44 Testskripte** (davon neu `test-offline-core.ts` 67 ✓, `test-offline-sync-e2e.ts` 38 ✓). diff --git a/messages/de/offline.json b/messages/de/offline.json new file mode 100644 index 0000000..07a4bbb --- /dev/null +++ b/messages/de/offline.json @@ -0,0 +1,171 @@ +{ + "badge": { + "label": "Synchronisation öffnen", + "synced": "Synchron", + "pending": "{count} offen", + "problem": "Prüfen", + "syncing": "Sync …" + }, + "update": { + "available": "Neue Version verfügbar – neu laden", + "reload": "Neu laden" + }, + "sync": { + "title": "Synchronisation", + "connection": "Verbindung", + "offlineHint": "Eingaben werden auf diesem Gerät gespeichert und übertragen, sobald wieder Verbindung besteht.", + "lastSync": "Letzte Synchronisation", + "lastPull": "Aufträge offline gespeichert", + "never": "Noch nie", + "stale": "Die Offline-Daten sind älter als {days} Tage. Bitte mit Verbindung aktualisieren.", + "status": "Stand", + "pendingOps": "{count, plural, =0 {Keine Änderungen offen} one {# Änderung wartet auf Übertragung} other {# Änderungen warten auf Übertragung}}", + "pendingUploads": "{count, plural, =0 {Keine Dateien offen} one {# Datei wartet auf Upload} other {# Dateien warten auf Upload}}", + "uploadSize": "{size} zu übertragen", + "uploading": "Datei wird hochgeladen … {percent} %", + "syncing": "Wird synchronisiert …", + "syncNow": "Jetzt synchronisieren", + "allSynced": "Alles übertragen.", + "waitingForAuth": "Anmeldung abgelaufen. Bitte neu anmelden – Ihre Eingaben bleiben auf dem Gerät gespeichert.", + "login": "Neu anmelden", + "problems": "Fehler und Konflikte", + "noProblems": "Keine Fehler oder Konflikte.", + "queue": "Warteschlange", + "attempts": "{count, plural, one {# Versuch} other {# Versuche}}", + "nextAttempt": "Nächster Versuch um {time}", + "createdAt": "Erfasst {time}", + "retry": "Erneut versuchen", + "discard": "Eintrag verwerfen", + "discardConfirm": "Eintrag endgültig verwerfen? Die Eingabe geht verloren.", + "discardYes": "Endgültig verwerfen", + "cancel": "Abbrechen", + "hide": "Hinweis ausblenden", + "storage": "Speicher auf diesem Gerät", + "storageUsage": "{usage} von {quota} belegt", + "storagePersisted": "Offline-Daten sind dauerhaft gespeichert.", + "storageNotPersisted": "Der Browser darf Offline-Daten bei Speichermangel löschen.", + "storageUnknown": "Speicherverbrauch nicht verfügbar.", + "offlineOrders": "{count, plural, =0 {Keine Aufträge offline verfügbar} one {# Auftrag offline verfügbar} other {# Aufträge offline verfügbar}}", + "saveOffline": "Für offline speichern", + "saveOfflineHint": "Heutige, kommende 3 Tage und laufende Aufträge inklusive Zeichnungen, Montageanleitungen und Sicherheitsunterlagen.", + "openOffline": "Offline-Ansicht öffnen", + "reset": "Lokale Daten zurücksetzen", + "resetConfirm": "Alle Offline-Daten Ihres Kontos auf diesem Gerät löschen?", + "resetWarning": "{count, plural, one {# Eingabe wurde} other {# Eingaben wurden}} noch nicht übertragen und {count, plural, one {geht} other {gehen}} verloren.", + "resetYes": "Jetzt zurücksetzen", + "resetDone": "Lokale Daten gelöscht." + }, + "op": { + "session_start": "Einsatz gestartet", + "session_pause": "Pause", + "session_resume": "Weiter", + "session_end": "Zeiterfassung beendet", + "work_order_transition": "Statusänderung", + "note_create": "Notiz", + "checklist_toggle": "Checkliste", + "material_upsert": "Material", + "photo_attach": "Foto", + "voice_attach": "Sprachnotiz", + "report_save_draft": "Berichtsentwurf", + "report_submit": "Bericht", + "signature_capture": "Unterschrift", + "emergency_create": "Notdiensteinsatz" + }, + "state": { + "queued": "Wartet", + "sending": "Wird gesendet", + "applied": "Übertragen", + "conflict": "Konflikt", + "rejected": "Nicht übernommen" + }, + "problem": { + "conflictTransition": "Auftrag wurde im Büro geändert – Ihre Statusänderung wurde nicht übernommen. Das Büro prüft den Vorgang.", + "conflictReport": "Auftrag wurde im Büro geändert – Ihr Bericht wurde nicht übernommen. Das Büro prüft den Vorgang.", + "conflict": "Auftrag wurde im Büro geändert – Ihre Änderung wurde nicht übernommen. Das Büro prüft den Vorgang.", + "notFound": "Der Auftrag ist nicht mehr für Sie freigegeben. Die Eingabe wurde nicht übernommen.", + "forbidden": "Für diese Aktion fehlt Ihnen die Berechtigung.", + "blocked": "Pflichtangaben fehlen noch – bitte am Auftrag ergänzen und erneut versuchen.", + "invalid": "Die Eingabe ist unvollständig oder nicht mehr gültig.", + "upload": "Die Datei wurde nicht angenommen (Format oder Größe).", + "unauthorized": "Anmeldung abgelaufen – bitte neu anmelden.", + "network": "Keine Verbindung – wird automatisch erneut versucht.", + "internal": "Technischer Fehler – wird automatisch erneut versucht." + }, + "view": { + "title": "Offline-Ansicht", + "offlineHint": "Keine Verbindung. Sie sehen die auf diesem Gerät gespeicherten Aufträge.", + "onlineHint": "Verbindung besteht wieder.", + "backOnline": "Zur normalen Ansicht", + "empty": "Keine Aufträge auf diesem Gerät gespeichert. Mit Verbindung werden heutige und kommende Aufträge automatisch gespeichert.", + "loading": "Offline-Daten werden geladen …", + "notReady": "Offline-Daten sind erst nach einer Anmeldung mit Verbindung verfügbar.", + "notFound": "Dieser Auftrag ist nicht auf dem Gerät gespeichert.", + "backToList": "Alle gespeicherten Aufträge", + "open": "Auftrag öffnen", + "count": "{count, plural, one {# Auftrag} other {# Aufträge}}", + "pending": "{count, plural, one {# Änderung wartet} other {# Änderungen warten}}", + "conflict": "Konflikt – siehe Synchronisation", + "rejected": "Nicht übernommen – siehe Synchronisation", + "savedAt": "Stand: {time}", + "noDate": "Ohne Termin", + "customer": "Kunde", + "site": "Objekt", + "contact": "Ansprechpartner", + "call": "Anrufen", + "access": "Zugang", + "parking": "Parken", + "safety": "Sicherheit", + "technical": "Technik", + "hints": "Hinweise für Monteure", + "scope": "Leistungsumfang", + "description": "Beschreibung", + "checklist": "Checkliste", + "checklistEmpty": "Keine Checkliste.", + "required": "Pflicht", + "done": "Erledigt", + "open_item": "Offen", + "materials": "Material", + "materialsProgress": "{done} von {total} Positionen erfasst", + "documents": "Dokumente", + "noDocuments": "Keine Dokumente.", + "docOffline": "Offline verfügbar", + "docOnline": "Nur mit Verbindung", + "history": "Objekt-Historie", + "noHistory": "Keine freigegebenen Berichte am Objekt.", + "notes": "Notizen", + "notePlaceholder": "Kurz beschreiben, was vor Ort passiert ist …", + "noteSave": "Notiz speichern", + "draftRestored": "Entwurf wiederhergestellt.", + "photos": "Fotos", + "photo": "Foto aufnehmen", + "photoRequirements": "Pflichtfotos", + "photoMissing": "Fehlt", + "photoDone": "Vorhanden", + "pendingBadge": "Wartet auf Übertragung", + "time": "Zeiterfassung", + "actionAccept": "Annehmen", + "actionTravel": "Losfahren", + "actionStart": "Arbeit starten", + "actionPause": "Pause", + "actionResume": "Weiter", + "actionEnd": "Zeiterfassung beenden", + "completeOnline": "Abschließen ist nur mit Verbindung möglich (Pflichtangaben werden geprüft).", + "saving": "Wird gespeichert …", + "queued": "Gespeichert – wird übertragen, sobald Verbindung besteht.", + "saved": "Gespeichert und übertragen.", + "error": "Konnte nicht gespeichert werden." + }, + "logout": { + "warning": "{count, plural, one {# Eingabe wurde} other {# Eingaben wurden}} noch nicht übertragen. Beim Abmelden werden die Offline-Daten dieses Geräts gelöscht – {count, plural, one {die Eingabe geht} other {die Eingaben gehen}} verloren.", + "syncFirst": "Erst synchronisieren", + "confirm": "Trotzdem abmelden", + "cancel": "Abbrechen" + }, + "install": { + "title": "App auf dem Gerät", + "installed": "Craftvia ist als App installiert.", + "button": "Auf dem Startbildschirm installieren", + "ios": "Auf iPhone oder iPad: unten das Teilen-Symbol antippen und „Zum Home-Bildschirm“ wählen.", + "other": "Im Browser-Menü „App installieren“ oder „Zum Startbildschirm hinzufügen“ wählen." + } +} diff --git a/messages/en/offline.json b/messages/en/offline.json new file mode 100644 index 0000000..2a982be --- /dev/null +++ b/messages/en/offline.json @@ -0,0 +1,171 @@ +{ + "badge": { + "label": "Open synchronisation", + "synced": "Synced", + "pending": "{count} open", + "problem": "Check", + "syncing": "Sync …" + }, + "update": { + "available": "New version available – reload", + "reload": "Reload" + }, + "sync": { + "title": "Synchronisation", + "connection": "Connection", + "offlineHint": "Entries are stored on this device and sent as soon as a connection is available.", + "lastSync": "Last synchronisation", + "lastPull": "Orders saved for offline use", + "never": "Never", + "stale": "Offline data is older than {days} days. Please update with a connection.", + "status": "As of", + "pendingOps": "{count, plural, =0 {No pending changes} one {# change waiting to be sent} other {# changes waiting to be sent}}", + "pendingUploads": "{count, plural, =0 {No pending files} one {# file waiting for upload} other {# files waiting for upload}}", + "uploadSize": "{size} to transfer", + "uploading": "Uploading file … {percent} %", + "syncing": "Synchronising …", + "syncNow": "Synchronise now", + "allSynced": "Everything sent.", + "waitingForAuth": "Your session expired. Please sign in again – your entries stay on this device.", + "login": "Sign in again", + "problems": "Errors and conflicts", + "noProblems": "No errors or conflicts.", + "queue": "Queue", + "attempts": "{count, plural, one {# attempt} other {# attempts}}", + "nextAttempt": "Next attempt at {time}", + "createdAt": "Recorded {time}", + "retry": "Try again", + "discard": "Discard entry", + "discardConfirm": "Discard this entry permanently? The input will be lost.", + "discardYes": "Discard permanently", + "cancel": "Cancel", + "hide": "Hide notice", + "storage": "Storage on this device", + "storageUsage": "{usage} of {quota} used", + "storagePersisted": "Offline data is stored persistently.", + "storageNotPersisted": "The browser may delete offline data when storage runs low.", + "storageUnknown": "Storage usage not available.", + "offlineOrders": "{count, plural, =0 {No orders available offline} one {# order available offline} other {# orders available offline}}", + "saveOffline": "Save for offline use", + "saveOfflineHint": "Today's, the next 3 days' and running orders including drawings, assembly instructions and safety documents.", + "openOffline": "Open offline view", + "reset": "Reset local data", + "resetConfirm": "Delete all offline data of your account on this device?", + "resetWarning": "{count, plural, one {# entry has} other {# entries have}} not been sent yet and will be lost.", + "resetYes": "Reset now", + "resetDone": "Local data deleted." + }, + "op": { + "session_start": "Job started", + "session_pause": "Break", + "session_resume": "Resume", + "session_end": "Time tracking ended", + "work_order_transition": "Status change", + "note_create": "Note", + "checklist_toggle": "Checklist", + "material_upsert": "Material", + "photo_attach": "Photo", + "voice_attach": "Voice note", + "report_save_draft": "Report draft", + "report_submit": "Report", + "signature_capture": "Signature", + "emergency_create": "Emergency job" + }, + "state": { + "queued": "Waiting", + "sending": "Sending", + "applied": "Sent", + "conflict": "Conflict", + "rejected": "Not accepted" + }, + "problem": { + "conflictTransition": "The order was changed in the office – your status change was not applied. The office will review it.", + "conflictReport": "The order was changed in the office – your report was not applied. The office will review it.", + "conflict": "The order was changed in the office – your change was not applied. The office will review it.", + "notFound": "The order is no longer assigned to you. The entry was not applied.", + "forbidden": "You are not allowed to perform this action.", + "blocked": "Required information is still missing – please complete it on the order and try again.", + "invalid": "The entry is incomplete or no longer valid.", + "upload": "The file was not accepted (format or size).", + "unauthorized": "Your session expired – please sign in again.", + "network": "No connection – will retry automatically.", + "internal": "Technical error – will retry automatically." + }, + "view": { + "title": "Offline view", + "offlineHint": "No connection. You are viewing the orders stored on this device.", + "onlineHint": "Connection is back.", + "backOnline": "Back to the regular view", + "empty": "No orders stored on this device. With a connection, today's and upcoming orders are saved automatically.", + "loading": "Loading offline data …", + "notReady": "Offline data is available after signing in with a connection.", + "notFound": "This order is not stored on this device.", + "backToList": "All stored orders", + "open": "Open order", + "count": "{count, plural, one {# order} other {# orders}}", + "pending": "{count, plural, one {# change waiting} other {# changes waiting}}", + "conflict": "Conflict – see synchronisation", + "rejected": "Not accepted – see synchronisation", + "savedAt": "As of: {time}", + "noDate": "No date", + "customer": "Customer", + "site": "Site", + "contact": "Contact", + "call": "Call", + "access": "Access", + "parking": "Parking", + "safety": "Safety", + "technical": "Technical", + "hints": "Notes for technicians", + "scope": "Scope of work", + "description": "Description", + "checklist": "Checklist", + "checklistEmpty": "No checklist.", + "required": "Required", + "done": "Done", + "open_item": "Open", + "materials": "Material", + "materialsProgress": "{done} of {total} items recorded", + "documents": "Documents", + "noDocuments": "No documents.", + "docOffline": "Available offline", + "docOnline": "Online only", + "history": "Site history", + "noHistory": "No approved reports at this site.", + "notes": "Notes", + "notePlaceholder": "Briefly describe what happened on site …", + "noteSave": "Save note", + "draftRestored": "Draft restored.", + "photos": "Photos", + "photo": "Take photo", + "photoRequirements": "Required photos", + "photoMissing": "Missing", + "photoDone": "Present", + "pendingBadge": "Waiting to be sent", + "time": "Time tracking", + "actionAccept": "Accept", + "actionTravel": "Start travel", + "actionStart": "Start work", + "actionPause": "Break", + "actionResume": "Resume", + "actionEnd": "End time tracking", + "completeOnline": "Completing requires a connection (required information is checked).", + "saving": "Saving …", + "queued": "Saved – will be sent as soon as a connection is available.", + "saved": "Saved and sent.", + "error": "Could not be saved." + }, + "logout": { + "warning": "{count, plural, one {# entry has} other {# entries have}} not been sent yet. Signing out deletes the offline data on this device – {count, plural, one {it} other {they}} will be lost.", + "syncFirst": "Synchronise first", + "confirm": "Sign out anyway", + "cancel": "Cancel" + }, + "install": { + "title": "App on this device", + "installed": "Craftvia is installed as an app.", + "button": "Install on home screen", + "ios": "On iPhone or iPad: tap the share icon and choose “Add to Home Screen”.", + "other": "Choose “Install app” or “Add to home screen” in the browser menu." + } +} diff --git a/public/site.webmanifest b/public/site.webmanifest index cfe1191..31f4b57 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -3,7 +3,7 @@ "short_name": "Craftvia", "description": "Handwerk. Digital auf Kurs.", "lang": "de", - "start_url": "/dashboard", + "start_url": "/m", "scope": "/", "theme_color": "#082E5B", "background_color": "#F3F5F7", diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..bc330b7 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,180 @@ +/* + * Craftvia service worker (lane L7 Offline & PWA, Spec §23, ARCHITEKTUR §4.6). + * Static file on purpose (no build plugin, Next.js 16). Registered by + * src/components/offline/offline-runtime-client.tsx with scope "/". + * + * Strategies + * /_next/static/** cache-first, versioned cache (hashed file names) + * navigations under /m/** network-first; offline → cached page, order views → /m/offline + * documents of the offline bundle cache-first from DOC_CACHE (filled by src/lib/offline/prefetch.ts, + * /api/v1/field/documents/ LRU limit 300 MB); documents not in the cache go to the network + * /files/ + * everything else not handled (browser default) — NEVER API mutations, auth + * routes, /api/v1/sync or uploads + * + * Constants mirrored in src/lib/offline/doc-cache.ts (checked by scripts/test-offline-core.ts). + * Bump VERSION when this file changes in a way that needs fresh static assets. + */ + +const VERSION = "2026-09-14.1"; +const STATIC_CACHE = "craftvia-static-" + VERSION; +const STATIC_PREFIX = "craftvia-static-"; +const PAGE_CACHE = "craftvia-pages-v1"; +const DOC_CACHE = "craftvia-docs-v1"; +const DOC_CACHE_MAX_BYTES = 300 * 1024 * 1024; +const STATIC_MAX_ENTRIES = 600; +const OFFLINE_URL = "/m/offline"; +const SYNC_TAG = "craftvia-outbox"; + +/** Never served from or written to a cache. */ +const NEVER_CACHE = ["/api/v1/sync", "/api/v1/uploads", "/api/v1/field/bundle", "/api/auth", "/api/platform-auth", "/login", "/logout", "/select-tenant", "/platform"]; +const DOC_PATTERNS = [/^\/api\/v1\/field\/documents\/[^/]+$/, /^\/files\/[^/]+$/]; +/** Order list/detail pages: offline they are rendered from the local bundle (/m/offline). */ +const BUNDLE_VIEW = /^\/m(\/orders(\/[^/]+(\/[^/]+)?)?)?\/?$/; +const STATIC_REF = /\/_next\/static\/[^"'\s)\\]+/g; + +const sw = self; + +const matchesPath = (path, prefix) => path === prefix || path.startsWith(prefix + "/"); + +sw.addEventListener("install", (event) => { + event.waitUntil( + (async () => { + await precacheOffline(); + // First install: take over right away. Updates wait for the user ("Neue Version verfügbar"). + if (!sw.registration.active) await sw.skipWaiting(); + })(), + ); +}); + +sw.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + const names = await caches.keys(); + await Promise.all(names.filter((n) => n.startsWith(STATIC_PREFIX) && n !== STATIC_CACHE).map((n) => caches.delete(n))); + await sw.clients.claim(); + })(), + ); +}); + +sw.addEventListener("message", (event) => { + const type = event.data && event.data.type; + if (type === "SKIP_WAITING") event.waitUntil(sw.skipWaiting()); + else if (type === "PRECACHE_OFFLINE") event.waitUntil(precacheOffline()); + else if (type === "CLEAR_USER_CACHES") event.waitUntil(Promise.all([caches.delete(PAGE_CACHE), caches.delete(DOC_CACHE)])); +}); + +// Background Sync (bonus, not available in Safari): wake open tabs to flush the outbox. +sw.addEventListener("sync", (event) => { + if (event.tag !== SYNC_TAG) return; + event.waitUntil( + sw.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => list.forEach((c) => c.postMessage({ type: "craftvia:sync" }))), + ); +}); + +sw.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + const url = new URL(request.url); + if (url.origin !== sw.location.origin) return; + const path = url.pathname; + if (NEVER_CACHE.some((p) => matchesPath(path, p))) return; + + if (path.startsWith("/_next/static/")) { + event.respondWith(staticCacheFirst(request)); + return; + } + if (!url.search && DOC_PATTERNS.some((re) => re.test(path))) { + event.respondWith(documentCacheFirst(event, request, url)); + return; + } + if (request.mode === "navigate" && matchesPath(path, "/m")) { + event.respondWith(pageNetworkFirst(request, url)); + } +}); + +async function staticCacheFirst(request) { + const cache = await caches.open(STATIC_CACHE); + const hit = await cache.match(request); + if (hit) return hit; + const res = await fetch(request); + if (res.ok && res.type === "basic") { + await cache.put(request, res.clone()); + trimStatic(cache); + } + return res; +} + +async function trimStatic(cache) { + const keys = await cache.keys(); + const excess = keys.length - STATIC_MAX_ENTRIES; + for (let i = 0; i < excess; i++) await cache.delete(keys[i]); +} + +async function documentCacheFirst(event, request, url) { + const cache = await caches.open(DOC_CACHE); + const hit = await cache.match(url.pathname); + if (!hit) return fetch(request); + // LRU bookkeeping: refresh the last-used stamp at most once per hour + const used = Number(hit.headers.get("x-craftvia-used") || 0); + if (Date.now() - used > 60 * 60 * 1000) { + event.waitUntil( + (async () => { + const copy = hit.clone(); + const headers = new Headers(copy.headers); + headers.set("x-craftvia-used", String(Date.now())); + await cache.put(url.pathname, new Response(await copy.blob(), { status: 200, headers })); + })().catch(() => undefined), + ); + } + return hit; +} + +async function pageNetworkFirst(request, url) { + const path = url.pathname; + try { + const res = await fetch(request); + if (res.status >= 500) throw new Error("server unavailable"); + const html = (res.headers.get("content-type") || "").includes("text/html"); + if (res.ok && res.type === "basic" && !res.redirected && html) { + const cache = await caches.open(PAGE_CACHE); + await cache.put(path === OFFLINE_URL ? OFFLINE_URL : request, res.clone()); + } + return res; + } catch { + const cache = await caches.open(PAGE_CACHE); + const offline = await cache.match(OFFLINE_URL, { ignoreSearch: true, ignoreVary: true }); + if (path === OFFLINE_URL) return offline || Response.error(); + if (BUNDLE_VIEW.test(path) && offline) { + return Response.redirect(OFFLINE_URL + "?from=" + encodeURIComponent(path + url.search), 302); + } + const cached = await cache.match(request, { ignoreVary: true }); + if (cached) return cached; + if (offline) return Response.redirect(OFFLINE_URL + "?from=" + encodeURIComponent(path + url.search), 302); + return Response.error(); + } +} + +/** Caches the offline fallback page and the static assets it references (runs with the user's session cookie). */ +async function precacheOffline() { + try { + const res = await fetch(OFFLINE_URL, { credentials: "include", cache: "no-store" }); + if (!res.ok || res.redirected) return; + const html = await res.clone().text(); + await (await caches.open(PAGE_CACHE)).put(OFFLINE_URL, res); + const assets = Array.from(new Set(html.match(STATIC_REF) || [])); + const cache = await caches.open(STATIC_CACHE); + await Promise.all( + assets.concat(["/site.webmanifest"]).map(async (asset) => { + if (await cache.match(asset)) return; + const r = await fetch(asset).catch(() => null); + if (r && r.ok) await cache.put(asset, r); + }), + ); + } catch { + // not signed in or offline — retried via PRECACHE_OFFLINE after the next login + } +} + +// referenced for documentation/tests: the doc cache limit is enforced by the client (prefetch.ts) +void DOC_CACHE_MAX_BYTES; diff --git a/scripts/test-offline-core.ts b/scripts/test-offline-core.ts new file mode 100644 index 0000000..4a41e40 --- /dev/null +++ b/scripts/test-offline-core.ts @@ -0,0 +1,379 @@ +// Lane L7 „Offline & PWA" — reine Outbox-/Bundle-Logik ohne IndexedDB und ohne Server: +// Reihenfolge je Auftrag, Retry/Backoff, idMap-Anwendung, Blob-Upload vor der Op, Konflikt stoppt +// keine unabhängigen Ops, verkettete baseVersion, Mandanten-/User-Trennung der lokalen Stores, +// optimistische Auftragsansicht, Vorab-Download-Auswahl, Dokument-Cache (LRU), Service-Worker-Regeln. +// +// Lauf: npx tsx scripts/test-offline-core.ts (keine Infrastruktur nötig) + +import { readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import type { SyncOperationInput, SyncOpResult } from "../src/lib/sync/envelope"; +import { createMemoryStore } from "../src/lib/offline/memory-store"; +import { + applyIdMapToPayload, + applyResults, + backoffMs, + BACKOFF_MAX_MS, + blobRef, + canDiscard, + createEntry, + MAX_BATCH, + problemKey, + prunable, + retryEntry, + selectBatch, + summarize, +} from "../src/lib/offline/outbox-core"; +import { enqueueBlob, enqueueOp, runSyncPass, type Transport } from "../src/lib/offline/sync-engine"; +import { buildOrderView, isBundleStale, parseMaxDays, selectOfflineOrders, toBundleRecords } from "../src/lib/offline/bundle-core"; +import { DOC_CACHE, DOC_CACHE_MAX_BYTES, PAGE_CACHE, selectEvictions, selectOfflineDocuments } from "../src/lib/offline/doc-cache"; +import { ctxKeyOf, type BundleOrderData, type OutboxEntry } from "../src/lib/offline/types"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +const A = { tenantId: "tenant-a", userId: "user-1" }; +const A2 = { tenantId: "tenant-a", userId: "user-2" }; +const B = { tenantId: "tenant-b", userId: "user-1" }; +const T0 = new Date("2026-09-14T08:00:00.000Z"); +const at = (ms: number) => new Date(T0.getTime() + ms); +const fixedRandom = () => 1; // backoff = full step + +/** Fake server: records every batch, answers per op via `decide`. */ +function fakeTransport(decide: (op: SyncOperationInput) => SyncOpResult, opts: { failBatches?: number; uploadError?: "network" | "invalid" } = {}) { + const batches: SyncOperationInput[][] = []; + const uploads: string[] = []; + const log: string[] = []; + let failBatches = opts.failBatches ?? 0; + const transport: Transport = { + async sendBatch(_device, operations) { + if (failBatches > 0) { + failBatches--; + log.push("batch:network"); + return { ok: false, error: "network" }; + } + batches.push(operations); + log.push(...operations.map((o) => `op:${o.opType}`)); + return { ok: true, response: { results: operations.map(decide), serverTime: new Date().toISOString() } }; + }, + async upload(blob) { + log.push(`upload:${blob.clientId}`); + if (opts.uploadError) return { ok: false, error: opts.uploadError }; + uploads.push(blob.clientId); + return { ok: true, documentId: `doc-${blob.clientId.slice(0, 8)}` }; + }, + }; + return { transport, batches, uploads, log }; +} + +const applied = (op: SyncOperationInput, extra: Partial = {}): SyncOpResult => ({ clientOpId: op.clientOpId, status: "applied", ...extra }); + +function order(id: string, extra: Partial = {}): BundleOrderData { + return { + id, + number: `A-${id}`, + title: `Auftrag ${id}`, + status: "assigned", + statusGroup: "planned", + priority: "normal", + isEmergency: false, + plannedStart: null, + plannedEnd: null, + version: 3, + customer: { companyName: "Kunde", firstName: null, lastName: null, street: null, houseNumber: null, postalCode: null, city: null }, + checklistItems: [{ id: "chk-1", label: "Absichern", required: true, requiresPhoto: false, checked: false, checkedAt: null, comment: null }], + photoRequirements: [{ id: "req-1", key: "typ", label: "Typenschild", _count: { photos: 0 } }], + materialPlans: [], + materialUsages: [], + documents: [], + siteHistory: [], + ...extra, + }; +} + +async function main() { + console.log("\n— Reihenfolge je Auftrag / Batch —"); + { + const store = createMemoryStore(); + const ids: string[] = []; + for (let i = 0; i < 3; i++) { + const e = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", clientId: randomUUID(), kind: "general", text: `n${i}` } }, randomUUID(), at(i)); + ids.push(e.clientOpId); + } + await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-2", clientId: randomUUID(), kind: "general", text: "x" } }, randomUUID(), at(10)); + const fake = fakeTransport((op) => applied(op)); + const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(100) }); + const sentIds = fake.batches.flat().map((o) => o.clientOpId); + ok(pass.applied === 4 && fake.batches.length === 1, "4 Ops in einem Batch übertragen und angewendet"); + ok(JSON.stringify(sentIds.slice(0, 3)) === JSON.stringify(ids), "Ops eines Auftrags in Erfassungsreihenfolge gesendet"); + ok((await store.listOps(ctxKeyOf(A))).every((o) => o.status === "applied" && !!o.appliedAt), "lokaler Status applied mit Zeitstempel"); + } + { + const ops: OutboxEntry[] = []; + for (let i = 0; i < 120; i++) ops.push(createEntry(A, { opType: "note.create", payload: { workOrderId: `wo-${i % 7}`, kind: "general", text: "t" } }, { clientOpId: randomUUID(), now: at(i), existing: [], seq: i })); + const b = selectBatch(ops, [], at(1000)); + ok(b.entries.length === MAX_BATCH && b.operations.length === MAX_BATCH, `Batch auf ${MAX_BATCH} Ops begrenzt`); + const store = createMemoryStore(); + for (const o of ops) await store.putOp(o); + const fake = fakeTransport((op) => applied(op)); + const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(2000) }); + ok(pass.applied === 120 && fake.batches.length === 3 && fake.batches.every((x) => x.length <= MAX_BATCH), "120 Ops → 3 Batches (50/50/20)"); + } + + console.log("\n— Retry / Backoff —"); + { + ok(backoffMs(1, fixedRandom) === 2000 && backoffMs(2, fixedRandom) === 4000 && backoffMs(4, fixedRandom) === 16000, "Backoff verdoppelt sich (2 s, 4 s, 16 s)"); + ok(backoffMs(30, fixedRandom) === BACKOFF_MAX_MS, "Backoff gedeckelt auf 5 min"); + ok(backoffMs(3, () => 0) === 4000, "Jitter halbiert höchstens"); + + const store = createMemoryStore(); + const e1 = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0)); + const e2 = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "b" } }, randomUUID(), at(1)); + const fake = fakeTransport((op) => applied(op), { failBatches: 1 }); + const p1 = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10), random: fixedRandom }); + let ops = await store.listOps(ctxKeyOf(A)); + ok(p1.stopped === "network" && ops.every((o) => o.status === "queued" && o.attempts === 1 && o.lastError?.code === "network"), "Netzfehler → zurück in die Warteschlange, Versuch gezählt, Fehler gespeichert"); + ok(ops.every((o) => o.nextAttemptAt === at(10 + 2000).toISOString()), "nächster Versuch nach Backoff terminiert"); + const p2 = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(500), random: fixedRandom }); + ok(p2.sent === 0 && fake.batches.length === 0, "während Backoff wird nicht gesendet"); + const p3 = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(3000), random: fixedRandom }); + ops = await store.listOps(ctxKeyOf(A)); + ok(p3.applied === 2 && ops.every((o) => o.status === "applied"), "nach Backoff erfolgreich übertragen"); + ok(fake.batches[0].map((o) => o.clientOpId).join() === [e1.clientOpId, e2.clientOpId].join(), "gleiche clientOpIds beim Wiederholen (Idempotenz)"); + + // server-side transient error per op + const store2 = createMemoryStore(); + await enqueueOp(store2, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0)); + const fake2 = fakeTransport((op) => ({ clientOpId: op.clientOpId, status: "rejected", errorCode: "internal" })); + await runSyncPass({ store: store2, ctx: A, transport: fake2.transport, deviceId: "d", now: () => at(10), random: fixedRandom }); + const [t] = await store2.listOps(ctxKeyOf(A)); + ok(t.status === "queued" && t.attempts === 1 && !!t.nextAttemptAt, "rejected internal → transient, erneuter Versuch geplant"); + const store3 = createMemoryStore(); + await enqueueOp(store3, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0)); + const fake3 = fakeTransport((op) => ({ clientOpId: op.clientOpId, status: "rejected", errorCode: "not_found" })); + await runSyncPass({ store: store3, ctx: A, transport: fake3.transport, deviceId: "d", now: () => at(10) }); + const [r] = await store3.listOps(ctxKeyOf(A)); + ok(r.status === "rejected" && r.lastError?.code === "not_found" && canDiscard(r) && problemKey(r) === "notFound", "rejected not_found → endgültig, verwerfbar, Klartext-Schlüssel"); + const retried = retryEntry(r, at(20), "new-op-id"); + ok(retried?.status === "queued" && retried.clientOpId === "new-op-id" && retried.attempts === 0, "erneut versuchen: neue clientOpId (Server hat altes Ergebnis gespeichert)"); + const unauthorized = fakeTransport((op) => applied(op)); + unauthorized.transport.sendBatch = async () => ({ ok: false, error: "unauthorized" }); + const store4 = createMemoryStore(); + await enqueueOp(store4, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0)); + const p4 = await runSyncPass({ store: store4, ctx: A, transport: unauthorized.transport, deviceId: "d", now: () => at(10) }); + ok(p4.stopped === "unauthorized" && summarize(await store4.listOps(ctxKeyOf(A)), []).waitingForAuth, "401 → Pass stoppt, Hinweis „neu anmelden“"); + } + + console.log("\n— Konflikt stoppt keine unabhängigen Ops —"); + { + const store = createMemoryStore(); + const tr = await enqueueOp(store, A, { opType: "work_order.transition", baseVersion: 3, payload: { workOrderId: "wo-1", to: "accepted" } }, randomUUID(), at(0)); + const sameOrderNote = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "danach" } }, randomUUID(), at(1)); + const other = await enqueueOp(store, A, { opType: "checklist.toggle", payload: { workOrderId: "wo-2", itemId: "c", checked: true } }, randomUUID(), at(2)); + const fake = fakeTransport((op) => (op.opType === "work_order.transition" ? { clientOpId: op.clientOpId, status: "conflict", errorCode: "conflict", entityVersion: 5 } : applied(op))); + const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10) }); + const byId = new Map((await store.listOps(ctxKeyOf(A))).map((o) => [o.clientOpId, o])); + ok(pass.conflicts === 1 && byId.get(tr.clientOpId)?.status === "conflict", "Statusänderung → conflict (nicht überschrieben)"); + ok(byId.get(sameOrderNote.clientOpId)?.status === "applied" && byId.get(other.clientOpId)?.status === "applied", "additive Op desselben Auftrags und Op eines anderen Auftrags trotzdem angewendet"); + ok(problemKey(byId.get(tr.clientOpId)!) === "conflictTransition" && !canDiscard(byId.get(tr.clientOpId)!), "Konflikt: Klartext „im Büro geändert“, nicht verwerfbar"); + ok(summarize([...byId.values()], []).conflicts === 1, "Konflikt in der Zusammenfassung gezählt"); + // duplicate of a stored conflict stays a conflict + const dupStore = createMemoryStore(); + await enqueueOp(dupStore, A, { opType: "work_order.transition", baseVersion: 3, payload: { workOrderId: "wo-1", to: "accepted" } }, randomUUID(), at(0)); + const dup = fakeTransport((op) => ({ clientOpId: op.clientOpId, status: "duplicate", errorCode: "conflict", message: "original status: conflict" })); + await runSyncPass({ store: dupStore, ctx: A, transport: dup.transport, deviceId: "d", now: () => at(10) }); + ok((await dupStore.listOps(ctxKeyOf(A)))[0].status === "conflict", "duplicate eines gespeicherten Konflikts bleibt conflict"); + } + { + // pending op (backoff) blocks only later ops of the same order + const e1 = createEntry(A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, { clientOpId: "a1", now: T0, existing: [], seq: 1 }); + const e2 = createEntry(A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "b" } }, { clientOpId: "a2", now: T0, existing: [], seq: 2 }); + const e3 = createEntry(A, { opType: "note.create", payload: { workOrderId: "wo-2", kind: "general", text: "c" } }, { clientOpId: "b1", now: T0, existing: [], seq: 3 }); + const b = selectBatch([{ ...e1, nextAttemptAt: at(60_000).toISOString(), attempts: 1 }, e2, e3], [], at(1000)); + ok(b.entries.map((e) => e.clientOpId).join() === "b1", "Op im Backoff blockiert nur Folge-Ops desselben Auftrags"); + } + + console.log("\n— Blob-Upload vor der Op —"); + { + const store = createMemoryStore(); + const clientId = randomUUID(); + const ref = await enqueueBlob(store, A, { clientId, workOrderId: "wo-1", kind: "photo", blob: new Blob([new Uint8Array(1024)], { type: "image/jpeg" }), fileName: "f.jpg" }, at(0)); + ok(ref === blobRef(clientId), "Blob-Referenz blob:"); + const op = await enqueueOp(store, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", clientId: randomUUID(), documentId: ref, phase: "during" } }, randomUUID(), at(1)); + ok(op.blobRefs.length === 1 && (await store.getBlob(clientId))?.opClientOpId === op.clientOpId, "Op mit Blob verknüpft"); + ok(selectBatch(await store.listOps(ctxKeyOf(A)), await store.listBlobs(ctxKeyOf(A)), at(2)).entries.length === 0, "ohne Upload wird die Op nicht gesendet"); + ok(summarize(await store.listOps(ctxKeyOf(A)), await store.listBlobs(ctxKeyOf(A))).pendingUploads === 1, "ausstehender Upload gezählt"); + const fake = fakeTransport((o) => applied(o)); + const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10) }); + ok(fake.log.indexOf(`upload:${clientId}`) === 0 && fake.log.indexOf("op:photo.attach") === 1, "Upload vor photo.attach"); + ok(fake.batches[0][0].payload.documentId === `doc-${clientId.slice(0, 8)}`, "Payload auf Server-documentId umgeschrieben"); + ok(pass.applied === 1 && (await store.getBlob(clientId)) === null, "nach applied: Blob lokal freigegeben"); + + const store2 = createMemoryStore(); + const c2 = randomUUID(); + const ref2 = await enqueueBlob(store2, A, { clientId: c2, workOrderId: "wo-1", kind: "photo", blob: new Blob(["x"]), fileName: "f.jpg" }, at(0)); + await enqueueOp(store2, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", documentId: ref2 } }, randomUUID(), at(1)); + const note = await enqueueOp(store2, A, { opType: "note.create", payload: { workOrderId: "wo-9", kind: "general", text: "unabhängig" } }, randomUUID(), at(2)); + const bad = fakeTransport((o) => applied(o), { uploadError: "invalid" }); + await runSyncPass({ store: store2, ctx: A, transport: bad.transport, deviceId: "d", now: () => at(10) }); + const ops2 = await store2.listOps(ctxKeyOf(A)); + const photoOp = ops2.find((o) => o.opType === "photo.attach")!; + ok(photoOp.status === "rejected" && problemKey(photoOp) === "upload", "Upload endgültig abgelehnt → Op rejected mit Klartext"); + ok(ops2.find((o) => o.clientOpId === note.clientOpId)?.status === "applied", "unabhängige Op trotzdem gesendet"); + const store3 = createMemoryStore(); + const c3 = randomUUID(); + await enqueueOp(store3, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", documentId: await enqueueBlob(store3, A, { clientId: c3, workOrderId: "wo-1", kind: "photo", blob: new Blob(["x"]), fileName: "f" }, at(0)) } }, randomUUID(), at(1)); + const offline = fakeTransport((o) => applied(o), { uploadError: "network" }); + const p = await runSyncPass({ store: store3, ctx: A, transport: offline.transport, deviceId: "d", now: () => at(10), random: fixedRandom }); + const blob3 = await store3.getBlob(c3); + ok(p.stopped === "network" && blob3?.status === "failed" && blob3.nextAttemptAt === at(2010).toISOString() && offline.batches.length === 0, "Upload ohne Netz → Backoff, Op wartet"); + } + + console.log("\n— idMap / verkettete baseVersion —"); + { + ok( + JSON.stringify(applyIdMapToPayload({ clientId: "c1", photoId: "c1", nested: { ref: "c1" }, list: ["c1", "x"] }, { c1: "srv-1" })) === + JSON.stringify({ clientId: "c1", photoId: "srv-1", nested: { ref: "srv-1" }, list: ["srv-1", "x"] }), + "idMap ersetzt Referenzen, eigenes clientId-Feld bleibt", + ); + const store = createMemoryStore(); + const photoClient = randomUUID(); + const first = await enqueueOp(store, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", clientId: photoClient, documentId: "doc-1" } }, randomUUID(), at(0)); + const second = await enqueueOp(store, A, { opType: "material.upsert", payload: { workOrderId: "wo-1", clientId: randomUUID(), name: "Rohr", quantity: 1, unit: "m", usageStatus: "additional", photoId: photoClient } }, randomUUID(), at(1)); + const ops = await store.listOps(ctxKeyOf(A)); + const { changed, idMap } = applyResults(ops, [ops[0]], [{ clientOpId: first.clientOpId, status: "applied", idMap: { [photoClient]: "photo-srv" } }], at(5)); + const rewritten = changed.find((c) => c.clientOpId === second.clientOpId); + ok(idMap[photoClient] === "photo-srv" && rewritten?.payload.photoId === "photo-srv", "idMap auf wartende Ops angewendet (client id → server id)"); + + const chain = createMemoryStore(); + const start = await enqueueOp(chain, A, { opType: "session.start", payload: { workOrderId: "wo-1", mode: "work", clientId: randomUUID() } }, randomUUID(), at(0)); + const complete = await enqueueOp(chain, A, { opType: "work_order.transition", baseVersion: 3, payload: { workOrderId: "wo-1", to: "technically_completed" } }, randomUUID(), at(1)); + ok(complete.chainedBase === true, "Statuswechsel hinter eigener Session-Op → baseVersion verkettet"); + const fake = fakeTransport((op) => (op.opType === "session.start" ? applied(op, { entityVersion: 5 }) : op.baseVersion === 5 ? applied(op, { entityVersion: 6 }) : { clientOpId: op.clientOpId, status: "conflict", errorCode: "conflict" })); + await runSyncPass({ store: chain, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10), maxBatch: 1 }); + const chained = (await chain.listOps(ctxKeyOf(A))).find((o) => o.clientOpId === complete.clientOpId); + ok(fake.batches.length === 2 && fake.batches[1][0].baseVersion === 5 && chained?.status === "applied", "baseVersion aus Server-Ergebnis der Vorgänger-Op übernommen"); + ok(start.chainedBase === undefined, "additive/Session-Op ohne Verkettung"); + } + + console.log("\n— Mandanten-/User-Trennung der lokalen Stores —"); + { + const store = createMemoryStore(); + await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "A" } }, randomUUID(), at(0)); + await enqueueOp(store, B, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "B" } }, randomUUID(), at(1)); + await enqueueOp(store, A2, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "A2" } }, randomUUID(), at(2)); + await enqueueBlob(store, B, { clientId: randomUUID(), workOrderId: "wo-1", kind: "photo", blob: new Blob(["b"]), fileName: "b" }, at(3)); + await store.replaceBundle(ctxKeyOf(A), toBundleRecords(ctxKeyOf(A), [order("wo-1")], at(0).toISOString())); + await store.replaceBundle(ctxKeyOf(B), toBundleRecords(ctxKeyOf(B), [order("wo-b")], at(0).toISOString())); + await store.setMeta(ctxKeyOf(A), "draft:note:wo-1", { text: "geheim A" }); + const opsA = await store.listOps(ctxKeyOf(A)); + ok(opsA.length === 1 && opsA[0].payload.text === "A" && opsA[0].tenantId === "tenant-a" && opsA[0].userId === "user-1", "Outbox liefert nur Ops des eigenen Mandanten+Users"); + ok((await store.listOps(ctxKeyOf(A2))).length === 1 && (await store.listOps(ctxKeyOf(B)))[0].payload.text === "B", "anderer User desselben Mandanten / anderer Mandant getrennt"); + ok((await store.listBlobs(ctxKeyOf(A))).length === 0 && (await store.listBlobs(ctxKeyOf(B))).length === 1, "Blobs je Kontext getrennt"); + ok((await store.listBundle(ctxKeyOf(B))).map((r) => r.workOrderId).join() === "wo-b", "Bundle je Kontext getrennt"); + ok((await store.getMeta(ctxKeyOf(B), "draft:note:wo-1")) === null, "Entwürfe je Kontext getrennt"); + await store.replaceBundle(ctxKeyOf(A), [...toBundleRecords(ctxKeyOf(B), [order("wo-injected")], T0.toISOString()), ...toBundleRecords(ctxKeyOf(A), [order("wo-1")], T0.toISOString())]); + ok((await store.listBundle(ctxKeyOf(B))).every((r) => r.workOrderId === "wo-b") && (await store.listBundle(ctxKeyOf(A))).length === 1, "replaceBundle schreibt keine Datensätze fremder Kontexte"); + await store.clearContext(ctxKeyOf(A)); + ok((await store.listOps(ctxKeyOf(A))).length === 0 && (await store.listBundle(ctxKeyOf(A))).length === 0 && (await store.getMeta(ctxKeyOf(A), "draft:note:wo-1")) === null, "Kontext A gelöscht (Logout)"); + ok((await store.listOps(ctxKeyOf(B))).length === 1 && (await store.listOps(ctxKeyOf(A2))).length === 1, "Daten anderer Kontexte bleiben erhalten"); + ok(!(await store.listContexts()).includes(ctxKeyOf(A)), "Kontext A nicht mehr gelistet"); + // a pass for context A never sends ops of B + const fake = fakeTransport((o) => applied(o)); + await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10) }); + ok(fake.batches.length === 0, "Sync-Pass von A sendet keine Ops von B/A2"); + } + + console.log("\n— Optimistische Auftragsansicht —"); + { + const record = toBundleRecords("tenant-a:user-1", [order("wo-1", { status: "accepted" })], at(0).toISOString())[0]; + const mk = (opType: OutboxEntry["opType"], payload: Record, seq: number, status: OutboxEntry["status"] = "queued") => ({ + ...createEntry(A, { opType, payload }, { clientOpId: `op-${seq}`, now: at(seq * 1000), existing: [], seq }), + status, + ...(status === "applied" ? { appliedAt: at(seq * 1000).toISOString() } : {}), + }); + const ops = [ + mk("session.start", { workOrderId: "wo-1", mode: "work" }, 1), + mk("note.create", { workOrderId: "wo-1", clientId: "n-1", kind: "work_done", text: "Montiert" }, 2), + mk("checklist.toggle", { workOrderId: "wo-1", itemId: "chk-1", checked: true }, 3), + mk("photo.attach", { workOrderId: "wo-1", clientId: "p-1", documentId: blobRef("b-1"), photoRequirementId: "req-1" }, 4), + mk("session.pause", { workOrderId: "wo-1" }, 5), + mk("note.create", { workOrderId: "wo-1", kind: "general", text: "abgelehnt" }, 6, "rejected"), + mk("note.create", { workOrderId: "wo-2", kind: "general", text: "anderer Auftrag" }, 7), + ]; + const view = buildOrderView(record, ops); + ok(view.status === "paused" && view.local.session === "paused" && view.statusGroup === "in_progress", "Zeiten/Status optimistisch angewendet"); + ok(view.local.notes.length === 1 && view.local.notes[0].text === "Montiert" && view.local.notes[0].pending, "Notiz lokal sichtbar (ausstehend), abgelehnte nicht"); + ok(view.checklistItems[0].checked && view.photoRequirements[0]._count.photos === 1 && view.local.photos[0].blobClientId === "b-1", "Checkliste und Pflichtfoto lokal aktualisiert"); + ok(view.local.pendingOps === 5 && view.local.rejected, "ausstehende Ops gezählt, Fehlerhinweis gesetzt"); + ok(record.data.status === "accepted" && !record.data.checklistItems[0].checked, "Server-Snapshot unverändert"); + const afterPull = buildOrderView({ ...record, syncedAt: at(60_000).toISOString() }, ops.map((o) => ({ ...o, status: "applied" as const, appliedAt: at(10_000).toISOString() }))); + ok(afterPull.status === "accepted" && afterPull.local.notes.length === 0, "nach neuem Pull gilt der Server-Stand"); + ok(prunable(ops.map((o) => ({ ...o, status: "applied" as const, appliedAt: at(10_000).toISOString() })), at(60_000).toISOString()).length === ops.length, "angewendete Ops nach Pull aufräumbar"); + } + + console.log("\n— Vorab-Download / Veraltet / Dokument-Cache —"); + { + const now = new Date("2026-09-14T10:00:00+02:00"); + const d = (days: number, h = 9) => { + const x = new Date(now); + x.setHours(h, 0, 0, 0); + x.setDate(x.getDate() + days); + return x.toISOString(); + }; + const orders = [ + order("today", { plannedStart: d(0), plannedEnd: d(0, 17) }), + order("in3", { plannedStart: d(3) }), + order("in5", { plannedStart: d(5) }), + order("running", { status: "in_progress", plannedStart: d(-10) }), + order("past-planned", { plannedStart: d(-2), plannedEnd: d(-2, 12) }), + order("undated"), + ]; + const sel = selectOfflineOrders(orders, now).map((o) => o.id); + ok(sel.join() === "today,in3,running", "heute + 3 Tage + laufende Aufträge ausgewählt"); + ok(!isBundleStale(new Date(now.getTime() - 6 * 864e5).toISOString(), now, 7) && isBundleStale(new Date(now.getTime() - 8 * 864e5).toISOString(), now, 7), "Bundle nach OFFLINE_MAX_DAYS als veraltet markiert"); + ok(parseMaxDays(undefined) === 7 && parseMaxDays("14") === 14 && parseMaxDays("0") === 7 && parseMaxDays("abc") === 7, "OFFLINE_MAX_DAYS mit Default 7"); + const docs = selectOfflineDocuments([ + { id: "1", category: "technical_drawing", fileSize: 1e6 }, + { id: "2", category: "assembly_instructions", fileSize: 25 * 1024 * 1024 }, + { id: "3", category: "safety_document", fileSize: 26 * 1024 * 1024 }, + { id: "4", category: "order_confirmation", fileSize: 1e5 }, + { id: "1", category: "technical_drawing", fileSize: 1e6 }, + ]); + ok(docs.map((x) => x.id).join() === "1,2", "Zeichnung/Montageanleitung/Sicherheit ≤ 25 MB, ohne Dubletten"); + const MB = 1024 * 1024; + const ev = selectEvictions( + [ + { url: "a", size: 100 * MB, lastUsed: 1 }, + { url: "b", size: 150 * MB, lastUsed: 3 }, + { url: "c", size: 40 * MB, lastUsed: 2 }, + ], + DOC_CACHE_MAX_BYTES, + 20 * MB, + ); + ok(ev.join() === "a", "LRU: ältester Eintrag verdrängt, bis 300 MB passen"); + ok(selectEvictions([{ url: "a", size: 10, lastUsed: 1 }], DOC_CACHE_MAX_BYTES).length === 0, "unter dem Limit keine Verdrängung"); + ok(selectEvictions([{ url: "keep", size: 200 * MB, lastUsed: 1 }, { url: "old", size: 200 * MB, lastUsed: 5 }], DOC_CACHE_MAX_BYTES, 0, new Set(["keep"])).join() === "old", "Dokumente des aktuellen Bundles zuletzt verdrängt"); + } + + console.log("\n— Service Worker (public/sw.js) —"); + { + const sw = readFileSync(new URL("../public/sw.js", import.meta.url), "utf8"); + ok(sw.includes(`"${DOC_CACHE}"`) && sw.includes(`"${PAGE_CACHE}"`) && sw.includes(String(DOC_CACHE_MAX_BYTES / 1024 / 1024)), "Cache-Namen/Limit in sw.js und doc-cache.ts identisch"); + ok(/request\.method !== "GET"/.test(sw), "nur GET-Anfragen werden behandelt (keine Mutationen)"); + ok(/NEVER_CACHE/.test(sw) && sw.includes('"/api/v1/sync"') && sw.includes('"/api/auth"') && sw.includes('"/login"'), "Sync-API, Auth-Routen und Login ausgeschlossen"); + ok(sw.includes('"/m/offline"') && sw.includes("/_next/static/"), "Offline-Fallback und statische Assets berücksichtigt"); + ok(sw.includes("SKIP_WAITING"), "Update-Flow über SKIP_WAITING"); + } + + console.log(`\n${failures === 0 ? "Alle Prüfungen bestanden." : `${failures} Prüfung(en) fehlgeschlagen.`}`); + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/test-offline-sync-e2e.ts b/scripts/test-offline-sync-e2e.ts new file mode 100644 index 0000000..d5113be --- /dev/null +++ b/scripts/test-offline-sync-e2e.ts @@ -0,0 +1,193 @@ +// Lane L7 „Offline & PWA" — Ende-zu-Ende gegen den echten Sync-Service (ohne HTTP): +// Outbox (In-Memory-Store) + Sync-Engine + applyOperations/storeFieldUpload. +// - 20 Ops offline erfasst (10 Tage alt, älter als OFFLINE_MAX_DAYS) → Foto-Upload vor der Op → +// EIN Batch → alle applied, clientCreatedAt serverseitig protokolliert +// - Wiederholung desselben Batches → alle duplicate, keine Doppelanlagen +// - Konflikt (Auftrag im Büro geändert) stoppt keine unabhängige Op +// - Mandantentrennung: Mandant B kann Auftrag von A weder lesen (bundle) noch ändern (Ops/Upload) +// - Rollen/Scope: Monteur ohne Zuweisung → not_found +// +// Lauf: npx tsx scripts/test-offline-sync-e2e.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 { getFieldBundle } from "../src/server/services/field/queries"; +import type { ServiceCtx } from "../src/server/services/context"; +import type { SyncOperationInput } from "../src/lib/sync/envelope"; +import { createMemoryStore } from "../src/lib/offline/memory-store"; +import { blobRef, selectBatch } from "../src/lib/offline/outbox-core"; +import { enqueueBlob, enqueueOp, runSyncPass, type Transport } from "../src/lib/offline/sync-engine"; +import { selectOfflineOrders } from "../src/lib/offline/bundle-core"; +import { ctxKeyOf, type OfflineContext, type OfflineStore } from "../src/lib/offline/types"; +import { createFixture, failures, ok } from "./lib/einsatz-fixture"; + +type Stats = { batches: SyncOperationInput[][]; uploads: number }; + +/** Transport that calls the server services in-process with the given (authenticated) context. */ +function serverTransport(ctx: ServiceCtx): { transport: Transport; stats: Stats } { + const stats: Stats = { batches: [], uploads: 0 }; + const transport: Transport = { + async sendBatch(deviceId, operations) { + stats.batches.push(operations); + return { ok: true, response: await applyOperations(ctx, { deviceId, operations }) }; + }, + async upload(blob) { + stats.uploads++; + try { + const r = await storeFieldUpload( + ctx, + { clientId: blob.clientId, workOrderId: blob.workOrderId, kind: blob.kind }, + { bytes: Buffer.from(await blob.blob.arrayBuffer()), name: blob.fileName, type: blob.blob.type }, + null, + ); + return { ok: true, documentId: r.documentId }; + } catch (err) { + const code = (err as { code?: string }).code; + return { ok: false, error: code === "not_found" || code === "forbidden" || code === "invalid" ? code : "internal" }; + } + }, + }; + return { transport, stats }; +} + +const localCtx = (ctx: ServiceCtx): OfflineContext => ({ tenantId: ctx.tenantId, userId: ctx.userId }); + +async function main() { + const f = await createFixture("l7e2e"); + const wo = f.orderA; + const A = localCtx(f.ctxTech); + const store: OfflineStore = createMemoryStore(); + try { + const jpeg = await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 30, g: 90, b: 160 } } }).jpeg().toBuffer(); + + console.log("\n— Vorab-Download (Bundle im Scope) —"); + const bundle = await getFieldBundle(f.ctxTech); + const offlineOrders = selectOfflineOrders(bundle.orders.map((o) => ({ ...o, plannedStart: o.plannedStart?.toISOString() ?? null, plannedEnd: o.plannedEnd?.toISOString() ?? null })), new Date()); + ok(offlineOrders.some((o) => o.id === wo.id), "heutiger Auftrag des Monteurs wird offline gespeichert"); + ok(!(await getFieldBundle(f.ctxB)).orders.some((o) => o.id === wo.id), "Mandant B erhält Auftrag von A nicht im Bundle"); + ok(!(await getFieldBundle(f.ctxOutsider)).orders.some((o) => o.id === wo.id), "Monteur ohne Zuweisung erhält den Auftrag nicht im Bundle"); + + console.log("\n— 20 Ops offline erfassen —"); + const offlineAt = Date.now() - 10 * 24 * 60 * 60 * 1000; // older than OFFLINE_MAX_DAYS (7) + let minute = 0; + const when = () => new Date(offlineAt + minute++ * 60_000); + const add = (op: Parameters[2]) => enqueueOp(store, A, op, randomUUID(), when()); + + await add({ opType: "work_order.transition", baseVersion: wo.version, payload: { workOrderId: wo.id, to: "accepted" } }); + await add({ opType: "session.start", payload: { workOrderId: wo.id, mode: "work", clientId: randomUUID(), offline: true } }); + for (let i = 1; i <= 8; i++) await add({ opType: "note.create", payload: { workOrderId: wo.id, clientId: randomUUID(), kind: "work_done", text: `Offline-Notiz ${i}` } }); + await add({ opType: "checklist.toggle", payload: { workOrderId: wo.id, itemId: wo.checklistItems[0].id, checked: true } }); + await add({ opType: "material.upsert", payload: { workOrderId: wo.id, materialPlanId: wo.materialPlans[0].id, quantity: 10, unit: "m", usageStatus: "fully_used" } }); + await add({ opType: "material.upsert", payload: { workOrderId: wo.id, clientId: randomUUID(), name: "Dichtband", quantity: 2, unit: "Rolle", usageStatus: "additional", deviationReason: "Undichte Verschraubung" } }); + const photoClient = randomUUID(); + const ref = await enqueueBlob(store, A, { clientId: photoClient, workOrderId: wo.id, kind: "photo", blob: new Blob([new Uint8Array(jpeg)], { type: "image/jpeg" }), fileName: "typenschild.jpg" }, when()); + ok(ref === blobRef(photoClient), "Foto in die Upload-Warteschlange aufgenommen"); + await add({ opType: "photo.attach", payload: { workOrderId: wo.id, clientId: randomUUID(), documentId: ref, phase: "after", photoRequirementId: wo.photoRequirements[0].id } }); + await add({ opType: "session.pause", payload: { workOrderId: wo.id } }); + await add({ opType: "session.resume", payload: { workOrderId: wo.id } }); + for (let i = 9; i <= 11; i++) await add({ opType: "note.create", payload: { workOrderId: wo.id, clientId: randomUUID(), kind: "general", text: `Offline-Notiz ${i}` } }); + await add({ opType: "session.end", payload: { workOrderId: wo.id } }); + + const queued = await store.listOps(ctxKeyOf(A)); + ok(queued.length === 20 && queued.every((o) => o.status === "queued"), "20 Ops lokal in der Outbox (queued)"); + const beforeUpload = selectBatch(queued, await store.listBlobs(ctxKeyOf(A)), new Date()); + ok(beforeUpload.entries.length === 13, "vor dem Upload: Ops ab dem Foto warten (Reihenfolge je Auftrag)"); + ok((await prisma.activityNote.count({ where: { workOrderId: wo.id } })) === 0, "offline: noch nichts auf dem Server"); + + console.log("\n— Verbindung da: ein Batch —"); + const tech = serverTransport(f.ctxTech); + const pass = await runSyncPass({ store, ctx: A, transport: tech.transport, deviceId: "l7-e2e-device" }); + ok(tech.stats.uploads === 1 && tech.stats.batches.length === 1 && tech.stats.batches[0].length === 20, "1 Upload, dann genau 1 Batch mit 20 Ops"); + ok(pass.applied === 20 && pass.conflicts === 0 && pass.rejected === 0, `alle 20 Ops applied (applied=${pass.applied}, conflict=${pass.conflicts}, rejected=${pass.rejected})`); + const afterPass = await store.listOps(ctxKeyOf(A)); + ok(afterPass.every((o) => o.status === "applied" && o.result?.status === "applied"), "lokaler Status aller Ops: applied"); + if (pass.applied !== 20) console.log(afterPass.filter((o) => o.status !== "applied").map((o) => `${o.opType}: ${o.status} ${o.lastError?.code} ${o.lastError?.message}`)); + const photoOp = tech.stats.batches[0].find((o) => o.opType === "photo.attach"); + ok(!!photoOp && typeof photoOp.payload.documentId === "string" && !String(photoOp.payload.documentId).startsWith("blob:"), "Foto-Op mit Server-documentId gesendet"); + ok((await store.getBlob(photoClient)) === null, "Foto-Blob nach erfolgreicher Übertragung lokal gelöscht"); + ok(Object.keys(pass.idMap).length >= 11, "idMap für angelegte Datensätze erhalten"); + + const clientOpIds = queued.map((o) => o.clientOpId); + const stored = await prisma.syncOperation.findMany({ where: { tenantId: f.tenantA.id, clientOpId: { in: clientOpIds } } }); + ok(stored.length === 20 && stored.every((s) => s.status === "applied"), "20 SyncOperations serverseitig protokolliert (applied)"); + const drift = Math.max(...stored.map((s) => Math.abs(s.clientCreatedAt.getTime() - new Date(queued.find((q) => q.clientOpId === s.clientOpId)!.clientCreatedAt).getTime()))); + ok(drift < 1000 && stored.every((s) => s.clientCreatedAt.getTime() < Date.now() - 7 * 864e5), "alte Ops trotzdem angewendet, clientCreatedAt (10 Tage alt) protokolliert"); + ok((await prisma.activityNote.count({ where: { workOrderId: wo.id } })) === 11, "11 Notizen angelegt"); + ok((await prisma.photo.count({ where: { workOrderId: wo.id } })) === 1, "Foto angehängt"); + ok((await prisma.materialUsage.count({ where: { workOrderId: wo.id } })) === 2, "Material (Plan + Zusatz) erfasst"); + ok((await prisma.checklistItem.findUniqueOrThrow({ where: { id: wo.checklistItems[0].id } })).checked, "Checkliste abgehakt"); + ok((await prisma.workSession.count({ where: { workOrderId: wo.id, status: "ended" } })) === 1, "Session gestartet, pausiert, fortgesetzt, beendet"); + + console.log("\n— Wiederholung → duplicate —"); + const replay = await applyOperations(f.ctxTech, { deviceId: "l7-e2e-device", operations: tech.stats.batches[0] }); + ok(replay.results.length === 20 && replay.results.every((r) => r.status === "duplicate"), "gleicher Batch erneut → 20× duplicate"); + // re-queue with the payloads as sent (the local photo blob was already released after applied) + const sentPayload = new Map(tech.stats.batches[0].map((o) => [o.clientOpId, o.payload])); + for (const e of afterPass) await store.putOp({ ...e, status: "queued", appliedAt: null, result: null, payload: sentPayload.get(e.clientOpId) ?? e.payload, blobRefs: [] }); + const replayPass = await runSyncPass({ store, ctx: A, transport: tech.transport, deviceId: "l7-e2e-device" }); + ok(replayPass.applied === 20 && (await store.listOps(ctxKeyOf(A))).every((o) => o.status === "applied" && o.result?.status === "duplicate"), "Outbox-Wiederholung: Server meldet duplicate, lokal applied"); + ok((await prisma.activityNote.count({ where: { workOrderId: wo.id } })) === 11 && (await prisma.photo.count({ where: { workOrderId: wo.id } })) === 1, "keine Doppelanlagen"); + ok((await prisma.syncOperation.count({ where: { tenantId: f.tenantA.id, clientOpId: { in: clientOpIds } } })) === 20, "SyncOperations nicht doppelt"); + + console.log("\n— Konflikt stoppt keine unabhängige Op —"); + const current = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } }); + await prisma.workOrder.update({ where: { id: wo.id }, data: { version: { increment: 1 }, technicianNotes: "Im Büro geändert" } }); + const conflictStore = createMemoryStore(); + const tr = await enqueueOp(conflictStore, A, { opType: "work_order.transition", baseVersion: current.version, payload: { workOrderId: wo.id, to: "paused" } }, randomUUID()); + const note = await enqueueOp(conflictStore, A, { opType: "note.create", payload: { workOrderId: wo.id, clientId: randomUUID(), kind: "general", text: "Nach dem Konflikt" } }, randomUUID()); + const cPass = await runSyncPass({ store: conflictStore, ctx: A, transport: tech.transport, deviceId: "l7-e2e-device" }); + const cOps = new Map((await conflictStore.listOps(ctxKeyOf(A))).map((o) => [o.clientOpId, o])); + ok(cPass.conflicts === 1 && cOps.get(tr.clientOpId)?.status === "conflict", "veraltete Statusänderung → conflict"); + ok(cOps.get(note.clientOpId)?.status === "applied", "Notiz danach trotzdem applied"); + const afterConflict = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } }); + ok(afterConflict.status === current.status && afterConflict.technicianNotes === "Im Büro geändert", "Büro-Stand nicht überschrieben"); + ok((await prisma.syncOperation.findFirst({ where: { tenantId: f.tenantA.id, clientOpId: tr.clientOpId } }))?.status === "conflict", "Konflikt fürs Backoffice protokolliert"); + + console.log("\n— Mandantentrennung —"); + const B = localCtx(f.ctxB); + const notesBefore = await prisma.activityNote.count({ where: { workOrderId: wo.id } }); + const bNote = await enqueueOp(store, B, { opType: "note.create", payload: { workOrderId: wo.id, clientId: randomUUID(), kind: "general", text: "Mandant B" } }, randomUUID()); + const bTransition = await enqueueOp(store, B, { opType: "work_order.transition", baseVersion: afterConflict.version, payload: { workOrderId: wo.id, to: "cancelled" } }, randomUUID()); + const bBlob = randomUUID(); + const bPhoto = await enqueueOp(store, B, { opType: "photo.attach", payload: { workOrderId: wo.id, documentId: await enqueueBlob(store, B, { clientId: bBlob, workOrderId: wo.id, kind: "photo", blob: new Blob([new Uint8Array(jpeg)], { type: "image/jpeg" }), fileName: "b.jpg" }) } }, randomUUID()); + ok((await store.listOps(ctxKeyOf(A))).every((o) => o.tenantId === f.tenantA.id) && (await store.listOps(ctxKeyOf(B))).length === 3, "lokale Outbox je Mandant getrennt"); + const bSrv = serverTransport(f.ctxB); + await runSyncPass({ store, ctx: B, transport: bSrv.transport, deviceId: "device-b" }); + const bOps = new Map((await store.listOps(ctxKeyOf(B))).map((o) => [o.clientOpId, o])); + ok(bOps.get(bNote.clientOpId)?.status === "rejected" && bOps.get(bNote.clientOpId)?.lastError?.code === "not_found", "B: Notiz auf Auftrag von A → rejected not_found"); + ok(bOps.get(bTransition.clientOpId)?.status === "rejected" && bOps.get(bTransition.clientOpId)?.lastError?.code === "not_found", "B: Statusänderung auf Auftrag von A → not_found (kein Versions-Leak)"); + ok(bOps.get(bPhoto.clientOpId)?.status === "rejected" && (await store.getBlob(bBlob))?.status === "failed", "B: Foto-Upload auf Auftrag von A abgelehnt"); + ok(bSrv.stats.batches.flat().every((o) => o.opType !== "photo.attach"), "B: Foto-Op ohne Upload nie gesendet"); + const woAfterB = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } }); + ok((await prisma.activityNote.count({ where: { workOrderId: wo.id } })) === notesBefore && woAfterB.status !== "cancelled", "Auftrag von A unverändert"); + ok((await store.listOps(ctxKeyOf(A))).length === 20, "Outbox von A durch B-Sync unberührt"); + + console.log("\n— Rollen/Scope —"); + const O = localCtx(f.ctxOutsider); + const oStore = createMemoryStore(); + const oNote = await enqueueOp(oStore, O, { opType: "note.create", payload: { workOrderId: wo.id, kind: "general", text: "ohne Zuweisung" } }, randomUUID()); + const oSession = await enqueueOp(oStore, O, { opType: "session.start", payload: { workOrderId: wo.id, mode: "work", clientId: randomUUID() } }, randomUUID()); + await runSyncPass({ store: oStore, ctx: O, transport: serverTransport(f.ctxOutsider).transport, deviceId: "device-o" }); + const oOps = new Map((await oStore.listOps(ctxKeyOf(O))).map((o) => [o.clientOpId, o])); + ok(oOps.get(oNote.clientOpId)?.lastError?.code === "not_found" && oOps.get(oSession.clientOpId)?.lastError?.code === "not_found", "Monteur ohne Zuweisung → not_found, endgültig (kein Retry)"); + ok([...oOps.values()].every((o) => o.status === "rejected" && o.nextAttemptAt === null), "keine Wiederholungsschleife für Scope-Fehler"); + ok((await prisma.workSession.count({ where: { workOrderId: wo.id, userId: f.outsider.id } })) === 0, "keine Session für den fremden Monteur angelegt"); + } finally { + await f.cleanup(); + await closeJobQueues().catch(() => undefined); + await prisma.$disconnect(); + } + console.log(`\n${failures === 0 ? "Alle Prüfungen bestanden." : `${failures} Prüfung(en) fehlgeschlagen.`}`); + process.exit(failures === 0 ? 0 : 1); +} + +main().catch(async (err) => { + console.error(err); + await prisma.$disconnect().catch(() => undefined); + process.exit(1); +}); diff --git a/src/app/(field)/m/(core)/profile/page.tsx b/src/app/(field)/m/(core)/profile/page.tsx index 6531a10..90ad296 100644 --- a/src/app/(field)/m/(core)/profile/page.tsx +++ b/src/app/(field)/m/(core)/profile/page.tsx @@ -1,6 +1,5 @@ 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"; @@ -8,6 +7,8 @@ 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"; +import { InstallHint } from "@/components/offline/install-hint"; +import { LogoutForm } from "@/components/offline/logout-form"; /** `/m/profile` — name, team, company, language, sign out. */ export default async function ProfilePage() { @@ -62,12 +63,9 @@ export default async function ProfilePage() { {t("backoffice")} )} -
- -
+ + {/* L7: deletes the local offline data of this tenant/user first (warning if unsent entries exist) */} + ); } diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx index f42b53d..5c7619b 100644 --- a/src/app/(field)/m/layout.tsx +++ b/src/app/(field)/m/layout.tsx @@ -5,6 +5,7 @@ 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"; +import { OfflineRuntime } from "@/components/offline/offline-runtime"; /** * Mobile shell `/m` (ARCHITEKTUR §5): same session/account/MFA checks as the backoffice @@ -24,6 +25,7 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
{/* Slot für L6: aus src/components/notifications/bell.tsx */} +
diff --git a/src/app/(field)/m/offline/layout.tsx b/src/app/(field)/m/offline/layout.tsx new file mode 100644 index 0000000..6f19e40 --- /dev/null +++ b/src/app/(field)/m/offline/layout.tsx @@ -0,0 +1,7 @@ +import { requireModule } from "@/server/modules"; + +/** Modul-Gate „field" für die Offline-Ansicht (Lane L7). */ +export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) { + await requireModule("field"); + return <>{children}; +} diff --git a/src/app/(field)/m/offline/page.tsx b/src/app/(field)/m/offline/page.tsx new file mode 100644 index 0000000..6e52cec --- /dev/null +++ b/src/app/(field)/m/offline/page.tsx @@ -0,0 +1,17 @@ +import { getTranslations } from "next-intl/server"; +import { OfflineView } from "@/components/offline/offline-view"; + +/** + * `/m/offline` — offline fallback (lane L7): order list/detail from the local bundle. Cached by the + * service worker; the content is rendered on the client from IndexedDB, so the cached HTML stays + * valid while offline. + */ +export default async function OfflinePage() { + const t = await getTranslations("offline.view"); + return ( +
+

{t("title")}

+ +
+ ); +} diff --git a/src/app/(field)/m/sync/layout.tsx b/src/app/(field)/m/sync/layout.tsx new file mode 100644 index 0000000..bfb3652 --- /dev/null +++ b/src/app/(field)/m/sync/layout.tsx @@ -0,0 +1,7 @@ +import { requireModule } from "@/server/modules"; + +/** Modul-Gate „field" für die Synchronisationsseite (Lane L7). */ +export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) { + await requireModule("field"); + return <>{children}; +} diff --git a/src/app/(field)/m/sync/page.tsx b/src/app/(field)/m/sync/page.tsx index 9a81ea5..18afede 100644 --- a/src/app/(field)/m/sync/page.tsx +++ b/src/app/(field)/m/sync/page.tsx @@ -1,23 +1,17 @@ import { getTranslations } from "next-intl/server"; -import { OnlineBadge } from "@/components/field/online-badge"; -import { card } from "@/components/field/ui"; +import { SyncPanel } from "@/components/offline/sync-panel"; /** - * 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). + * `/m/sync` (lane L7, Spec §22/§23.4, US-012): connection, last sync, pending changes and uploads + * with progress, errors/conflicts in plain language (retry / discard), storage usage, reset. The + * data lives on the device (IndexedDB), so the panel is a client component. */ -export default async function SyncPlaceholderPage() { - const t = await getTranslations("field.sync"); +export default async function SyncPage() { + const t = await getTranslations("offline.sync"); return (

{t("title")}

-
-

{t("status")}

- -

{t("immediate")}

-

{t("offlineHint")}

-
+
); } diff --git a/src/components/field/note-form.tsx b/src/components/field/note-form.tsx index cf7bc8e..5e30d02 100644 --- a/src/components/field/note-form.tsx +++ b/src/components/field/note-form.tsx @@ -1,55 +1,33 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { CircleCheck, TriangleAlert } from "lucide-react"; import { cn } from "@/lib/utils"; import { NOTE_KINDS, type NoteKind } from "@/lib/sync/ops"; -import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; +import { errorKey, isQueued, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; +import { useOfflineDraft } from "@/components/offline/hooks"; import { btnPrimary, chip, inputClass, noticeError, noticeOk } from "./ui"; type Draft = { kind: NoteKind; text: string; clientId: string }; -const draftKey = (workOrderId: string) => `craftvia.field.noteDraft.${workOrderId}`; - -function readDraft(workOrderId: string): Draft | null { - try { - const raw = localStorage.getItem(draftKey(workOrderId)); - return raw ? (JSON.parse(raw) as Draft) : null; - } catch { - return null; - } -} - /** * Activity note (Spec §12.3): kind as large chips + text. The draft (incl. its clientId, so a retry - * stays idempotent) is kept in localStorage until the server confirmed it (US-006). + * stays idempotent) is stored automatically in IndexedDB per tenant/user (lane L7) until the note + * is saved (US-006); offline the note goes into the outbox. */ export function NoteForm({ workOrderId }: { workOrderId: string }) { const t = useTranslations("field"); const router = useRouter(); - const [draft, setDraft] = useState({ kind: "work_done", text: "", clientId: "" }); + const [draft, setDraft, clearDraft] = useOfflineDraft(`note:${workOrderId}`, { kind: "work_done", text: "", clientId: "" }); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); - useEffect(() => { - const stored = readDraft(workOrderId); - // restore an unsent draft after reload / connection loss (client-only storage) - // eslint-disable-next-line react-hooks/set-state-in-effect - if (stored) setDraft(stored); - }, [workOrderId]); - function update(next: Partial) { - const value = { ...draft, ...next, clientId: draft.clientId || newClientId() }; - setDraft(value); + setDraft({ ...draft, ...next, clientId: draft.clientId || newClientId() }); setSaved(false); - try { - localStorage.setItem(draftKey(workOrderId), JSON.stringify(value)); - } catch { - // storage unavailable (private mode) — the form still works - } } async function submit(e: React.FormEvent) { @@ -64,14 +42,10 @@ export function NoteForm({ workOrderId }: { workOrderId: string }) { setError(`${t(`errors.${errorKey(result)}`)} ${t("notes.draftKept")}`); return; } - try { - localStorage.removeItem(draftKey(workOrderId)); - } catch { - /* ignore */ - } setDraft({ kind: draft.kind, text: "", clientId: "" }); + await clearDraft(); setSaved(true); - router.refresh(); + if (!isQueued(result)) router.refresh(); } return ( diff --git a/src/components/field/photo-capture.tsx b/src/components/field/photo-capture.tsx index 155c594..5ed59d7 100644 --- a/src/components/field/photo-capture.tsx +++ b/src/components/field/photo-capture.tsx @@ -6,9 +6,8 @@ import { useTranslations } from "next-intl"; import { Camera, CircleCheck, Image as ImageIcon, LoaderCircle, TriangleAlert, Upload } from "lucide-react"; import { cn } from "@/lib/utils"; import { PHOTO_PHASES, type PhotoPhase } from "@/lib/sync/ops"; -import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; +import { errorKey, isQueued, isSuccess, newClientId, queueBlob, submitOp } from "@/lib/field/client-ops"; import { compressImage, currentPosition } from "@/lib/field/image"; -import { uploadFieldFile } from "@/lib/field/upload"; import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk } from "./ui"; type Option = { id: string; label: string }; @@ -47,18 +46,18 @@ function usePhotoSave(workOrderId: string) { } setProgress({ stage: "uploading", percent: 0 }); const name = `${(file.name || "foto").replace(/\.[^.]+$/, "")}.jpg`; - const up = await uploadFieldFile({ + // L7: the file goes into the local upload queue; the outbox uploads it before photo.attach + const up = await queueBlob({ workOrderId, kind: "photo", - clientId: newClientId(), - file: image, + blob: image, fileName: thumbnail ? name : file.name || name, preview: thumbnail, onProgress: (percent) => setProgress({ stage: "uploading", percent }), }); if (!up.ok) { setProgress({ stage: "idle" }); - setError(t(`errors.${up.error}`)); + setError(t(`errors.${["network", "invalid", "forbidden", "not_found"].includes(up.error) ? up.error : "internal"}`)); return false; } documentId = up.documentId; @@ -87,7 +86,7 @@ function usePhotoSave(workOrderId: string) { } uploaded.current = null; setSaved(true); - router.refresh(); + if (!isQueued(result)) router.refresh(); return true; } diff --git a/src/components/field/voice-recorder.tsx b/src/components/field/voice-recorder.tsx index 4cff881..caf0f2c 100644 --- a/src/components/field/voice-recorder.tsx +++ b/src/components/field/voice-recorder.tsx @@ -4,8 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { CircleCheck, LoaderCircle, Mic, Square, TriangleAlert, Upload } from "lucide-react"; -import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; -import { uploadFieldFile } from "@/lib/field/upload"; +import { errorKey, isQueued, isSuccess, newClientId, queueBlob, submitOp } from "@/lib/field/client-ops"; import { btnPrimary, btnSecondary, noticeError, noticeOk } from "./ui"; export const MAX_RECORDING_SECONDS = 300; @@ -87,9 +86,10 @@ export function VoiceRecorder({ workOrderId }: { workOrderId: string }) { setState("saving"); setError(null); const type = clip.blob.type || "audio/webm"; - const up = await uploadFieldFile({ workOrderId, kind: "voice_note", clientId: newClientId(), file: clip.blob, fileName: `sprachnotiz.${extensionFor(type)}`, onProgress: setPercent }); + // L7: queued locally, uploaded by the outbox before voice.attach + const up = await queueBlob({ workOrderId, kind: "voice_note", blob: clip.blob, fileName: `sprachnotiz.${extensionFor(type)}`, onProgress: setPercent }); if (!up.ok) { - setError(t(`errors.${up.error}`)); + setError(t(`errors.${["network", "invalid", "forbidden", "not_found"].includes(up.error) ? up.error : "internal"}`)); setState("recorded"); return; } @@ -105,7 +105,7 @@ export function VoiceRecorder({ workOrderId }: { workOrderId: string }) { setClip(null); setSaved(true); setState("idle"); - router.refresh(); + if (!isQueued(result)) router.refresh(); } if (!supported) return

{t("voice.unsupported")}

; diff --git a/src/components/offline/format.ts b/src/components/offline/format.ts new file mode 100644 index 0000000..415d326 --- /dev/null +++ b/src/components/offline/format.ts @@ -0,0 +1,22 @@ +/** Formatting helpers of the offline UI (locale-aware, no texts). */ + +export function fmtBytes(bytes: number, locale: string): string { + const units = ["B", "KB", "MB", "GB"]; + let v = bytes; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${new Intl.NumberFormat(locale, { maximumFractionDigits: i === 0 ? 0 : 1 }).format(v)} ${units[i]}`; +} + +export function fmtDateTimeShort(iso: string | null, locale: string): string { + if (!iso) return ""; + return new Intl.DateTimeFormat(locale, { dateStyle: "short", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(iso)); +} + +export function fmtTimeShort(iso: string | null, locale: string): string { + if (!iso) return ""; + return new Intl.DateTimeFormat(locale, { timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(iso)); +} diff --git a/src/components/offline/hooks.ts b/src/components/offline/hooks.ts new file mode 100644 index 0000000..f6bafdf --- /dev/null +++ b/src/components/offline/hooks.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { getOfflineState, getServerOfflineState, subscribeOffline, type OfflineState } from "@/lib/offline/outbox"; +import { clearDraft, loadDraft, saveDraft } from "@/lib/offline/drafts"; + +/** Live outbox/sync state of the mobile shell. */ +export function useOfflineState(): OfflineState { + return useSyncExternalStore(subscribeOffline, getOfflineState, getServerOfflineState); +} + +/** + * Form state with automatic draft storage in IndexedDB (debounced 400 ms). Returns + * [value, setValue, clear, restored] — `restored` is true when a stored draft was loaded. + */ +export function useOfflineDraft(key: string, initial: T): [T, (next: T) => void, () => Promise, boolean] { + const [value, setValue] = useState(initial); + const [restored, setRestored] = useState(false); + const timer = useRef | null>(null); + const touched = useRef(false); + + useEffect(() => { + let cancelled = false; + touched.current = false; + loadDraft(key).then((draft) => { + if (cancelled || draft === null || touched.current) return; + setValue(draft); + setRestored(true); + }); + return () => { + cancelled = true; + }; + }, [key]); + + useEffect(() => () => (timer.current ? clearTimeout(timer.current) : undefined), []); + + const update = useCallback( + (next: T) => { + touched.current = true; + setValue(next); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => void saveDraft(key, next), 400); + }, + [key], + ); + + const clear = useCallback(async () => { + if (timer.current) clearTimeout(timer.current); + setRestored(false); + await clearDraft(key); + }, [key]); + + return [value, update, clear, restored]; +} diff --git a/src/components/offline/install-hint.tsx b/src/components/offline/install-hint.tsx new file mode 100644 index 0000000..6a1dfb8 --- /dev/null +++ b/src/components/offline/install-hint.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore } from "react"; +import { useTranslations } from "next-intl"; +import { CircleCheck, Download, Smartphone } from "lucide-react"; +import { btnSecondary, card } from "@/components/field/ui"; + +type Mode = "unknown" | "installed" | "ios" | "other"; +type InstallPromptEvent = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed" }> }; + +function detect(): Mode { + const standalone = window.matchMedia("(display-mode: standalone)").matches || (navigator as Navigator & { standalone?: boolean }).standalone === true; + if (standalone) return "installed"; + const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + return ios ? "ios" : "other"; +} + +const noSubscribe = () => () => undefined; + +/** Installation hint in the profile (lane L7, Spec §3.2): native prompt where available, iOS instructions otherwise. */ +export function InstallHint() { + const t = useTranslations("offline.install"); + const mode = useSyncExternalStore(noSubscribe, detect, () => "unknown"); + const [prompt, setPrompt] = useState(null); + const [installed, setInstalled] = useState(false); + + useEffect(() => { + const onPrompt = (e: Event) => { + e.preventDefault(); + setPrompt(e as InstallPromptEvent); + }; + const onInstalled = () => setInstalled(true); + window.addEventListener("beforeinstallprompt", onPrompt); + window.addEventListener("appinstalled", onInstalled); + return () => { + window.removeEventListener("beforeinstallprompt", onPrompt); + window.removeEventListener("appinstalled", onInstalled); + }; + }, []); + + if (mode === "unknown") return null; + return ( +
+

+ + {t("title")} +

+ {mode === "installed" || installed ? ( +

+ + {t("installed")} +

+ ) : prompt ? ( + + ) : ( +

{mode === "ios" ? t("ios") : t("other")}

+ )} +
+ ); +} diff --git a/src/components/offline/logout-form.tsx b/src/components/offline/logout-form.tsx new file mode 100644 index 0000000..ef35e01 --- /dev/null +++ b/src/components/offline/logout-form.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { LoaderCircle, LogOut, RefreshCw, TriangleAlert } from "lucide-react"; +import { btnSecondary, noticeError } from "@/components/field/ui"; +import { clearLocalData, syncNow, unsentCount } from "@/lib/offline/outbox"; + +/** + * Sign-out of the mobile app (lane L7, Spec §23): local data of this tenant/user is deleted before + * the session ends. If ops/uploads are still unsent, the user is warned first and can sync. + */ +export function LogoutForm({ action, label }: { action: () => Promise; label: string }) { + const t = useTranslations("offline.logout"); + const form = useRef(null); + const proceed = useRef(false); + const [unsent, setUnsent] = useState(0); + const [busy, setBusy] = useState(false); + + async function finish() { + setBusy(true); + await clearLocalData().catch(() => undefined); + proceed.current = true; + form.current?.requestSubmit(); + } + + return ( +
{ + if (proceed.current) return; + e.preventDefault(); + const n = await unsentCount().catch(() => 0); + if (n > 0) { + setUnsent(n); + return; + } + await finish(); + }} + > + {unsent > 0 ? ( +
+

+ + {t("warning", { count: unsent })} +

+ + + +
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/offline/offline-runtime-client.tsx b/src/components/offline/offline-runtime-client.tsx new file mode 100644 index 0000000..5a99028 --- /dev/null +++ b/src/components/offline/offline-runtime-client.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { RefreshCw } from "lucide-react"; +import { configureOffline, startSyncLoop } from "@/lib/offline/outbox"; +import { SyncBadge } from "./sync-badge"; + +const SW_URL = "/sw.js"; +const SW_DEV_FLAG = "craftvia.sw"; +const UPDATE_CHECK_MS = 30 * 60_000; + +/** In development the SW is opt-in (localStorage craftvia.sw = "1"), otherwise HMR chunks would be cached. */ +function swEnabled(): boolean { + if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return false; + if (process.env.NODE_ENV === "production") return true; + try { + return localStorage.getItem(SW_DEV_FLAG) === "1"; + } catch { + return false; + } +} + +export function OfflineRuntimeClient({ tenantId, userId, maxDays }: { tenantId: string; userId: string; maxDays: number }) { + const t = useTranslations("offline.update"); + const [waiting, setWaiting] = useState(null); + const reloading = useRef(false); + + // outbox context + sync loop + useEffect(() => { + let stop: (() => void) | undefined; + let cancelled = false; + configureOffline({ tenantId, userId }, { maxDays }).then(() => { + if (!cancelled) stop = startSyncLoop(); + }); + return () => { + cancelled = true; + stop?.(); + }; + }, [tenantId, userId, maxDays]); + + // service worker registration + update flow + useEffect(() => { + if (!swEnabled()) return; + let timer: ReturnType | undefined; + const onControllerChange = () => { + if (!reloading.current) return; + reloading.current = false; + window.location.reload(); + }; + navigator.serviceWorker.addEventListener("controllerchange", onControllerChange); + navigator.serviceWorker + .register(SW_URL, { scope: "/", updateViaCache: "none" }) + .then((reg) => { + if (reg.waiting && navigator.serviceWorker.controller) setWaiting(reg.waiting); + reg.addEventListener("updatefound", () => { + const installing = reg.installing; + installing?.addEventListener("statechange", () => { + if (installing.state === "installed" && navigator.serviceWorker.controller) setWaiting(installing); + }); + }); + // refresh the cached offline page with the current build and the signed-in session + (reg.active ?? reg.installing ?? reg.waiting)?.postMessage({ type: "PRECACHE_OFFLINE" }); + timer = setInterval(() => void reg.update().catch(() => undefined), UPDATE_CHECK_MS); + }) + .catch(() => undefined); + return () => { + navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange); + if (timer) clearInterval(timer); + }; + }, []); + + return ( + <> + + {waiting && ( +
+ +

{t("available")}

+ +
+ )} + + ); +} diff --git a/src/components/offline/offline-runtime.tsx b/src/components/offline/offline-runtime.tsx new file mode 100644 index 0000000..f46c60b --- /dev/null +++ b/src/components/offline/offline-runtime.tsx @@ -0,0 +1,16 @@ +import { auth } from "@/server/auth"; +import { parseMaxDays } from "@/lib/offline/bundle-core"; +import { OfflineRuntimeClient } from "./offline-runtime-client"; + +/** + * Offline runtime of the mobile shell (lane L7) — mounted once in `(field)/m/layout.tsx`. + * Passes the signed-in tenant/user (local data is separated per context) and OFFLINE_MAX_DAYS + * to the client: service worker registration + update notice, outbox sync loop, sync badge. + */ +export async function OfflineRuntime() { + const session = await auth(); + const tenantId = session?.user?.tenantId; + const userId = session?.user?.id; + if (!tenantId || !userId) return null; + return ; +} diff --git a/src/components/offline/offline-view.tsx b/src/components/offline/offline-view.tsx new file mode 100644 index 0000000..e1412a1 --- /dev/null +++ b/src/components/offline/offline-view.tsx @@ -0,0 +1,590 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { useLocale, useTranslations } from "next-intl"; +import { Camera, Check, ChevronLeft, ChevronRight, CircleCheck, Clock, FileText, History, LoaderCircle, MapPin, Package, Phone, StickyNote, TriangleAlert, WifiOff } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { fmtWindow } from "@/lib/field/format"; +import { compressImage } from "@/lib/field/image"; +import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; +import type { WorkOrderStatus } from "@/lib/work-orders/status"; +import { NOTE_KINDS, type NoteKind } from "@/lib/sync/ops"; +import { isQueued, queueBlob } from "@/lib/offline/outbox"; +import { readOrders, type OrdersRead } from "@/lib/offline/read"; +import { cachedDocumentIds } from "@/lib/offline/prefetch"; +import { docUrl } from "@/lib/offline/doc-cache"; +import type { OrderView } from "@/lib/offline/bundle-core"; +import type { SyncOpResult } from "@/lib/sync/envelope"; +import { useOnline } from "@/components/field/online-badge"; +import { resolveActions } from "@/components/field/primary-action"; +import { StatusBadge } from "@/components/field/status-badge"; +import { btnPrimary, btnSecondary, card, chip, inputClass, noticeError, noticeOk, noticeWarn, toneClasses } from "@/components/field/ui"; +import { useOfflineDraft, useOfflineState } from "./hooks"; +import { fmtDateTimeShort } from "./format"; + +/** + * `/m/offline` (lane L7, Spec §23): order list and detail rendered from the local bundle + * (IndexedDB) incl. own unsent changes. The service worker redirects offline navigations of + * `/m`, `/m/orders` and `/m/orders/` here (`?from=`). Navigation inside the view is local + * (no server round trip). + */ + +function initialOrderId(): string | null { + if (typeof window === "undefined") return null; + const from = new URLSearchParams(window.location.search).get("from") ?? ""; + const m = from.match(/^\/m\/orders\/([^/?#]+)/); + return m ? decodeURIComponent(m[1]) : null; +} + +export function OfflineView() { + const t = useTranslations("offline.view"); + const tSync = useTranslations("offline.sync"); + const locale = useLocale(); + const online = useOnline(); + const s = useOfflineState(); + const [orderId, setOrderId] = useState(initialOrderId); + const [data, setData] = useState(null); + const [tick, setTick] = useState(0); + const reload = useCallback(() => setTick((n) => n + 1), []); + + useEffect(() => { + let cancelled = false; + readOrders().then((r) => { + if (!cancelled) setData(r); + }); + return () => { + cancelled = true; + }; + }, [tick, s.ready, s.lastPullAt, s.summary]); + + const open = (id: string | null) => { + setOrderId(id); + const from = id ? `?from=${encodeURIComponent(`/m/orders/${id}`)}` : ""; + window.history.replaceState(null, "", `/m/offline${from}`); + window.scrollTo(0, 0); + }; + + if (!data) { + return ( +

+ + {t("loading")} +

+ ); + } + + const order = orderId ? data.orders.find((o) => o.id === orderId) ?? null : null; + + return ( +
+ {online ? ( +
+ + + {t("onlineHint")}{" "} + + {t("backOnline")} + + +
+ ) : ( +

+ + {t("offlineHint")} +

+ )} + {data.syncedAt &&

{t("savedAt", { time: fmtDateTimeShort(data.syncedAt, locale) })}

} + {data.stale && data.orders.length > 0 && ( +

+ + {tSync("stale", { days: s.maxDays })} +

+ )} + + {orderId ? ( + <> + + {order ? :

{t("notFound")}

} + + ) : !data.ready ? ( +

{t("notReady")}

+ ) : data.orders.length === 0 ? ( +

{t("empty")}

+ ) : ( + <> +

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

+
    + {data.orders.map((o) => ( +
  • + open(o.id)} /> +
  • + ))} +
+ + )} +
+ ); +} + +const customerName = (c: OrderView["customer"]) => c.companyName?.trim() || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—"; +const address = (a: { street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null } | null | undefined) => + a ? [[a.street, a.houseNumber].filter(Boolean).join(" "), [a.postalCode, a.city].filter(Boolean).join(" ")].filter(Boolean).join(", ") || null : null; +const dateOrNull = (iso: string | null) => (iso ? new Date(iso) : null); + +function LocalNotices({ order }: { order: OrderView }) { + const t = useTranslations("offline.view"); + return ( + <> + {order.local.pendingOps > 0 && ( +

+ + {t("pending", { count: order.local.pendingOps })} +

+ )} + {(order.local.conflict || order.local.rejected) && ( + + + {order.local.conflict ? t("conflict") : t("rejected")} + + )} + + ); +} + +function OrderListCard({ order, locale, onOpen }: { order: OrderView; locale: string; onOpen: () => void }) { + const t = useTranslations("offline.view"); + const window = fmtWindow(dateOrNull(order.plannedStart), dateOrNull(order.plannedEnd), locale); + const addr = address(order.site) ?? address(order.customer); + return ( +
[0])?.edge)}> +
+ {order.number} + +
+

{order.title}

+

{customerName(order.customer)}

+
+
+ +
{window ?? t("noDate")}
+
+ {addr && ( +
+ +
{addr}
+
+ )} +
+
+ +
+ +
+ ); +} + +type Feedback = { kind: "queued" | "saved" | "error"; text: string } | null; + +function useFeedback() { + const t = useTranslations("offline.view"); + const tf = useTranslations("field"); + const [feedback, setFeedback] = useState(null); + const report = (result: SyncOpResult) => { + if (isQueued(result)) setFeedback({ kind: "queued", text: t("queued") }); + else if (isSuccess(result)) setFeedback({ kind: "saved", text: t("saved") }); + else setFeedback({ kind: "error", text: tf(`errors.${errorKey(result)}`) }); + }; + return { feedback, setFeedback, report }; +} + +function FeedbackNotice({ feedback }: { feedback: Feedback }) { + if (!feedback) return null; + const Icon = feedback.kind === "error" ? TriangleAlert : CircleCheck; + return ( +

+ + {feedback.text} +

+ ); +} + +function Section({ title, icon: Icon, children }: { title: string; icon: typeof Clock; children: React.ReactNode }) { + return ( +
+

+ + {title} +

+
{children}
+
+ ); +} + +function OrderDetail({ order, onChanged }: { order: OrderView; onChanged: () => void }) { + const t = useTranslations("offline.view"); + const tf = useTranslations("field"); + const locale = useLocale(); + const [cached, setCached] = useState>(new Set()); + + useEffect(() => { + let cancelled = false; + cachedDocumentIds().then((ids) => { + if (!cancelled) setCached(ids); + }); + return () => { + cancelled = true; + }; + }, [order.id]); + + const siteAddress = address(order.site) ?? address(order.customer); + const hints: Array<[string, string | null | undefined]> = [ + [t("access"), order.site?.accessNotes], + [t("parking"), order.site?.parkingNotes], + [t("safety"), order.site?.safetyNotes], + [t("technical"), order.site?.technicalNotes], + ]; + const contact = order.site?.contact ?? order.contact; + const phone = contact?.mobile || contact?.phone || order.customer.mobile || order.customer.phone; + const recorded = order.materialPlans.filter((p) => order.materialUsages.some((u) => u.materialPlanId === p.id)).length; + + return ( +
+
+
+ {order.number} + +
+

{order.title}

+

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

+ + +
+ + {order.technicianNotes && ( +
+

{order.technicianNotes}

+
+ )} + +
+ {order.site?.name &&

{order.site.name}

} + {siteAddress &&

{siteAddress}

} +
+ {hints + .filter(([, v]) => !!v) + .map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ +
+

{customerName(order.customer)}

+ {contact && ( +

+ {t("contact")}: {contact.name} + {contact.role ? ` (${contact.role})` : ""} +

+ )} + {phone && ( + + + {t("call")} + + )} +
+ + {(order.scope || order.description) && ( +
+ {order.scope &&

{order.scope}

} + {order.description &&

{order.description}

} +
+ )} + +
+ {order.checklistItems.length === 0 ?

{t("checklistEmpty")}

: } +
+ +
+ + {order.photoRequirements.length > 0 && ( +
    + {order.photoRequirements.map((r) => ( +
  • + {r.label} + 0 ? "text-[var(--ok)]" : "text-muted-foreground")}>{r._count.photos > 0 ? t("photoDone") : t("photoMissing")} +
  • + ))} +
+ )} + {order.local.photos.length > 0 && ( +
    + {order.local.photos.map((p) => ( +
  • + + {fmtDateTimeShort(p.createdAt, locale)} {p.phase ? `· ${tf(`photos.phase.${p.phase}`)}` : ""} {p.pending && · {t("pendingBadge")}} +
  • + ))} +
+ )} +
+ +
+ + {order.local.notes.map((n) => ( +
+

+ {tf(`notes.kind.${n.kind}`)} · {fmtDateTimeShort(n.createdAt, locale)} {n.pending && · {t("pendingBadge")}} +

+

{n.text}

+
+ ))} +
+ + {order.materialPlans.length > 0 && ( +
+

{t("materialsProgress", { done: recorded, total: order.materialPlans.length })}

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

{t("noDocuments")}

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

{t("noHistory")}

+ ) : ( +
    + {order.siteHistory.map((h) => ( +
  • +

    + {h.workOrderNumber} · {tf(`detail.reportType.${h.reportType}`)} +

    +

    + {fmtDateTimeShort(h.reportDate, locale)} · {h.workOrderTitle} +

    +
  • + ))} +
+ )} +
+
+ ); +} + +type Action = "accept" | "travel" | "start" | "pause" | "resume" | "end"; + +function TimeActions({ order, onChanged }: { order: OrderView; onChanged: () => void }) { + const t = useTranslations("offline.view"); + const { feedback, report } = useFeedback(); + const [busy, setBusy] = useState(null); + const resolved = resolveActions(order.status as WorkOrderStatus, order.local.session); + const actions = [resolved.primary, resolved.secondary] + .map((a) => (a === "complete" ? (order.local.session === "running" || order.local.session === "paused" ? "end" : null) : a)) + .filter((a, i, arr): a is Action => !!a && arr.indexOf(a) === i); + const showCompleteHint = resolved.primary === "complete" || resolved.secondary === "complete"; + const labels: Record = { accept: t("actionAccept"), travel: t("actionTravel"), start: t("actionStart"), pause: t("actionPause"), resume: t("actionResume"), end: t("actionEnd") }; + + async function run(action: Action) { + setBusy(action); + const at = new Date().toISOString(); + const workOrderId = order.id; + const offline = !navigator.onLine; + const startPayload = (mode: "travel" | "work") => ({ workOrderId, mode, clientId: newClientId(), at, offline, deviceInfo: navigator.userAgent.slice(0, 200) }); + const result = + action === "accept" + ? await submitOp({ opType: "work_order.transition", baseVersion: order.version, payload: { workOrderId, to: "accepted" } }) + : action === "travel" || action === "start" + ? await submitOp({ opType: "session.start", payload: startPayload(action === "travel" ? "travel" : "work") }) + : await submitOp({ opType: action === "pause" ? "session.pause" : action === "resume" ? "session.resume" : "session.end", payload: { workOrderId, at } }); + setBusy(null); + report(result); + onChanged(); + } + + if (actions.length === 0 && !showCompleteHint) return null; + return ( +
+

{t("time")}

+ {actions.map((a, i) => ( + + ))} + {showCompleteHint &&

{t("completeOnline")}

} + +
+ ); +} + +function Checklist({ order, onChanged }: { order: OrderView; onChanged: () => void }) { + const t = useTranslations("offline.view"); + const { feedback, report } = useFeedback(); + const [busy, setBusy] = useState(null); + return ( + <> +
    + {order.checklistItems.map((item) => ( +
  • + +
  • + ))} +
+ + + ); +} + +function NoteQuick({ order, onChanged }: { order: OrderView; onChanged: () => void }) { + const t = useTranslations("offline.view"); + const tf = useTranslations("field"); + const { feedback, report, setFeedback } = useFeedback(); + const [draft, setDraft, clearDraft, restored] = useOfflineDraft<{ kind: NoteKind; text: string; clientId: string }>(`note:${order.id}`, { kind: "work_done", text: "", clientId: "" }); + const [busy, setBusy] = useState(false); + + return ( +
{ + e.preventDefault(); + if (!draft.text.trim()) return; + setBusy(true); + const clientId = draft.clientId || newClientId(); + const result = await submitOp({ opType: "note.create", payload: { workOrderId: order.id, clientId, kind: draft.kind, text: draft.text.trim() } }); + setBusy(false); + report(result); + if (isQueued(result) || isSuccess(result)) { + setDraft({ kind: draft.kind, text: "", clientId: "" }); + await clearDraft(); + onChanged(); + } + }} + > +
+ {NOTE_KINDS.slice(0, 4).map((k) => ( + + ))} +
+