Phase 6: alles, was einen Ort hat — Loeschwasser, Objekte mit Feuerwehrplan, die Lagekarte eines Einsatzes und die geplante Foerderstrecke. Keine Kacheln aus dem Netz. Wo eine Wehr ausrueckt und wo ihre Hydranten liegen, ist niemandes Geschaeft; der Kartenserver laeuft selbst (tileserver-gl im Compose-Profil "karte"). Ohne ihn zeigt die Karte einen leeren Grund und sagt das auch — Punkte, Entfernungen und Umkreissuche funktionieren trotzdem. Dasselbe gilt fuer einen eingetragenen, aber nicht erreichbaren Server. Die Umkreissuche laeuft ueber PostGIS: ST_DWithin auf der Geografie mit GiST-Index rechnet in Metern und nutzt den Index. Gefragt wird waehrend der Fahrt, und bei tausend Hydranten waere alles andere ein vollstaendiger Tabellendurchlauf. Der Import aus GeoJSON und Stadtwerke-CSV ist nachsichtig, aber ehrlich: er erkennt "DN 100", deutsche Kommazahlen, Datumsangaben und vertauschte Achsen — raet aber nichts. Fehlt die Position, wird die Zeile uebersprungen; ohne Statusangabe steht "ungeprueft", nicht "einsatzbereit". 22 taktische Zeichen nach FwDV 102 selbst gezeichnet, die Lagekarte gleicht ueber denselben Ereignisstrom ab wie die Atemschutzueberwachung. Die Wasserfoerderung rechnet Reibung, Hoehe und Pumpenzahl; das Ergebnis wird nie gespeichert, sondern bei jeder Anzeige neu gerechnet. Geht eine Strecke nicht auf, sagt die Ausgabe das. Behoben: die Reibungsbeiwerte lagen im ersten Wurf um den Faktor zehn daneben. Aufgefallen ist es nur, weil die Tests gegen die Tabellenwerte der Ausbildung pruefen und nicht gegen die eigene Implementierung. Dazu ein Abruf fuer abgelegte Dateien (/api/dateien/[id]) — bis hierher gab es nur den Upload. 59 neue Tests (48 Unit, 11 E2E je Browser). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
258 lines
8.0 KiB
TypeScript
258 lines
8.0 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import {
|
|
Album,
|
|
BookOpenCheck,
|
|
CalendarDays,
|
|
ClipboardCheck,
|
|
Flame,
|
|
Gauge,
|
|
GraduationCap,
|
|
Map,
|
|
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: ["ausbildung.lesen"],
|
|
abPhase: 7,
|
|
},
|
|
{ titel: "Kasse", pfad: "/kasse", symbol: Wallet, rechte: ["kasse.lesen"], abPhase: 9 },
|
|
{
|
|
// Das Audit-Log wird seit Phase 0 geschrieben, eine Ansicht dafür gibt es
|
|
// noch nicht — sie gehört zum Löschkonzept in Phase 8. Bis dahin steht der
|
|
// Eintrag ausgegraut da statt ins Leere zu führen.
|
|
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 = 6;
|
|
|
|
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)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|