L7 Offline & PWA: Sync-Seite, Offline-Ansicht, Sync-Badge, Installationshinweis, Logout-Schutz

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 17:20:44 +02:00
co-authored by Claude Opus 5
parent 4dd330b9fd
commit f0620f9c3b
15 changed files with 1633 additions and 13 deletions
+171
View File
@@ -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."
}
}
+171
View File
@@ -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."
}
}
+7
View File
@@ -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}</>;
}
+17
View File
@@ -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 (
<main className="space-y-4 p-4">
<h1 className="text-[26px]">{t("title")}</h1>
<OfflineView />
</main>
);
}
+7
View File
@@ -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}</>;
}
+7 -13
View File
@@ -1,23 +1,17 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { OnlineBadge } from "@/components/field/online-badge"; import { SyncPanel } from "@/components/offline/sync-panel";
import { card } from "@/components/field/ui";
/** /**
* PLACEHOLDER (lane L4) — `/m/sync` belongs to lane L7 (Offline/PWA), which replaces this page * `/m/sync` (lane L7, Spec §22/§23.4, US-012): connection, last sync, pending changes and uploads
* with the outbox status, errors and conflicts. Until then it shows the connection state and that * with progress, errors/conflicts in plain language (retry / discard), storage usage, reset. The
* ops are sent immediately (src/lib/field/client-ops.ts). * data lives on the device (IndexedDB), so the panel is a client component.
*/ */
export default async function SyncPlaceholderPage() { export default async function SyncPage() {
const t = await getTranslations("field.sync"); const t = await getTranslations("offline.sync");
return ( return (
<main className="space-y-4 p-4"> <main className="space-y-4 p-4">
<h1 className="text-[26px]">{t("title")}</h1> <h1 className="text-[26px]">{t("title")}</h1>
<section className={`${card} space-y-3`}> <SyncPanel />
<p className="text-[13px] font-semibold text-muted-foreground">{t("status")}</p>
<OnlineBadge large />
<p className="text-[15px]">{t("immediate")}</p>
<p className="text-[14px] text-muted-foreground">{t("offlineHint")}</p>
</section>
</main> </main>
); );
} }
+22
View File
@@ -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));
}
+54
View File
@@ -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<T>(key: string, initial: T): [T, (next: T) => void, () => Promise<void>, boolean] {
const [value, setValue] = useState<T>(initial);
const [restored, setRestored] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const touched = useRef(false);
useEffect(() => {
let cancelled = false;
touched.current = false;
loadDraft<T>(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];
}
+72
View File
@@ -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<void>; 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<Mode>(noSubscribe, detect, () => "unknown");
const [prompt, setPrompt] = useState<InstallPromptEvent | null>(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 (
<section className={`${card} space-y-2`}>
<h2 className="flex items-center gap-2 text-[17px]">
<Smartphone className="size-5 text-muted-foreground" aria-hidden />
{t("title")}
</h2>
{mode === "installed" || installed ? (
<p className="flex items-center gap-2 text-[15px]">
<CircleCheck className="size-4.5 text-[var(--ok)]" aria-hidden />
{t("installed")}
</p>
) : prompt ? (
<button
type="button"
className={btnSecondary}
onClick={async () => {
await prompt.prompt();
const choice = await prompt.userChoice.catch(() => null);
if (choice?.outcome === "accepted") setInstalled(true);
setPrompt(null);
}}
>
<Download className="size-5" aria-hidden />
{t("button")}
</button>
) : (
<p className="text-[15px]">{mode === "ios" ? t("ios") : t("other")}</p>
)}
</section>
);
}
+79
View File
@@ -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<void>; label: string }) {
const t = useTranslations("offline.logout");
const form = useRef<HTMLFormElement>(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 (
<form
ref={form}
action={action}
className="space-y-2"
onSubmit={async (e) => {
if (proceed.current) return;
e.preventDefault();
const n = await unsentCount().catch(() => 0);
if (n > 0) {
setUnsent(n);
return;
}
await finish();
}}
>
{unsent > 0 ? (
<div className="space-y-2" role="alertdialog" aria-label={t("warning", { count: unsent })}>
<p className={noticeError}>
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("warning", { count: unsent })}
</p>
<button
type="button"
className={btnSecondary}
disabled={busy}
onClick={async () => {
setBusy(true);
await syncNow().catch(() => undefined);
setUnsent(await unsentCount().catch(() => 0));
setBusy(false);
}}
>
<RefreshCw className="size-5" aria-hidden />
{t("syncFirst")}
</button>
<button type="button" className={btnSecondary} disabled={busy} onClick={() => void finish()}>
<LogOut className="size-5" aria-hidden />
{t("confirm")}
</button>
<button type="button" className={btnSecondary} disabled={busy} onClick={() => setUnsent(0)}>
{t("cancel")}
</button>
</div>
) : (
<button type="submit" className={btnSecondary} disabled={busy}>
{busy ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <LogOut className="size-5" aria-hidden />}
{label}
</button>
)}
</form>
);
}
@@ -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<ServiceWorker | null>(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<typeof setInterval> | 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 (
<>
<SyncBadge />
{waiting && (
<div role="status" aria-live="polite" className="fixed inset-x-3 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-40 mx-auto flex max-w-xl items-center gap-3 rounded-xl border bg-card p-3 shadow-card">
<RefreshCw className="size-5 shrink-0 text-primary" aria-hidden />
<p className="flex-1 text-[14px] font-semibold">{t("available")}</p>
<button
type="button"
className="inline-flex min-h-12 items-center rounded-xl bg-cta px-4 text-[15px] font-semibold text-cta-foreground"
onClick={() => {
reloading.current = true;
waiting.postMessage({ type: "SKIP_WAITING" });
}}
>
{t("reload")}
</button>
</div>
)}
</>
);
}
@@ -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 <OfflineRuntimeClient tenantId={tenantId} userId={userId} maxDays={parseMaxDays(process.env.OFFLINE_MAX_DAYS)} />;
}
+590
View File
@@ -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/<id>` 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<string | null>(initialOrderId);
const [data, setData] = useState<OrdersRead | null>(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 (
<p className="flex items-center gap-2 p-4 text-[15px] text-muted-foreground" role="status">
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
{t("loading")}
</p>
);
}
const order = orderId ? data.orders.find((o) => o.id === orderId) ?? null : null;
return (
<div className="space-y-4">
{online ? (
<div className={noticeOk} role="status">
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
<span>
{t("onlineHint")}{" "}
<a href={orderId ? `/m/orders/${encodeURIComponent(orderId)}` : "/m"} className="font-semibold underline">
{t("backOnline")}
</a>
</span>
</div>
) : (
<p className={noticeWarn} role="status">
<WifiOff className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("offlineHint")}
</p>
)}
{data.syncedAt && <p className="text-[13px] text-muted-foreground">{t("savedAt", { time: fmtDateTimeShort(data.syncedAt, locale) })}</p>}
{data.stale && data.orders.length > 0 && (
<p className={noticeWarn} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
{tSync("stale", { days: s.maxDays })}
</p>
)}
{orderId ? (
<>
<button type="button" onClick={() => open(null)} className="-ml-2 inline-flex min-h-12 items-center gap-1 rounded-xl px-2 text-[15px] font-semibold text-primary">
<ChevronLeft className="size-5" aria-hidden />
{t("backToList")}
</button>
{order ? <OrderDetail order={order} onChanged={reload} /> : <p className={cn(card, "text-[15px]")}>{t("notFound")}</p>}
</>
) : !data.ready ? (
<p className={cn(card, "text-[15px] text-muted-foreground")}>{t("notReady")}</p>
) : data.orders.length === 0 ? (
<p className={cn(card, "text-[15px] text-muted-foreground")}>{t("empty")}</p>
) : (
<>
<p className="text-[15px] font-semibold">{t("count", { count: data.orders.length })}</p>
<ul className="space-y-3">
{data.orders.map((o) => (
<li key={o.id}>
<OrderListCard order={o} locale={locale} onOpen={() => open(o.id)} />
</li>
))}
</ul>
</>
)}
</div>
);
}
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 && (
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--info)]">
<Clock className="size-4" aria-hidden />
{t("pending", { count: order.local.pendingOps })}
</p>
)}
{(order.local.conflict || order.local.rejected) && (
<Link href="/m/sync" className="flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-foreground underline">
<TriangleAlert className="size-4 text-[var(--warn)]" aria-hidden />
{order.local.conflict ? t("conflict") : t("rejected")}
</Link>
)}
</>
);
}
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 (
<article className={cn("rounded-xl border border-l-4 bg-card p-4 shadow-card", toneClasses(order.statusGroup as Parameters<typeof toneClasses>[0])?.edge)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono text-[13px] font-semibold text-muted-foreground">{order.number}</span>
<StatusBadge status={order.status as WorkOrderStatus} />
</div>
<h2 className="mt-2 text-[18px] leading-snug">{order.title}</h2>
<p className="mt-0.5 text-[15px] font-semibold">{customerName(order.customer)}</p>
<dl className="mt-2 space-y-1.5 text-[15px]">
<div className="flex items-start gap-2">
<Clock className="mt-0.5 size-4.5 shrink-0 text-muted-foreground" aria-hidden />
<dd>{window ?? t("noDate")}</dd>
</div>
{addr && (
<div className="flex items-start gap-2">
<MapPin className="mt-0.5 size-4.5 shrink-0 text-muted-foreground" aria-hidden />
<dd>{addr}</dd>
</div>
)}
</dl>
<div className="mt-2 space-y-1">
<LocalNotices order={order} />
</div>
<button type="button" onClick={onOpen} className={cn(btnPrimary, "mt-3")}>
{t("open")}
<ChevronRight className="size-5" aria-hidden />
</button>
</article>
);
}
type Feedback = { kind: "queued" | "saved" | "error"; text: string } | null;
function useFeedback() {
const t = useTranslations("offline.view");
const tf = useTranslations("field");
const [feedback, setFeedback] = useState<Feedback>(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 (
<p className={feedback.kind === "error" ? noticeError : noticeOk} role={feedback.kind === "error" ? "alert" : "status"}>
<Icon className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{feedback.text}
</p>
);
}
function Section({ title, icon: Icon, children }: { title: string; icon: typeof Clock; children: React.ReactNode }) {
return (
<section className={cn(card, "space-y-2")}>
<h2 className="flex min-h-10 items-center gap-2.5 text-[17px]">
<Icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
{title}
</h2>
<div className="space-y-2 text-[15px]">{children}</div>
</section>
);
}
function OrderDetail({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
const t = useTranslations("offline.view");
const tf = useTranslations("field");
const locale = useLocale();
const [cached, setCached] = useState<Set<string>>(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 (
<div className="space-y-4">
<header className={cn(card, "space-y-2")}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono text-[13px] font-semibold text-muted-foreground">{order.number}</span>
<StatusBadge status={order.status as WorkOrderStatus} />
</div>
<h1 className="text-[22px] leading-snug">{order.title}</h1>
<p className="text-[15px]">{fmtWindow(dateOrNull(order.plannedStart), dateOrNull(order.plannedEnd), locale) ?? t("noDate")}</p>
<LocalNotices order={order} />
<TimeActions order={order} onChanged={onChanged} />
</header>
{order.technicianNotes && (
<Section title={t("hints")} icon={TriangleAlert}>
<p className="whitespace-pre-line">{order.technicianNotes}</p>
</Section>
)}
<Section title={t("site")} icon={MapPin}>
{order.site?.name && <p className="font-semibold">{order.site.name}</p>}
{siteAddress && <p>{siteAddress}</p>}
<dl className="space-y-1.5">
{hints
.filter(([, v]) => !!v)
.map(([label, value]) => (
<div key={label} className="rounded-lg bg-muted px-3 py-2">
<dt className="text-[13px] font-semibold">{label}</dt>
<dd className="whitespace-pre-line">{value}</dd>
</div>
))}
</dl>
</Section>
<Section title={t("customer")} icon={Phone}>
<p className="font-semibold">{customerName(order.customer)}</p>
{contact && (
<p>
{t("contact")}: {contact.name}
{contact.role ? ` (${contact.role})` : ""}
</p>
)}
{phone && (
<a href={`tel:${phone.replace(/[^\d+]/g, "")}`} className={btnSecondary}>
<Phone className="size-5" aria-hidden />
{t("call")}
</a>
)}
</Section>
{(order.scope || order.description) && (
<Section title={order.scope ? t("scope") : t("description")} icon={FileText}>
{order.scope && <p className="whitespace-pre-line">{order.scope}</p>}
{order.description && <p className="whitespace-pre-line text-muted-foreground">{order.description}</p>}
</Section>
)}
<Section title={t("checklist")} icon={Check}>
{order.checklistItems.length === 0 ? <p className="text-muted-foreground">{t("checklistEmpty")}</p> : <Checklist order={order} onChanged={onChanged} />}
</Section>
<Section title={t("photos")} icon={Camera}>
<PhotoQuick order={order} onChanged={onChanged} />
{order.photoRequirements.length > 0 && (
<ul className="divide-y">
{order.photoRequirements.map((r) => (
<li key={r.id} className="flex min-h-11 items-center justify-between gap-2">
<span>{r.label}</span>
<span className={cn("text-[13px] font-semibold", r._count.photos > 0 ? "text-[var(--ok)]" : "text-muted-foreground")}>{r._count.photos > 0 ? t("photoDone") : t("photoMissing")}</span>
</li>
))}
</ul>
)}
{order.local.photos.length > 0 && (
<ul className="space-y-1">
{order.local.photos.map((p) => (
<li key={p.id} className="flex items-center gap-2 text-[14px]">
<Camera className="size-4 text-muted-foreground" aria-hidden />
{fmtDateTimeShort(p.createdAt, locale)} {p.phase ? `· ${tf(`photos.phase.${p.phase}`)}` : ""} {p.pending && <span className="font-semibold text-[var(--info)]">· {t("pendingBadge")}</span>}
</li>
))}
</ul>
)}
</Section>
<Section title={t("notes")} icon={StickyNote}>
<NoteQuick order={order} onChanged={onChanged} />
{order.local.notes.map((n) => (
<div key={n.id} className="rounded-lg bg-muted px-3 py-2">
<p className="text-[13px] font-semibold">
{tf(`notes.kind.${n.kind}`)} · {fmtDateTimeShort(n.createdAt, locale)} {n.pending && <span className="text-[var(--info)]">· {t("pendingBadge")}</span>}
</p>
<p className="whitespace-pre-line">{n.text}</p>
</div>
))}
</Section>
{order.materialPlans.length > 0 && (
<Section title={t("materials")} icon={Package}>
<p>{t("materialsProgress", { done: recorded, total: order.materialPlans.length })}</p>
</Section>
)}
<Section title={t("documents")} icon={FileText}>
{order.documents.length === 0 ? (
<p className="text-muted-foreground">{t("noDocuments")}</p>
) : (
<ul className="divide-y">
{order.documents.map((d) => (
<li key={d.id}>
<a href={docUrl(d.id)} target="_blank" rel="noopener" className="flex min-h-12 items-center gap-2 py-1.5">
<FileText className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">
<span className="block font-semibold">{d.title || d.fileName}</span>
<span className="text-[13px] text-muted-foreground">{cached.has(d.id) ? t("docOffline") : t("docOnline")}</span>
</span>
</a>
</li>
))}
</ul>
)}
</Section>
<Section title={t("history")} icon={History}>
{order.siteHistory.length === 0 ? (
<p className="text-muted-foreground">{t("noHistory")}</p>
) : (
<ul className="divide-y">
{order.siteHistory.map((h) => (
<li key={h.reportId} className="py-2">
<p className="font-semibold">
{h.workOrderNumber} · {tf(`detail.reportType.${h.reportType}`)}
</p>
<p className="text-[13px] text-muted-foreground">
{fmtDateTimeShort(h.reportDate, locale)} · {h.workOrderTitle}
</p>
</li>
))}
</ul>
)}
</Section>
</div>
);
}
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<Action | null>(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<Action, string> = { 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 (
<div className="space-y-2.5 pt-1">
<h2 className="sr-only">{t("time")}</h2>
{actions.map((a, i) => (
<button key={a} type="button" className={i === 0 ? btnPrimary : btnSecondary} disabled={busy !== null} onClick={() => run(a)}>
{busy === a ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Clock className="size-5" aria-hidden />}
{busy === a ? t("saving") : labels[a]}
</button>
))}
{showCompleteHint && <p className="text-[14px] text-muted-foreground">{t("completeOnline")}</p>}
<FeedbackNotice feedback={feedback} />
</div>
);
}
function Checklist({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
const t = useTranslations("offline.view");
const { feedback, report } = useFeedback();
const [busy, setBusy] = useState<string | null>(null);
return (
<>
<ul className="space-y-2">
{order.checklistItems.map((item) => (
<li key={item.id}>
<button
type="button"
role="checkbox"
aria-checked={item.checked}
disabled={busy !== null}
className="flex min-h-14 w-full items-center gap-3 text-left"
onClick={async () => {
setBusy(item.id);
const result = await submitOp({ opType: "checklist.toggle", payload: { workOrderId: order.id, itemId: item.id, checked: !item.checked } });
setBusy(null);
report(result);
onChanged();
}}
>
<span className={cn("grid size-9 shrink-0 place-items-center rounded-lg border-2", item.checked ? "border-[var(--ok)] bg-[var(--ok)] text-white" : "border-input bg-card")}>
{busy === item.id ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : item.checked && <Check className="size-6" aria-hidden />}
</span>
<span className="flex-1">
<span className="block font-semibold leading-snug">{item.label}</span>
<span className="text-[12.5px] font-semibold text-muted-foreground">
{item.checked ? t("done") : t("open_item")}
{item.required ? ` · ${t("required")}` : ""}
</span>
</span>
</button>
</li>
))}
</ul>
<FeedbackNotice feedback={feedback} />
</>
);
}
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 (
<form
className="space-y-3"
onSubmit={async (e) => {
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();
}
}}
>
<div className="grid grid-cols-2 gap-2">
{NOTE_KINDS.slice(0, 4).map((k) => (
<button key={k} type="button" aria-pressed={draft.kind === k} className={chip(draft.kind === k)} onClick={() => setDraft({ ...draft, kind: k, clientId: draft.clientId || newClientId() })}>
{tf(`notes.kind.${k}`)}
</button>
))}
</div>
<textarea
rows={3}
maxLength={10000}
aria-label={t("notes")}
className={cn(inputClass, "py-3")}
placeholder={t("notePlaceholder")}
value={draft.text}
onChange={(e) => {
setFeedback(null);
setDraft({ ...draft, text: e.target.value, clientId: draft.clientId || newClientId() });
}}
/>
{restored && draft.text && <p className="text-[13px] text-muted-foreground">{t("draftRestored")}</p>}
<FeedbackNotice feedback={feedback} />
<button type="submit" className={btnSecondary} disabled={busy || !draft.text.trim()}>
{busy ? t("saving") : t("noteSave")}
</button>
</form>
);
}
function PhotoQuick({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
const t = useTranslations("offline.view");
const tf = useTranslations("field");
const { feedback, report, setFeedback } = useFeedback();
const [busy, setBusy] = useState(false);
return (
<>
<label className={cn(btnPrimary, "cursor-pointer", busy && "opacity-60")}>
{busy ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Camera className="size-5" aria-hidden />}
{busy ? t("saving") : t("photo")}
<input
type="file"
accept="image/*"
capture="environment"
className="sr-only"
disabled={busy}
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setBusy(true);
setFeedback(null);
try {
const { image, thumbnail } = await compressImage(file);
const name = `${(file.name || "foto").replace(/\.[^.]+$/, "")}.jpg`;
const queued = await queueBlob({ workOrderId: order.id, kind: "photo", blob: image, preview: thumbnail, fileName: name });
if (!queued.ok) {
setFeedback({ kind: "error", text: tf("errors.internal") });
return;
}
const result = await submitOp({
opType: "photo.attach",
payload: { workOrderId: order.id, clientId: newClientId(), documentId: queued.documentId, phase: "during", takenAt: new Date(Math.min(file.lastModified || Date.now(), Date.now())).toISOString() },
});
report(result);
onChanged();
} catch {
setFeedback({ kind: "error", text: tf("errors.image") });
} finally {
setBusy(false);
}
}}
/>
</label>
<FeedbackNotice feedback={feedback} />
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { CircleCheck, RefreshCw, TriangleAlert } from "lucide-react";
import { cn } from "@/lib/utils";
import { useOfflineState } from "./hooks";
/**
* Sync badge of the mobile header (Spec §22 "Synchronisationsstatus sichtbar"): number of pending
* changes/uploads, warning symbol on errors/conflicts/expired session. Text + icon, never colour only.
*/
export function SyncBadge() {
const t = useTranslations("offline.badge");
const s = useOfflineState();
if (!s.ready) return null;
const pending = s.summary.pendingOps + s.summary.pendingUploads;
const problem = s.summary.conflicts + s.summary.rejected + s.summary.failedUploads > 0 || s.summary.waitingForAuth;
const Icon = problem ? TriangleAlert : pending > 0 || s.syncing ? RefreshCw : CircleCheck;
const label = problem ? t("problem") : pending > 0 ? t("pending", { count: pending }) : s.syncing ? t("syncing") : t("synced");
return (
<Link
href="/m/sync"
aria-label={`${t("label")}: ${label}`}
className={cn(
"inline-flex min-h-11 items-center gap-1.5 rounded-full px-2.5 text-[12.5px] font-semibold",
problem
? "bg-[color-mix(in_oklch,var(--warn)_16%,transparent)] text-foreground"
: pending > 0
? "bg-[color-mix(in_oklch,var(--info)_12%,transparent)] text-[var(--info)]"
: "bg-muted text-muted-foreground",
)}
>
<Icon className={cn("size-4", problem && "text-[var(--warn)]", s.syncing && !problem && "animate-spin")} aria-hidden />
{label}
</Link>
);
}
+287
View File
@@ -0,0 +1,287 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { CircleCheck, CloudDownload, HardDrive, LoaderCircle, RefreshCw, RotateCcw, Trash2, TriangleAlert, WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { OnlineBadge } from "@/components/field/online-badge";
import { btnPrimary, btnSecondary, card, noticeError, noticeOk, noticeWarn } from "@/components/field/ui";
import { acknowledgeConflict, clearLocalData, discardOp, listOutbox, retryOp, syncNow, unsentCount, type OutboxListing } from "@/lib/offline/outbox";
import { canDiscard, isPending, problemKey } from "@/lib/offline/outbox-core";
import { isBundleStale } from "@/lib/offline/bundle-core";
import { readOrders } from "@/lib/offline/read";
import type { OutboxEntry } from "@/lib/offline/types";
import { useOfflineState } from "./hooks";
import { fmtBytes, fmtDateTimeShort, fmtTimeShort } from "./format";
/** `/m/sync` (Spec §22/§23.4, US-012): connection, last sync, pending ops/uploads, errors & conflicts, storage, reset. */
export function SyncPanel() {
const t = useTranslations("offline");
const locale = useLocale();
const s = useOfflineState();
const [listing, setListing] = useState<OutboxListing>({ ops: [], blobs: [] });
const [offlineOrders, setOfflineOrders] = useState<number | null>(null);
const [stale, setStale] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
const [reset, setReset] = useState<{ unsent: number } | null>(null);
const [resetDone, setResetDone] = useState(false);
useEffect(() => {
if (!s.ready) return;
let cancelled = false;
Promise.all([listOutbox(), readOrders()]).then(([l, r]) => {
if (cancelled) return;
setListing(l);
setOfflineOrders(r.orders.length);
setStale(r.orders.length > 0 && isBundleStale(s.lastPullAt, new Date(), s.maxDays));
});
return () => {
cancelled = true;
};
}, [s.ready, s.summary, s.syncing, s.lastPullAt, s.maxDays]);
if (!s.ready) {
return (
<p className="flex items-center gap-2 text-[15px] text-muted-foreground" role="status">
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
{t("view.loading")}
</p>
);
}
const problems = listing.ops.filter((o) => (o.status === "conflict" || o.status === "rejected") && !o.acknowledged);
const queue = listing.ops.filter(isPending);
const uploads = Object.entries(s.uploads);
const nothingOpen = s.summary.pendingOps === 0 && s.summary.pendingUploads === 0;
return (
<div className="space-y-4">
<section className={cn(card, "space-y-3")} aria-labelledby="sync-connection">
<h2 id="sync-connection" className="text-[13px] font-semibold text-muted-foreground">
{t("sync.connection")}
</h2>
<OnlineBadge large />
{!s.online && (
<p className="flex items-start gap-2 text-[15px]">
<WifiOff className="mt-0.5 size-4.5 shrink-0 text-muted-foreground" aria-hidden />
{t("sync.offlineHint")}
</p>
)}
<dl className="grid grid-cols-1 gap-2 text-[15px] sm:grid-cols-2">
<div>
<dt className="text-[13px] text-muted-foreground">{t("sync.lastSync")}</dt>
<dd className="font-semibold">{s.lastSyncAt ? fmtDateTimeShort(s.lastSyncAt, locale) : t("sync.never")}</dd>
</div>
<div>
<dt className="text-[13px] text-muted-foreground">{t("sync.lastPull")}</dt>
<dd className="font-semibold">{s.lastPullAt ? fmtDateTimeShort(s.lastPullAt, locale) : t("sync.never")}</dd>
</div>
</dl>
{stale && (
<p className={noticeWarn} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
{t("sync.stale", { days: s.maxDays })}
</p>
)}
{s.summary.waitingForAuth && (
<div className={noticeWarn} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
<div className="space-y-2">
<p>{t("sync.waitingForAuth")}</p>
<a href="/login?callbackUrl=/m/sync" className="font-semibold underline">
{t("sync.login")}
</a>
</div>
</div>
)}
</section>
<section className={cn(card, "space-y-3")} aria-labelledby="sync-pending">
<h2 id="sync-pending" className="text-[17px]">
{t("sync.queue")}
</h2>
<p className="text-[15px] font-semibold">{t("sync.pendingOps", { count: s.summary.pendingOps })}</p>
<p className="text-[15px]">
{t("sync.pendingUploads", { count: s.summary.pendingUploads })}
{s.summary.uploadBytes > 0 && <span className="text-muted-foreground"> · {t("sync.uploadSize", { size: fmtBytes(s.summary.uploadBytes, locale) })}</span>}
</p>
{uploads.map(([id, percent]) => (
<div key={id} role="status" aria-live="polite" className="space-y-1.5">
<p className="text-[14px] font-semibold">{t("sync.uploading", { percent })}</p>
<div className="h-2.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-[width]" style={{ width: `${percent}%` }} />
</div>
</div>
))}
{nothingOpen && !s.syncing && (
<p className={noticeOk} role="status">
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("sync.allSynced")}
</p>
)}
{queue.length > 0 && (
<ul className="divide-y">
{queue.map((op) => (
<QueueRow key={op.clientOpId} op={op} locale={locale} />
))}
</ul>
)}
<button type="button" className={btnPrimary} disabled={s.syncing || !s.online} onClick={() => void syncNow({ pull: true })}>
{s.syncing ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <RefreshCw className="size-5" aria-hidden />}
{s.syncing ? t("sync.syncing") : t("sync.syncNow")}
</button>
</section>
<section className={cn(card, "space-y-3")} aria-labelledby="sync-problems">
<h2 id="sync-problems" className="text-[17px]">
{t("sync.problems")}
</h2>
{problems.length === 0 ? (
<p className="text-[15px] text-muted-foreground">{t("sync.noProblems")}</p>
) : (
<ul className="space-y-3">
{problems.map((op) => (
<li key={op.clientOpId} className={cn(op.status === "conflict" ? noticeWarn : noticeError, "flex-col gap-2")}>
<p className="flex items-center gap-2 font-semibold text-foreground">
<TriangleAlert className="size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
{t(`op.${op.opType}`)} · {t(`state.${op.status}`)}
</p>
<p className="text-foreground">{t(`problem.${problemKey(op)}`)}</p>
<p className="text-[13px] text-muted-foreground">{t("sync.createdAt", { time: fmtDateTimeShort(op.clientCreatedAt, locale) })}</p>
{op.status === "conflict" ? (
<button type="button" className={btnSecondary} onClick={() => void acknowledgeConflict(op.clientOpId)}>
{t("sync.hide")}
</button>
) : confirmDiscard === op.clientOpId ? (
<div className="w-full space-y-2" role="alertdialog" aria-label={t("sync.discardConfirm")}>
<p className="font-semibold text-foreground">{t("sync.discardConfirm")}</p>
<button
type="button"
className={cn(btnSecondary, "border-[var(--risk)] text-[var(--risk)]")}
onClick={async () => {
await discardOp(op.clientOpId);
setConfirmDiscard(null);
}}
>
<Trash2 className="size-5" aria-hidden />
{t("sync.discardYes")}
</button>
<button type="button" className={btnSecondary} onClick={() => setConfirmDiscard(null)}>
{t("sync.cancel")}
</button>
</div>
) : (
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
<button type="button" className={btnSecondary} onClick={() => void retryOp(op.clientOpId)}>
<RotateCcw className="size-5" aria-hidden />
{t("sync.retry")}
</button>
{canDiscard(op) && (
<button type="button" className={btnSecondary} onClick={() => setConfirmDiscard(op.clientOpId)}>
<Trash2 className="size-5" aria-hidden />
{t("sync.discard")}
</button>
)}
</div>
)}
</li>
))}
</ul>
)}
</section>
<section className={cn(card, "space-y-3")} aria-labelledby="sync-storage">
<h2 id="sync-storage" className="flex items-center gap-2 text-[17px]">
<HardDrive className="size-5 text-muted-foreground" aria-hidden />
{t("sync.storage")}
</h2>
{offlineOrders !== null && <p className="text-[15px] font-semibold">{t("sync.offlineOrders", { count: offlineOrders })}</p>}
{s.storage ? (
<>
<p className="text-[15px]">{t("sync.storageUsage", { usage: fmtBytes(s.storage.usage, locale), quota: fmtBytes(s.storage.quota, locale) })}</p>
<p className="text-[14px] text-muted-foreground">{s.storage.persisted ? t("sync.storagePersisted") : t("sync.storageNotPersisted")}</p>
</>
) : (
<p className="text-[14px] text-muted-foreground">{t("sync.storageUnknown")}</p>
)}
<p className="text-[14px] text-muted-foreground">{t("sync.saveOfflineHint")}</p>
<button type="button" className={btnSecondary} disabled={s.syncing || !s.online} onClick={() => void syncNow({ pull: true })}>
<CloudDownload className="size-5" aria-hidden />
{t("sync.saveOffline")}
</button>
<Link href="/m/offline" className={btnSecondary}>
{t("sync.openOffline")}
</Link>
</section>
<section className={cn(card, "space-y-3")} aria-labelledby="sync-reset">
<h2 id="sync-reset" className="text-[17px]">
{t("sync.reset")}
</h2>
{resetDone && (
<p className={noticeOk} role="status">
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("sync.resetDone")}
</p>
)}
{reset ? (
<div className="space-y-2" role="alertdialog" aria-label={t("sync.resetConfirm")}>
<p className="text-[15px] font-semibold">{t("sync.resetConfirm")}</p>
{reset.unsent > 0 && (
<p className={noticeError}>
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("sync.resetWarning", { count: reset.unsent })}
</p>
)}
<button
type="button"
className={cn(btnSecondary, "border-[var(--risk)] text-[var(--risk)]")}
onClick={async () => {
await clearLocalData({ refetch: true });
setReset(null);
setResetDone(true);
}}
>
<Trash2 className="size-5" aria-hidden />
{t("sync.resetYes")}
</button>
<button type="button" className={btnSecondary} onClick={() => setReset(null)}>
{t("sync.cancel")}
</button>
</div>
) : (
<button
type="button"
className={btnSecondary}
onClick={async () => {
setResetDone(false);
setReset({ unsent: await unsentCount() });
}}
>
<Trash2 className="size-5" aria-hidden />
{t("sync.reset")}
</button>
)}
</section>
</div>
);
}
function QueueRow({ op, locale }: { op: OutboxEntry; locale: string }) {
const t = useTranslations("offline");
return (
<li className="space-y-0.5 py-2.5 text-[15px]">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="font-semibold">{t(`op.${op.opType}`)}</span>
<span className="text-[13px] font-semibold text-muted-foreground">{t(`state.${op.status}`)}</span>
</div>
<p className="text-[13px] text-muted-foreground">
{t("sync.createdAt", { time: fmtDateTimeShort(op.clientCreatedAt, locale) })}
{op.attempts > 0 && ` · ${t("sync.attempts", { count: op.attempts })}`}
{op.nextAttemptAt && ` · ${t("sync.nextAttempt", { time: fmtTimeShort(op.nextAttemptAt, locale) })}`}
</p>
{op.lastError && <p className="text-[13px]">{t(`problem.${problemKey(op)}`)}</p>}
</li>
);
}