Phase 3: Einsatz- und Uebungserfassung
Schnellerfassung im Alarmfall, sechs Zeitstempel mit Ausrueckzeit, Anfahrt und Hilfsfrist, Staerke nach FwDV 3, Personalvorschlag mit Qualifikationsstand, Einsatzbericht als PDF, Jahresexport fuer die Gemeinde, Statistik und Uebungen mit Anwesenheitsliste. Dazu das Einsatzportal fuer Fremdwehren: ein signierter Link ohne Anmeldung, zeitlich und mengenbegrenzt, der Stichwort, Ort, Zeiten und Lagemeldungen zeigt und Meldungen mit Foto annimmt. Es sind die ersten oeffentlich erreichbaren Endpunkte in FLORIAN. Acht neue Tabellen, alle mit organisationId und Row Level Security. Ein freigegebener Einsatzbericht ist unveraenderlich; ein Datenbanktrigger setzt das durch. Koordinaten liegen doppelt — als Dezimalgrad und als PostGIS-Punkt, zusammengehalten von einem Trigger. 57 neue Tests (32 Unit, 14 gegen eine echte Datenbank, 11 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bc62a9c1e8
commit
91954c402b
@@ -0,0 +1,201 @@
|
||||
"use client";
|
||||
|
||||
import type { EinsatzKategorie, EinsatzStatus, UebungsArt } from "@prisma/client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
EINSATZ_STATUS_TEXT,
|
||||
KATEGORIE_GRUPPE,
|
||||
KATEGORIE_TEXT,
|
||||
UEBUNGSART_TEXT,
|
||||
} from "@/lib/einsatz/texte";
|
||||
import { alsDauer } from "@/lib/einsatz/zeiten";
|
||||
|
||||
/**
|
||||
* Anzeigebausteine für Einsätze und Übungen.
|
||||
*
|
||||
* Die Farblehre bleibt dieselbe wie im Rest der Anwendung. Neu ist nur, was
|
||||
* Rot hier bedeutet: nicht „kaputt", sondern „läuft gerade" — ein laufender
|
||||
* Einsatz ist das Dringendste, was die Anwendung zeigen kann.
|
||||
*/
|
||||
|
||||
export function EinsatzStatusAbzeichen({ status }: { status: EinsatzStatus }) {
|
||||
const variante =
|
||||
status === "LAUFEND"
|
||||
? "gesperrt"
|
||||
: status === "ENTWURF"
|
||||
? "pruefungFaellig"
|
||||
: status === "FREIGEGEBEN"
|
||||
? "einsatzbereit"
|
||||
: "outline";
|
||||
|
||||
return <Badge variant={variante}>{EINSATZ_STATUS_TEXT[status]}</Badge>;
|
||||
}
|
||||
|
||||
export function KategorieAbzeichen({ kategorie }: { kategorie: EinsatzKategorie }) {
|
||||
const gruppe = KATEGORIE_GRUPPE[kategorie];
|
||||
const variante =
|
||||
gruppe === "brand" ? "gesperrt" : gruppe === "hilfe" ? "pruefungFaellig" : "secondary";
|
||||
|
||||
return <Badge variant={variante}>{KATEGORIE_TEXT[kategorie]}</Badge>;
|
||||
}
|
||||
|
||||
export function UebungsartAbzeichen({ art }: { art: UebungsArt }) {
|
||||
return <Badge variant="outline">{UEBUNGSART_TEXT[art]}</Badge>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Zeitleiste eines Einsatzes.
|
||||
*
|
||||
* Sechs Stempel nebeneinander. Was fehlt, bleibt sichtbar leer — das ist die
|
||||
* Aufforderung, es nachzutragen, und gleichzeitig die ehrliche Auskunft
|
||||
* darüber, was dokumentiert ist und was nicht.
|
||||
*/
|
||||
export function Zeitleiste({
|
||||
stempel,
|
||||
}: {
|
||||
stempel: Array<{ bezeichnung: string; uhrzeit: string | null; gesetzt: boolean }>;
|
||||
}) {
|
||||
return (
|
||||
<ol className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{stempel.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.bezeichnung}
|
||||
className={`rounded-md border p-3 text-center ${
|
||||
eintrag.gesetzt
|
||||
? "border-[var(--border)] bg-[var(--card)]"
|
||||
: "border-dashed border-[var(--border)] text-[var(--muted-foreground)]"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
{eintrag.bezeichnung}
|
||||
</div>
|
||||
<div className="tabellenzahlen text-lg font-semibold">{eintrag.uhrzeit ?? "—"}</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** Die drei Kennzahlen, die aus den Stempeln folgen. */
|
||||
export function Zeitkennzahlen({
|
||||
ausrueckzeit,
|
||||
anfahrtszeit,
|
||||
hilfsfrist,
|
||||
dauer,
|
||||
}: {
|
||||
ausrueckzeit: number | null;
|
||||
anfahrtszeit: number | null;
|
||||
hilfsfrist: number | null;
|
||||
dauer: number | null;
|
||||
}) {
|
||||
const werte = [
|
||||
{ text: "Ausrückzeit", wert: ausrueckzeit, hinweis: "Alarm bis Ausrücken" },
|
||||
{ text: "Anfahrt", wert: anfahrtszeit, hinweis: "Ausrücken bis Eintreffen" },
|
||||
{ text: "Hilfsfrist", wert: hilfsfrist, hinweis: "Alarm bis Eintreffen" },
|
||||
{ text: "Einsatzdauer", wert: dauer, hinweis: "Alarm bis Einrücken" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{werte.map((eintrag) => (
|
||||
<div
|
||||
key={eintrag.text}
|
||||
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-3"
|
||||
>
|
||||
<div className="tabellenzahlen text-xl font-semibold">{alsDauer(eintrag.wert)}</div>
|
||||
<div className="text-sm font-medium">{eintrag.text}</div>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">{eintrag.hinweis}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Stärke nach FwDV 3, groß dargestellt.
|
||||
*
|
||||
* Vier Zahlen, die jeder in der Wehr lesen kann — und darunter der Klartext
|
||||
* für alle anderen.
|
||||
*/
|
||||
export function StaerkeAnzeige({
|
||||
staerke,
|
||||
einheit,
|
||||
}: {
|
||||
staerke: { fuehrer: number; unterfuehrer: number; mannschaft: number; gesamt: number };
|
||||
einheit?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
|
||||
<div className="tabellenzahlen text-2xl font-semibold">
|
||||
{staerke.fuehrer}/{staerke.unterfuehrer}/{staerke.mannschaft}/{staerke.gesamt}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{staerke.fuehrer} Führer, {staerke.unterfuehrer} Unterführer, {staerke.mannschaft}{" "}
|
||||
Mannschaft
|
||||
{einheit ? ` · entspricht einer ${einheit}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hinweise zu Zeiten und Stärke — sichtbar, aber nicht blockierend. */
|
||||
export function Hinweisliste({ hinweise }: { hinweise: string[] }) {
|
||||
if (hinweise.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{hinweise.map((hinweis) => (
|
||||
<li key={hinweis}>{hinweis}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein einfaches Balkendiagramm.
|
||||
*
|
||||
* Bewusst ohne Diagrammbibliothek: zwölf Balken mit Beschriftung sind ein
|
||||
* Stück CSS, und jede Bibliothek dafür wäre ein zusätzliches Paket, das
|
||||
* gepflegt und mitgeliefert werden müsste — bei einer Anwendung ohne CDN
|
||||
* fällt das ins Gewicht.
|
||||
*/
|
||||
export function Balkendiagramm({
|
||||
werte,
|
||||
einheit = "",
|
||||
}: {
|
||||
werte: Array<{ beschriftung: string; wert: number }>;
|
||||
einheit?: string;
|
||||
}) {
|
||||
const hoechster = Math.max(1, ...werte.map((eintrag) => eintrag.wert));
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{werte.map((eintrag) => (
|
||||
<div key={eintrag.beschriftung} className="flex items-center gap-2 text-sm">
|
||||
{/* Breit genug für "Technische Hilfeleistung klein" — sonst sehen zwei
|
||||
Kategorien in der Statistik gleich aus. Der volle Text steht
|
||||
zusätzlich im title-Attribut. */}
|
||||
<span
|
||||
className="w-44 shrink-0 truncate text-[var(--muted-foreground)]"
|
||||
title={eintrag.beschriftung}
|
||||
>
|
||||
{eintrag.beschriftung}
|
||||
</span>
|
||||
<div className="h-4 flex-1 overflow-hidden rounded bg-[var(--muted)]">
|
||||
<div
|
||||
className="h-full bg-[var(--primary)]"
|
||||
style={{ width: `${(eintrag.wert / hoechster) * 100}%` }}
|
||||
role="presentation"
|
||||
/>
|
||||
</div>
|
||||
<span className="tabellenzahlen w-14 shrink-0 text-right">
|
||||
{eintrag.wert}
|
||||
{einheit}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ClipboardCheck,
|
||||
Flame,
|
||||
Gauge,
|
||||
GraduationCap,
|
||||
Map,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
@@ -81,6 +82,13 @@ const EINTRAEGE: Eintrag[] = [
|
||||
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,
|
||||
},
|
||||
{
|
||||
titel: "Terminal",
|
||||
pfad: "/kiosk",
|
||||
@@ -115,7 +123,7 @@ const EINTRAEGE: Eintrag[] = [
|
||||
|
||||
/** 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 = 2;
|
||||
const AKTUELLE_PHASE = 3;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
|
||||
Reference in New Issue
Block a user