- 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>
117 lines
4.7 KiB
TypeScript
117 lines
4.7 KiB
TypeScript
import Link from "next/link";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { signOut } from "@/server/auth";
|
|
import { dbForTenant } from "@/server/db";
|
|
import { requireAppAccess } from "@/server/app-access";
|
|
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
|
|
import { resolveTenantBranding } from "@/lib/brand";
|
|
import { Button } from "@/components/ui/button";
|
|
import { NavLink } from "@/components/nav-link";
|
|
import { TenantBrand } from "@/components/brand/tenant-brand";
|
|
import { TenantSwitcher } from "@/components/tenant-switcher";
|
|
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,
|
|
}: Readonly<{ children: React.ReactNode }>) {
|
|
// Session-, Konto-, Passwort- und MFA-Prüfungen: gemeinsam mit der Mobile-Shell
|
|
// (src/server/app-access.ts).
|
|
const access = await requireAppAccess();
|
|
if (access.kind === "inactive") return <AccountInactiveNotice />;
|
|
const { session, identity } = access;
|
|
|
|
const t = await getTranslations("nav");
|
|
const tc = await getTranslations("common");
|
|
|
|
// Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort;
|
|
// Seiten/Actions prüfen serverseitig selbst).
|
|
const [moduleRows, brandingSettings] = await Promise.all([
|
|
dbForTenant(session.user.tenantId).tenantModule.findMany(),
|
|
dbForTenant(session.user.tenantId).tenantSettings.findUnique({
|
|
where: { tenantId: session.user.tenantId },
|
|
select: { accent: true },
|
|
}),
|
|
]);
|
|
const branding = resolveTenantBranding(brandingSettings);
|
|
const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
|
|
const navItems = visibleNavItems(NAV_ITEMS, {
|
|
disabledModules,
|
|
permissions: session.user.permissions ?? [],
|
|
});
|
|
const mainNav = navItems.filter((i) => i.section === "main");
|
|
const adminNav = navItems.filter((i) => i.section === "admin");
|
|
|
|
const initials = (session.user.name ?? "?")
|
|
.split(/\s+/)
|
|
.map((p) => p[0])
|
|
.slice(0, 2)
|
|
.join("")
|
|
.toUpperCase();
|
|
|
|
async function logout() {
|
|
"use server";
|
|
await signOut({ redirectTo: "/login" });
|
|
}
|
|
|
|
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} />
|
|
</Link>
|
|
<TenantSwitcher memberships={session.user.memberships ?? []} activeSlug={session.user.tenantSlug} />
|
|
</div>
|
|
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2.5 py-3">
|
|
{mainNav.map((item) => (
|
|
<NavLink key={item.href} href={item.href}>
|
|
<item.icon className="size-[18px] opacity-85" />
|
|
{t(item.label)}
|
|
</NavLink>
|
|
))}
|
|
|
|
{adminNav.length > 0 && (
|
|
<div className="mt-2 space-y-0.5 border-t border-sidebar-border pt-2">
|
|
{adminNav.map((item) => (
|
|
<NavLink key={item.href} href={item.href}>
|
|
<item.icon className="size-[18px] opacity-85" />
|
|
{t(item.label)}
|
|
</NavLink>
|
|
))}
|
|
</div>
|
|
)}
|
|
</nav>
|
|
</>
|
|
);
|
|
|
|
// 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="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>
|
|
<div className="bg-grad-soft grid size-9.5 shrink-0 place-items-center rounded-full font-heading text-sm font-bold text-white">
|
|
{initials}
|
|
</div>
|
|
</Link>
|
|
<form action={logout}>
|
|
<Button type="submit" variant="ghost" size="sm">
|
|
{tc("logout")}
|
|
</Button>
|
|
</form>
|
|
</>
|
|
}>
|
|
{children}
|
|
</BackofficeFrame>
|
|
);
|
|
}
|