Files
craftvia/src/lib/nav.ts
T
msolarczekandClaude Opus 5 06d320908f L17 Pakete: Stufen Basis/Profi, Lotse-Chat-Plätze und Kontingent
- Datenmodell: Tenant.tier (Default PROFI), lotseChatSeats, lotseChatHardLimit;
  Tabelle lotse_chat_seats (RLS, TENANT_MODELS, pii-fields), Index für die
  Monatszählung der Chat-Nachrichten (Migration 20260921100000_pakete)
- src/lib/plans.ts: Stufenregeln, 150 Chats je Platz, Mehrverbrauch in 100er-Paketen
- src/server/plan.ts: effektive Freischaltung = Stufe UND TenantModule, genutzt von
  requireModule, assertModuleEnabled, API, Sync (Offline-Op → rejected mit Klartext),
  Navigation, isLotseEnabled und planningAccess (Planung nur in Profi)
- Lotse-Chat: Platzprüfung (no_seat), Testphase ohne Platz, Kontingent mit
  hartem Limit (quota_exhausted); Platzvergabe durch den Mandanten-Admin
- Betreiber: Stufe/Plätze/hartes Limit im Mandantendetail mit Bestätigung
  und Plattform-Audit, Verbrauch laufender Monat/Vormonat, Stufe als Badge
- Demo-Seed: demo = Profi mit 3 Plätzen, demo2 = Basis
- Tests: test-pakete-{rules,gates,seats}

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:13:27 +02:00

103 lines
5.1 KiB
TypeScript

import {
LayoutDashboard,
ClipboardList,
FileInput,
Users,
Building2,
UsersRound,
FileText,
FolderOpen,
Settings,
Bell,
History,
Mail,
ListChecks,
Siren,
Compass,
Timer,
CalendarRange,
LayoutGrid,
MapPinned,
Receipt,
Download,
type LucideIcon,
} from "lucide-react";
import type { ModuleKey } from "@/lib/modules";
import type { PlanFeature } from "@/lib/plans";
import type { Permission } from "@/server/rbac";
/**
* Sidebar-Navigation des Backoffice (src/app/(app)/layout.tsx).
*
* Ein Eintrag ist sichtbar, wenn
* - sein Modul (falls gesetzt) für den Mandanten aktiv ist UND
* - die Session mindestens EINE der `permissions` hat (leer = immer).
* Das ist reiner Komfort — Seiten und Actions prüfen Modul + Rechte serverseitig selbst.
*
* Neue Backoffice-Seite ⇒ hier eintragen; `label` ist ein Schlüssel in messages nav.*.
* Mobile-Einträge (/m, /m/emergency) gehören NICHT hierher (eigene Mobile-Navigation).
*/
export interface NavItem {
href: string;
label: string;
icon: LucideIcon;
module?: ModuleKey;
permissions?: readonly Permission[];
section: "main" | "admin";
/** L13: indented sub entry of the preceding item */
sub?: boolean;
/** L13: active only on exactly this path (not on sub paths) */
exact?: boolean;
/** L17 Pakete: feature without own module that the package tier must include (planning) */
feature?: PlanFeature;
}
export const NAV_ITEMS: readonly NavItem[] = [
{ href: "/dashboard", label: "dashboard", icon: LayoutDashboard, section: "main" },
// L13 Planung: top-level entry with sub entries; backoffice (read_all) + team leads (report:approve_team, own crews read-only)
{ href: "/planning", label: "planning", icon: CalendarRange, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main" },
{ href: "/planning", label: "planningBoard", icon: LayoutGrid, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true, exact: true },
{ href: "/planning/live", label: "planningLive", icon: MapPinned, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true },
{
href: "/work-orders",
label: "workOrders",
icon: ClipboardList,
module: "work_orders",
permissions: ["work_order:read_all", "work_order:read_team"],
section: "main",
},
{ href: "/work-orders/time-approvals", label: "timeApprovals", icon: Timer, module: "work_orders", permissions: ["time:approve"], section: "main" },
{ href: "/billing", label: "billing", icon: Receipt, module: "billing", permissions: ["billing:read"], section: "main" }, // L14
{ href: "/work-orders/emergency-review", label: "emergencyReview", icon: Siren, module: "emergency", permissions: ["emergency:review"], section: "main" },
{ href: "/imports", label: "imports", icon: FileInput, module: "imports", permissions: ["import:write"], section: "main" },
{ href: "/customers", label: "customers", icon: Users, module: "customers", permissions: ["customer:read"], section: "main" },
{ href: "/sites", label: "sites", icon: Building2, module: "sites", permissions: ["site:read"], section: "main" },
{ href: "/teams", label: "teams", icon: UsersRound, module: "teams", permissions: ["team:read"], section: "main" },
{ href: "/reports", label: "reports", icon: FileText, module: "reports", permissions: ["report:read"], section: "main" },
{ href: "/documents", label: "documents", icon: FolderOpen, module: "documents", permissions: ["document:read"], section: "main" },
{ href: "/notifications", label: "notifications", icon: Bell, module: "notifications", permissions: ["notification:read"], section: "main" },
{ href: "/settings/order-types", label: "templates", icon: ListChecks, permissions: ["settings:templates"], section: "admin" },
{ href: "/settings", label: "settings", icon: Settings, permissions: ["tenant:manage"], section: "admin" },
{ href: "/settings/email", label: "email", icon: Mail, module: "notifications", permissions: ["tenant:manage"], section: "admin" },
{ href: "/settings/audit", label: "audit", icon: History, permissions: ["audit:read"], section: "admin" },
{ href: "/settings/lotse", label: "lotse", icon: Compass, permissions: ["tenant:manage"], section: "admin" },
{ href: "/settings/export", label: "dataExport", icon: Download, permissions: ["tenant:manage"], section: "admin" }, // L15
];
/**
* Filtert die Navigation nach aktiven Modulen und Rechten der Session. `disabledModules` = effektiv
* inaktive Module (L17: abgeschaltet ODER nicht in der Paketstufe), `lockedFeatures` = nicht enthaltene
* Paket-Features (Planung).
*/
export function visibleNavItems(
items: readonly NavItem[],
opts: { disabledModules: ReadonlySet<string>; permissions: readonly string[]; lockedFeatures?: ReadonlySet<string> },
): NavItem[] {
return items.filter(
(item) =>
(!item.module || !opts.disabledModules.has(item.module)) &&
(!item.feature || !opts.lockedFeatures?.has(item.feature)) &&
(!item.permissions?.length || item.permissions.some((p) => opts.permissions.includes(p))),
);
}