From 21d6dc016a127336e10773e5c838e3ca82cb180d Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 18:19:19 +0200 Subject: [PATCH] =?UTF-8?q?L10b=20Betrieb=20&=20Aufr=C3=A4umen:=20einklapp?= =?UTF-8?q?bare=20Backoffice-Sidebar,=20Berichtseditor=20mit=20Offline-Ent?= =?UTF-8?q?wurf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aufräumpunkt g (L1 offener Punkt 10): components/backoffice-frame.tsx – unter 1024 px ist die Sidebar ein Drawer hinter einem Menü-Button (44 px, aria-expanded, schließt bei Navigation, Hintergrund, Escape); ab 1024 px statisch wie bisher. Header kompakter auf schmalen Screens. - Aufräumpunkt i (L7 offener Punkt 7): mobiler Berichtseditor speichert ungesicherte Eingaben über useOfflineDraft (IndexedDB je Mandant/Nutzer). Ein Entwurf wird nur wiederhergestellt, solange die Servertexte unverändert sind (sonst gewinnt der Server, z. B. nach Übernahme eines Lotse-Vorschlags); nach Speichern/Absenden gelöscht. Co-Authored-By: Claude Opus 5 --- messages/de/nav.json | 4 +- messages/en/nav.json | 4 +- src/app/(app)/layout.tsx | 27 ++++--- src/components/backoffice-frame.tsx | 77 +++++++++++++++++++ .../reports/mobile/report-editor.tsx | 40 ++++++++-- 5 files changed, 133 insertions(+), 19 deletions(-) create mode 100644 src/components/backoffice-frame.tsx diff --git a/messages/de/nav.json b/messages/de/nav.json index 72a2338..efed3cc 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -14,5 +14,7 @@ "audit": "Audit-Protokoll", "email": "E-Mail-Versand", "lotse": "Lotse (KI)", - "admin": "Admin-Konsole" + "admin": "Admin-Konsole", + "openMenu": "Menü öffnen", + "closeMenu": "Menü schließen" } diff --git a/messages/en/nav.json b/messages/en/nav.json index 46a6776..91f4ba6 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -14,5 +14,7 @@ "audit": "Audit log", "email": "E-mail delivery", "lotse": "Lotse (AI)", - "admin": "Admin console" + "admin": "Admin console", + "openMenu": "Open menu", + "closeMenu": "Close menu" } diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index a30979f..8f77b5e 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -13,6 +13,7 @@ import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { CraftviaLogo } from "@/components/brand/craftvia-logo"; import { NotificationBell } from "@/components/notifications/bell"; import { AccountInactiveNotice } from "@/components/account-inactive-notice"; +import { BackofficeFrame } from "@/components/backoffice-frame"; export default async function AppLayout({ children, @@ -56,9 +57,8 @@ export default async function AppLayout({ await signOut({ redirectTo: "/login" }); } - return ( -
- + + ); -
-
-
+ // L10b: collapsible sidebar below 1024 px (src/components/backoffice-frame.tsx) + return ( + +
-
+

{session.user.name}

{session.user.tenantSlug}

@@ -105,9 +108,9 @@ export default async function AppLayout({ {tc("logout")} -
- {children} -
-
+ + }> + {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 (

{t("mobile.edit")}

+ {restored && !stale && ( +

+ + {tOffline("view.draftRestored")} +

+ )} {REPORT_TEXT_FIELDS.map((f) => (