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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+15
-12
@@ -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 (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
|
||||
const sidebar = (
|
||||
<>
|
||||
<div className="border-b border-sidebar-border px-4 pt-5 pb-3.5">
|
||||
<Link href="/dashboard" aria-label={branding.productName}>
|
||||
<TenantBrand branding={branding} height={34} />
|
||||
@@ -84,15 +84,18 @@ export default async function AppLayout({
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-10 flex items-center gap-4 border-b bg-[var(--panel)] px-6 py-2.5 backdrop-blur-md">
|
||||
<form action="/search" role="search" className="flex-1"><input type="search" name="q" aria-label={tc("search")} placeholder={tc("search")} className="h-10 w-full max-w-md rounded-lg border border-input bg-background px-3 text-sm" /></form>
|
||||
// L10b: collapsible sidebar below 1024 px (src/components/backoffice-frame.tsx)
|
||||
return (
|
||||
<BackofficeFrame sidebar={sidebar} labels={{ open: t("openMenu"), close: t("closeMenu") }} header={
|
||||
<>
|
||||
<form action="/search" role="search" className="min-w-0 flex-1"><input type="search" name="q" aria-label={tc("search")} placeholder={tc("search")} className="h-10 w-full max-w-md rounded-lg border border-input bg-background px-3 text-sm" /></form>
|
||||
<NotificationBell />
|
||||
<UiLocaleSwitcher current={identity.uiLocale} />
|
||||
<Link href="/account" className="flex items-center gap-3">
|
||||
<div className="text-right leading-tight">
|
||||
<div className="hidden text-right leading-tight sm:block">
|
||||
<p className="text-[13px] font-semibold">{session.user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{session.user.tenantSlug}</p>
|
||||
</div>
|
||||
@@ -105,9 +108,9 @@ export default async function AppLayout({
|
||||
{tc("logout")}
|
||||
</Button>
|
||||
</form>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}>
|
||||
{children}
|
||||
</BackofficeFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user