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",
|
"audit": "Audit-Protokoll",
|
||||||
"email": "E-Mail-Versand",
|
"email": "E-Mail-Versand",
|
||||||
"lotse": "Lotse (KI)",
|
"lotse": "Lotse (KI)",
|
||||||
"admin": "Admin-Konsole"
|
"admin": "Admin-Konsole",
|
||||||
|
"openMenu": "Menü öffnen",
|
||||||
|
"closeMenu": "Menü schließen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,7 @@
|
|||||||
"audit": "Audit log",
|
"audit": "Audit log",
|
||||||
"email": "E-mail delivery",
|
"email": "E-mail delivery",
|
||||||
"lotse": "Lotse (AI)",
|
"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 { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||||
import { NotificationBell } from "@/components/notifications/bell";
|
import { NotificationBell } from "@/components/notifications/bell";
|
||||||
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
||||||
|
import { BackofficeFrame } from "@/components/backoffice-frame";
|
||||||
|
|
||||||
export default async function AppLayout({
|
export default async function AppLayout({
|
||||||
children,
|
children,
|
||||||
@@ -56,9 +57,8 @@ export default async function AppLayout({
|
|||||||
await signOut({ redirectTo: "/login" });
|
await signOut({ redirectTo: "/login" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const sidebar = (
|
||||||
<div className="flex min-h-screen">
|
<>
|
||||||
<aside className="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
|
|
||||||
<div className="border-b border-sidebar-border px-4 pt-5 pb-3.5">
|
<div className="border-b border-sidebar-border px-4 pt-5 pb-3.5">
|
||||||
<Link href="/dashboard" aria-label={branding.productName}>
|
<Link href="/dashboard" aria-label={branding.productName}>
|
||||||
<TenantBrand branding={branding} height={34} />
|
<TenantBrand branding={branding} height={34} />
|
||||||
@@ -84,15 +84,18 @@ export default async function AppLayout({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
// L10b: collapsible sidebar below 1024 px (src/components/backoffice-frame.tsx)
|
||||||
<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">
|
return (
|
||||||
<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>
|
<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 />
|
<NotificationBell />
|
||||||
<UiLocaleSwitcher current={identity.uiLocale} />
|
<UiLocaleSwitcher current={identity.uiLocale} />
|
||||||
<Link href="/account" className="flex items-center gap-3">
|
<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-[13px] font-semibold">{session.user.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{session.user.tenantSlug}</p>
|
<p className="text-xs text-muted-foreground">{session.user.tenantSlug}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,9 +108,9 @@ export default async function AppLayout({
|
|||||||
{tc("logout")}
|
{tc("logout")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</header>
|
</>
|
||||||
{children}
|
}>
|
||||||
</div>
|
{children}
|
||||||
</div>
|
</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 { useRouter } from "next/navigation";
|
||||||
import { useActionState, useEffect, useState } from "react";
|
import { useActionState, useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { saveReportTextsAction, submitReportAction } from "@/server/actions/reports/workflow";
|
||||||
import { ActionMessage } from "../action-message";
|
import { ActionMessage } from "../action-message";
|
||||||
import { LotseReviewConfirm } from "@/components/lotse/review-confirm";
|
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.
|
* Mobile report editor: technician checks/extends the prefilled texts.
|
||||||
* Daily report: save or submit directly (signature optional). Completion: save and continue to signature.
|
* 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 }) {
|
export function ReportEditor({ reportId, type, texts, signHref, aiDrafted = false }: { reportId: string; type: ReportType; texts: ReportTexts; signHref?: string; aiDrafted?: boolean }) {
|
||||||
const t = useTranslations("reports");
|
const t = useTranslations("reports");
|
||||||
|
const tOffline = useTranslations("offline");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [intent, setIntent] = useState<"save" | "sign">("save");
|
const [intent, setIntent] = useState<"save" | "sign">("save");
|
||||||
const [saveState, save, saving] = useActionState(saveReportTextsAction, IDLE);
|
const [saveState, save, saving] = useActionState(saveReportTextsAction, IDLE);
|
||||||
const [submitState, submit, submitting] = useActionState(submitReportAction, IDLE);
|
const [submitState, submit, submitting] = useActionState(submitReportAction, IDLE);
|
||||||
const required = new Set<string>(REPORT_REQUIRED_TEXTS[type]);
|
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(() => {
|
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);
|
if (saveState.status === "ok" && intent === "sign" && signHref) router.push(signHref);
|
||||||
}, [saveState, intent, router, signHref]);
|
}, [saveState, intent, router, signHref, clearDraft]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (submitState.status === "ok") router.refresh();
|
if (submitState.status === "ok") {
|
||||||
}, [submitState, router]);
|
void clearDraft();
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}, [submitState, router, clearDraft]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={save} className="shadow-card space-y-4 rounded-xl border bg-card p-4">
|
<form action={save} className="shadow-card space-y-4 rounded-xl border bg-card p-4">
|
||||||
<input type="hidden" name="reportId" value={reportId} />
|
<input type="hidden" name="reportId" value={reportId} />
|
||||||
<h2 className="font-heading text-[15px] font-semibold">{t("mobile.edit")}</h2>
|
<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) => (
|
{REPORT_TEXT_FIELDS.map((f) => (
|
||||||
<div key={f}>
|
<div key={f}>
|
||||||
<Label htmlFor={`rt-${f}`} className="text-[13px]">
|
<Label htmlFor={`rt-${f}`} className="text-[13px]">
|
||||||
@@ -45,7 +74,8 @@ export function ReportEditor({ reportId, type, texts, signHref, aiDrafted = fals
|
|||||||
<Textarea
|
<Textarea
|
||||||
id={`rt-${f}`}
|
id={`rt-${f}`}
|
||||||
name={f}
|
name={f}
|
||||||
defaultValue={texts[f]}
|
value={values[f] ?? ""}
|
||||||
|
onChange={(e) => setDraft({ base: texts, texts: { ...values, [f]: e.target.value } })}
|
||||||
maxLength={TEXT_MAX}
|
maxLength={TEXT_MAX}
|
||||||
rows={f === "workPerformed" ? 5 : 2}
|
rows={f === "workPerformed" ? 5 : 2}
|
||||||
className="mt-1 min-h-12 text-base"
|
className="mt-1 min-h-12 text-base"
|
||||||
|
|||||||
Reference in New Issue
Block a user