L7 Offline & PWA: submitOp über Outbox, Foto/Sprachnotiz über Upload-Warteschlange, Entwürfe in IndexedDB
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { LogOut } from "lucide-react";
|
|
||||||
import { auth, signOut } from "@/server/auth";
|
import { auth, signOut } from "@/server/auth";
|
||||||
import { prisma } from "@/server/db";
|
import { prisma } from "@/server/db";
|
||||||
import { can } from "@/server/services/context";
|
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 { fieldPageContext } from "@/server/services/field/page-context";
|
||||||
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
|
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
|
||||||
import { btnSecondary, card } from "@/components/field/ui";
|
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. */
|
/** `/m/profile` — name, team, company, language, sign out. */
|
||||||
export default async function ProfilePage() {
|
export default async function ProfilePage() {
|
||||||
@@ -62,12 +63,9 @@ export default async function ProfilePage() {
|
|||||||
{t("backoffice")}
|
{t("backoffice")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<form action={logout}>
|
<InstallHint />
|
||||||
<button type="submit" className={btnSecondary}>
|
{/* L7: deletes the local offline data of this tenant/user first (warning if unsent entries exist) */}
|
||||||
<LogOut className="size-5" aria-hidden />
|
<LogoutForm action={logout} label={t("logout")} />
|
||||||
{t("logout")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
|||||||
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
||||||
import { BottomNav } from "@/components/field/bottom-nav";
|
import { BottomNav } from "@/components/field/bottom-nav";
|
||||||
import { OnlineBadge } from "@/components/field/online-badge";
|
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
|
* 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
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Slot für L6: <NotificationBell variant="mobile" /> aus src/components/notifications/bell.tsx */}
|
{/* Slot für L6: <NotificationBell variant="mobile" /> aus src/components/notifications/bell.tsx */}
|
||||||
|
<OfflineRuntime />
|
||||||
<OnlineBadge />
|
<OnlineBadge />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,55 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { CircleCheck, TriangleAlert } from "lucide-react";
|
import { CircleCheck, TriangleAlert } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { NOTE_KINDS, type NoteKind } from "@/lib/sync/ops";
|
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";
|
import { btnPrimary, chip, inputClass, noticeError, noticeOk } from "./ui";
|
||||||
|
|
||||||
type Draft = { kind: NoteKind; text: string; clientId: string };
|
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
|
* 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 }) {
|
export function NoteForm({ workOrderId }: { workOrderId: string }) {
|
||||||
const t = useTranslations("field");
|
const t = useTranslations("field");
|
||||||
const router = useRouter();
|
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 [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saved, setSaved] = useState(false);
|
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>) {
|
function update(next: Partial<Draft>) {
|
||||||
const value = { ...draft, ...next, clientId: draft.clientId || newClientId() };
|
setDraft({ ...draft, ...next, clientId: draft.clientId || newClientId() });
|
||||||
setDraft(value);
|
|
||||||
setSaved(false);
|
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) {
|
async function submit(e: React.FormEvent) {
|
||||||
@@ -64,14 +42,10 @@ export function NoteForm({ workOrderId }: { workOrderId: string }) {
|
|||||||
setError(`${t(`errors.${errorKey(result)}`)} ${t("notes.draftKept")}`);
|
setError(`${t(`errors.${errorKey(result)}`)} ${t("notes.draftKept")}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
localStorage.removeItem(draftKey(workOrderId));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
setDraft({ kind: draft.kind, text: "", clientId: "" });
|
setDraft({ kind: draft.kind, text: "", clientId: "" });
|
||||||
|
await clearDraft();
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
router.refresh();
|
if (!isQueued(result)) router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import { useTranslations } from "next-intl";
|
|||||||
import { Camera, CircleCheck, Image as ImageIcon, LoaderCircle, TriangleAlert, Upload } from "lucide-react";
|
import { Camera, CircleCheck, Image as ImageIcon, LoaderCircle, TriangleAlert, Upload } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PHOTO_PHASES, type PhotoPhase } from "@/lib/sync/ops";
|
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 { compressImage, currentPosition } from "@/lib/field/image";
|
||||||
import { uploadFieldFile } from "@/lib/field/upload";
|
|
||||||
import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk } from "./ui";
|
import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk } from "./ui";
|
||||||
|
|
||||||
type Option = { id: string; label: string };
|
type Option = { id: string; label: string };
|
||||||
@@ -47,18 +46,18 @@ function usePhotoSave(workOrderId: string) {
|
|||||||
}
|
}
|
||||||
setProgress({ stage: "uploading", percent: 0 });
|
setProgress({ stage: "uploading", percent: 0 });
|
||||||
const name = `${(file.name || "foto").replace(/\.[^.]+$/, "")}.jpg`;
|
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,
|
workOrderId,
|
||||||
kind: "photo",
|
kind: "photo",
|
||||||
clientId: newClientId(),
|
blob: image,
|
||||||
file: image,
|
|
||||||
fileName: thumbnail ? name : file.name || name,
|
fileName: thumbnail ? name : file.name || name,
|
||||||
preview: thumbnail,
|
preview: thumbnail,
|
||||||
onProgress: (percent) => setProgress({ stage: "uploading", percent }),
|
onProgress: (percent) => setProgress({ stage: "uploading", percent }),
|
||||||
});
|
});
|
||||||
if (!up.ok) {
|
if (!up.ok) {
|
||||||
setProgress({ stage: "idle" });
|
setProgress({ stage: "idle" });
|
||||||
setError(t(`errors.${up.error}`));
|
setError(t(`errors.${["network", "invalid", "forbidden", "not_found"].includes(up.error) ? up.error : "internal"}`));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
documentId = up.documentId;
|
documentId = up.documentId;
|
||||||
@@ -87,7 +86,7 @@ function usePhotoSave(workOrderId: string) {
|
|||||||
}
|
}
|
||||||
uploaded.current = null;
|
uploaded.current = null;
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
router.refresh();
|
if (!isQueued(result)) router.refresh();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { CircleCheck, LoaderCircle, Mic, Square, TriangleAlert, Upload } from "lucide-react";
|
import { CircleCheck, LoaderCircle, Mic, Square, TriangleAlert, Upload } from "lucide-react";
|
||||||
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
|
import { errorKey, isQueued, isSuccess, newClientId, queueBlob, submitOp } from "@/lib/field/client-ops";
|
||||||
import { uploadFieldFile } from "@/lib/field/upload";
|
|
||||||
import { btnPrimary, btnSecondary, noticeError, noticeOk } from "./ui";
|
import { btnPrimary, btnSecondary, noticeError, noticeOk } from "./ui";
|
||||||
|
|
||||||
export const MAX_RECORDING_SECONDS = 300;
|
export const MAX_RECORDING_SECONDS = 300;
|
||||||
@@ -87,9 +86,10 @@ export function VoiceRecorder({ workOrderId }: { workOrderId: string }) {
|
|||||||
setState("saving");
|
setState("saving");
|
||||||
setError(null);
|
setError(null);
|
||||||
const type = clip.blob.type || "audio/webm";
|
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) {
|
if (!up.ok) {
|
||||||
setError(t(`errors.${up.error}`));
|
setError(t(`errors.${["network", "invalid", "forbidden", "not_found"].includes(up.error) ? up.error : "internal"}`));
|
||||||
setState("recorded");
|
setState("recorded");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ export function VoiceRecorder({ workOrderId }: { workOrderId: string }) {
|
|||||||
setClip(null);
|
setClip(null);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setState("idle");
|
setState("idle");
|
||||||
router.refresh();
|
if (!isQueued(result)) router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!supported) return <p className="text-[14px] text-muted-foreground">{t("voice.unsupported")}</p>;
|
if (!supported) return <p className="text-[14px] text-muted-foreground">{t("voice.unsupported")}</p>;
|
||||||
|
|||||||
@@ -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 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
|
* Client wrapper for mobile mutations (ARCHITEKTUR §4.6). Since lane L7 every op goes through the
|
||||||
* POST /api/v1/sync immediately. Lane L7 replaces the implementation with the IndexedDB outbox —
|
* IndexedDB outbox (src/lib/offline/outbox.ts): stored locally first, sent right away when online
|
||||||
* keep the signature `submitOp(op) → Promise<SyncOpResult>` stable.
|
* (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<T extends SyncOpType = SyncOpType> = {
|
export type ClientOp<T extends SyncOpType = SyncOpType> = {
|
||||||
@@ -15,56 +17,11 @@ export type ClientOp<T extends SyncOpType = SyncOpType> = {
|
|||||||
baseVersion?: number;
|
baseVersion?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** RFC 4122 v4 id; falls back to getRandomValues outside secure contexts. */
|
export { deviceId, newClientId } from "@/lib/offline/ids";
|
||||||
export function newClientId(): string {
|
export { isQueued, queueBlob } from "@/lib/offline/outbox";
|
||||||
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 async function submitOp<T extends SyncOpType>(op: ClientOp<T>): Promise<SyncOpResult> {
|
export async function submitOp<T extends SyncOpType>(op: ClientOp<T>): Promise<SyncOpResult> {
|
||||||
const clientOpId = newClientId();
|
return submitToOutbox(op);
|
||||||
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" };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** i18n key (messages field.errors.*) for a failed op result. */
|
/** i18n key (messages field.errors.*) for a failed op result. */
|
||||||
|
|||||||
Reference in New Issue
Block a user