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,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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user