Files
FireAdministration/src/components/layout/seitenleiste.tsx
T

299 lines
8.7 KiB
TypeScript

"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Album,
BellRing,
BookMarked,
BookOpenCheck,
Bot,
CalendarDays,
ClipboardCheck,
Flame,
Gauge,
GraduationCap,
IdCard,
Map,
ReceiptText,
Menu,
MonitorSmartphone,
QrCode,
ShieldAlert,
Siren,
TriangleAlert,
Truck,
Wallet,
Users,
Wrench,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import type { Recht, Rolle } from "@/lib/authz";
/**
* Hauptnavigation.
*
* Welche Einträge jemand sieht, entscheidet NICHT diese Komponente. Sie bekommt
* die Rechte der Sitzung übergeben und blendet danach aus — die Prüfung selbst
* bleibt in lib/authz.ts, wie es die Projektregeln verlangen. Ein ausgeblendeter
* Eintrag ist Bequemlichkeit, kein Schutz; der Schutz sitzt in der tRPC-Schicht.
*
* Die Phasenangaben entsprechen den Dateien Phase-0.md bis Phase-9.md.
*/
interface Eintrag {
titel: string;
pfad: string;
symbol: React.ComponentType<{ className?: string }>;
/** Sichtbar, wenn eines dieser Rechte vorliegt. Leer = für alle. */
rechte?: Recht[];
/** Ab welcher Phase es die Ansicht wirklich gibt. */
abPhase?: number;
}
const EINTRAEGE: Eintrag[] = [
{ titel: "Übersicht", pfad: "/", symbol: Gauge },
{ titel: "Mitglieder", pfad: "/mitglieder", symbol: Users, rechte: ["person.lesen"], abPhase: 1 },
{ titel: "Fahrzeuge", pfad: "/fahrzeuge", symbol: Truck, rechte: ["fahrzeug.lesen"], abPhase: 1 },
{ titel: "Geräte", pfad: "/geraete", symbol: Wrench, rechte: ["geraet.lesen"], abPhase: 1 },
{
titel: "Qualifikationen",
pfad: "/qualifikationen",
symbol: BookOpenCheck,
rechte: ["qualifikation.lesen"],
abPhase: 1,
},
{
titel: "Prüfungen",
pfad: "/pruefungen",
symbol: ClipboardCheck,
rechte: ["pruefung.lesen"],
abPhase: 2,
},
{
titel: "Mängel",
pfad: "/maengel",
symbol: TriangleAlert,
rechte: ["pruefung.lesen"],
abPhase: 2,
},
{
titel: "Rückrufe",
pfad: "/rueckrufe",
symbol: ShieldAlert,
rechte: ["pruefung.lesen"],
abPhase: 2,
},
{ titel: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 },
{
titel: "Übungen",
pfad: "/uebungen",
symbol: GraduationCap,
rechte: ["ausbildung.lesen"],
abPhase: 3,
},
{
// Führt bewusst auf die Übersicht in der Verwaltung, nicht auf das
// Terminal selbst: das läuft am Wandtablet unter /kiosk und hat eine eigene
// Oberfläche. Wer hier klickt, will sehen, was dort zusammengekommen ist.
titel: "Terminal",
pfad: "/terminal",
symbol: MonitorSmartphone,
rechte: ["fahrtenbuch.lesen"],
abPhase: 4,
},
{
titel: "Anmeldemarke",
pfad: "/anmeldemarke",
symbol: QrCode,
rechte: ["person.eigene"],
abPhase: 4,
},
{
titel: "Atemschutz",
pfad: "/atemschutz",
symbol: Siren,
rechte: ["atemschutz.lesen"],
abPhase: 5,
},
{ titel: "Karte", pfad: "/karte", symbol: Map, rechte: ["einsatz.lesen"], abPhase: 6 },
{
titel: "Termine",
pfad: "/termine",
symbol: CalendarDays,
rechte: ["termin.lesen"],
abPhase: 7,
},
{
titel: "Dienstausweis",
pfad: "/ausweis",
symbol: IdCard,
rechte: ["person.eigene"],
abPhase: 7,
},
{
titel: "Benachrichtigungen",
pfad: "/benachrichtigungen",
symbol: BellRing,
rechte: ["benachrichtigung.eigene"],
abPhase: 7,
},
{
titel: "Wissen",
pfad: "/wiki",
symbol: BookMarked,
rechte: ["wiki.lesen"],
abPhase: 8,
},
{
titel: "Belege",
pfad: "/belege",
symbol: ReceiptText,
rechte: ["beleg.erfassen"],
abPhase: 8,
},
{
titel: "KI-Protokoll",
pfad: "/ki-protokoll",
symbol: Bot,
rechte: ["ki.protokoll"],
abPhase: 8,
},
{ titel: "Kasse", pfad: "/kasse", symbol: Wallet, rechte: ["kasse.lesen"], abPhase: 9 },
{
// Das Audit-Log wird seit Phase 0 geschrieben; die Ansicht dazu kam in
// Phase 8, zusammen mit dem KI-Protokoll. Beides nebeneinander beantwortet
// die beiden Fragen, die im Zweifel gestellt werden: was hat ein Mensch
// getan, und was hat die Maschine getan.
titel: "Protokoll",
pfad: "/protokoll",
symbol: Album,
rechte: ["system.auditlog"],
abPhase: 8,
},
];
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
const AKTUELLE_PHASE = 9;
export interface SeitenleisteProps {
rechte: Recht[];
rollen: Rolle[];
organisationName: string;
personName: string;
}
export function Seitenleiste({ rechte, organisationName, personName }: SeitenleisteProps) {
const pfad = usePathname();
const [offen, setOffen] = React.useState(false);
const rechteMenge = React.useMemo(() => new Set(rechte), [rechte]);
const sichtbar = EINTRAEGE.filter(
(eintrag) => !eintrag.rechte || eintrag.rechte.some((recht) => rechteMenge.has(recht)),
);
return (
<>
{/* Auf schmalen Geräten aufklappbar. */}
<Button
variant="ghost"
size="icon"
className="fixed left-3 top-3 z-50 md:hidden nicht-drucken"
onClick={() => setOffen((zustand) => !zustand)}
aria-label={offen ? "Navigation schließen" : "Navigation öffnen"}
aria-expanded={offen}
>
{offen ? <X aria-hidden /> : <Menu aria-hidden />}
</Button>
<aside
className={cn(
"fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-[var(--border)] bg-[var(--card)] transition-transform nicht-drucken",
"md:translate-x-0",
offen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="flex h-16 items-center gap-3 border-b border-[var(--border)] px-6">
<Flame className="size-6 text-[var(--primary)]" aria-hidden />
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-tight">FLORIAN</p>
<p className="truncate text-xs text-[var(--muted-foreground)]">
{organisationName}
</p>
</div>
</div>
<nav className="flex-1 overflow-y-auto p-3" aria-label="Hauptnavigation">
<ul className="space-y-1">
{sichtbar.map((eintrag) => {
const aktiv =
eintrag.pfad === "/" ? pfad === "/" : pfad?.startsWith(eintrag.pfad);
const nochNicht = (eintrag.abPhase ?? 0) > AKTUELLE_PHASE;
const Symbol = eintrag.symbol;
if (nochNicht) {
return (
<li key={eintrag.pfad}>
<span
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-[var(--muted-foreground)] opacity-60"
title={`Kommt in Phase ${eintrag.abPhase}`}
>
<Symbol className="size-4 shrink-0" aria-hidden />
<span className="truncate">{eintrag.titel}</span>
<span className="ml-auto text-[10px] uppercase tracking-wide">
P{eintrag.abPhase}
</span>
</span>
</li>
);
}
return (
<li key={eintrag.pfad}>
<Link
href={eintrag.pfad}
onClick={() => setOffen(false)}
aria-current={aktiv ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
aktiv
? "bg-[var(--primary)] text-[var(--primary-foreground)] font-medium"
: "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
)}
>
<Symbol className="size-4 shrink-0" aria-hidden />
<span className="truncate">{eintrag.titel}</span>
</Link>
</li>
);
})}
</ul>
</nav>
<div className="border-t border-[var(--border)] p-4">
<p className="truncate text-sm font-medium">{personName}</p>
<Link
href="/api/auth/signout"
className="text-xs text-[var(--muted-foreground)] underline-offset-4 hover:underline"
>
Abmelden
</Link>
</div>
</aside>
{offen && (
<button
type="button"
aria-label="Navigation schließen"
className="fixed inset-0 z-30 bg-black/40 md:hidden"
onClick={() => setOffen(false)}
/>
)}
</>
);
}