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:
@@ -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));
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />;
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user