Phase 0: Fundament der Feuerwehr-Verwaltungsplattform
Legt den kompletten Unterbau an, auf dem die Fachmodule aufsetzen. Betrieb: - docker-compose.yml mit Traefik, App, Worker, PostgreSQL 17 + PostGIS, Redis, MinIO und Gotenberg; Entwicklungs-Override ohne Traefik, dafuer mit Mailpit und Adminer - Mehrstufiges Dockerfile: eigener Benutzer, tini als PID 1, Healthcheck - .env.example mit einem Kommentar zu jeder Variable Mandantentrennung: - Row Level Security auf allen mandantenpflichtigen Tabellen - Zwei getrennte Datenbankverbindungen: florian_app mit aktiver RLS fuer die Anwendung, Eigentuemer-Zugang nur fuer Migration, Seed und Anmeldung - fuerOrganisation() als einziger Weg zu Fachdaten Nachvollziehbarkeit: - AuditLog append-only per Trigger; UPDATE ist dauerhaft gesperrt, DELETE nur im gekennzeichneten Aufbewahrungslauf - Geheimnisse werden vor dem Schreiben entfernt Anmeldung: - OIDC gegen ein extern betriebenes authentik, Rollen aus Gruppen nur als Vorbelegung beim ersten Login - Lokaler E-Mail/Passwort-Login als Rueckfallweg - Terminal per PIN und RFID mit zwei Schranken (Geraet, dann Person) Weiteres: - lib/authz.ts mit 9 Rollen und 38 Einzelrechten - lib/storage.ts (MinIO), lib/audit.ts, lib/warteschlange.ts (BullMQ) - Integrationen fuer Mail, Telegram und LLM hinter abschaltbaren Interfaces - tRPC v11, Basis-Layout mit Seitenleiste und dunkler Darstellung - Seed mit einer Beispielwehr: 16 Personen, 3 Fahrzeuge, 40 Geraete - 32 Vitest-Tests (davon 8 gegen eine echte Datenbank), 10 Playwright-Tests - README, Anleitung mit Screenshots, Roadmap, Aufwandsschaetzung, Devlog, Versionierungsregeln und Changelog Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Album,
|
||||
BookOpenCheck,
|
||||
CalendarDays,
|
||||
ClipboardCheck,
|
||||
Flame,
|
||||
Gauge,
|
||||
Map,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
Siren,
|
||||
Truck,
|
||||
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-8.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: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 },
|
||||
{
|
||||
titel: "Terminal",
|
||||
pfad: "/kiosk",
|
||||
symbol: MonitorSmartphone,
|
||||
rechte: ["kiosk.erfassen"],
|
||||
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: "Protokoll",
|
||||
pfad: "/protokoll",
|
||||
symbol: Album,
|
||||
rechte: ["system.auditlog"],
|
||||
abPhase: 1,
|
||||
},
|
||||
];
|
||||
|
||||
/** In Phase 0 gibt es nur die Übersicht — alles andere ist noch Ausblick. */
|
||||
const AKTUELLE_PHASE = 0;
|
||||
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user