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,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { rechteVon } from "@/lib/authz";
|
||||
import { Seitenleiste } from "@/components/layout/seitenleiste";
|
||||
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||
|
||||
/**
|
||||
* Layout für alles, was eine Anmeldung verlangt. Wer nicht angemeldet ist,
|
||||
* landet auf der Anmeldeseite — geprüft wird serverseitig, nicht im Browser.
|
||||
*/
|
||||
export default async function AngemeldetLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const organisation = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.organisation.findUnique({
|
||||
where: { id: sitzung.organisationId },
|
||||
select: { name: true, kurzname: true },
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Seitenleiste
|
||||
rechte={[...rechteVon(sitzung.rollen)]}
|
||||
rollen={sitzung.rollen}
|
||||
organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"}
|
||||
personName={sitzung.name}
|
||||
/>
|
||||
|
||||
<div className="md:pl-64">
|
||||
<header className="flex h-16 items-center justify-end gap-2 border-b border-[var(--border)] px-6 nicht-drucken">
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
<main className="p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { AlertTriangle, Truck, Users, Wrench } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { ROLLEN_BEZEICHNUNG } from "@/lib/authz";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { alsDatumZeit } from "@/lib/utils";
|
||||
|
||||
export const metadata = { title: "Übersicht" };
|
||||
|
||||
/** Startseite nach der Anmeldung. */
|
||||
export default async function UebersichtSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) return null;
|
||||
|
||||
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const [organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt] =
|
||||
await Promise.all([
|
||||
db.organisation.findUnique({ where: { id: sitzung.organisationId } }),
|
||||
db.person.count({ where: { aktiv: true, geloeschtAm: null } }),
|
||||
db.fahrzeug.count({ where: { aktiv: true, geloeschtAm: null } }),
|
||||
db.geraet.count({ where: { geloeschtAm: null } }),
|
||||
db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }),
|
||||
db.geraet.count({ where: { status: "GESPERRT", geloeschtAm: null } }),
|
||||
]);
|
||||
return { organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt };
|
||||
});
|
||||
|
||||
const kacheln = [
|
||||
{ titel: "Aktive Mitglieder", wert: daten.mitglieder, symbol: Users },
|
||||
{ titel: "Fahrzeuge", wert: daten.fahrzeuge, symbol: Truck },
|
||||
{ titel: "Geräte", wert: daten.geraete, symbol: Wrench },
|
||||
{ titel: "Prüfung fällig", wert: daten.pruefungFaellig, symbol: AlertTriangle },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{daten.organisation?.name ?? "Feuerwehr"}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Angemeldet als {sitzung.name} —{" "}
|
||||
{sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Sicherheitshinweis nach den Projektleitplanken. Er bleibt stehen, bis
|
||||
die Wehr die Anwendung ausdrücklich freigegeben hat.
|
||||
*/}
|
||||
<div
|
||||
role="note"
|
||||
className="flex items-start gap-3 rounded-lg border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-4"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="mt-0.5 size-5 shrink-0 text-[var(--pruefung-faellig)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Diese Installation ist noch nicht freigegeben.</p>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Atemschutzüberwachung und Alarmmonitor sind sicherheitsrelevant. Solange die
|
||||
Wehr das System nicht ausdrücklich freigegeben hat, bleibt die analoge
|
||||
Überwachung führend. FLORIAN ersetzt sie nicht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{kacheln.map((kachel) => {
|
||||
const Symbol = kachel.symbol;
|
||||
return (
|
||||
<Card key={kachel.titel}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{kachel.titel}
|
||||
</CardTitle>
|
||||
<Symbol className="size-4 text-[var(--muted-foreground)]" aria-hidden />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold tabellenzahlen">{kachel.wert}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stand des Aufbaus</CardTitle>
|
||||
<CardDescription>
|
||||
Phase 0 ist abgeschlossen: Fundament, Mandantentrennung, Anmeldung,
|
||||
Dateiablage und Protokollierung stehen. Die Fachmodule kommen Phase für
|
||||
Phase dazu.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="einsatzbereit">Phase 0 — Fundament</Badge>
|
||||
<Badge variant="outline">Phase 1 — Stammdaten</Badge>
|
||||
<Badge variant="outline">Phase 2 — Wartung und Prüfung</Badge>
|
||||
<Badge variant="outline">Phase 3 — Einsätze</Badge>
|
||||
<Badge variant="outline">Phase 4 — Terminal</Badge>
|
||||
<Badge variant="outline">Phase 5 — Atemschutz</Badge>
|
||||
</div>
|
||||
<dl className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-[var(--muted-foreground)]">Gesperrte Geräte</dt>
|
||||
<dd className="tabellenzahlen">{daten.gesperrt}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--muted-foreground)]">Zeitpunkt</dt>
|
||||
<dd className="tabellenzahlen">{alsDatumZeit(new Date())}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user