Files
craftvia/src/app/(app)/layout.tsx
T
msolarczekandClaude Opus 5 21d6dc016a 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>
2026-09-14 18:19:19 +02:00

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>
);
}