Files
FireAdministration/src/components/layout/seitenleiste.tsx
T
Florian WildeandClaude Opus 5 f7ccd9a908 Phase 8: Lokale KI
Wiki mit semantischer Suche ueber pgvector, Diktat ueber faster-whisper,
Berichtsassistent und Belegerfassung — alles gegen selbst betriebene
Modelle. Dazu zwei Protokollansichten: was die Maschine getan hat und was
Menschen getan haben.

Nichts, was ein Modell erzeugt, gelangt ungeprueft in einen Nachweis. Jede
Ausgabe ist zuerst ein eigener Datensatz, gekennzeichnet, mit zwei gleich
grossen Knoepfen davor. Die Datenbank haelt gegen: Eingabe, Ausgabe und
Modell sind unveraenderlich, geloescht wird nicht, entschieden wird einmal.

Kein Rueckfall auf einen Anbieter: fehlen die Container, fehlt die Funktion.
Dafuer bleibt alles andere ohne Modell vollstaendig benutzbar — geprueft von
vierzehn E2E-Tests, die ohne laufendes Modell durchlaufen.

Postgres bringt jetzt PostGIS und pgvector mit (eigenes Image), Ollama und
faster-whisper stehen im Compose-Profil "ki".

73 neue Unit-Tests, 14 neue E2E-Tests, Version 0.9.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 11:06:03 +02:00

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 = 8;
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)}
/>
)}
</>
);
}