Phase 1: Stammdatenverwaltung

Ab hier fuehrt die Wehr ihren Bestand in FLORIAN.

Datenmodell:
- Standort, Geraeteraum, Geraetetyp, Qualifikationstyp und Qualifikation
- Fahrzeug um Fahrgestellnummer, DIN-Typ, Erstzulassung, Sitzplaetze,
  Tankinhalt und UVV-Frist erweitert
- Geraet um Geraetetyp, Lebensdauerkategorie, errechnetes Lebensdauerende,
  Anschaffungspreis und die Lagerung erweitert
- Ein Geraet steht entweder an einem Standort oder ist auf einem Fahrzeug
  verlastet. Eine Pruefbedingung setzt das durch, ein Trigger stellt zusaetzlich
  sicher, dass ein angegebener Geraeteraum zum angegebenen Fahrzeug gehoert.
- RLS auf allen neuen Tabellen, Trigram-Indizes fuer die Volltextsuche

Fachlogik:
- lib/stammdaten/fristen.ts rechnet Prueftermine, Lebensdauer und den
  abgeleiteten Geraetestatus. Der Geraetetyp gibt vor, das Geraet darf
  abweichen.
- lib/stammdaten/qr.ts und qr-parser.ts erzeugen und lesen die Codes
- lib/stammdaten/etiketten.ts baut den A4-Bogen mit 65 Etiketten
- lib/stammdaten/import.ts liest CSV und Excel, erkennt deutsche Datums- und
  Zahlenformate, Windows-1252 und Dubletten innerhalb der Datei
- lib/pdf.ts spricht Gotenberg an

Oberflaeche:
- Geraeteliste mit Volltextsuche, Statusfilter, serverseitiger Sortierung,
  Blaettern, Spaltenauswahl und Massenbearbeitung
- Geraetedetail mit QR-Code, Pruefvorgabe aus dem Geraetetyp und Verlauf
- Fahrzeugliste und -detail mit Beladung nach Beladeordnung
- Mitgliederliste nach Abteilung, Mitgliedsdetail mit Qualifikationen
- Qualifikationen mit Ablaufwarnung je Vorwarnzeit des Typs
- Import-Assistent mit Zuordnung und Vorschau vor dem Uebernehmen
- QR-Scanner ueber die Kamera, mit Eingabefeld als Rueckfallweg

Behoben:
- Fristenrechnung lief ueber startOfDay aus date-fns und damit in der Zeitzone
  des Servers. Ein DATE aus der Datenbank kommt als UTC-Mitternacht und wurde
  dadurch auf den Vortag geschoben — Fristen waeren einen Tag zu frueh faellig
  geworden. Jetzt eigene Kalenderarithmetik auf UTC-Bestandteilen.
- ctx.sitzung blieb in tRPC-Prozeduren nullbar, weil t.middleware() die
  Verengung der vorigen Middleware verwirft. Middleware jetzt direkt an .use().
- CardTitle war ein div und damit keine Ueberschrift. Wer mit einer
  Sprachausgabe arbeitet, springt von Ueberschrift zu Ueberschrift; Karten
  tauchten in dieser Navigation nicht auf.

Daten und Tests:
- Seed um 3 Standorte, 20 Geraeteraeume, 14 Geraetetypen mit echten
  Pruefvorgaben, 49 Geraete und 107 Qualifikationen erweitert
- 104 Vitest-Tests (vorher 32), 44 Playwright-Tests (vorher 10)
- Anleitung um sieben bebilderte Abschnitte erweitert, Changelog, Devlog,
  Aufwandsschaetzung und Roadmap fortgeschrieben

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 08:31:08 +02:00
co-authored by Claude Opus 5
parent 4b3021bb50
commit 26b62a3477
79 changed files with 11738 additions and 217 deletions
+202
View File
@@ -0,0 +1,202 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft, Package } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { FristAnzeige, StatusAbzeichen } from "@/components/stammdaten/anzeige";
export const metadata = { title: "Fahrzeug" };
/**
* Fahrzeugdetail mit der Beladung, nach Geräteräumen geordnet.
*
* Genau so sucht der Gerätewart: nicht "wo ist die Motorsäge", sondern "was
* ist in G3". Deshalb steht die Beladeordnung im Mittelpunkt und nicht eine
* alphabetische Geräteliste.
*/
export default async function FahrzeugSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
const fahrzeug = await fuerOrganisation(sitzung.organisationId, (db) =>
db.fahrzeug.findUnique({
where: { id },
include: {
standort: { select: { id: true, name: true } },
geraeteraeume: {
where: { geloeschtAm: null },
orderBy: [{ sortierung: "asc" }, { bezeichnung: "asc" }],
include: {
geraete: {
where: { geloeschtAm: null },
orderBy: { bezeichnung: "asc" },
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
naechstePruefung: true,
},
},
},
},
geraete: {
where: { geloeschtAm: null, geraeteraumId: null },
orderBy: { bezeichnung: "asc" },
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
naechstePruefung: true,
},
},
},
}),
);
if (!fahrzeug) notFound();
const angaben: Array<[string, React.ReactNode]> = [
["Typ nach DIN", fahrzeug.dinTyp ?? "—"],
["Kennzeichen", <span className="tabellenzahlen">{fahrzeug.kennzeichen ?? "—"}</span>],
["Hersteller", fahrzeug.hersteller ?? "—"],
["Aufbauhersteller", fahrzeug.aufbauhersteller ?? "—"],
[
"Fahrgestellnummer",
<span className="tabellenzahlen">{fahrzeug.fahrgestellnummer ?? "—"}</span>,
],
["Baujahr", <span className="tabellenzahlen">{fahrzeug.baujahr ?? "—"}</span>],
["Erstzulassung", alsDatum(fahrzeug.erstzulassung)],
["Indienststellung", alsDatum(fahrzeug.indienststellung)],
["Nächster TÜV", <FristAnzeige datum={fahrzeug.tuevBis} vorwarnTage={60} />],
["Sicherheitsprüfung (UVV)", <FristAnzeige datum={fahrzeug.uvvBis} vorwarnTage={60} />],
["Sitzplätze", <span className="tabellenzahlen">{fahrzeug.sitzplaetze ?? "—"}</span>],
[
"Löschwasser",
fahrzeug.tankinhalt ? (
<span className="tabellenzahlen">{fahrzeug.tankinhalt.toLocaleString("de-DE")} l</span>
) : (
"—"
),
],
["Standort", fahrzeug.standort?.name ?? "—"],
];
const alleRaeume = [
...fahrzeug.geraeteraeume,
// Was keinem Raum zugeordnet ist, bekommt einen eigenen Abschnitt statt
// stillschweigend zu verschwinden.
...(fahrzeug.geraete.length > 0
? [
{
id: "__ohne__",
bezeichnung: "Ohne Raumangabe",
beschreibung: "Verlastet, aber keinem Geräteraum zugeordnet",
geraete: fahrzeug.geraete,
},
]
: []),
];
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/fahrzeuge">
<ArrowLeft aria-hidden />
Zurück zu den Fahrzeugen
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{fahrzeug.funkrufname}</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{fahrzeug.dinTyp ? `${fahrzeug.dinTyp} — ` : ""}
{fahrzeug.bezeichnung}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Angaben</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
{angaben.map(([bezeichnung, wert]) => (
<div key={bezeichnung}>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{bezeichnung}
</dt>
<dd className="text-sm">{wert}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Beladung</CardTitle>
<CardDescription>
Nach Geräteräumen geordnet, in der Reihenfolge der Beladeordnung.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{alleRaeume.length === 0 && (
<p className="text-sm text-[var(--muted-foreground)]">
Für dieses Fahrzeug sind noch keine Geräteräume angelegt.
</p>
)}
{alleRaeume.map((raum) => (
<div key={raum.id}>
<div className="mb-2 flex items-baseline gap-2">
<Badge variant="secondary">{raum.bezeichnung}</Badge>
<span className="text-xs text-[var(--muted-foreground)]">
{raum.beschreibung}
</span>
<span className="ml-auto text-xs text-[var(--muted-foreground)]">
{raum.geraete.length}{" "}
{raum.geraete.length === 1 ? "Gerät" : "Geräte"}
</span>
</div>
{raum.geraete.length === 0 ? (
<p className="pl-1 text-sm text-[var(--muted-foreground)]">leer</p>
) : (
<ul className="divide-y divide-[var(--border)] rounded-md border border-[var(--border)]">
{raum.geraete.map((geraet) => (
<li
key={geraet.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<Package className="size-4 text-[var(--muted-foreground)]" aria-hidden />
<Link
href={`/geraete/${geraet.id}`}
className="font-medium underline-offset-4 hover:underline"
>
{geraet.bezeichnung}
</Link>
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
{geraet.inventarnummer}
</span>
<span className="ml-auto flex items-center gap-3">
<FristAnzeige datum={geraet.naechstePruefung} />
<StatusAbzeichen status={geraet.status} />
</span>
</li>
))}
</ul>
)}
</div>
))}
</CardContent>
</Card>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { Truck } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
export const metadata = { title: "Fahrzeuge" };
const ART_TEXT: Record<string, string> = {
LOESCHFAHRZEUG: "Löschfahrzeug",
DREHLEITER: "Drehleiter",
RUESTWAGEN: "Rüstwagen",
GERAETEWAGEN: "Gerätewagen",
MANNSCHAFTSTRANSPORT: "Mannschaftstransport",
EINSATZLEITWAGEN: "Einsatzleitwagen",
WECHSELLADER: "Wechsellader",
ANHAENGER: "Anhänger",
SONSTIGES: "Sonstiges",
};
/**
* Fahrzeugübersicht als Kacheln statt als Tabelle.
*
* Eine Wehr hat drei bis zehn Fahrzeuge, nicht dreihundert. Für diese Menge
* ist eine Kachel mit den wichtigen Terminen brauchbarer als eine Zeile: der
* TÜV-Termin und die Zahl der verlasteten Geräte sind das, wonach man sucht.
*/
export default async function FahrzeugeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const fahrzeuge = await fuerOrganisation(sitzung.organisationId, (db) =>
db.fahrzeug.findMany({
where: { geloeschtAm: null },
orderBy: { funkrufname: "asc" },
include: {
standort: { select: { name: true } },
_count: { select: { geraete: true, geraeteraeume: true } },
},
}),
);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Fahrzeuge</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Einsatzfahrzeuge der Wehr mit Terminen und verlasteten Geräten.
</p>
</div>
{fahrzeuge.length === 0 ? (
<NichtsGefunden
titel="Noch keine Fahrzeuge erfasst"
hinweis="Fahrzeuge lassen sich einzeln anlegen oder zusammen mit den Geräten importieren."
/>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{fahrzeuge.map((fahrzeug) => (
<Card key={fahrzeug.id} className={!fahrzeug.aktiv ? "opacity-60" : undefined}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="truncate">
<Link
href={`/fahrzeuge/${fahrzeug.id}`}
className="underline-offset-4 hover:underline"
>
{fahrzeug.funkrufname}
</Link>
</CardTitle>
<CardDescription className="truncate">
{fahrzeug.dinTyp ? `${fahrzeug.dinTyp} — ` : ""}
{fahrzeug.bezeichnung}
</CardDescription>
</div>
<Truck className="size-5 shrink-0 text-[var(--muted-foreground)]" aria-hidden />
</div>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">{ART_TEXT[fahrzeug.art] ?? fahrzeug.art}</Badge>
{fahrzeug.kennzeichen && (
<Badge variant="outline" className="tabellenzahlen">
{fahrzeug.kennzeichen}
</Badge>
)}
{!fahrzeug.aktiv && <Badge variant="ausgesondert">außer Dienst</Badge>}
</div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Nächster TÜV
</dt>
<dd>
<FristAnzeige datum={fahrzeug.tuevBis} vorwarnTage={60} />
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Baujahr
</dt>
<dd className="tabellenzahlen">{fahrzeug.baujahr ?? "—"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Standort
</dt>
<dd className="truncate">{fahrzeug.standort?.name ?? "—"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Indienststellung
</dt>
<dd className="tabellenzahlen">{alsDatum(fahrzeug.indienststellung)}</dd>
</div>
</dl>
<p className="border-t border-[var(--border)] pt-2 text-[var(--muted-foreground)]">
<Link
href={`/geraete?fahrzeug=${fahrzeug.id}`}
className="underline-offset-4 hover:underline"
>
{fahrzeug._count.geraete}{" "}
{fahrzeug._count.geraete === 1 ? "Gerät" : "Geräte"} verlastet
</Link>
{" · "}
{fahrzeug._count.geraeteraeume}{" "}
{fahrzeug._count.geraeteraeume === 1 ? "Geräteraum" : "Geräteräume"}
</p>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { ArrowLeft, Printer } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { qrAlsDataUri, qrZiel } from "@/lib/stammdaten/qr";
import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { StatusAbzeichen, FristAnzeige } from "@/components/stammdaten/anzeige";
export const metadata = { title: "Gerät" };
/**
* Detailseite eines Geräts.
*
* Serverseitig gerendert: hier wird nichts gefiltert und nichts sortiert, also
* braucht es auch keinen Zustand im Browser. Der QR-Code wird auf dem Server
* erzeugt und als Data-URI eingebettet — so lädt die Seite nichts nach.
*/
export default async function GeraetSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const geraet = await db.geraet.findUnique({
where: { id },
include: {
geraetetyp: true,
standort: { select: { id: true, name: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
geraeteraum: { select: { id: true, bezeichnung: true } },
},
});
if (!geraet) return null;
const verlauf = await db.auditLog.findMany({
where: { entitaet: "Geraet", entitaetId: id },
orderBy: { zeitpunkt: "desc" },
take: 30,
select: {
id: true,
zeitpunkt: true,
aktion: true,
beschreibung: true,
akteurName: true,
},
});
return { geraet, verlauf };
});
if (!daten) notFound();
const { geraet, verlauf } = daten;
const qr = await qrAlsDataUri(geraet.inventarnummer, 320);
const angaben: Array<[string, React.ReactNode]> = [
["Inventarnummer", <span className="tabellenzahlen">{geraet.inventarnummer}</span>],
["Gerätetyp", geraet.geraetetyp?.name ?? "—"],
["Hersteller", geraet.hersteller ?? "—"],
["Modell", geraet.modell ?? "—"],
["Seriennummer", <span className="tabellenzahlen">{geraet.seriennummer ?? "—"}</span>],
["Herstellungsdatum", alsDatum(geraet.herstellungsdatum)],
["Indienststellung", alsDatum(geraet.indienststellung)],
[
"Anschaffungspreis",
geraet.anschaffungspreis ? alsEuro(Number(geraet.anschaffungspreis)) : "—",
],
[
"Prüfintervall",
geraet.pruefintervallMonate
? `alle ${geraet.pruefintervallMonate} Monate`
: (geraet.geraetetyp?.pruefintervallMonate
? `alle ${geraet.geraetetyp.pruefintervallMonate} Monate (aus dem Gerätetyp)`
: "keines hinterlegt"),
],
["Letzte Prüfung", alsDatum(geraet.letztePruefung)],
["Nächste Prüfung", <FristAnzeige datum={geraet.naechstePruefung} />],
["Lebensdauer bis", <FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={90} />],
["Prüfvorschrift", geraet.geraetetyp?.pruefvorschrift ?? "—"],
];
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/geraete">
<ArrowLeft aria-hidden />
Zurück zur Liste
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{geraet.bezeichnung}</h1>
<p className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
<span className="tabellenzahlen">{geraet.inventarnummer}</span>
<StatusAbzeichen status={geraet.status} />
</p>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Angaben</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
{angaben.map(([bezeichnung, wert]) => (
<div key={bezeichnung}>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{bezeichnung}
</dt>
<dd className="text-sm">{wert}</dd>
</div>
))}
</dl>
<div className="mt-6 border-t border-[var(--border)] pt-4">
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Lagerung
</p>
<p className="text-sm">
{geraet.fahrzeug ? (
<>
Verlastet auf{" "}
<Link
href={`/fahrzeuge/${geraet.fahrzeug.id}`}
className="underline underline-offset-4"
>
{geraet.fahrzeug.funkrufname}
</Link>
{geraet.geraeteraum && `, ${geraet.geraeteraum.bezeichnung}`}
</>
) : (
`Standort ${geraet.standort?.name ?? "unbekannt"}`
)}
{geraet.lagerort && ` — ${geraet.lagerort}`}
</p>
</div>
{geraet.bemerkung && (
<div className="mt-4 border-t border-[var(--border)] pt-4">
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Bemerkung
</p>
<p className="whitespace-pre-line text-sm">{geraet.bemerkung}</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>QR-Code</CardTitle>
<CardDescription>
Aufkleber im Geräteraum. Wer ihn scannt, landet direkt auf dieser Seite.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* Data-URI: die Grafik kommt aus dem Server-Rendering, nicht aus
einem Nachladen. */}
<Image
src={qr}
alt={`QR-Code für ${geraet.inventarnummer}`}
width={320}
height={320}
unoptimized
className="mx-auto h-auto w-full max-w-56 rounded border border-[var(--border)] bg-white p-2"
/>
<p className="break-all text-center text-xs text-[var(--muted-foreground)]">
{qrZiel(geraet.inventarnummer)}
</p>
<form action="/api/geraete/etiketten" method="post">
<input type="hidden" name="id" value={geraet.id} />
<Button type="submit" variant="outline" className="w-full">
<Printer aria-hidden />
Etikett drucken
</Button>
</form>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Verlauf</CardTitle>
<CardDescription>
Gerendert aus dem Audit-Log. Es gibt bewusst keine zweite Historie —
zwei Quellen könnten auseinanderlaufen.
</CardDescription>
</CardHeader>
<CardContent>
{verlauf.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
Zu diesem Gerät ist noch nichts protokolliert.
</p>
) : (
<ol className="space-y-3">
{verlauf.map((eintrag) => (
<li
key={eintrag.id}
className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-l-2 border-[var(--border)] pl-3 text-sm"
>
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
{alsDatumZeit(eintrag.zeitpunkt)}
</span>
<span>{eintrag.beschreibung ?? eintrag.aktion}</span>
{eintrag.akteurName && (
<span className="text-xs text-[var(--muted-foreground)]">
— {eintrag.akteurName}
</span>
)}
</li>
))}
</ol>
)}
</CardContent>
</Card>
</div>
);
}
+572
View File
@@ -0,0 +1,572 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { GeraetStatus } from "@prisma/client";
import {
ArrowDown,
ArrowUp,
ChevronLeft,
ChevronRight,
Columns3,
Printer,
QrCode,
Search,
Upload,
X,
} from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
FristAnzeige,
Fehlermeldung,
GERAET_STATUS_TEXT,
LadeZeilen,
NichtsGefunden,
StatusAbzeichen,
} from "@/components/stammdaten/anzeige";
import { alsDatum, cn } from "@/lib/utils";
/**
* Geräteliste.
*
* Sortierung, Filter und Blättern laufen serverseitig — bei einer Wehr mit
* mehreren hundert Geräten will man nicht alles in den Browser laden, nur um
* dort zu sortieren. Die Suche wartet bewusst 300 ms, sonst geht bei jedem
* Tastendruck eine Abfrage raus.
*
* Die Spaltenauswahl bleibt im Browser gespeichert: der Gerätewart schaut auf
* Prüftermine, der Kassier auf Anschaffungspreise, und beide wollen ihre
* Einstellung beim nächsten Mal wiederfinden.
*/
interface Spalte {
schluessel: string;
titel: string;
sortierbar?: boolean;
/** Standardmäßig eingeblendet. */
vorgabe: boolean;
/** Rechtsbündig für Zahlen und Daten. */
zahl?: boolean;
}
const SPALTEN: Spalte[] = [
{ schluessel: "inventarnummer", titel: "Inventarnr.", sortierbar: true, vorgabe: true },
{ schluessel: "bezeichnung", titel: "Bezeichnung", sortierbar: true, vorgabe: true },
{ schluessel: "geraetetyp", titel: "Gerätetyp", vorgabe: false },
{ schluessel: "hersteller", titel: "Hersteller", sortierbar: true, vorgabe: true },
{ schluessel: "modell", titel: "Modell", vorgabe: false },
{ schluessel: "seriennummer", titel: "Seriennummer", vorgabe: false },
{ schluessel: "lagerung", titel: "Lagerung", vorgabe: true },
{ schluessel: "status", titel: "Status", sortierbar: true, vorgabe: true },
{
schluessel: "naechstePruefung",
titel: "Nächste Prüfung",
sortierbar: true,
vorgabe: true,
zahl: true,
},
{ schluessel: "letztePruefung", titel: "Letzte Prüfung", vorgabe: false, zahl: true },
{
schluessel: "lebensdauerBis",
titel: "Lebensdauer bis",
sortierbar: true,
vorgabe: false,
zahl: true,
},
{
schluessel: "indienststellung",
titel: "Indienststellung",
sortierbar: true,
vorgabe: false,
zahl: true,
},
];
const SPEICHERSCHLUESSEL = "florian.geraete.spalten";
export function GeraeteTabelle({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const [suchtext, setSuchtext] = React.useState("");
const [suche, setSuche] = React.useState("");
const [seite, setSeite] = React.useState(1);
const [sortierung, setSortierung] = React.useState<string>("inventarnummer");
const [richtung, setRichtung] = React.useState<"asc" | "desc">("asc");
const [statusFilter, setStatusFilter] = React.useState<GeraetStatus | "alle">("alle");
const [ausgewaehlt, setAusgewaehlt] = React.useState<Set<string>>(new Set());
const [spaltenOffen, setSpaltenOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [sichtbar, setSichtbar] = React.useState<Set<string>>(
() => new Set(SPALTEN.filter((spalte) => spalte.vorgabe).map((spalte) => spalte.schluessel)),
);
// Gespeicherte Spaltenauswahl nachladen — erst nach der Hydratation, sonst
// unterscheidet sich der Serverbaum vom Browserbaum.
React.useEffect(() => {
const gespeichert = window.localStorage.getItem(SPEICHERSCHLUESSEL);
if (!gespeichert) return;
try {
const liste = JSON.parse(gespeichert) as string[];
if (Array.isArray(liste) && liste.length > 0) setSichtbar(new Set(liste));
} catch {
// Kaputter Eintrag: Vorgabe behalten.
}
}, []);
React.useEffect(() => {
window.localStorage.setItem(SPEICHERSCHLUESSEL, JSON.stringify([...sichtbar]));
}, [sichtbar]);
// Entprellte Suche.
React.useEffect(() => {
const zeitgeber = setTimeout(() => {
setSuche(suchtext);
setSeite(1);
}, 300);
return () => clearTimeout(zeitgeber);
}, [suchtext]);
const abfrage = trpc.geraet.liste.useQuery({
suche: suche || undefined,
seite,
seitengroesse: 50,
sortierung: sortierung as never,
richtung,
status: statusFilter === "alle" ? undefined : [statusFilter],
});
const werkzeuge = trpc.useUtils();
const statusSetzen = trpc.geraet.statusSetzen.useMutation({
onSuccess: (ergebnis) => {
setMeldung(`${ergebnis.geaendert} Geräte geändert.`);
setAusgewaehlt(new Set());
void werkzeuge.geraet.liste.invalidate();
void werkzeuge.organisation.kennzahlen.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = abfrage.data?.eintraege ?? [];
const alleAusgewaehlt = eintraege.length > 0 && eintraege.every((g) => ausgewaehlt.has(g.id));
function sortiereNach(spalte: string) {
if (sortierung === spalte) {
setRichtung((alt) => (alt === "asc" ? "desc" : "asc"));
} else {
setSortierung(spalte);
setRichtung("asc");
}
setSeite(1);
}
function auswahlUmschalten(id: string) {
setAusgewaehlt((alt) => {
const neu = new Set(alt);
if (neu.has(id)) neu.delete(id);
else neu.add(id);
return neu;
});
}
function alleUmschalten() {
setAusgewaehlt((alt) => {
if (alleAusgewaehlt) {
const neu = new Set(alt);
for (const geraet of eintraege) neu.delete(geraet.id);
return neu;
}
return new Set([...alt, ...eintraege.map((geraet) => geraet.id)]);
});
}
/** Etikettenbogen für die Auswahl erzeugen und herunterladen. */
async function etikettenDrucken() {
setMeldung(null);
try {
const antwort = await fetch("/api/geraete/etiketten", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: [...ausgewaehlt] }),
});
if (!antwort.ok) {
const text = await antwort.text();
setMeldung(`Der Etikettenbogen ließ sich nicht erzeugen: ${text}`);
return;
}
const datei = await antwort.blob();
const adresse = URL.createObjectURL(datei);
window.open(adresse, "_blank", "noopener");
// Nach dem Öffnen wieder freigeben, sonst bleibt der Speicher belegt.
setTimeout(() => URL.revokeObjectURL(adresse), 60_000);
} catch (fehler) {
setMeldung(`Der Etikettenbogen ließ sich nicht erzeugen: ${String(fehler)}`);
}
}
const zeigeSpalte = (schluessel: string) => sichtbar.has(schluessel);
const spaltenAnzahl = [...sichtbar].length + 1;
return (
<div className="space-y-4">
{/* Werkzeugleiste */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-56 flex-1">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
value={suchtext}
onChange={(ereignis) => setSuchtext(ereignis.target.value)}
placeholder="Inventarnummer, Bezeichnung, Hersteller, Seriennummer …"
className="pl-8"
aria-label="Geräte durchsuchen"
/>
{suchtext && (
<button
type="button"
onClick={() => setSuchtext("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
aria-label="Suche zurücksetzen"
>
<X className="size-4" aria-hidden />
</button>
)}
</div>
<Select
value={statusFilter}
onValueChange={(wert) => {
setStatusFilter(wert as GeraetStatus | "alle");
setSeite(1);
}}
>
<SelectTrigger className="w-48" aria-label="Nach Status filtern">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Zustände</SelectItem>
{Object.entries(GERAET_STATUS_TEXT).map(([wert, text]) => (
<SelectItem key={wert} value={wert}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Button
variant="outline"
onClick={() => setSpaltenOffen((offen) => !offen)}
aria-expanded={spaltenOffen}
>
<Columns3 aria-hidden />
Spalten
</Button>
{spaltenOffen && (
<>
<button
type="button"
className="fixed inset-0 z-40 cursor-default"
aria-label="Spaltenauswahl schließen"
onClick={() => setSpaltenOffen(false)}
/>
<div className="absolute right-0 z-50 mt-1 w-64 rounded-md border border-[var(--border)] bg-[var(--popover)] p-2 shadow-md">
{SPALTEN.map((spalte) => (
<label
key={spalte.schluessel}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-[var(--accent)]"
>
<Checkbox
checked={sichtbar.has(spalte.schluessel)}
onCheckedChange={(an) =>
setSichtbar((alt) => {
const neu = new Set(alt);
if (an) neu.add(spalte.schluessel);
else neu.delete(spalte.schluessel);
// Mindestens eine Spalte muss stehen bleiben.
return neu.size === 0 ? alt : neu;
})
}
/>
{spalte.titel}
</label>
))}
</div>
</>
)}
</div>
<Button variant="outline" asChild>
<Link href="/geraete/scannen">
<QrCode aria-hidden />
Scannen
</Link>
</Button>
{darfBearbeiten && (
<Button variant="outline" asChild>
<Link href="/geraete/importieren">
<Upload aria-hidden />
Importieren
</Link>
</Button>
)}
</div>
{/* Sammelaktionen — erscheinen erst, wenn etwas ausgewählt ist. */}
{ausgewaehlt.size > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--muted)] px-3 py-2">
<span className="text-sm font-medium">
{ausgewaehlt.size} {ausgewaehlt.size === 1 ? "Gerät" : "Geräte"} ausgewählt
</span>
<div className="ml-auto flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={etikettenDrucken}>
<Printer aria-hidden />
Etiketten drucken
</Button>
{darfBearbeiten && (
<Select
onValueChange={(wert) =>
statusSetzen.mutate({
ids: [...ausgewaehlt],
status: wert as GeraetStatus,
})
}
>
<SelectTrigger className="h-8 w-52" aria-label="Status aller ausgewählten setzen">
<SelectValue placeholder="Status setzen auf …" />
</SelectTrigger>
<SelectContent>
{Object.entries(GERAET_STATUS_TEXT).map(([wert, text]) => (
<SelectItem key={wert} value={wert}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button variant="ghost" size="sm" onClick={() => setAusgewaehlt(new Set())}>
Auswahl aufheben
</Button>
</div>
</div>
)}
{meldung && <Fehlermeldung meldung={meldung} />}
{abfrage.error && <Fehlermeldung meldung={abfrage.error.message} />}
{/* Tabelle */}
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={alleAusgewaehlt}
onCheckedChange={alleUmschalten}
aria-label="Alle auf dieser Seite auswählen"
/>
</TableHead>
{SPALTEN.filter((spalte) => zeigeSpalte(spalte.schluessel)).map((spalte) => (
<TableHead key={spalte.schluessel} className={cn(spalte.zahl && "text-right")}>
{spalte.sortierbar ? (
<button
type="button"
onClick={() => sortiereNach(spalte.schluessel)}
className="inline-flex items-center gap-1 uppercase hover:text-[var(--foreground)]"
aria-label={`Nach ${spalte.titel} sortieren`}
>
{spalte.titel}
{sortierung === spalte.schluessel &&
(richtung === "asc" ? (
<ArrowUp className="size-3" aria-hidden />
) : (
<ArrowDown className="size-3" aria-hidden />
))}
</button>
) : (
spalte.titel
)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{abfrage.isLoading && <LadeZeilen spalten={spaltenAnzahl} />}
{!abfrage.isLoading &&
eintraege.map((geraet) => (
<TableRow key={geraet.id} data-ausgewaehlt={ausgewaehlt.has(geraet.id)}>
<TableCell>
<Checkbox
checked={ausgewaehlt.has(geraet.id)}
onCheckedChange={() => auswahlUmschalten(geraet.id)}
aria-label={`${geraet.inventarnummer} auswählen`}
/>
</TableCell>
{zeigeSpalte("inventarnummer") && (
<TableCell className="font-medium tabellenzahlen">
<Link
href={`/geraete/${geraet.id}`}
className="underline-offset-4 hover:underline"
>
{geraet.inventarnummer}
</Link>
</TableCell>
)}
{zeigeSpalte("bezeichnung") && <TableCell>{geraet.bezeichnung}</TableCell>}
{zeigeSpalte("geraetetyp") && (
<TableCell className="text-[var(--muted-foreground)]">
{geraet.geraetetyp?.name ?? "—"}
</TableCell>
)}
{zeigeSpalte("hersteller") && <TableCell>{geraet.hersteller ?? "—"}</TableCell>}
{zeigeSpalte("modell") && <TableCell>{geraet.modell ?? "—"}</TableCell>}
{zeigeSpalte("seriennummer") && (
<TableCell className="tabellenzahlen">{geraet.seriennummer ?? "—"}</TableCell>
)}
{zeigeSpalte("lagerung") && (
<TableCell className="whitespace-nowrap text-sm">
{geraet.fahrzeug ? (
<>
{geraet.fahrzeug.funkrufname}
{geraet.geraeteraum && (
<span className="text-[var(--muted-foreground)]">
{" "}
· {geraet.geraeteraum.bezeichnung}
</span>
)}
</>
) : (
(geraet.standort?.name ?? "—")
)}
</TableCell>
)}
{zeigeSpalte("status") && (
<TableCell>
<StatusAbzeichen status={geraet.status} />
</TableCell>
)}
{zeigeSpalte("naechstePruefung") && (
<TableCell className="text-right">
<FristAnzeige datum={geraet.naechstePruefung} />
</TableCell>
)}
{zeigeSpalte("letztePruefung") && (
<TableCell className="text-right tabellenzahlen">
{alsDatum(geraet.letztePruefung)}
</TableCell>
)}
{zeigeSpalte("lebensdauerBis") && (
<TableCell className="text-right">
<FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={90} />
</TableCell>
)}
{zeigeSpalte("indienststellung") && (
<TableCell className="text-right tabellenzahlen">
{alsDatum(geraet.indienststellung)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
{!abfrage.isLoading && eintraege.length === 0 && (
<div className="p-6">
<NichtsGefunden
titel={suche ? "Kein Gerät gefunden" : "Noch keine Geräte erfasst"}
hinweis={
suche
? `Zu "${suche}" gibt es keinen Treffer. Vielleicht hilft ein Teil der Inventarnummer.`
: "Geräte lassen sich einzeln anlegen oder aus einer vorhandenen Tabelle importieren."
}
aktion={
!suche && darfBearbeiten ? (
<Button asChild>
<Link href="/geraete/importieren">
<Upload aria-hidden />
Aus Datei importieren
</Link>
</Button>
) : undefined
}
/>
</div>
)}
</div>
{/* Blättern */}
{abfrage.data && abfrage.data.gesamt > 0 && (
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<p className="text-[var(--muted-foreground)]">
{abfrage.data.gesamt} {abfrage.data.gesamt === 1 ? "Gerät" : "Geräte"}
{abfrage.data.seitenGesamt > 1 &&
` · Seite ${abfrage.data.seite} von ${abfrage.data.seitenGesamt}`}
</p>
{abfrage.data.seitenGesamt > 1 && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={seite <= 1}
onClick={() => setSeite((alt) => Math.max(1, alt - 1))}
>
<ChevronLeft aria-hidden />
Zurück
</Button>
<Button
variant="outline"
size="sm"
disabled={seite >= abfrage.data.seitenGesamt}
onClick={() => setSeite((alt) => alt + 1)}
>
Weiter
<ChevronRight aria-hidden />
</Button>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,421 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { AlertTriangle, CheckCircle2, FileSpreadsheet, Upload } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { cn } from "@/lib/utils";
/**
* Import-Assistent.
*
* Drei Schritte, und der mittlere ist der eigentliche Punkt der Übung:
*
* 1. Datei wählen
* 2. Zuordnung prüfen und Vorschau ansehen
* 3. Übernehmen
*
* Zwischen 2 und 3 sitzt ein Mensch. Eine falsch zugeordnete Spalte verteilt
* sonst Seriennummern auf Inventarnummern, und das merkt man erst Wochen
* später am Regal.
*
* Die Datei wird beim Übernehmen erneut zum Server geschickt und dort neu
* eingelesen. Das kostet eine Übertragung, verhindert aber, dass jemand die
* Prüfung umgeht und eigene Werte einschleust.
*/
type ImportArt = "geraet" | "person" | "fahrzeug";
const ART_BEZEICHNUNG: Record<ImportArt, string> = {
geraet: "Geräte",
person: "Mitglieder",
fahrzeug: "Fahrzeuge",
};
export function ImportAssistent() {
const router = useRouter();
const werkzeuge = trpc.useUtils();
const [art, setArt] = React.useState<ImportArt>("geraet");
const [datei, setDatei] = React.useState<{ name: string; inhalt: string } | null>(null);
const [zuordnung, setZuordnung] = React.useState<Record<string, string | null>>({});
const [aktualisieren, setAktualisieren] = React.useState(false);
const [fehler, setFehler] = React.useState<string | null>(null);
const [ergebnis, setErgebnis] = React.useState<{
angelegt: number;
aktualisiert: number;
uebersprungen: number;
nichtUebernommen: number;
fehler: string[];
} | null>(null);
const vorschau = trpc.datenImport.vorschau.useMutation({
onSuccess: (daten) => {
setZuordnung(daten.zuordnung);
setFehler(null);
},
onError: (ausnahme) => setFehler(ausnahme.message),
});
const uebernehmen = trpc.datenImport.uebernehmen.useMutation({
onSuccess: (daten) => {
setErgebnis(daten);
setFehler(null);
void werkzeuge.geraet.liste.invalidate();
void werkzeuge.organisation.kennzahlen.invalidate();
},
onError: (ausnahme) => setFehler(ausnahme.message),
});
async function dateiWaehlen(ereignis: React.ChangeEvent<HTMLInputElement>) {
const gewaehlt = ereignis.target.files?.[0];
if (!gewaehlt) return;
setErgebnis(null);
setFehler(null);
const puffer = await gewaehlt.arrayBuffer();
// Blockweise umwandeln: bei großen Dateien sprengt ein einziger
// String.fromCharCode-Aufruf den Aufrufstapel.
const bytes = new Uint8Array(puffer);
let roh = "";
const block = 8192;
for (let i = 0; i < bytes.length; i += block) {
roh += String.fromCharCode(...bytes.subarray(i, i + block));
}
const inhalt = btoa(roh);
setDatei({ name: gewaehlt.name, inhalt });
vorschau.mutate({ dateiname: gewaehlt.name, inhalt, art });
}
function zuordnungAendern(spalte: string, feld: string) {
const neu = { ...zuordnung, [spalte]: feld === "__keine__" ? null : feld };
setZuordnung(neu);
if (datei) {
vorschau.mutate({ dateiname: datei.name, inhalt: datei.inhalt, art, zuordnung: neu });
}
}
const daten = vorschau.data;
// Schritt 3 geschafft.
if (ergebnis) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<CheckCircle2 className="size-6 text-[var(--einsatzbereit)]" aria-hidden />
<div>
<CardTitle>Import abgeschlossen</CardTitle>
<CardDescription>Aus der Datei „{datei?.name}“.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-4 sm:grid-cols-4">
{[
["Neu angelegt", ergebnis.angelegt],
["Aktualisiert", ergebnis.aktualisiert],
["Übersprungen", ergebnis.uebersprungen],
["Nicht übernommen", ergebnis.nichtUebernommen],
].map(([bezeichnung, wert]) => (
<div key={bezeichnung as string}>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{bezeichnung}
</dt>
<dd className="text-2xl font-semibold tabellenzahlen">{wert}</dd>
</div>
))}
</dl>
{ergebnis.uebersprungen > 0 && !aktualisieren && (
<p className="text-sm text-[var(--muted-foreground)]">
Übersprungen wurden Datensätze, die es schon gibt. Mit „Vorhandene
aktualisieren“ werden sie stattdessen überschrieben.
</p>
)}
{ergebnis.fehler.length > 0 && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3">
<p className="mb-1 text-sm font-medium">
{ergebnis.fehler.length} Zeilen ließen sich nicht schreiben:
</p>
<ul className="list-disc space-y-0.5 pl-5 text-xs">
{ergebnis.fehler.slice(0, 20).map((meldung) => (
<li key={meldung}>{meldung}</li>
))}
</ul>
</div>
)}
<div className="flex gap-2">
<Button onClick={() => router.push("/geraete")}>Zur Geräteliste</Button>
<Button
variant="outline"
onClick={() => {
setErgebnis(null);
setDatei(null);
vorschau.reset();
}}
>
Weitere Datei importieren
</Button>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{fehler && <Fehlermeldung meldung={fehler} />}
{/* Schritt 1 */}
<Card>
<CardHeader>
<CardTitle>1. Datei wählen</CardTitle>
<CardDescription>
CSV oder Excel (.xlsx). Die erste Zeile muss die Spaltenüberschriften
enthalten. Höchstens 10 MB und 5.000 Zeilen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="art">Was wird importiert?</Label>
<Select
value={art}
onValueChange={(wert) => {
setArt(wert as ImportArt);
setDatei(null);
vorschau.reset();
}}
>
<SelectTrigger id="art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ART_BEZEICHNUNG).map(([wert, text]) => (
<SelectItem key={wert} value={wert}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="datei">Datei</Label>
<input
id="datei"
type="file"
accept=".csv,.txt,.xlsx,.xlsm"
onChange={dateiWaehlen}
className="block w-full cursor-pointer rounded-md border border-[var(--input)] bg-[var(--background)] text-sm file:mr-3 file:cursor-pointer file:rounded-l-md file:border-0 file:bg-[var(--secondary)] file:px-3 file:py-2 file:text-sm file:font-medium"
/>
</div>
</div>
{datei && (
<p className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
<FileSpreadsheet className="size-4" aria-hidden />
{datei.name}
</p>
)}
</CardContent>
</Card>
{vorschau.isPending && (
<p className="text-sm text-[var(--muted-foreground)]">Die Datei wird gelesen …</p>
)}
{/* Schritt 2 */}
{daten && (
<>
<Card>
<CardHeader>
<CardTitle>2. Spalten zuordnen</CardTitle>
<CardDescription>
Die Zuordnung wurde nach den Überschriften geraten. Bitte prüfen und
nötigenfalls korrigieren — danach passt sich die Vorschau sofort an.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{daten.fehlendePflichtfelder.length > 0 && (
<div className="flex items-start gap-2 rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
<AlertTriangle
className="mt-0.5 size-4 shrink-0 text-[var(--destructive)]"
aria-hidden
/>
<span>
Diesen Pflichtfeldern ist keine Spalte zugeordnet:{" "}
<strong>{daten.fehlendePflichtfelder.join(", ")}</strong>. Ohne sie lässt
sich nichts übernehmen.
</span>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{daten.spalten.map((spalte) => (
<div key={spalte} className="space-y-1">
<Label className="text-xs text-[var(--muted-foreground)]">
Spalte „{spalte}“
</Label>
<Select
value={zuordnung[spalte] ?? "__keine__"}
onValueChange={(wert) => zuordnungAendern(spalte, wert)}
>
<SelectTrigger aria-label={`Zielfeld für Spalte ${spalte}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__keine__">— nicht übernehmen —</SelectItem>
{daten.felder.map((feld) => (
<SelectItem key={feld.schluessel} value={feld.schluessel}>
{feld.bezeichnung}
{feld.pflicht ? " *" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Vorschau</CardTitle>
<CardDescription>
{daten.anzahlUebernehmbar} von {daten.zeilenGesamt} Zeilen sind
übernehmbar
{daten.anzahlFehlerhaft > 0 && `, ${daten.anzahlFehlerhaft} haben Fehler`}.
{daten.gekuerzt && " Die Datei wurde auf 5.000 Zeilen gekürzt."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="max-h-96 overflow-y-auto rounded-md border border-[var(--border)]">
<Table>
<TableHeader className="sticky top-0 bg-[var(--card)]">
<TableRow>
<TableHead className="w-12">Zeile</TableHead>
{daten.felder
.filter((feld) =>
Object.values(zuordnung).includes(feld.schluessel),
)
.map((feld) => (
<TableHead key={feld.schluessel}>{feld.bezeichnung}</TableHead>
))}
<TableHead>Hinweis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{daten.zeilen.map((zeile) => (
<TableRow
key={zeile.nummer}
className={cn(!zeile.uebernehmbar && "bg-[var(--destructive)]/5")}
>
<TableCell className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
{zeile.nummer}
</TableCell>
{daten.felder
.filter((feld) =>
Object.values(zuordnung).includes(feld.schluessel),
)
.map((feld) => {
const wert = zeile.werte[feld.schluessel];
return (
<TableCell key={feld.schluessel} className="max-w-48 truncate text-sm">
{wert instanceof Date
? wert.toLocaleDateString("de-DE")
: wert === undefined || wert === null
? "—"
: String(wert)}
</TableCell>
);
})}
<TableCell className="text-xs">
{zeile.fehler.length === 0 ? (
<span className="text-[var(--einsatzbereit)]">in Ordnung</span>
) : (
<span className="text-[var(--destructive)]">
{zeile.fehler.map((f) => f.meldung).join("; ")}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={aktualisieren}
onCheckedChange={(an) => setAktualisieren(Boolean(an))}
/>
Vorhandene Datensätze aktualisieren statt überspringen
</label>
<div className="flex flex-wrap items-center gap-3">
<Button
disabled={
!datei ||
daten.anzahlUebernehmbar === 0 ||
daten.fehlendePflichtfelder.length > 0 ||
uebernehmen.isPending
}
onClick={() =>
datei &&
uebernehmen.mutate({
dateiname: datei.name,
inhalt: datei.inhalt,
art,
zuordnung,
vorhandeneAktualisieren: aktualisieren,
})
}
>
<Upload aria-hidden />
{uebernehmen.isPending
? "Wird übernommen …"
: `${daten.anzahlUebernehmbar} Zeilen übernehmen`}
</Button>
{daten.anzahlFehlerhaft > 0 && (
<p className="text-sm text-[var(--muted-foreground)]">
Zeilen mit Fehlern werden übersprungen und bleiben in der Datei.
</p>
)}
</div>
</CardContent>
</Card>
</>
)}
</div>
);
}
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button";
import { ImportAssistent } from "@/app/(app)/geraete/importieren/import-assistent";
export const metadata = { title: "Import" };
export default async function ImportSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
// Der Import schreibt Stammdaten — ohne dieses Recht gibt es die Seite nicht.
if (!hatRecht(sitzung, "geraet.bearbeiten")) redirect("/geraete");
return (
<div className="mx-auto max-w-5xl space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/geraete">
<ArrowLeft aria-hidden />
Zurück zur Liste
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Daten importieren</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Aus einer vorhandenen Excel- oder CSV-Tabelle. Vor dem Übernehmen wird
angezeigt, was wohin geschrieben würde — geschrieben wird erst danach.
</p>
</div>
<ImportAssistent />
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { GeraeteTabelle } from "@/app/(app)/geraete/geraete-tabelle";
export const metadata = { title: "Geräte" };
/**
* Geräteliste. Die eigentliche Tabelle ist eine Client-Komponente, weil
* Sortieren, Filtern und Auswählen ohne Zustand im Browser nicht geht. Die
* Rechteprüfung passiert hier auf dem Server — was die Komponente bekommt,
* ist nur die Antwort auf "darf diese Person bearbeiten".
*/
export default async function GeraeteSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Geräte</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Alle Geräte der Wehr mit Prüfterminen, Lebensdauer und Lagerung.
</p>
</div>
<GeraeteTabelle darfBearbeiten={hatRecht(sitzung, "geraet.bearbeiten")} />
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Scanner } from "@/app/(app)/geraete/scannen/scanner";
export const metadata = { title: "Gerät scannen" };
export default function ScannenSeite() {
return (
<div className="mx-auto max-w-2xl space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/geraete">
<ArrowLeft aria-hidden />
Zurück zur Liste
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Gerät scannen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
QR-Code auf dem Geräteetikett vor die Kamera halten. Die Gerätedetailseite
öffnet sich dann von selbst.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Kamera</CardTitle>
<CardDescription>
Der Browser fragt beim ersten Mal nach der Freigabe. Das Bild verlässt das
Gerät nicht — ausgewertet wird direkt im Browser.
</CardDescription>
</CardHeader>
<CardContent>
<Scanner />
</CardContent>
</Card>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { BrowserQRCodeReader } from "@zxing/browser";
import type { Result } from "@zxing/library";
import { Camera, CameraOff, KeyboardIcon, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { inventarnummerAusScan } from "@/lib/stammdaten/qr-parser";
/**
* QR-Scanner über die Kamera.
*
* Bewusst mit @zxing/browser statt der eingebauten BarcodeDetector-API: die
* gibt es in Firefox und in Safari auf dem iPad nicht, und im Gerätehaus steht
* genau so ein Tablet.
*
* Zwei Dinge, die in der Praxis zählen:
* - Die Kamera wird beim Verlassen der Seite wirklich freigegeben. Sonst
* bleibt die Leuchte an und der nächste Aufruf bekommt kein Bild.
* - Es gibt immer einen Weg ohne Kamera. Wenn die Freigabe verweigert wurde
* oder das Etikett zerkratzt ist, tippt man die Nummer ein.
*/
export function Scanner() {
const router = useRouter();
const videoRef = React.useRef<HTMLVideoElement>(null);
const steuerungRef = React.useRef<{ stop: () => void } | null>(null);
const [laeuft, setLaeuft] = React.useState(false);
const [fehler, setFehler] = React.useState<string | null>(null);
const [letzterScan, setLetzterScan] = React.useState<string | null>(null);
const [eingabe, setEingabe] = React.useState("");
const stoppen = React.useCallback(() => {
steuerungRef.current?.stop();
steuerungRef.current = null;
setLaeuft(false);
}, []);
// Kamera in jedem Fall freigeben, auch bei einem Wechsel per Zurück-Taste.
React.useEffect(() => stoppen, [stoppen]);
const oeffnen = React.useCallback(
(inventarnummer: string) => {
stoppen();
router.push(`/g/${encodeURIComponent(inventarnummer)}`);
},
[router, stoppen],
);
async function starten() {
setFehler(null);
setLetzterScan(null);
if (!videoRef.current) return;
if (!navigator.mediaDevices?.getUserMedia) {
setFehler(
"Dieser Browser gibt keinen Kamerazugriff frei. Bitte die Inventarnummer eintippen.",
);
return;
}
try {
const leser = new BrowserQRCodeReader();
const steuerung = await leser.decodeFromVideoDevice(
undefined,
videoRef.current,
(ergebnis: Result | undefined) => {
if (!ergebnis) return;
const roh = ergebnis.getText();
const nummer = inventarnummerAusScan(roh);
if (!nummer) {
// Fremde Codes kommen ständig vor — der Hersteller-Barcode klebt
// oft direkt daneben. Kein Abbruch, nur ein Hinweis.
setLetzterScan(roh);
return;
}
oeffnen(nummer);
},
);
steuerungRef.current = steuerung;
setLaeuft(true);
} catch (ausnahme) {
const name = (ausnahme as { name?: string })?.name;
if (name === "NotAllowedError") {
setFehler(
"Der Kamerazugriff wurde abgelehnt. Er lässt sich in den Browsereinstellungen wieder freigeben — oder Sie tippen die Inventarnummer ein.",
);
} else if (name === "NotFoundError") {
setFehler("An diesem Gerät wurde keine Kamera gefunden.");
} else {
setFehler(`Die Kamera ließ sich nicht öffnen: ${String(ausnahme)}`);
}
}
}
function eingabeAbsenden(ereignis: React.FormEvent) {
ereignis.preventDefault();
const nummer = inventarnummerAusScan(eingabe);
if (!nummer) {
setFehler("Das sieht nicht nach einer Inventarnummer aus.");
return;
}
oeffnen(nummer);
}
return (
<div className="space-y-6">
{fehler && <Fehlermeldung meldung={fehler} />}
<div className="overflow-hidden rounded-lg border border-[var(--border)] bg-black">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoRef}
className="aspect-video w-full object-cover"
playsInline
muted
aria-label="Kamerabild zum Scannen"
/>
</div>
<div className="flex flex-wrap gap-2">
{laeuft ? (
<Button variant="outline" onClick={stoppen}>
<CameraOff aria-hidden />
Kamera anhalten
</Button>
) : (
<Button onClick={starten}>
<Camera aria-hidden />
Kamera starten
</Button>
)}
{laeuft && (
<Button variant="ghost" onClick={() => void starten()}>
<RefreshCw aria-hidden />
Neu starten
</Button>
)}
</div>
{letzterScan && (
<p className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
Erkannt, aber kein FLORIAN-Etikett:{" "}
<span className="break-all font-mono text-xs">{letzterScan}</span>
<br />
<span className="text-[var(--muted-foreground)]">
Vermutlich der Barcode des Herstellers. Bitte den Aufkleber mit dem FLORIAN-Code
scannen.
</span>
</p>
)}
<form
onSubmit={eingabeAbsenden}
className="space-y-2 rounded-lg border border-[var(--border)] p-4"
>
<Label htmlFor="inventarnummer" className="flex items-center gap-2">
<KeyboardIcon className="size-4" aria-hidden />
Oder Inventarnummer eintippen
</Label>
<div className="flex gap-2">
<Input
id="inventarnummer"
value={eingabe}
onChange={(ereignis) => setEingabe(ereignis.target.value)}
placeholder="INV-0042"
autoComplete="off"
/>
<Button type="submit" disabled={!eingabe.trim()}>
Öffnen
</Button>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
Funktioniert auch ohne Kamera — praktisch, wenn das Etikett unleserlich geworden ist.
</p>
</form>
</div>
);
}
+16 -13
View File
@@ -4,6 +4,7 @@ import { fuerOrganisation } from "@/lib/db";
import { rechteVon } from "@/lib/authz";
import { Seitenleiste } from "@/components/layout/seitenleiste";
import { ThemeToggle } from "@/components/layout/theme-toggle";
import { TrpcProvider } from "@/lib/trpc/provider";
/**
* Layout für alles, was eine Anmeldung verlangt. Wer nicht angemeldet ist,
@@ -23,20 +24,22 @@ export default async function AngemeldetLayout({
);
return (
<div className="min-h-screen">
<Seitenleiste
rechte={[...rechteVon(sitzung.rollen)]}
rollen={sitzung.rollen}
organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"}
personName={sitzung.name}
/>
<TrpcProvider>
<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 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>
</div>
</TrpcProvider>
);
}
+211
View File
@@ -0,0 +1,211 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { darfPersonSehen } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum, personAnrede } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
export const metadata = { title: "Mitglied" };
const DIENSTGRAD_TEXT: Record<string, string> = {
FEUERWEHRANWAERTER: "Feuerwehranwärter",
FEUERWEHRMANN: "Feuerwehrmann",
OBERFEUERWEHRMANN: "Oberfeuerwehrmann",
HAUPTFEUERWEHRMANN: "Hauptfeuerwehrmann",
LOESCHMEISTER: "Löschmeister",
OBERLOESCHMEISTER: "Oberlöschmeister",
HAUPTLOESCHMEISTER: "Hauptlöschmeister",
BRANDMEISTER: "Brandmeister",
OBERBRANDMEISTER: "Oberbrandmeister",
HAUPTBRANDMEISTER: "Hauptbrandmeister",
KEIN: "—",
};
/**
* Mitgliedsdetail.
*
* Die Zugriffsprüfung läuft über darfPersonSehen(): die eigenen Daten darf
* jeder sehen, fremde nur mit dem Recht person.lesen. Adressen und
* Geburtsdatum sind personenbezogene Daten — sie stehen deshalb hinter
* derselben Schranke wie alles andere.
*/
export default async function MitgliedSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
if (!darfPersonSehen(sitzung, id)) redirect("/mitglieder");
const person = await fuerOrganisation(sitzung.organisationId, (db) =>
db.person.findUnique({
where: { id },
select: {
id: true,
vorname: true,
nachname: true,
mitgliedsnummer: true,
dienstgrad: true,
abteilung: true,
email: true,
telefon: true,
mobil: true,
strasse: true,
plz: true,
ort: true,
geburtsdatum: true,
eintrittsdatum: true,
austrittsdatum: true,
aktiv: true,
letzteAnmeldung: true,
rollen: { include: { rolle: true } },
qualifikationen: {
where: { geloeschtAm: null },
include: { typ: true },
orderBy: [{ gueltigBis: "asc" }, { erworbenAm: "desc" }],
},
ausweisTokens: {
where: { geloeschtAm: null, aktiv: true },
select: { id: true, typ: true, bezeichnung: true, letzteNutzung: true },
},
},
}),
);
if (!person) notFound();
const angaben: Array<[string, React.ReactNode]> = [
["Mitgliedsnummer", <span className="tabellenzahlen">{person.mitgliedsnummer ?? "—"}</span>],
["Dienstgrad", DIENSTGRAD_TEXT[person.dienstgrad] ?? person.dienstgrad],
["Abteilung", person.abteilung ?? "—"],
["Eintritt", alsDatum(person.eintrittsdatum)],
["Geburtsdatum", alsDatum(person.geburtsdatum)],
["E-Mail", person.email ?? "—"],
["Telefon", person.telefon ?? "—"],
["Mobil", person.mobil ?? "—"],
[
"Anschrift",
person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—",
],
];
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/mitglieder">
<ArrowLeft aria-hidden />
Zurück zur Mitgliederliste
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{personAnrede(person)}</h1>
<div className="mt-1 flex flex-wrap items-center gap-2">
{person.rollen.map((zuweisung) => (
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
{zuweisung.rolle.name}
</Badge>
))}
{!person.aktiv && <Badge variant="ausgesondert">ausgetreten</Badge>}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Angaben</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
{angaben.map(([bezeichnung, wert]) => (
<div key={bezeichnung}>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{bezeichnung}
</dt>
<dd className="text-sm">{wert}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Terminal-Zugang</CardTitle>
<CardDescription>
Anmeldemittel für das Gerätehaus. Gespeichert wird nur ein Hash.
</CardDescription>
</CardHeader>
<CardContent>
{person.ausweisTokens.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
Kein Terminal-Zugang hinterlegt.
</p>
) : (
<ul className="space-y-2 text-sm">
{person.ausweisTokens.map((token) => (
<li key={token.id} className="flex items-center justify-between gap-2">
<span>
<Badge variant="outline">{token.typ}</Badge>{" "}
{token.bezeichnung ?? ""}
</span>
<span className="text-xs text-[var(--muted-foreground)]">
{token.letzteNutzung ? alsDatum(token.letzteNutzung) : "noch nicht benutzt"}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Qualifikationen</CardTitle>
<CardDescription>
Lehrgänge, Befähigungen und Untersuchungen samt Gültigkeit.
</CardDescription>
</CardHeader>
<CardContent>
{person.qualifikationen.length === 0 ? (
<NichtsGefunden
titel="Keine Qualifikationen erfasst"
hinweis="Sobald Lehrgänge oder Untersuchungen eingetragen sind, erscheinen sie hier mit ihrer Gültigkeit."
/>
) : (
<ul className="divide-y divide-[var(--border)]">
{person.qualifikationen.map((qualifikation) => (
<li
key={qualifikation.id}
className="flex flex-wrap items-center gap-x-4 gap-y-1 py-2 text-sm"
>
<span className="font-medium">{qualifikation.typ.name}</span>
{qualifikation.typ.kuerzel && (
<Badge variant="outline">{qualifikation.typ.kuerzel}</Badge>
)}
<span className="text-[var(--muted-foreground)]">
erworben {alsDatum(qualifikation.erworbenAm)}
</span>
<span className="ml-auto">
{qualifikation.gueltigBis ? (
<FristAnzeige
datum={qualifikation.gueltigBis}
vorwarnTage={qualifikation.typ.vorwarnzeitTage}
/>
) : (
<span className="text-[var(--muted-foreground)]">unbefristet</span>
)}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum, personName } from "@/lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { NichtsGefunden } from "@/components/stammdaten/anzeige";
export const metadata = { title: "Mitglieder" };
const DIENSTGRAD_TEXT: Record<string, string> = {
FEUERWEHRANWAERTER: "Feuerwehranwärter",
FEUERWEHRMANN: "Feuerwehrmann",
OBERFEUERWEHRMANN: "Oberfeuerwehrmann",
HAUPTFEUERWEHRMANN: "Hauptfeuerwehrmann",
LOESCHMEISTER: "Löschmeister",
OBERLOESCHMEISTER: "Oberlöschmeister",
HAUPTLOESCHMEISTER: "Hauptlöschmeister",
BRANDMEISTER: "Brandmeister",
OBERBRANDMEISTER: "Oberbrandmeister",
HAUPTBRANDMEISTER: "Hauptbrandmeister",
KEIN: "—",
};
/**
* Mitgliederliste, nach Abteilung gruppiert.
*
* Aktive, Jugendfeuerwehr und Passive werden getrennt geführt — eine
* durchlaufende alphabetische Liste vermischt Gruppen, die in der Wehr nichts
* miteinander zu tun haben.
*/
export default async function MitgliederSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "person.lesen")) redirect("/");
const personen = await fuerOrganisation(sitzung.organisationId, (db) =>
db.person.findMany({
where: { geloeschtAm: null },
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
select: {
id: true,
vorname: true,
nachname: true,
mitgliedsnummer: true,
dienstgrad: true,
abteilung: true,
email: true,
mobil: true,
eintrittsdatum: true,
aktiv: true,
rollen: { select: { rolle: { select: { name: true, schluessel: true } } } },
_count: { select: { qualifikationen: true } },
},
}),
);
const nachAbteilung = new Map<string, typeof personen>();
for (const person of personen) {
const gruppe = person.abteilung ?? "Ohne Abteilung";
if (!nachAbteilung.has(gruppe)) nachAbteilung.set(gruppe, []);
nachAbteilung.get(gruppe)!.push(person);
}
const reihenfolge = ["Aktive", "Jugendfeuerwehr", "Passive", "Verwaltung", "Ohne Abteilung"];
const gruppen = [...nachAbteilung.entries()].sort((a, b) => {
const ia = reihenfolge.indexOf(a[0]);
const ib = reihenfolge.indexOf(b[0]);
if (ia === -1 && ib === -1) return a[0].localeCompare(b[0], "de");
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mitglieder</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{personen.length} {personen.length === 1 ? "Mitglied" : "Mitglieder"}, nach
Abteilung geordnet.
</p>
</div>
{personen.length === 0 ? (
<NichtsGefunden
titel="Noch keine Mitglieder erfasst"
hinweis="Mitglieder lassen sich einzeln anlegen oder aus einer vorhandenen Liste importieren."
/>
) : (
gruppen.map(([abteilung, liste]) => (
<section key={abteilung} className="space-y-2">
<h2 className="text-sm font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{abteilung} ({liste.length})
</h2>
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Nr.</TableHead>
<TableHead>Dienstgrad</TableHead>
<TableHead>Funktion</TableHead>
<TableHead>Kontakt</TableHead>
<TableHead className="text-right">Eintritt</TableHead>
<TableHead className="text-right">Qualifikationen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{liste.map((person) => (
<TableRow key={person.id} className={!person.aktiv ? "opacity-60" : undefined}>
<TableCell className="font-medium">
<Link
href={`/mitglieder/${person.id}`}
className="underline-offset-4 hover:underline"
>
{personName(person)}
</Link>
</TableCell>
<TableCell className="tabellenzahlen text-[var(--muted-foreground)]">
{person.mitgliedsnummer ?? "—"}
</TableCell>
<TableCell className="text-sm">
{DIENSTGRAD_TEXT[person.dienstgrad] ?? person.dienstgrad}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{person.rollen
.filter((zuweisung) => zuweisung.rolle.schluessel !== "MITGLIED")
.map((zuweisung) => (
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
{zuweisung.rolle.name}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-sm text-[var(--muted-foreground)]">
{person.mobil ?? person.email ?? "—"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{alsDatum(person.eintrittsdatum)}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{person._count.qualifikationen}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
))
)}
</div>
);
}
+4 -4
View File
@@ -90,15 +90,15 @@ export default async function UebersichtSeite() {
<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.
Phase 1 ist abgeschlossen: Standorte, Fahrzeuge mit Geräteräumen,
Gerätetypen, Geräte mit QR-Etiketten, Qualifikationen und der Import aus
vorhandenen Tabellen stehen. Als Nächstes kommt das Prüfmanagement.
</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="einsatzbereit">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>
+232
View File
@@ -0,0 +1,232 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { AlertTriangle } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum, personName } from "@/lib/utils";
import { qualifikationZustand } from "@/lib/stammdaten/fristen";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
export const metadata = { title: "Qualifikationen" };
const ART_TEXT: Record<string, string> = {
LEHRGANG: "Lehrgang",
BEFAEHIGUNG: "Befähigung",
UNTERSUCHUNG: "Untersuchung",
FUEHRERSCHEIN: "Führerschein",
UNTERWEISUNG: "Unterweisung",
SONSTIGES: "Sonstiges",
};
/**
* Qualifikationen mit Ablaufwarnung.
*
* Oben steht, was abgelaufen ist oder demnächst abläuft — das ist der Grund,
* warum jemand diese Seite öffnet. Der Katalog der Typen steht darunter.
*
* Gewarnt wird mit der Vorwarnzeit des jeweiligen Typs, nicht mit einer
* pauschalen Frist: eine G26.3-Untersuchung braucht mehr Vorlauf als eine
* jährliche Unterweisung, weil erst ein Termin beim Arzt gefunden werden muss.
*/
export default async function QualifikationenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "qualifikation.lesen")) redirect("/");
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const in180Tagen = new Date(Date.now() + 180 * 864e5);
const [ablaufend, typen] = await Promise.all([
db.qualifikation.findMany({
where: {
geloeschtAm: null,
gueltigBis: { not: null, lte: in180Tagen },
person: { aktiv: true, geloeschtAm: null },
},
include: {
typ: {
select: {
id: true,
name: true,
kuerzel: true,
art: true,
vorwarnzeitTage: true,
einsatzrelevant: true,
},
},
person: { select: { id: true, vorname: true, nachname: true } },
},
orderBy: { gueltigBis: "asc" },
take: 100,
}),
db.qualifikationstyp.findMany({
where: { geloeschtAm: null },
orderBy: [{ art: "asc" }, { name: "asc" }],
include: { _count: { select: { qualifikationen: true } } },
}),
]);
return { ablaufend, typen };
});
const dringend = daten.ablaufend.filter((eintrag) => {
const zustand = qualifikationZustand(eintrag.gueltigBis, eintrag.typ.vorwarnzeitTage);
return zustand === "ueberfaellig" || zustand === "faellig" || zustand === "bald_faellig";
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Qualifikationen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Lehrgänge, Befähigungen und Untersuchungen der Mannschaft.
</p>
</div>
<Card>
<CardHeader>
<div className="flex items-start gap-3">
<AlertTriangle
className={
dringend.length > 0
? "mt-0.5 size-5 shrink-0 text-[var(--pruefung-faellig)]"
: "mt-0.5 size-5 shrink-0 text-[var(--muted-foreground)]"
}
aria-hidden
/>
<div>
<CardTitle>Läuft ab</CardTitle>
<CardDescription>
{dringend.length === 0
? "Derzeit läuft nichts innerhalb der jeweiligen Vorwarnzeit ab."
: `${dringend.length} ${dringend.length === 1 ? "Qualifikation ist" : "Qualifikationen sind"} innerhalb der Vorwarnzeit oder bereits abgelaufen.`}
</CardDescription>
</div>
</div>
</CardHeader>
{dringend.length > 0 && (
<CardContent className="px-0 pb-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Mitglied</TableHead>
<TableHead>Qualifikation</TableHead>
<TableHead>Art</TableHead>
<TableHead className="text-right">Erworben</TableHead>
<TableHead className="pr-6 text-right">Gültig bis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dringend.map((eintrag) => (
<TableRow key={eintrag.id}>
<TableCell className="pl-6 font-medium">
<Link
href={`/mitglieder/${eintrag.person.id}`}
className="underline-offset-4 hover:underline"
>
{personName(eintrag.person)}
</Link>
</TableCell>
<TableCell>
{eintrag.typ.name}
{eintrag.typ.einsatzrelevant && (
<Badge variant="gesperrt" className="ml-2">
einsatzrelevant
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-[var(--muted-foreground)]">
{ART_TEXT[eintrag.typ.art] ?? eintrag.typ.art}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{alsDatum(eintrag.erworbenAm)}
</TableCell>
<TableCell className="pr-6 text-right">
<FristAnzeige
datum={eintrag.gueltigBis}
vorwarnTage={eintrag.typ.vorwarnzeitTage}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
)}
</Card>
<Card>
<CardHeader>
<CardTitle>Katalog</CardTitle>
<CardDescription>
Welche Qualifikationen die Wehr führt, und wie lange sie jeweils gelten.
</CardDescription>
</CardHeader>
<CardContent className="px-0 pb-0">
{daten.typen.length === 0 ? (
<div className="px-6 pb-6">
<NichtsGefunden
titel="Noch keine Qualifikationstypen angelegt"
hinweis="Erst wenn Typen wie „Maschinist“ oder „G26.3“ angelegt sind, lassen sich Qualifikationen zuweisen."
/>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Bezeichnung</TableHead>
<TableHead>Kürzel</TableHead>
<TableHead>Art</TableHead>
<TableHead className="text-right">Gültigkeit</TableHead>
<TableHead className="text-right">Vorwarnzeit</TableHead>
<TableHead className="pr-6 text-right">Erfasst</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{daten.typen.map((typ) => (
<TableRow key={typ.id}>
<TableCell className="pl-6 font-medium">
{typ.name}
{typ.einsatzrelevant && (
<Badge variant="secondary" className="ml-2">
einsatzrelevant
</Badge>
)}
</TableCell>
<TableCell className="tabellenzahlen">{typ.kuerzel ?? "—"}</TableCell>
<TableCell className="text-sm text-[var(--muted-foreground)]">
{ART_TEXT[typ.art] ?? typ.art}
</TableCell>
<TableCell className="text-right">
{typ.gueltigkeitMonate
? `${typ.gueltigkeitMonate} Monate`
: "unbefristet"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{typ.vorwarnzeitTage} Tage
</TableCell>
<TableCell className="pr-6 text-right tabellenzahlen">
{typ._count.qualifikationen}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}