From 9b3c50923f67e898352797d014573cf11f893cdc Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 17:21:14 +0200 Subject: [PATCH] =?UTF-8?q?L7=20Offline=20&=20PWA:=20submitOp=20=C3=BCber?= =?UTF-8?q?=20Outbox,=20Foto/Sprachnotiz=20=C3=BCber=20Upload-Warteschlang?= =?UTF-8?q?e,=20Entw=C3=BCrfe=20in=20IndexedDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- src/app/(field)/m/(core)/profile/page.tsx | 12 ++--- src/app/(field)/m/layout.tsx | 2 + src/components/field/note-form.tsx | 44 ++++------------ src/components/field/photo-capture.tsx | 13 +++-- src/components/field/voice-recorder.tsx | 10 ++-- src/lib/field/client-ops.ts | 61 ++++------------------- 6 files changed, 36 insertions(+), 106 deletions(-) diff --git a/src/app/(field)/m/(core)/profile/page.tsx b/src/app/(field)/m/(core)/profile/page.tsx index 6531a10..90ad296 100644 --- a/src/app/(field)/m/(core)/profile/page.tsx +++ b/src/app/(field)/m/(core)/profile/page.tsx @@ -1,6 +1,5 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; -import { LogOut } from "lucide-react"; import { auth, signOut } from "@/server/auth"; import { prisma } from "@/server/db"; import { can } from "@/server/services/context"; @@ -8,6 +7,8 @@ import { activeTeamIds } from "@/server/services/work-orders/visibility"; import { fieldPageContext } from "@/server/services/field/page-context"; import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { btnSecondary, card } from "@/components/field/ui"; +import { InstallHint } from "@/components/offline/install-hint"; +import { LogoutForm } from "@/components/offline/logout-form"; /** `/m/profile` — name, team, company, language, sign out. */ export default async function ProfilePage() { @@ -62,12 +63,9 @@ export default async function ProfilePage() { {t("backoffice")} )} -
- -
+ + {/* L7: deletes the local offline data of this tenant/user first (warning if unsent entries exist) */} + ); } diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx index f42b53d..5c7619b 100644 --- a/src/app/(field)/m/layout.tsx +++ b/src/app/(field)/m/layout.tsx @@ -5,6 +5,7 @@ import { CraftviaLogo } from "@/components/brand/craftvia-logo"; import { AccountInactiveNotice } from "@/components/account-inactive-notice"; import { BottomNav } from "@/components/field/bottom-nav"; import { OnlineBadge } from "@/components/field/online-badge"; +import { OfflineRuntime } from "@/components/offline/offline-runtime"; /** * Mobile shell `/m` (ARCHITEKTUR §5): same session/account/MFA checks as the backoffice @@ -24,6 +25,7 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
{/* Slot für L6: aus src/components/notifications/bell.tsx */} +
diff --git a/src/components/field/note-form.tsx b/src/components/field/note-form.tsx index cf7bc8e..5e30d02 100644 --- a/src/components/field/note-form.tsx +++ b/src/components/field/note-form.tsx @@ -1,55 +1,33 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { CircleCheck, TriangleAlert } from "lucide-react"; import { cn } from "@/lib/utils"; import { NOTE_KINDS, type NoteKind } from "@/lib/sync/ops"; -import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; +import { errorKey, isQueued, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; +import { useOfflineDraft } from "@/components/offline/hooks"; import { btnPrimary, chip, inputClass, noticeError, noticeOk } from "./ui"; type Draft = { kind: NoteKind; text: string; clientId: string }; -const draftKey = (workOrderId: string) => `craftvia.field.noteDraft.${workOrderId}`; - -function readDraft(workOrderId: string): Draft | null { - try { - const raw = localStorage.getItem(draftKey(workOrderId)); - return raw ? (JSON.parse(raw) as Draft) : null; - } catch { - return null; - } -} - /** * Activity note (Spec §12.3): kind as large chips + text. The draft (incl. its clientId, so a retry - * stays idempotent) is kept in localStorage until the server confirmed it (US-006). + * stays idempotent) is stored automatically in IndexedDB per tenant/user (lane L7) until the note + * is saved (US-006); offline the note goes into the outbox. */ export function NoteForm({ workOrderId }: { workOrderId: string }) { const t = useTranslations("field"); const router = useRouter(); - const [draft, setDraft] = useState({ kind: "work_done", text: "", clientId: "" }); + const [draft, setDraft, clearDraft] = useOfflineDraft(`note:${workOrderId}`, { kind: "work_done", text: "", clientId: "" }); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); - useEffect(() => { - const stored = readDraft(workOrderId); - // restore an unsent draft after reload / connection loss (client-only storage) - // eslint-disable-next-line react-hooks/set-state-in-effect - if (stored) setDraft(stored); - }, [workOrderId]); - function update(next: Partial) { - const value = { ...draft, ...next, clientId: draft.clientId || newClientId() }; - setDraft(value); + setDraft({ ...draft, ...next, clientId: draft.clientId || newClientId() }); setSaved(false); - try { - localStorage.setItem(draftKey(workOrderId), JSON.stringify(value)); - } catch { - // storage unavailable (private mode) — the form still works - } } async function submit(e: React.FormEvent) { @@ -64,14 +42,10 @@ export function NoteForm({ workOrderId }: { workOrderId: string }) { setError(`${t(`errors.${errorKey(result)}`)} ${t("notes.draftKept")}`); return; } - try { - localStorage.removeItem(draftKey(workOrderId)); - } catch { - /* ignore */ - } setDraft({ kind: draft.kind, text: "", clientId: "" }); + await clearDraft(); setSaved(true); - router.refresh(); + if (!isQueued(result)) router.refresh(); } return ( diff --git a/src/components/field/photo-capture.tsx b/src/components/field/photo-capture.tsx index 155c594..5ed59d7 100644 --- a/src/components/field/photo-capture.tsx +++ b/src/components/field/photo-capture.tsx @@ -6,9 +6,8 @@ import { useTranslations } from "next-intl"; import { Camera, CircleCheck, Image as ImageIcon, LoaderCircle, TriangleAlert, Upload } from "lucide-react"; import { cn } from "@/lib/utils"; import { PHOTO_PHASES, type PhotoPhase } from "@/lib/sync/ops"; -import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; +import { errorKey, isQueued, isSuccess, newClientId, queueBlob, submitOp } from "@/lib/field/client-ops"; import { compressImage, currentPosition } from "@/lib/field/image"; -import { uploadFieldFile } from "@/lib/field/upload"; import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk } from "./ui"; type Option = { id: string; label: string }; @@ -47,18 +46,18 @@ function usePhotoSave(workOrderId: string) { } setProgress({ stage: "uploading", percent: 0 }); const name = `${(file.name || "foto").replace(/\.[^.]+$/, "")}.jpg`; - const up = await uploadFieldFile({ + // L7: the file goes into the local upload queue; the outbox uploads it before photo.attach + const up = await queueBlob({ workOrderId, kind: "photo", - clientId: newClientId(), - file: image, + blob: image, fileName: thumbnail ? name : file.name || name, preview: thumbnail, onProgress: (percent) => setProgress({ stage: "uploading", percent }), }); if (!up.ok) { setProgress({ stage: "idle" }); - setError(t(`errors.${up.error}`)); + setError(t(`errors.${["network", "invalid", "forbidden", "not_found"].includes(up.error) ? up.error : "internal"}`)); return false; } documentId = up.documentId; @@ -87,7 +86,7 @@ function usePhotoSave(workOrderId: string) { } uploaded.current = null; setSaved(true); - router.refresh(); + if (!isQueued(result)) router.refresh(); return true; } diff --git a/src/components/field/voice-recorder.tsx b/src/components/field/voice-recorder.tsx index 4cff881..caf0f2c 100644 --- a/src/components/field/voice-recorder.tsx +++ b/src/components/field/voice-recorder.tsx @@ -4,8 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { CircleCheck, LoaderCircle, Mic, Square, TriangleAlert, Upload } from "lucide-react"; -import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; -import { uploadFieldFile } from "@/lib/field/upload"; +import { errorKey, isQueued, isSuccess, newClientId, queueBlob, submitOp } from "@/lib/field/client-ops"; import { btnPrimary, btnSecondary, noticeError, noticeOk } from "./ui"; export const MAX_RECORDING_SECONDS = 300; @@ -87,9 +86,10 @@ export function VoiceRecorder({ workOrderId }: { workOrderId: string }) { setState("saving"); setError(null); const type = clip.blob.type || "audio/webm"; - const up = await uploadFieldFile({ workOrderId, kind: "voice_note", clientId: newClientId(), file: clip.blob, fileName: `sprachnotiz.${extensionFor(type)}`, onProgress: setPercent }); + // L7: queued locally, uploaded by the outbox before voice.attach + const up = await queueBlob({ workOrderId, kind: "voice_note", blob: clip.blob, fileName: `sprachnotiz.${extensionFor(type)}`, onProgress: setPercent }); if (!up.ok) { - setError(t(`errors.${up.error}`)); + setError(t(`errors.${["network", "invalid", "forbidden", "not_found"].includes(up.error) ? up.error : "internal"}`)); setState("recorded"); return; } @@ -105,7 +105,7 @@ export function VoiceRecorder({ workOrderId }: { workOrderId: string }) { setClip(null); setSaved(true); setState("idle"); - router.refresh(); + if (!isQueued(result)) router.refresh(); } if (!supported) return

{t("voice.unsupported")}

; diff --git a/src/lib/field/client-ops.ts b/src/lib/field/client-ops.ts index 1b3ce96..4c7613e 100644 --- a/src/lib/field/client-ops.ts +++ b/src/lib/field/client-ops.ts @@ -1,10 +1,12 @@ -import type { SyncOpResult, SyncOpType, SyncResponse } from "@/lib/sync/envelope"; +import type { SyncOpResult, SyncOpType } from "@/lib/sync/envelope"; import type { OpPayload } from "@/lib/sync/ops"; +import { submitOp as submitToOutbox } from "@/lib/offline/outbox"; /** - * Client wrapper for mobile mutations (ARCHITEKTUR §4.6). Today every op is sent to - * POST /api/v1/sync immediately. Lane L7 replaces the implementation with the IndexedDB outbox — - * keep the signature `submitOp(op) → Promise` stable. + * Client wrapper for mobile mutations (ARCHITEKTUR §4.6). Since lane L7 every op goes through the + * IndexedDB outbox (src/lib/offline/outbox.ts): stored locally first, sent right away when online + * (the server result is returned), otherwise queued and synchronised later — then the result is + * `status: "applied", message: "queued"` (check with `isQueued`). Signature unchanged. */ export type ClientOp = { @@ -15,56 +17,11 @@ export type ClientOp = { baseVersion?: number; }; -/** RFC 4122 v4 id; falls back to getRandomValues outside secure contexts. */ -export function newClientId(): string { - const c = globalThis.crypto; - if (typeof c?.randomUUID === "function") return c.randomUUID(); - const b = new Uint8Array(16); - c.getRandomValues(b); - b[6] = (b[6] & 0x0f) | 0x40; - b[8] = (b[8] & 0x3f) | 0x80; - const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join(""); - return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; -} - -const DEVICE_KEY = "craftvia.field.deviceId"; - -export function deviceId(): string { - try { - let id = localStorage.getItem(DEVICE_KEY); - if (!id) { - id = newClientId(); - localStorage.setItem(DEVICE_KEY, id); - } - return id; - } catch { - return "unknown-device"; - } -} - -function errorCodeForStatus(status: number): SyncOpResult["errorCode"] { - if (status === 400 || status === 413) return "invalid"; - if (status === 401 || status === 403) return "forbidden"; - if (status === 404) return "not_found"; - if (status === 409) return "conflict"; - return "internal"; -} +export { deviceId, newClientId } from "@/lib/offline/ids"; +export { isQueued, queueBlob } from "@/lib/offline/outbox"; export async function submitOp(op: ClientOp): Promise { - const clientOpId = newClientId(); - try { - const res = await fetch("/api/v1/sync", { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ deviceId: deviceId(), operations: [{ ...op, clientOpId, clientCreatedAt: new Date().toISOString() }] }), - }); - if (!res.ok) return { clientOpId, status: "rejected", errorCode: errorCodeForStatus(res.status), message: `HTTP ${res.status}` }; - const body = (await res.json()) as SyncResponse; - return body.results[0] ?? { clientOpId, status: "rejected", errorCode: "internal" }; - } catch { - return { clientOpId, status: "rejected", errorCode: "internal", message: "network" }; - } + return submitToOutbox(op); } /** i18n key (messages field.errors.*) for a failed op result. */