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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { KeyRound, LogIn } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export interface AnmeldeFormularProps {
|
||||
oidcVerfuegbar: boolean;
|
||||
oidcName: string;
|
||||
lokalVerfuegbar: boolean;
|
||||
fehler?: string;
|
||||
weiterZu?: string;
|
||||
}
|
||||
|
||||
/** Wandelt die Fehlerkennungen von Auth.js in verständliches Deutsch. */
|
||||
function fehlertext(kennung?: string): string | null {
|
||||
if (!kennung) return null;
|
||||
switch (kennung) {
|
||||
case "CredentialsSignin":
|
||||
return "E-Mail-Adresse oder Passwort stimmt nicht. Nach mehreren Fehlversuchen wird das Konto vorübergehend gesperrt.";
|
||||
case "AccessDenied":
|
||||
return "Für dieses Konto gibt es in FLORIAN keinen Zugang. Bitte beim Kommandanten melden.";
|
||||
case "OAuthAccountNotLinked":
|
||||
return "Dieses Konto ist bereits mit einem anderen Anmeldeweg verknüpft.";
|
||||
case "Configuration":
|
||||
return "Die Anmeldung ist nicht vollständig eingerichtet. Bitte in der Serverkonfiguration prüfen.";
|
||||
default:
|
||||
return "Die Anmeldung ist fehlgeschlagen. Bitte erneut versuchen.";
|
||||
}
|
||||
}
|
||||
|
||||
export function AnmeldeFormular({
|
||||
oidcVerfuegbar,
|
||||
oidcName,
|
||||
lokalVerfuegbar,
|
||||
fehler,
|
||||
weiterZu,
|
||||
}: AnmeldeFormularProps) {
|
||||
const router = useRouter();
|
||||
const [laeuft, setLaeuft] = React.useState(false);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(fehlertext(fehler));
|
||||
|
||||
async function lokalAnmelden(ereignis: React.FormEvent<HTMLFormElement>) {
|
||||
ereignis.preventDefault();
|
||||
setMeldung(null);
|
||||
setLaeuft(true);
|
||||
|
||||
const daten = new FormData(ereignis.currentTarget);
|
||||
const ergebnis = await signIn("passwort", {
|
||||
email: String(daten.get("email") ?? ""),
|
||||
passwort: String(daten.get("passwort") ?? ""),
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
setLaeuft(false);
|
||||
|
||||
if (ergebnis?.error) {
|
||||
setMeldung(fehlertext("CredentialsSignin"));
|
||||
return;
|
||||
}
|
||||
router.push(weiterZu ?? "/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{meldung && (
|
||||
<p
|
||||
role="alert"
|
||||
data-testid="anmeldefehler"
|
||||
className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm text-[var(--destructive)]"
|
||||
>
|
||||
{meldung}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oidcVerfuegbar && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={laeuft}
|
||||
onClick={() => {
|
||||
setLaeuft(true);
|
||||
void signIn("authentik", { callbackUrl: weiterZu ?? "/" });
|
||||
}}
|
||||
>
|
||||
<KeyRound aria-hidden />
|
||||
{oidcName}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{oidcVerfuegbar && lokalVerfuegbar && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-[var(--border)]" />
|
||||
<span className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
oder
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[var(--border)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lokalVerfuegbar && (
|
||||
<form onSubmit={lokalAnmelden} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-Mail-Adresse</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
placeholder="kommandant@beispielwehr.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="passwort">Passwort</Label>
|
||||
<Input
|
||||
id="passwort"
|
||||
name="passwort"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={laeuft}>
|
||||
<LogIn aria-hidden />
|
||||
{laeuft ? "Wird geprüft ..." : "Anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!oidcVerfuegbar && !lokalVerfuegbar && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Es ist kein Anmeldeweg eingerichtet. Bitte in der Serverkonfiguration
|
||||
AUTH_LOCAL_ENABLED oder AUTH_OIDC_ENABLED setzen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Am Terminal im Gerätehaus wird stattdessen mit PIN oder Ausweiskarte
|
||||
angemeldet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Flame } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { env, oidcVerfuegbar } from "@/lib/env";
|
||||
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card";
|
||||
import { AnmeldeFormular } from "@/app/anmelden/formular";
|
||||
|
||||
export const metadata = { title: "Anmelden" };
|
||||
|
||||
/**
|
||||
* Anmeldeseite. Zeigt genau die Wege, die in dieser Installation eingerichtet
|
||||
* sind: OIDC gegen authentik, lokaler Login, oder beides.
|
||||
*/
|
||||
export default async function AnmeldenSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string; callbackUrl?: string }>;
|
||||
}) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (sitzung) redirect("/");
|
||||
|
||||
const { error, callbackUrl } = await searchParams;
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[var(--muted)] p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Flame className="size-8 text-[var(--primary)]" aria-hidden />
|
||||
<div>
|
||||
{/* Echte h1: die Seite braucht eine Ueberschrift, die
|
||||
Hilfsmittel und Tests ansteuern koennen. */}
|
||||
<h1 className="text-xl font-semibold leading-none tracking-tight">
|
||||
FLORIAN
|
||||
</h1>
|
||||
<CardDescription className="mt-1.5">Feuerwehrverwaltung</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<AnmeldeFormular
|
||||
oidcVerfuegbar={oidcVerfuegbar}
|
||||
oidcName={env.AUTH_OIDC_NAME}
|
||||
lokalVerfuegbar={env.AUTH_LOCAL_ENABLED}
|
||||
fehler={error}
|
||||
weiterZu={callbackUrl}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Auth.js-Endpunkte. Die Rückleitung von authentik landet hier unter
|
||||
* /api/auth/callback/authentik — genau diese Adresse muss im authentik-Provider
|
||||
* als Redirect-URI eingetragen sein.
|
||||
*/
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { datenbankErreichbar } from "@/lib/db";
|
||||
import { speicherErreichbar } from "@/lib/storage";
|
||||
import { warteschlangeErreichbar } from "@/lib/warteschlange";
|
||||
|
||||
/**
|
||||
* Erreichbarkeitsprüfung für Docker, Traefik und die Überwachung.
|
||||
* 200, wenn Datenbank, Objektspeicher und Warteschlange antworten — sonst 503.
|
||||
* Bewusst ohne personenbezogene Angaben, damit der Endpunkt offen sein kann.
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function GET() {
|
||||
const beginn = Date.now();
|
||||
|
||||
const [datenbank, speicher, warteschlange] = await Promise.all([
|
||||
datenbankErreichbar(),
|
||||
speicherErreichbar(),
|
||||
warteschlangeErreichbar(),
|
||||
]);
|
||||
|
||||
const gesund = datenbank && speicher && warteschlange;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: gesund ? "ok" : "gestoert",
|
||||
dienste: { datenbank, speicher, warteschlange },
|
||||
dauerMs: Date.now() - beginn,
|
||||
zeitpunkt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
status: gesund ? 200 : 503,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { appRouter } from "@/server/routers/_app";
|
||||
import { kontextErzeugen } from "@/server/context";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/** Einstiegspunkt für alle internen tRPC-Aufrufe. */
|
||||
function verarbeiten(anfrage: Request) {
|
||||
return fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req: anfrage,
|
||||
router: appRouter,
|
||||
createContext: () => kontextErzeugen({ req: anfrage }),
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(`[trpc] Fehler in ${path ?? "<unbekannt>"}:`, error.message);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export { verarbeiten as GET, verarbeiten as POST };
|
||||
@@ -0,0 +1,170 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
Farbwelt FLORIAN.
|
||||
Grundton ist das Rot der Feuerwehr, bewusst entsättigt — eine Oberfläche, in
|
||||
der jemand acht Stunden Prüfungen dokumentiert, darf nicht schreien. Signalrot
|
||||
bleibt für das reserviert, was wirklich Alarm ist: gesperrte Geräte,
|
||||
überzogene Fristen, laufende Atemschutzüberwachung.
|
||||
|
||||
Keine Google Fonts, keine externen CDNs — es werden ausschließlich Schriften
|
||||
benutzt, die auf dem Gerät vorhanden sind.
|
||||
*/
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Statusfarben der Gerätezustände — überall dieselben. */
|
||||
--color-einsatzbereit: var(--einsatzbereit);
|
||||
--color-pruefung-faellig: var(--pruefung-faellig);
|
||||
--color-gesperrt: var(--gesperrt);
|
||||
--color-ausgesondert: var(--ausgesondert);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--font-sans: "Inter", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Cascadia Code", Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
|
||||
--background: oklch(0.99 0.002 60);
|
||||
--foreground: oklch(0.21 0.01 40);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.21 0.01 40);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.21 0.01 40);
|
||||
|
||||
/* Feuerwehrrot, für Flächen nutzbar gedämpft. */
|
||||
--primary: oklch(0.52 0.19 27);
|
||||
--primary-foreground: oklch(0.99 0.005 60);
|
||||
|
||||
--secondary: oklch(0.96 0.005 60);
|
||||
--secondary-foreground: oklch(0.28 0.015 40);
|
||||
--muted: oklch(0.96 0.005 60);
|
||||
--muted-foreground: oklch(0.53 0.012 45);
|
||||
--accent: oklch(0.95 0.012 40);
|
||||
--accent-foreground: oklch(0.28 0.015 40);
|
||||
|
||||
--destructive: oklch(0.58 0.23 27);
|
||||
--destructive-foreground: oklch(0.99 0.005 60);
|
||||
|
||||
--border: oklch(0.91 0.005 60);
|
||||
--input: oklch(0.91 0.005 60);
|
||||
--ring: oklch(0.52 0.19 27);
|
||||
|
||||
--einsatzbereit: oklch(0.62 0.15 150);
|
||||
--pruefung-faellig: oklch(0.72 0.16 75);
|
||||
--gesperrt: oklch(0.58 0.23 27);
|
||||
--ausgesondert: oklch(0.6 0.01 60);
|
||||
}
|
||||
|
||||
/*
|
||||
Dunkle Darstellung. Nicht bloß invertiert: im Gerätehaus wird abends und
|
||||
nachts gearbeitet, deshalb ein warmes, kontrastarmes Grau statt Schwarz —
|
||||
das blendet nicht, wenn man vom Fahrzeug hereinkommt.
|
||||
*/
|
||||
.dark {
|
||||
--background: oklch(0.17 0.008 40);
|
||||
--foreground: oklch(0.94 0.004 60);
|
||||
--card: oklch(0.21 0.009 40);
|
||||
--card-foreground: oklch(0.94 0.004 60);
|
||||
--popover: oklch(0.21 0.009 40);
|
||||
--popover-foreground: oklch(0.94 0.004 60);
|
||||
|
||||
--primary: oklch(0.63 0.19 27);
|
||||
--primary-foreground: oklch(0.98 0.005 60);
|
||||
|
||||
--secondary: oklch(0.26 0.01 40);
|
||||
--secondary-foreground: oklch(0.94 0.004 60);
|
||||
--muted: oklch(0.26 0.01 40);
|
||||
--muted-foreground: oklch(0.68 0.01 50);
|
||||
--accent: oklch(0.29 0.012 40);
|
||||
--accent-foreground: oklch(0.94 0.004 60);
|
||||
|
||||
--destructive: oklch(0.65 0.21 27);
|
||||
--destructive-foreground: oklch(0.98 0.005 60);
|
||||
|
||||
--border: oklch(0.29 0.01 40);
|
||||
--input: oklch(0.29 0.01 40);
|
||||
--ring: oklch(0.63 0.19 27);
|
||||
|
||||
--einsatzbereit: oklch(0.68 0.15 150);
|
||||
--pruefung-faellig: oklch(0.78 0.15 75);
|
||||
--gesperrt: oklch(0.65 0.21 27);
|
||||
--ausgesondert: oklch(0.55 0.01 60);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Tastaturbedienung muss sichtbar sein — am Terminal wird auch getabbt. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Zahlen in Tabellen sollen untereinander stehen. */
|
||||
table,
|
||||
.tabellenzahlen {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* Am Kiosk-Terminal wird mit dem Finger bedient: größere Ziele, keine Auswahl. */
|
||||
.kiosk {
|
||||
--radius: 0.875rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.kiosk button,
|
||||
.kiosk [role="button"] {
|
||||
min-height: 3.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.nicht-drucken {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/layout/theme-provider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "FLORIAN — Feuerwehrverwaltung",
|
||||
template: "%s · FLORIAN",
|
||||
},
|
||||
description:
|
||||
"Verwaltungsplattform für Freiwillige Feuerwehren: Mitglieder, Fahrzeuge, Geräte, Prüfungen, Atemschutz, Einsätze.",
|
||||
applicationName: "FLORIAN",
|
||||
// Keine Suchmaschinen: die Installation steht im Gerätehaus, nicht im Netz.
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
// Am Terminal soll nicht versehentlich gezoomt werden.
|
||||
maximumScale: 5,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#1c1a19" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="de" suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user