Merge lane/offline in feature/craftvia-mvp

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