299 lines
8.7 KiB
TypeScript
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)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|