+ >
+ }>
+ {children}
+
);
}
diff --git a/src/components/backoffice-frame.tsx b/src/components/backoffice-frame.tsx
new file mode 100644
index 0000000..d3473fd
--- /dev/null
+++ b/src/components/backoffice-frame.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { useState } from "react";
+import { Menu, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+/**
+ * Backoffice shell (L10b, L1 offener Punkt 10): below 1024 px the sidebar is collapsed behind a
+ * menu button and opens as an overlay drawer, so backoffice pages are usable on tablets and
+ * phones. From 1024 px the sidebar is static as before. The drawer closes on navigation (link
+ * click), on the backdrop, the close button and Escape. Closed drawers are `invisible` below
+ * 1024 px, so their links leave the tab order.
+ */
+export function BackofficeFrame({
+ sidebar,
+ header,
+ children,
+ labels,
+}: {
+ sidebar: React.ReactNode;
+ header: React.ReactNode;
+ children: React.ReactNode;
+ labels: { open: string; close: string };
+}) {
+ const [open, setOpen] = useState(false);
+
+ const closeOnLink = (e: React.MouseEvent) => {
+ if ((e.target as HTMLElement).closest("a")) setOpen(false);
+ };
+ const closeOnEscape = (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") setOpen(false);
+ };
+
+ return (
+
+ {open &&
setOpen(false)} />}
+
+
+
+
+
+ {header}
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/reports/mobile/report-editor.tsx b/src/components/reports/mobile/report-editor.tsx
index 9457e9c..d14f015 100644
--- a/src/components/reports/mobile/report-editor.tsx
+++ b/src/components/reports/mobile/report-editor.tsx
@@ -3,7 +3,7 @@
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
-import { ArrowRight, Save, Send } from "lucide-react";
+import { ArrowRight, History, Save, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -12,30 +12,59 @@ import { REPORT_REQUIRED_TEXTS, REPORT_TEXT_FIELDS, TEXT_MAX, type ReportTexts,
import { saveReportTextsAction, submitReportAction } from "@/server/actions/reports/workflow";
import { ActionMessage } from "../action-message";
import { LotseReviewConfirm } from "@/components/lotse/review-confirm";
+import { useOfflineDraft } from "@/components/offline/hooks";
+
+/** Local draft (IndexedDB, L7) + the server texts it was based on. */
+type ReportDraft = { base: ReportTexts; texts: ReportTexts };
+
+const sameTexts = (a: ReportTexts | undefined, b: ReportTexts) => !!a && REPORT_TEXT_FIELDS.every((f) => (a[f] ?? "") === (b[f] ?? ""));
/**
* Mobile report editor: technician checks/extends the prefilled texts.
* Daily report: save or submit directly (signature optional). Completion: save and continue to signature.
+ *
+ * L10b (L7 offener Punkt 7): unsaved input is kept as an offline draft (`useOfflineDraft`, per
+ * tenant + user, removed on logout). A draft is only restored while the server texts are still the
+ * ones it was based on — if the report changed meanwhile (saved on another device, Lotse suggestion
+ * taken over) the server version wins and the stale draft is discarded.
*/
export function ReportEditor({ reportId, type, texts, signHref, aiDrafted = false }: { reportId: string; type: ReportType; texts: ReportTexts; signHref?: string; aiDrafted?: boolean }) {
const t = useTranslations("reports");
+ const tOffline = useTranslations("offline");
const router = useRouter();
const [intent, setIntent] = useState<"save" | "sign">("save");
const [saveState, save, saving] = useActionState(saveReportTextsAction, IDLE);
const [submitState, submit, submitting] = useActionState(submitReportAction, IDLE);
const required = new Set
(REPORT_REQUIRED_TEXTS[type]);
+ const [draft, setDraft, clearDraft, restored] = useOfflineDraft(`report:${reportId}`, { base: texts, texts });
+ const stale = !sameTexts(draft.base, texts);
+ const values = stale ? texts : draft.texts;
+
useEffect(() => {
+ if (restored && stale) void clearDraft();
+ }, [restored, stale, clearDraft]);
+ useEffect(() => {
+ if (saveState.status === "ok") void clearDraft();
if (saveState.status === "ok" && intent === "sign" && signHref) router.push(signHref);
- }, [saveState, intent, router, signHref]);
+ }, [saveState, intent, router, signHref, clearDraft]);
useEffect(() => {
- if (submitState.status === "ok") router.refresh();
- }, [submitState, router]);
+ if (submitState.status === "ok") {
+ void clearDraft();
+ router.refresh();
+ }
+ }, [submitState, router, clearDraft]);
return (