diff --git a/messages/de/offline.json b/messages/de/offline.json new file mode 100644 index 0000000..3e040f4 --- /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..b4eed23 --- /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/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/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) => ( + + ))} +
+