L10b Betrieb & Aufräumen: einklappbare Backoffice-Sidebar, Berichtseditor mit Offline-Entwurf

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 18:19:19 +02:00
co-authored by Claude Opus 5
parent b0aedb5d23
commit 21d6dc016a
5 changed files with 133 additions and 19 deletions
+77
View File
@@ -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 (
<div className="flex min-h-screen" onKeyDown={closeOnEscape}>
{open && <div aria-hidden className="fixed inset-0 z-30 bg-foreground/30 lg:hidden" onClick={() => setOpen(false)} />}
<aside
id="backoffice-sidebar"
onClick={closeOnLink}
className={cn(
"fixed inset-y-0 left-0 z-40 flex w-72 max-w-[85vw] shrink-0 flex-col border-r border-sidebar-border bg-sidebar shadow-lg transition-transform duration-200",
"lg:sticky lg:top-0 lg:h-screen lg:w-60 lg:translate-x-0 lg:shadow-none",
open ? "translate-x-0" : "max-lg:invisible -translate-x-full",
)}
>
<div className="flex justify-end px-2 pt-2 lg:hidden">
<button
type="button"
onClick={() => setOpen(false)}
aria-label={labels.close}
className="grid size-11 place-items-center rounded-lg text-sidebar-foreground hover:bg-secondary"
>
<X className="size-5" aria-hidden />
</button>
</div>
{sidebar}
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-10 flex items-center gap-2 border-b bg-[var(--panel)] px-3 py-2.5 backdrop-blur-md sm:gap-4 md:px-6">
<button
type="button"
onClick={() => setOpen(true)}
aria-label={labels.open}
aria-controls="backoffice-sidebar"
aria-expanded={open}
className="grid size-11 shrink-0 place-items-center rounded-lg hover:bg-secondary lg:hidden"
>
<Menu className="size-5" aria-hidden />
</button>
{header}
</header>
{children}
</div>
</div>
);
}
@@ -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<string>(REPORT_REQUIRED_TEXTS[type]);
const [draft, setDraft, clearDraft, restored] = useOfflineDraft<ReportDraft>(`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 (
<form action={save} className="shadow-card space-y-4 rounded-xl border bg-card p-4">
<input type="hidden" name="reportId" value={reportId} />
<h2 className="font-heading text-[15px] font-semibold">{t("mobile.edit")}</h2>
{restored && !stale && (
<p role="status" className="flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-[13px]">
<History className="size-4 shrink-0" aria-hidden />
{tOffline("view.draftRestored")}
</p>
)}
{REPORT_TEXT_FIELDS.map((f) => (
<div key={f}>
<Label htmlFor={`rt-${f}`} className="text-[13px]">
@@ -45,7 +74,8 @@ export function ReportEditor({ reportId, type, texts, signHref, aiDrafted = fals
<Textarea
id={`rt-${f}`}
name={f}
defaultValue={texts[f]}
value={values[f] ?? ""}
onChange={(e) => setDraft({ base: texts, texts: { ...values, [f]: e.target.value } })}
maxLength={TEXT_MAX}
rows={f === "workPerformed" ? 5 : 2}
className="mt-1 min-h-12 text-base"