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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
/**
|
||||
* Hell/Dunkel. Standard ist die Systemeinstellung — wer im Gerätehaus abends
|
||||
* am Terminal steht, bekommt dann von selbst die dunkle Darstellung.
|
||||
*/
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/** Umschalter zwischen heller und dunkler Darstellung. */
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [bereit, setBereit] = React.useState(false);
|
||||
|
||||
// Vor der Hydratation ist das Thema unbekannt — sonst blinkt das Symbol.
|
||||
React.useEffect(() => setBereit(true), []);
|
||||
|
||||
const dunkel = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(dunkel ? "light" : "dark")}
|
||||
aria-label={dunkel ? "Zur hellen Darstellung wechseln" : "Zur dunklen Darstellung wechseln"}
|
||||
title={dunkel ? "Helle Darstellung" : "Dunkle Darstellung"}
|
||||
>
|
||||
{bereit && dunkel ? <Sun aria-hidden /> : <Moon aria-hidden />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Statusabzeichen. Die Gerätezustände haben feste Farben, die überall gleich
|
||||
* bedeuten — grün einsatzbereit, gelb Prüfung fällig, rot gesperrt, grau
|
||||
* ausgesondert. Wer die Farbe kennt, muss den Text nicht lesen.
|
||||
*/
|
||||
const badgeVarianten = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-[var(--primary)] text-[var(--primary-foreground)]",
|
||||
secondary:
|
||||
"border-transparent bg-[var(--secondary)] text-[var(--secondary-foreground)]",
|
||||
outline: "text-[var(--foreground)] border-[var(--border)]",
|
||||
einsatzbereit:
|
||||
"border-transparent bg-[var(--einsatzbereit)]/15 text-[var(--einsatzbereit)]",
|
||||
pruefungFaellig:
|
||||
"border-transparent bg-[var(--pruefung-faellig)]/20 text-[var(--pruefung-faellig)]",
|
||||
gesperrt: "border-transparent bg-[var(--gesperrt)]/15 text-[var(--gesperrt)]",
|
||||
ausgesondert:
|
||||
"border-transparent bg-[var(--ausgesondert)]/15 text-[var(--ausgesondert)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVarianten> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVarianten({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVarianten };
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVarianten = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 shadow-sm",
|
||||
destructive:
|
||||
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90 shadow-sm",
|
||||
outline:
|
||||
"border border-[var(--border)] bg-[var(--background)] hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
|
||||
secondary:
|
||||
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80",
|
||||
ghost: "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
|
||||
link: "text-[var(--primary)] underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
/** Für das Terminal im Gerätehaus — mit Handschuhen bedienbar. */
|
||||
kiosk: "h-16 rounded-xl px-8 text-lg",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVarianten> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Komponente = asChild ? Slot : "button";
|
||||
return (
|
||||
<Komponente
|
||||
className={cn(buttonVarianten({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVarianten };
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-[var(--muted-foreground)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-1 text-sm shadow-sm transition-colors",
|
||||
"placeholder:text-[var(--muted-foreground)]",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--background)]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
Reference in New Issue
Block a user