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>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { hatRecht } from "@/lib/authz";
import { etikettenPdf, ETIKETTEN_JE_SEITE } from "@/lib/stammdaten/etiketten";
import { AuditAktion, protokolliere } from "@/lib/audit";
/**
* Etikettenbogen als PDF.
*
* Eigener Endpunkt statt einer tRPC-Prozedur: Ein PDF ist eine Datei, keine
* JSON-Antwort. Über tRPC müsste es Base64-kodiert durch die Leitung, und der
* Browser könnte es nicht einfach in einem neuen Tab öffnen.
*
* Nimmt zwei Formen entgegen:
* - JSON { ids: [...], startPosition?: n } aus der Geräteliste
* - Formular mit einem Feld "id" von der Detailseite
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const eingabe = z.object({
ids: z.array(z.string().uuid()).min(1).max(500),
/** Ab welchem Etikett des Bogens gedruckt wird — für angebrochene Bögen. */
startPosition: z.number().int().min(1).max(ETIKETTEN_JE_SEITE).default(1),
});
export async function POST(anfrage: Request) {
const sitzung = await aktuelleSitzung();
if (!sitzung) {
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
}
if (!hatRecht(sitzung, "geraet.lesen")) {
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
}
let daten: z.infer<typeof eingabe>;
const inhaltstyp = anfrage.headers.get("content-type") ?? "";
if (inhaltstyp.includes("application/json")) {
const geprueft = eingabe.safeParse(await anfrage.json());
if (!geprueft.success) {
return NextResponse.json(
{ fehler: "Es wurde kein gültiges Gerät übergeben." },
{ status: 400 },
);
}
daten = geprueft.data;
} else {
const formular = await anfrage.formData();
const id = formular.get("id");
const start = Number(formular.get("startPosition") ?? 1);
const geprueft = eingabe.safeParse({
ids: typeof id === "string" ? [id] : [],
startPosition: Number.isFinite(start) ? start : 1,
});
if (!geprueft.success) {
return NextResponse.json(
{ fehler: "Es wurde kein gültiges Gerät übergeben." },
{ status: 400 },
);
}
daten = geprueft.data;
}
const { geraete, wehrname } = await fuerOrganisation(sitzung.organisationId, async (db) => {
const [liste, organisation] = await Promise.all([
db.geraet.findMany({
where: { id: { in: daten.ids }, geloeschtAm: null },
select: {
inventarnummer: true,
bezeichnung: true,
fahrzeug: { select: { funkrufname: true } },
standort: { select: { kuerzel: true, name: true } },
},
orderBy: { inventarnummer: "asc" },
}),
db.organisation.findUnique({
where: { id: sitzung.organisationId },
select: { name: true, kurzname: true },
}),
]);
return {
geraete: liste,
wehrname: organisation?.kurzname ?? organisation?.name ?? "Feuerwehr",
};
});
if (geraete.length === 0) {
return NextResponse.json({ fehler: "Keines dieser Geräte gibt es." }, { status: 404 });
}
try {
const pdf = await etikettenPdf(
geraete.map((geraet) => ({
inventarnummer: geraet.inventarnummer,
bezeichnung: geraet.bezeichnung,
zusatz: geraet.fahrzeug?.funkrufname ?? geraet.standort?.kuerzel ?? geraet.standort?.name,
})),
wehrname,
daten.startPosition,
);
// Der Abruf von Etiketten ist protokollpflichtig: daran lässt sich später
// nachvollziehen, wann welche Aufkleber gedruckt wurden.
await protokolliere({
organisationId: sitzung.organisationId,
aktion: AuditAktion.EXPORTIERT,
entitaet: "Geraet",
beschreibung: `Etikettenbogen für ${geraete.length} Geräte erzeugt`,
sitzung,
kontext: { anzahl: geraete.length, startPosition: daten.startPosition },
});
return new NextResponse(new Uint8Array(pdf), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="etiketten-${geraete.length}.pdf"`,
"Cache-Control": "no-store",
},
});
} catch (fehler) {
console.error("[etiketten] PDF fehlgeschlagen:", fehler);
return NextResponse.json(
{
fehler:
"Der Etikettenbogen ließ sich nicht erzeugen. Läuft der Gotenberg-Container? (docker compose ps)",
},
{ status: 502 },
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { QrCode } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
/**
* Ziel der QR-Codes auf den Geräteetiketten: /g/<inventarnummer>
*
* Die Route leitet nur weiter. Sie ist bewusst kurz, damit der Code auf einem
* 38 × 21 mm großen Etikett mit dicken Modulen auskommt — je länger die URL,
* desto feiner das Muster und desto unzuverlässiger der Scan im Geräteraum.
*
* Wichtig: Die Adresse ist keine Berechtigung. Wer nicht angemeldet ist, landet
* auf der Anmeldeseite und kommt danach hierher zurück.
*/
export const metadata = { title: "Gerät suchen" };
export default async function QrZielSeite({
params,
}: {
params: Promise<{ nummer: string }>;
}) {
const { nummer } = await params;
const inventarnummer = decodeURIComponent(nummer);
const sitzung = await aktuelleSitzung();
if (!sitzung) {
redirect(`/anmelden?callbackUrl=${encodeURIComponent(`/g/${nummer}`)}`);
}
const geraet = await fuerOrganisation(sitzung.organisationId, (db) =>
db.geraet.findFirst({
where: { inventarnummer, geloeschtAm: null },
select: { id: true },
}),
);
if (geraet) redirect(`/geraete/${geraet.id}`);
// Kein Treffer: das kann ein Etikett einer anderen Wehr sein, ein Gerät, das
// ausgesondert wurde, oder ein Tippfehler. Alle drei Fälle sehen für den
// Scanner gleich aus — deshalb eine Meldung statt einer 404-Seite.
return (
<div className="mx-auto max-w-md">
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<QrCode className="size-6 text-[var(--muted-foreground)]" aria-hidden />
<div>
<CardTitle>Kein Gerät gefunden</CardTitle>
<CardDescription>
Zur Inventarnummer{" "}
<span className="tabellenzahlen font-medium">{inventarnummer}</span> gibt es in
dieser Wehr kein Gerät.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<ul className="list-disc space-y-1 pl-5 text-sm text-[var(--muted-foreground)]">
<li>Das Etikett gehört zu einer anderen Wehr.</li>
<li>Das Gerät wurde ausgesondert und ist nicht mehr in der Liste.</li>
<li>Die Inventarnummer wurde beim Anlegen anders geschrieben.</li>
</ul>
<div className="flex flex-wrap gap-2">
<Button asChild>
<Link href={`/geraete?suche=${encodeURIComponent(inventarnummer)}`}>
In der Liste suchen
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/geraete/scannen">Nochmal scannen</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+3 -2
View File
@@ -97,8 +97,9 @@ const EINTRAEGE: Eintrag[] = [
},
];
/** In Phase 0 gibt es nur die Übersicht — alles andere ist noch Ausblick. */
const AKTUELLE_PHASE = 0;
/** 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 = 1;
export interface SeitenleisteProps {
rechte: Recht[];
+144
View File
@@ -0,0 +1,144 @@
"use client";
import * as React from "react";
import type { GeraetStatus } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { alsDatum } from "@/lib/utils";
import { fristzustand, tageBis, type Fristzustand } from "@/lib/stammdaten/fristen";
/**
* Anzeigebausteine für Stammdaten.
*
* Farbe bedeutet überall dasselbe: grün einsatzbereit, gelb läuft ab, rot
* gesperrt oder überfällig, grau ausgesondert. Wer die Farbe einmal gelernt
* hat, muss den Text nicht mehr lesen — und genau darum geht es bei einer
* Liste mit dreihundert Zeilen.
*
* Farbe allein reicht aber nicht: jede Angabe trägt zusätzlich Klartext.
* Rot-Grün-Sehschwäche ist häufig, und im Gerätehaus arbeitet niemand mit
* kalibriertem Bildschirm.
*/
const STATUS_TEXT: Record<GeraetStatus, string> = {
EINSATZBEREIT: "einsatzbereit",
PRUEFUNG_FAELLIG: "Prüfung fällig",
GESPERRT: "gesperrt",
AUSGESONDERT: "ausgesondert",
IN_REPARATUR: "in Reparatur",
};
const STATUS_VARIANTE: Record<
GeraetStatus,
"einsatzbereit" | "pruefungFaellig" | "gesperrt" | "ausgesondert" | "secondary"
> = {
EINSATZBEREIT: "einsatzbereit",
PRUEFUNG_FAELLIG: "pruefungFaellig",
GESPERRT: "gesperrt",
AUSGESONDERT: "ausgesondert",
IN_REPARATUR: "secondary",
};
export function StatusAbzeichen({ status }: { status: GeraetStatus }) {
return <Badge variant={STATUS_VARIANTE[status]}>{STATUS_TEXT[status]}</Badge>;
}
export const GERAET_STATUS_TEXT = STATUS_TEXT;
/**
* Ein Datum mit Ampel. Zeigt zusätzlich, wie viele Tage noch bleiben —
* "in 12 Tagen" sagt mehr als ein Datum, das man erst im Kopf ausrechnen muss.
*/
export function FristAnzeige({
datum,
vorwarnTage = 30,
ohneAngabe = "—",
}: {
datum: Date | string | null | undefined;
vorwarnTage?: number;
ohneAngabe?: string;
}) {
if (!datum) {
return <span className="text-[var(--muted-foreground)]">{ohneAngabe}</span>;
}
const alsDate = datum instanceof Date ? datum : new Date(datum);
const zustand = fristzustand(alsDate, vorwarnTage);
const tage = tageBis(alsDate);
const farbe: Record<Fristzustand, string> = {
ueberfaellig: "text-[var(--gesperrt)] font-medium",
faellig: "text-[var(--gesperrt)] font-medium",
bald_faellig: "text-[var(--pruefung-faellig)]",
in_ordnung: "",
ohne_frist: "text-[var(--muted-foreground)]",
};
const zusatz =
tage === null
? ""
: tage < 0
? `seit ${Math.abs(tage)} ${Math.abs(tage) === 1 ? "Tag" : "Tagen"}`
: tage === 0
? "heute"
: `in ${tage} ${tage === 1 ? "Tag" : "Tagen"}`;
return (
<span className={cn("tabellenzahlen whitespace-nowrap", farbe[zustand])}>
{alsDatum(alsDate)}
{zusatz && (
<span className="ml-1.5 text-xs text-[var(--muted-foreground)]">({zusatz})</span>
)}
</span>
);
}
/** Leerer Zustand einer Liste — mit einem Hinweis, was als Nächstes zu tun ist. */
export function NichtsGefunden({
titel,
hinweis,
aktion,
}: {
titel: string;
hinweis?: string;
aktion?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-[var(--border)] px-6 py-16 text-center">
<p className="font-medium">{titel}</p>
{hinweis && (
<p className="max-w-md text-sm text-[var(--muted-foreground)]">{hinweis}</p>
)}
{aktion}
</div>
);
}
/** Platzhalterzeilen, solange geladen wird. */
export function LadeZeilen({ zeilen = 8, spalten = 6 }: { zeilen?: number; spalten?: number }) {
return (
<>
{Array.from({ length: zeilen }).map((_, zeile) => (
<tr key={zeile} className="border-b border-[var(--border)]">
{Array.from({ length: spalten }).map((__, spalte) => (
<td key={spalte} className="px-3 py-3">
<div className="h-4 animate-pulse rounded bg-[var(--muted)]" />
</td>
))}
</tr>
))}
</>
);
}
/** Fehlermeldung, die sagt, was schiefging — nicht nur, dass etwas schiefging. */
export function Fehlermeldung({ meldung }: { meldung: string }) {
return (
<div
role="alert"
className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm text-[var(--destructive)]"
>
{meldung}
</div>
);
}
+27 -18
View File
@@ -22,26 +22,35 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
),
);
/**
* Kartenüberschrift.
*
* Bewusst ein echtes h3 und kein div: Wer mit einer Sprachausgabe arbeitet,
* springt von Überschrift zu Überschrift. Eine Karte, deren Titel technisch
* keine Überschrift ist, taucht in dieser Navigation nicht auf — die Seite
* wirkt dann leer, obwohl sie voll ist.
*
* Über "as" lässt sich die Ebene anpassen, wenn die Karte im Dokument tiefer
* oder höher steht.
*/
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement> & { as?: "h2" | "h3" | "h4" }
>(({ className, as: Element = "h3", ...props }, ref) => (
<Element
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
),
);
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-[var(--muted-foreground)]", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
+35
View File
@@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer size-4 shrink-0 rounded-sm border border-[var(--border)] shadow-sm",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-1",
"disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:border-[var(--primary)] data-[state=checked]:bg-[var(--primary)] data-[state=checked]:text-[var(--primary-foreground)]",
"data-[state=indeterminate]:border-[var(--primary)] data-[state=indeterminate]:bg-[var(--primary)] data-[state=indeterminate]:text-[var(--primary-foreground)]",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
{props.checked === "indeterminate" ? (
<Minus className="size-3" aria-hidden />
) : (
<Check className="size-3" aria-hidden />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+104
View File
@@ -0,0 +1,104 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4",
"max-h-[90vh] overflow-y-auto rounded-lg border border-[var(--border)] bg-[var(--background)] p-6 shadow-lg",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
aria-label="Schließen"
>
<X className="size-4" aria-hidden />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col space-y-1.5", className)} {...props} />;
}
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
}
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+105
View File
@@ -0,0 +1,105 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-1 text-sm shadow-sm",
"focus:outline-none focus:ring-2 focus:ring-[var(--ring)] focus:ring-offset-1 focus:ring-offset-[var(--background)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"[&>span]:line-clamp-1 [&>span]:text-left",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 shrink-0 opacity-50" aria-hidden />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
position={position}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--popover)] text-[var(--popover-foreground)] shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
position === "popper" && "data-[side=bottom]:translate-y-1",
className,
)}
{...props}
>
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-xs font-medium text-[var(--muted-foreground)]", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"focus:bg-[var(--accent)] focus:text-[var(--accent-foreground)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-4" aria-hidden />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
};
+90
View File
@@ -0,0 +1,90 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Tabellenbausteine.
*
* Der Rahmen scrollt waagerecht, statt die Seite zu sprengen — eine
* Geräteliste mit zwölf Spalten passt auf keinem Tablet in die Breite, und
* eine umbrechende Tabelle ist unlesbar.
*/
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-x-auto">
<table
ref={ref}
className={cn("w-full caption-bottom border-collapse text-sm", className)}
{...props}
/>
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
));
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b border-[var(--border)] transition-colors hover:bg-[var(--muted)]/60 data-[ausgewaehlt=true]:bg-[var(--accent)]",
className,
)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 whitespace-nowrap px-3 text-left align-middle text-xs font-medium uppercase tracking-wide text-[var(--muted-foreground)]",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td ref={ref} className={cn("px-3 py-2 align-middle", className)} {...props} />
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-3 text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption };
+121
View File
@@ -0,0 +1,121 @@
import "server-only";
import { env } from "@/lib/env";
/**
* PDF-Erzeugung über Gotenberg.
*
* Gotenberg ist ein eigener Container im Stack, der Chromium kapselt. Die
* Anwendung schickt fertiges HTML hin und bekommt ein PDF zurück — dadurch
* muss im Anwendungscontainer kein Browser stecken, und die Erzeugung kann
* abstürzen, ohne die Anwendung mitzunehmen.
*
* Regel für alle Vorlagen: das HTML muss vollständig eigenständig sein.
* Keine externen Schriften, keine CDN-Verweise, kein nachgeladenes CSS. Was
* nicht im übergebenen HTML steht, ist im PDF nicht drin.
*/
export interface PdfEinstellungen {
/** Seitengröße in Zoll. A4 ist 8.27 × 11.7. */
breite?: number;
hoehe?: number;
/** Ränder in Zoll. */
randOben?: number;
randUnten?: number;
randLinks?: number;
randRechts?: number;
quer?: boolean;
/** Hintergrundfarben und -bilder mitdrucken. */
hintergrundDrucken?: boolean;
/** Kopf- und Fußzeile als eigenes HTML-Fragment. */
kopfzeileHtml?: string;
fusszeileHtml?: string;
}
const A4 = { breite: 8.27, hoehe: 11.7 };
/**
* Rendert HTML zu PDF.
*
* @throws wenn Gotenberg nicht erreichbar ist oder das Zeitlimit reißt — der
* Aufrufer entscheidet, ob das ein Fehler für die Oberfläche ist oder
* ein Wiederholungsversuch im Hintergrundprozess.
*/
export async function htmlZuPdf(
html: string,
einstellungen: PdfEinstellungen = {},
): Promise<Buffer> {
if (!env.GOTENBERG_URL) {
throw new Error("GOTENBERG_URL ist nicht gesetzt — ohne Gotenberg gibt es keine PDFs.");
}
const formular = new FormData();
formular.append("files", new Blob([html], { type: "text/html" }), "index.html");
formular.append("paperWidth", String(einstellungen.breite ?? A4.breite));
formular.append("paperHeight", String(einstellungen.hoehe ?? A4.hoehe));
formular.append("marginTop", String(einstellungen.randOben ?? 0));
formular.append("marginBottom", String(einstellungen.randUnten ?? 0));
formular.append("marginLeft", String(einstellungen.randLinks ?? 0));
formular.append("marginRight", String(einstellungen.randRechts ?? 0));
formular.append("landscape", String(Boolean(einstellungen.quer)));
formular.append("printBackground", String(einstellungen.hintergrundDrucken ?? true));
// Ohne diese Einstellung wartet Chromium auf Netzwerkanfragen, die es bei
// eigenständigem HTML gar nicht gibt.
formular.append("skipNetworkIdleEvent", "true");
if (einstellungen.kopfzeileHtml) {
formular.append(
"files",
new Blob([einstellungen.kopfzeileHtml], { type: "text/html" }),
"header.html",
);
}
if (einstellungen.fusszeileHtml) {
formular.append(
"files",
new Blob([einstellungen.fusszeileHtml], { type: "text/html" }),
"footer.html",
);
}
const abbruch = AbortSignal.timeout(env.GOTENBERG_TIMEOUT * 1000);
const antwort = await fetch(`${env.GOTENBERG_URL}/forms/chromium/convert/html`, {
method: "POST",
body: formular,
signal: abbruch,
});
if (!antwort.ok) {
const text = await antwort.text().catch(() => "");
throw new Error(
`Gotenberg konnte das PDF nicht erzeugen (${antwort.status}). ${text.slice(0, 300)}`,
);
}
return Buffer.from(await antwort.arrayBuffer());
}
/** Erreichbarkeitsprüfung. */
export async function pdfDienstErreichbar(): Promise<boolean> {
if (!env.GOTENBERG_URL) return false;
try {
const antwort = await fetch(`${env.GOTENBERG_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
return antwort.ok;
} catch {
return false;
}
}
/** Maskiert Text für die Einbettung in HTML. */
export function html(text: string | null | undefined): string {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+205
View File
@@ -0,0 +1,205 @@
import "server-only";
import { htmlZuPdf, html } from "@/lib/pdf";
import { qrAlsSvg } from "@/lib/stammdaten/qr";
/**
* Etikettenbogen für Geräte.
*
* Zielformat ist der gängige Bogen mit 65 Etiketten je DIN-A4-Seite
* (38,1 × 21,2 mm, 5 Spalten × 13 Zeilen) — den gibt es in jedem Schreibwaren-
* laden, und er passt in jeden Laserdrucker.
*
* Auf jedes Etikett kommen drei Dinge, in dieser Reihenfolge der Wichtigkeit:
* 1. der QR-Code, damit das Gerät am Regal gescannt werden kann
* 2. die Inventarnummer, damit man sie auch ohne Handy ablesen kann
* 3. die Bezeichnung, gekürzt — auf 21 mm Höhe passt nicht mehr
*
* Die Etiketten sind absichtlich karg. Ein Aufkleber im Geräteraum bekommt
* Staub, Öl und Schrammen ab; alles, was nicht gebraucht wird, macht den Code
* nur kleiner.
*/
/** Maße des Bogens in Millimetern. */
export const BOGEN = {
spalten: 5,
zeilen: 13,
etikettBreite: 38.1,
etikettHoehe: 21.2,
randOben: 10.7,
randLinks: 4.75,
abstandX: 2.5,
abstandY: 0,
} as const;
export const ETIKETTEN_JE_SEITE = BOGEN.spalten * BOGEN.zeilen;
export interface EtikettAngaben {
inventarnummer: string;
bezeichnung: string;
/** Zusatzzeile, z. B. der Funkrufname des Fahrzeugs. */
zusatz?: string | null;
}
/**
* Baut den Bogen als eigenständiges HTML.
*
* @param startPosition Ab welchem Etikett gedruckt wird, 1-basiert. Damit
* lässt sich ein angebrochener Bogen weiterverwenden —
* sonst wandert jeder Bogen mit drei benutzten Etiketten
* in den Müll.
*/
export async function etikettenHtml(
geraete: EtikettAngaben[],
wehrname: string,
startPosition = 1,
): Promise<string> {
const leerVorne = Math.max(0, Math.min(startPosition - 1, ETIKETTEN_JE_SEITE - 1));
const zellen: string[] = [];
for (let i = 0; i < leerVorne; i += 1) {
zellen.push('<div class="etikett leer"></div>');
}
for (const geraet of geraete) {
const qr = await qrAlsSvg(geraet.inventarnummer, 16);
zellen.push(`
<div class="etikett">
<div class="qr">${qr}</div>
<div class="text">
<div class="nummer">${html(geraet.inventarnummer)}</div>
<div class="bezeichnung">${html(kuerzen(geraet.bezeichnung, 34))}</div>
${geraet.zusatz ? `<div class="zusatz">${html(kuerzen(geraet.zusatz, 28))}</div>` : ""}
</div>
</div>
`);
}
// Die letzte Seite auffüllen, damit das Raster nicht verrutscht.
while (zellen.length % ETIKETTEN_JE_SEITE !== 0) {
zellen.push('<div class="etikett leer"></div>');
}
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Etiketten — ${html(wehrname)}</title>
<style>
/*
Keine externen Schriften: gedruckt wird mit dem, was das System hergibt.
Eine geladene Schrift würde den Druck verzögern und im Zweifel gar nicht
ankommen.
*/
@page {
size: A4;
margin: 0;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
-webkit-print-color-adjust: exact;
}
.bogen {
width: 210mm;
padding-top: ${BOGEN.randOben}mm;
padding-left: ${BOGEN.randLinks}mm;
display: grid;
grid-template-columns: repeat(${BOGEN.spalten}, ${BOGEN.etikettBreite}mm);
grid-auto-rows: ${BOGEN.etikettHoehe}mm;
column-gap: ${BOGEN.abstandX}mm;
row-gap: ${BOGEN.abstandY}mm;
}
.etikett {
width: ${BOGEN.etikettBreite}mm;
height: ${BOGEN.etikettHoehe}mm;
padding: 1mm 1.2mm;
display: flex;
align-items: center;
gap: 1.2mm;
overflow: hidden;
}
.etikett.leer { visibility: hidden; }
.qr {
flex: 0 0 16mm;
width: 16mm;
height: 16mm;
display: flex;
align-items: center;
justify-content: center;
}
.qr svg { display: block; }
.text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.3mm;
}
.nummer {
font-size: 7.5pt;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bezeichnung {
font-size: 5.5pt;
line-height: 1.15;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.zusatz {
font-size: 5pt;
color: #444;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<div class="bogen">
${zellen.join("\n")}
</div>
</body>
</html>`;
}
/** Erzeugt den Bogen als PDF. */
export async function etikettenPdf(
geraete: EtikettAngaben[],
wehrname: string,
startPosition = 1,
): Promise<Buffer> {
const inhalt = await etikettenHtml(geraete, wehrname, startPosition);
return htmlZuPdf(inhalt, {
randOben: 0,
randUnten: 0,
randLinks: 0,
randRechts: 0,
hintergrundDrucken: true,
});
}
function kuerzen(text: string, zeichen: number): string {
if (text.length <= zeichen) return text;
return `${text.slice(0, zeichen - 1).trimEnd()}…`;
}
+255
View File
@@ -0,0 +1,255 @@
import { formatInTimeZone } from "date-fns-tz";
/**
* Fristen- und Lebensdauerberechnung.
*
* Bewusst reine Funktionen ohne Datenbankzugriff: was hier steht, entscheidet
* am Ende darüber, ob ein Gerät als einsatzbereit gilt oder gesperrt wird.
* Solche Logik gehört an eine Stelle, an der man sie ohne laufenden Stack
* durchtesten kann.
*
* Alle Berechnungen laufen serverseitig und rechnen auf Kalendertagen — die
* Uhrzeit spielt bei Prüffristen keine Rolle, das Datum schon.
*/
/**
* Alle Rechnungen laufen über UTC-Kalendertage.
*
* Der naheliegende Weg wäre startOfDay() aus date-fns — der rechnet aber in
* der Zeitzone des Servers. Ein Prüfdatum kommt aus der Datenbank als
* DATE-Spalte und damit als UTC-Mitternacht; startOfDay() in Europe/Berlin
* schiebt es auf den Vortag zurück. Aus dem 01.03. wird der 28.02., und eine
* Frist wäre einen Tag zu früh fällig.
*
* Deshalb hier eigene Arithmetik auf den UTC-Bestandteilen. Sie ist
* unabhängig davon, wo der Server steht.
*/
/** Setzt ein Datum auf UTC-Mitternacht seines eigenen Kalendertages. */
function utcTag(wert: Date): Date {
return new Date(Date.UTC(wert.getUTCFullYear(), wert.getUTCMonth(), wert.getUTCDate()));
}
/**
* Addiert Monate auf UTC-Basis. Überzählige Tage werden auf das Monatsende
* geklemmt: der 31. Januar plus einen Monat ist der 28. Februar, nicht der
* 3. März.
*/
function monateAddieren(basis: Date, monate: number): Date {
const jahr = basis.getUTCFullYear();
const monat = basis.getUTCMonth() + monate;
const tag = basis.getUTCDate();
const letzterTagImZielmonat = new Date(Date.UTC(jahr, monat + 1, 0)).getUTCDate();
return new Date(Date.UTC(jahr, monat, Math.min(tag, letzterTagImZielmonat)));
}
/** Addiert Jahre auf UTC-Basis, mit derselben Klemmung am 29. Februar. */
function jahreAddieren(basis: Date, jahre: number): Date {
return monateAddieren(basis, jahre * 12);
}
/**
* Der heutige Kalendertag in Europe/Berlin, als UTC-Mitternacht.
*
* Wichtig für die Stunden um Mitternacht: Um 00:30 Uhr Berliner Zeit ist es
* nach UTC noch der Vortag. Eine Frist, die "heute" abläuft, darf davon nicht
* abhängen.
*/
function heutigerTag(jetzt: Date): Date {
const [jahr, monat, tag] = formatInTimeZone(jetzt, "Europe/Berlin", "yyyy-MM-dd")
.split("-")
.map(Number);
return new Date(Date.UTC(jahr!, monat! - 1, tag!));
}
/** Kalendertage zwischen zwei UTC-Tagen. */
function tagesabstand(von: Date, bis: Date): number {
return Math.round((utcTag(bis).getTime() - utcTag(von).getTime()) / 86_400_000);
}
/** Wie dringend eine Frist ist. */
export type Fristzustand =
| "ueberfaellig"
| "faellig"
| "bald_faellig"
| "in_ordnung"
| "ohne_frist";
export const FRISTZUSTAND_BEZEICHNUNG: Record<Fristzustand, string> = {
ueberfaellig: "überfällig",
faellig: "heute fällig",
bald_faellig: "läuft bald ab",
in_ordnung: "in Ordnung",
ohne_frist: "ohne Frist",
};
/**
* Vorgaben, die vom Gerätetyp kommen und vom einzelnen Gerät überschrieben
* werden dürfen. Der Hersteller einer bestimmten Charge schreibt manchmal
* etwas anderes vor als die Bauart allgemein.
*/
export interface Vorgabe {
pruefintervallMonate?: number | null;
lebensdauerJahre?: number | null;
lebensdauerBezug?: "HERSTELLUNG" | "INDIENSTSTELLUNG" | null;
vorwarnzeitMonate?: number | null;
}
/** Der jeweils speziellere Wert gewinnt: Gerät vor Typ. */
export function effektiverWert<T>(amGeraet: T | null | undefined, amTyp: T | null | undefined): T | null {
if (amGeraet !== null && amGeraet !== undefined) return amGeraet;
if (amTyp !== null && amTyp !== undefined) return amTyp;
return null;
}
/**
* Nächster Prüftermin.
*
* Gerechnet wird ab der letzten Prüfung, ersatzweise ab der Indienststellung.
* Ohne beides und ohne Intervall gibt es keinen Termin — das ist kein Fehler,
* sondern der Normalfall für Geräte ohne Prüfpflicht.
*/
export function naechstePruefung(angaben: {
letztePruefung?: Date | null;
indienststellung?: Date | null;
intervallMonate?: number | null;
}): Date | null {
const { letztePruefung, indienststellung, intervallMonate } = angaben;
if (!intervallMonate || intervallMonate <= 0) return null;
const basis = letztePruefung ?? indienststellung;
if (!basis) return null;
return monateAddieren(utcTag(basis), intervallMonate);
}
/**
* Ende der Nutzungsdauer.
*
* Helme, Seile, Pressluftatmer und Schutzkleidung sind unabhängig vom Zustand
* nur eine begrenzte Zeit zugelassen. Woran sich das bemisst, ist je nach
* Vorschrift unterschiedlich: bei Seilen ab Herstellung, bei Schutzkleidung
* meist ab Indienststellung.
*/
export function lebensdauerEnde(angaben: {
herstellungsdatum?: Date | null;
indienststellung?: Date | null;
lebensdauerJahre?: number | null;
bezug?: "HERSTELLUNG" | "INDIENSTSTELLUNG" | null;
}): Date | null {
const { herstellungsdatum, indienststellung, lebensdauerJahre, bezug } = angaben;
if (!lebensdauerJahre || lebensdauerJahre <= 0) return null;
const basis =
bezug === "HERSTELLUNG"
? (herstellungsdatum ?? indienststellung)
: (indienststellung ?? herstellungsdatum);
if (!basis) return null;
return jahreAddieren(utcTag(basis), lebensdauerJahre);
}
/**
* Wie es um eine Frist steht.
*
* @param frist Stichtag, oder null wenn es keinen gibt
* @param vorwarnTage ab wann gewarnt wird
* @param heute überschreibbar, damit Tests nicht von der Uhr abhängen
*/
export function fristzustand(
frist: Date | null | undefined,
vorwarnTage = 30,
heute: Date = new Date(),
): Fristzustand {
if (!frist) return "ohne_frist";
const tage = tagesabstand(heutigerTag(heute), frist);
if (tage < 0) return "ueberfaellig";
if (tage === 0) return "faellig";
if (tage <= vorwarnTage) return "bald_faellig";
return "in_ordnung";
}
/** Verbleibende Kalendertage bis zur Frist. Negativ, wenn sie vorbei ist. */
export function tageBis(frist: Date | null | undefined, heute: Date = new Date()): number | null {
if (!frist) return null;
return tagesabstand(heutigerTag(heute), frist);
}
/**
* Welchen Status ein Gerät haben müsste.
*
* Vorsatz dabei: die Berechnung entscheidet nur über den Übergang
* einsatzbereit ⇄ pruefung_faellig. Wer ein Gerät gesperrt oder ausgesondert
* hat, hat das bewusst getan — das übersteuert keine Fristenrechnung.
*/
export function abgeleiteterGeraetStatus(angaben: {
aktuellerStatus: "EINSATZBEREIT" | "PRUEFUNG_FAELLIG" | "GESPERRT" | "AUSGESONDERT" | "IN_REPARATUR";
naechstePruefung?: Date | null;
lebensdauerBis?: Date | null;
heute?: Date;
}): "EINSATZBEREIT" | "PRUEFUNG_FAELLIG" | "GESPERRT" | "AUSGESONDERT" | "IN_REPARATUR" {
const { aktuellerStatus, naechstePruefung: pruefung, lebensdauerBis, heute = new Date() } = angaben;
// Von Hand gesetzte Zustände bleiben, wie sie sind.
if (
aktuellerStatus === "GESPERRT" ||
aktuellerStatus === "AUSGESONDERT" ||
aktuellerStatus === "IN_REPARATUR"
) {
return aktuellerStatus;
}
// Abgelaufene Lebensdauer wiegt schwerer als eine fällige Prüfung: das Gerät
// darf nicht mehr eingesetzt werden, auch wenn es äußerlich in Ordnung ist.
if (lebensdauerBis && fristzustand(lebensdauerBis, 0, heute) === "ueberfaellig") {
return "GESPERRT";
}
const zustand = fristzustand(pruefung, 0, heute);
if (zustand === "ueberfaellig" || zustand === "faellig") return "PRUEFUNG_FAELLIG";
return "EINSATZBEREIT";
}
/**
* Ablaufdatum einer Qualifikation.
* Ohne Gültigkeitsdauer gilt sie unbefristet — dann gibt es kein Ablaufdatum.
*/
export function qualifikationGueltigBis(
erworbenAm: Date,
gueltigkeitMonate: number | null | undefined,
): Date | null {
if (!gueltigkeitMonate || gueltigkeitMonate <= 0) return null;
return monateAddieren(utcTag(erworbenAm), gueltigkeitMonate);
}
/** Zustand einer Qualifikation, mit der Vorwarnzeit ihres Typs. */
export function qualifikationZustand(
gueltigBis: Date | null | undefined,
vorwarnzeitTage = 90,
heute: Date = new Date(),
): Fristzustand {
return fristzustand(gueltigBis, vorwarnzeitTage, heute);
}
/**
* Zeitfenster für das Fristen-Dashboard ab Phase 2. Steht hier, weil die
* Grenzen schon in Phase 1 für die Ablaufwarnung gebraucht werden.
*/
export function fristFenster(heute: Date = new Date()) {
const start = heutigerTag(heute);
const plusTage = (tage: number) => new Date(start.getTime() + tage * 86_400_000);
return {
heute: start,
inSiebenTagen: plusTage(7),
inDreissigTagen: plusTage(30),
inNeunzigTagen: plusTage(90),
};
}
+602
View File
@@ -0,0 +1,602 @@
/**
* Feldbeschreibungen für den Import.
*
* Bewusst frei von Datei- und Datenbankzugriff: Was ein Wert bedeutet und wann
* er gültig ist, lässt sich so ohne laufenden Stack durchtesten. Das Einlesen
* der Datei steht in import.ts.
*
* Jede Wehr bringt eine andere Tabelle mit. Deshalb hat jedes Feld eine Liste
* gebräuchlicher Spaltenüberschriften — damit die Zuordnung meistens schon
* stimmt, bevor jemand etwas anklickt.
*/
export type FeldTyp = "text" | "zahl" | "dezimal" | "datum" | "auswahl" | "jaNein";
export interface ImportFeld {
/** Feldname im Datenmodell. */
schluessel: string;
bezeichnung: string;
typ: FeldTyp;
pflicht: boolean;
/** Überschriften, unter denen dieses Feld erfahrungsgemäß auftaucht. */
aliase: string[];
/** Erlaubte Werte bei typ "auswahl", jeweils Anzeige -> gespeicherter Wert. */
auswahl?: Record<string, string>;
hinweis?: string;
maxLaenge?: number;
}
/** Vergleichsform einer Überschrift: ohne Umlaute, Sonderzeichen, Groß/Klein. */
export function normalisiereUeberschrift(text: string): string {
return text
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]/g, "");
}
// -----------------------------------------------------------------------------
// Wertumwandlung
// -----------------------------------------------------------------------------
export interface WertErgebnis<T = unknown> {
ok: boolean;
wert?: T;
fehler?: string;
}
/**
* Datum aus dem, was in Tabellen tatsächlich steht.
*
* Abgedeckt: 02.09.2026, 2.9.2026, 02.09.26, 2026-09-02, 02/09/2026 und die
* Seriennummer, die Excel intern verwendet. Bei zweistelligen Jahren gilt die
* übliche Grenze: 70 und darüber ist 19xx, darunter 20xx.
*/
export function datumLesen(roh: string): WertErgebnis<Date> {
const text = roh.trim();
if (!text) return { ok: true, wert: undefined };
// Excel-Seriennummer (Tage seit dem 30.12.1899).
if (/^\d{5}$/.test(text)) {
const tage = Number(text);
const datum = new Date(Date.UTC(1899, 11, 30));
datum.setUTCDate(datum.getUTCDate() + tage);
return { ok: true, wert: datum };
}
// ISO: 2026-09-02
const iso = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (iso) {
return bauDatum(Number(iso[1]), Number(iso[2]), Number(iso[3]));
}
// Deutsch: 02.09.2026 oder 2.9.26, auch mit / oder -
const deutsch = text.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2}|\d{4})$/);
if (deutsch) {
let jahr = Number(deutsch[3]);
if (jahr < 100) jahr += jahr >= 70 ? 1900 : 2000;
return bauDatum(jahr, Number(deutsch[2]), Number(deutsch[1]));
}
return { ok: false, fehler: `"${roh}" ist kein erkennbares Datum (erwartet: TT.MM.JJJJ)` };
}
function bauDatum(jahr: number, monat: number, tag: number): WertErgebnis<Date> {
if (monat < 1 || monat > 12) return { ok: false, fehler: `Monat ${monat} gibt es nicht` };
if (tag < 1 || tag > 31) return { ok: false, fehler: `Tag ${tag} gibt es nicht` };
const datum = new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0));
// Rollt der 31.02. auf den 03.03. weiter, war die Eingabe falsch.
if (datum.getUTCMonth() !== monat - 1 || datum.getUTCDate() !== tag) {
return { ok: false, fehler: `Den ${tag}.${monat}.${jahr} gibt es nicht` };
}
if (jahr < 1900 || jahr > 2100) {
return { ok: false, fehler: `Das Jahr ${jahr} ist unplausibel` };
}
return { ok: true, wert: datum };
}
/** Ganze Zahl. Punkt als Tausendertrenner wird entfernt. */
export function zahlLesen(roh: string): WertErgebnis<number> {
const text = roh.trim();
if (!text) return { ok: true, wert: undefined };
const bereinigt = text.replace(/\./g, "").replace(/\s/g, "");
if (!/^-?\d+$/.test(bereinigt)) {
return { ok: false, fehler: `"${roh}" ist keine ganze Zahl` };
}
return { ok: true, wert: Number(bereinigt) };
}
/** Dezimalzahl in deutscher oder englischer Schreibweise. */
export function dezimalLesen(roh: string): WertErgebnis<number> {
const text = roh.trim().replace(/[€\s]/g, "");
if (!text) return { ok: true, wert: undefined };
// 1.234,56 -> 1234.56 | 1,234.56 -> 1234.56 | 1234,56 -> 1234.56
let bereinigt = text;
const hatKomma = text.includes(",");
const hatPunkt = text.includes(".");
if (hatKomma && hatPunkt) {
bereinigt =
text.lastIndexOf(",") > text.lastIndexOf(".")
? text.replace(/\./g, "").replace(",", ".")
: text.replace(/,/g, "");
} else if (hatKomma) {
bereinigt = text.replace(",", ".");
}
if (!/^-?\d+(\.\d+)?$/.test(bereinigt)) {
return { ok: false, fehler: `"${roh}" ist keine Zahl` };
}
return { ok: true, wert: Number(bereinigt) };
}
/** Wahrheitswert aus den Schreibweisen, die in Tabellen vorkommen. */
export function jaNeinLesen(roh: string): WertErgebnis<boolean> {
const text = roh.trim().toLowerCase();
if (!text) return { ok: true, wert: undefined };
if (["ja", "j", "wahr", "true", "x", "1", "yes", "y"].includes(text)) {
return { ok: true, wert: true };
}
if (["nein", "n", "falsch", "false", "0", "no", "-"].includes(text)) {
return { ok: true, wert: false };
}
return { ok: false, fehler: `"${roh}" ist weder ja noch nein` };
}
/** Auswahlwert, tolerant gegenüber Schreibweise. */
export function auswahlLesen(roh: string, auswahl: Record<string, string>): WertErgebnis<string> {
const text = roh.trim();
if (!text) return { ok: true, wert: undefined };
const gesucht = normalisiereUeberschrift(text);
for (const [anzeige, wert] of Object.entries(auswahl)) {
if (normalisiereUeberschrift(anzeige) === gesucht) return { ok: true, wert };
if (normalisiereUeberschrift(wert) === gesucht) return { ok: true, wert };
}
return {
ok: false,
fehler: `"${roh}" ist kein gültiger Wert. Erlaubt: ${Object.keys(auswahl).join(", ")}`,
};
}
/** Wandelt einen Rohwert nach der Feldbeschreibung um. */
export function wertLesen(feld: ImportFeld, roh: string): WertErgebnis {
const text = (roh ?? "").trim();
if (!text) {
return feld.pflicht
? { ok: false, fehler: `${feld.bezeichnung} ist ein Pflichtfeld` }
: { ok: true, wert: undefined };
}
switch (feld.typ) {
case "datum":
return datumLesen(text);
case "zahl":
return zahlLesen(text);
case "dezimal":
return dezimalLesen(text);
case "jaNein":
return jaNeinLesen(text);
case "auswahl":
return auswahlLesen(text, feld.auswahl ?? {});
case "text":
default:
if (feld.maxLaenge && text.length > feld.maxLaenge) {
return {
ok: false,
fehler: `${feld.bezeichnung} ist zu lang (${text.length} von höchstens ${feld.maxLaenge} Zeichen)`,
};
}
return { ok: true, wert: text };
}
}
// -----------------------------------------------------------------------------
// Felder je Datenart
// -----------------------------------------------------------------------------
const GERAETE_STATUS = {
einsatzbereit: "EINSATZBEREIT",
"Prüfung fällig": "PRUEFUNG_FAELLIG",
gesperrt: "GESPERRT",
ausgesondert: "AUSGESONDERT",
"in Reparatur": "IN_REPARATUR",
};
export const GERAET_FELDER: ImportFeld[] = [
{
schluessel: "inventarnummer",
bezeichnung: "Inventarnummer",
typ: "text",
pflicht: true,
maxLaenge: 60,
aliase: ["inventarnummer", "invnr", "inventarnr", "nummer", "nr", "invnummer", "barcode"],
hinweis: "Muss innerhalb der Wehr eindeutig sein.",
},
{
schluessel: "bezeichnung",
bezeichnung: "Bezeichnung",
typ: "text",
pflicht: true,
maxLaenge: 160,
aliase: ["bezeichnung", "name", "geraet", "geraetebezeichnung", "artikel", "beschreibung"],
},
{
schluessel: "hersteller",
bezeichnung: "Hersteller",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["hersteller", "fabrikat", "marke", "lieferant"],
},
{
schluessel: "modell",
bezeichnung: "Modell",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["modell", "typ", "typbezeichnung", "ausfuehrung"],
},
{
schluessel: "seriennummer",
bezeichnung: "Seriennummer",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["seriennummer", "seriennr", "sn", "fabriknummer", "geraetenummer"],
},
{
schluessel: "herstellungsdatum",
bezeichnung: "Herstellungsdatum",
typ: "datum",
pflicht: false,
aliase: ["herstellungsdatum", "baujahr", "herstelldatum", "produktionsdatum", "fertigung"],
},
{
schluessel: "indienststellung",
bezeichnung: "Indienststellung",
typ: "datum",
pflicht: false,
aliase: ["indienststellung", "indienst", "anschaffung", "anschaffungsdatum", "kaufdatum", "beschaffung"],
},
{
schluessel: "anschaffungspreis",
bezeichnung: "Anschaffungspreis",
typ: "dezimal",
pflicht: false,
aliase: ["anschaffungspreis", "preis", "kosten", "betrag", "wert", "kaufpreis"],
},
{
schluessel: "status",
bezeichnung: "Status",
typ: "auswahl",
pflicht: false,
auswahl: GERAETE_STATUS,
aliase: ["status", "zustand", "einsatzbereit", "verfuegbarkeit"],
},
{
schluessel: "pruefintervallMonate",
bezeichnung: "Prüfintervall (Monate)",
typ: "zahl",
pflicht: false,
aliase: ["pruefintervall", "intervall", "pruefintervallmonate", "pruefungalle", "wartungsintervall"],
},
{
schluessel: "letztePruefung",
bezeichnung: "Letzte Prüfung",
typ: "datum",
pflicht: false,
aliase: ["letztepruefung", "letzteprufung", "gepruefjtam", "pruefdatum", "letztewartung"],
},
{
schluessel: "naechstePruefung",
bezeichnung: "Nächste Prüfung",
typ: "datum",
pflicht: false,
aliase: ["naechstepruefung", "faelligam", "faellig", "naechstewartung", "pruefungfaellig"],
},
{
schluessel: "lebensdauerJahre",
bezeichnung: "Lebensdauer (Jahre)",
typ: "zahl",
pflicht: false,
aliase: ["lebensdauer", "lebensdauerjahre", "nutzungsdauer", "maxalter"],
},
{
schluessel: "standort",
bezeichnung: "Standort",
typ: "text",
pflicht: false,
maxLaenge: 160,
aliase: ["standort", "ort", "lagerort", "geraetehaus", "raum"],
hinweis: "Wird über den Namen zugeordnet. Unbekannte Standorte werden angelegt.",
},
{
schluessel: "fahrzeug",
bezeichnung: "Fahrzeug",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["fahrzeug", "funkrufname", "verlastung", "verlastetauf"],
hinweis: "Funkrufname oder Kennzeichen. Wird nur zugeordnet, wenn das Fahrzeug existiert.",
},
{
schluessel: "geraeteraum",
bezeichnung: "Geräteraum",
typ: "text",
pflicht: false,
maxLaenge: 40,
aliase: ["geraeteraum", "raumamfahrzeug", "fach", "g", "entnahmestelle"],
},
{
schluessel: "bemerkung",
bezeichnung: "Bemerkung",
typ: "text",
pflicht: false,
maxLaenge: 1000,
aliase: ["bemerkung", "notiz", "kommentar", "anmerkung", "hinweis"],
},
];
export const PERSON_FELDER: ImportFeld[] = [
{
schluessel: "nachname",
bezeichnung: "Nachname",
typ: "text",
pflicht: true,
maxLaenge: 100,
aliase: ["nachname", "name", "familienname", "zuname"],
},
{
schluessel: "vorname",
bezeichnung: "Vorname",
typ: "text",
pflicht: true,
maxLaenge: 100,
aliase: ["vorname", "rufname"],
},
{
schluessel: "mitgliedsnummer",
bezeichnung: "Mitgliedsnummer",
typ: "text",
pflicht: false,
maxLaenge: 30,
aliase: ["mitgliedsnummer", "mitgliednr", "nummer", "personalnummer", "nr"],
},
{
schluessel: "geburtsdatum",
bezeichnung: "Geburtsdatum",
typ: "datum",
pflicht: false,
aliase: ["geburtsdatum", "geboren", "gebdatum", "geburtstag"],
},
{
schluessel: "email",
bezeichnung: "E-Mail",
typ: "text",
pflicht: false,
maxLaenge: 200,
aliase: ["email", "mail", "emailadresse", "epost"],
},
{
schluessel: "telefon",
bezeichnung: "Telefon",
typ: "text",
pflicht: false,
maxLaenge: 40,
aliase: ["telefon", "tel", "festnetz", "telefonnummer"],
},
{
schluessel: "mobil",
bezeichnung: "Mobil",
typ: "text",
pflicht: false,
maxLaenge: 40,
aliase: ["mobil", "handy", "mobilnummer", "handynummer"],
},
{
schluessel: "strasse",
bezeichnung: "Straße",
typ: "text",
pflicht: false,
maxLaenge: 200,
aliase: ["strasse", "adresse", "anschrift", "strassehausnummer"],
},
{
schluessel: "plz",
bezeichnung: "PLZ",
typ: "text",
pflicht: false,
maxLaenge: 10,
aliase: ["plz", "postleitzahl"],
},
{
schluessel: "ort",
bezeichnung: "Ort",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["ort", "wohnort", "stadt", "gemeinde"],
},
{
schluessel: "eintrittsdatum",
bezeichnung: "Eintrittsdatum",
typ: "datum",
pflicht: false,
aliase: ["eintrittsdatum", "eintritt", "beitritt", "dabeiseit", "mitgliedseit"],
},
{
schluessel: "abteilung",
bezeichnung: "Abteilung",
typ: "text",
pflicht: false,
maxLaenge: 60,
aliase: ["abteilung", "gruppe", "status", "zugehoerigkeit"],
hinweis: "Zum Beispiel: Aktive, Jugendfeuerwehr, Passive, Ehrenmitglieder.",
},
];
export const FAHRZEUG_FELDER: ImportFeld[] = [
{
schluessel: "funkrufname",
bezeichnung: "Funkrufname",
typ: "text",
pflicht: true,
maxLaenge: 120,
aliase: ["funkrufname", "funkruf", "rufname", "florian"],
},
{
schluessel: "bezeichnung",
bezeichnung: "Bezeichnung",
typ: "text",
pflicht: true,
maxLaenge: 120,
aliase: ["bezeichnung", "name", "fahrzeug", "fahrzeugbezeichnung"],
},
{
schluessel: "dinTyp",
bezeichnung: "Typ nach DIN",
typ: "text",
pflicht: false,
maxLaenge: 40,
aliase: ["dintyp", "typ", "fahrzeugtyp", "din", "kurzbezeichnung"],
},
{
schluessel: "kennzeichen",
bezeichnung: "Kennzeichen",
typ: "text",
pflicht: false,
maxLaenge: 20,
aliase: ["kennzeichen", "kfzkennzeichen", "amtlicheskennzeichen", "nummernschild"],
},
{
schluessel: "hersteller",
bezeichnung: "Hersteller",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["hersteller", "fahrgestell", "marke", "fabrikat"],
},
{
schluessel: "aufbauhersteller",
bezeichnung: "Aufbauhersteller",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["aufbauhersteller", "aufbau", "ausbau"],
},
{
schluessel: "fahrgestellnummer",
bezeichnung: "Fahrgestellnummer",
typ: "text",
pflicht: false,
maxLaenge: 30,
aliase: ["fahrgestellnummer", "fin", "vin", "fahrgestellnr"],
},
{
schluessel: "baujahr",
bezeichnung: "Baujahr",
typ: "zahl",
pflicht: false,
aliase: ["baujahr", "jahr", "bj"],
},
{
schluessel: "erstzulassung",
bezeichnung: "Erstzulassung",
typ: "datum",
pflicht: false,
aliase: ["erstzulassung", "ez", "zulassung"],
},
{
schluessel: "tuevBis",
bezeichnung: "TÜV bis",
typ: "datum",
pflicht: false,
aliase: ["tuevbis", "tuev", "hu", "hauptuntersuchung", "naechstertuev"],
},
{
schluessel: "sitzplaetze",
bezeichnung: "Sitzplätze",
typ: "zahl",
pflicht: false,
aliase: ["sitzplaetze", "besatzung", "plaetze", "sitze"],
},
{
schluessel: "tankinhalt",
bezeichnung: "Tankinhalt (Liter)",
typ: "zahl",
pflicht: false,
aliase: ["tankinhalt", "loeschwasser", "wassertank", "tank"],
},
];
export const IMPORT_ARTEN = {
geraet: { bezeichnung: "Geräte", felder: GERAET_FELDER },
person: { bezeichnung: "Mitglieder", felder: PERSON_FELDER },
fahrzeug: { bezeichnung: "Fahrzeuge", felder: FAHRZEUG_FELDER },
} as const;
export type ImportArt = keyof typeof IMPORT_ARTEN;
/**
* Schlägt für jede Spalte der Datei ein Zielfeld vor.
*
* Zuerst über die Aliasliste, dann über einen Teilstringvergleich. Was nicht
* zugeordnet werden kann, bleibt leer — die Zuordnung wird ohnehin angezeigt
* und lässt sich von Hand ändern, bevor irgendetwas geschrieben wird.
*/
export function spaltenZuordnen(
spalten: string[],
felder: ImportFeld[],
): Record<string, string | null> {
const zuordnung: Record<string, string | null> = {};
const vergeben = new Set<string>();
// Erste Runde: genaue Treffer über die Aliase.
for (const spalte of spalten) {
const normal = normalisiereUeberschrift(spalte);
const treffer = felder.find(
(feld) =>
!vergeben.has(feld.schluessel) &&
(normalisiereUeberschrift(feld.schluessel) === normal ||
feld.aliase.some((alias) => normalisiereUeberschrift(alias) === normal)),
);
if (treffer) {
zuordnung[spalte] = treffer.schluessel;
vergeben.add(treffer.schluessel);
}
}
// Zweite Runde: Teiltreffer für alles, was übrig ist.
for (const spalte of spalten) {
if (zuordnung[spalte]) continue;
const normal = normalisiereUeberschrift(spalte);
if (!normal) {
zuordnung[spalte] = null;
continue;
}
const treffer = felder.find(
(feld) =>
!vergeben.has(feld.schluessel) &&
feld.aliase.some((alias) => {
const a = normalisiereUeberschrift(alias);
return a.length >= 4 && (normal.includes(a) || a.includes(normal));
}),
);
zuordnung[spalte] = treffer?.schluessel ?? null;
if (treffer) vergeben.add(treffer.schluessel);
}
return zuordnung;
}
+344
View File
@@ -0,0 +1,344 @@
import "server-only";
import Papa from "papaparse";
import ExcelJS from "exceljs";
import {
IMPORT_ARTEN,
spaltenZuordnen,
wertLesen,
type ImportArt,
type ImportFeld,
} from "@/lib/stammdaten/import-felder";
/**
* Datei-Import für Stammdaten.
*
* Ablauf in drei Schritten, und der mittlere ist der wichtige:
*
* 1. Datei einlesen — CSV oder Excel, Spaltenüberschriften erkennen
* 2. Zuordnung und Vorschau — was landet wo, und was stimmt daran nicht
* 3. Übernehmen — erst hier wird geschrieben
*
* Zwischen 2 und 3 sitzt ein Mensch. Ohne diese Vorschau würde eine Wehr dem
* Ergebnis nicht trauen, und zu Recht: eine falsch zugeordnete Spalte
* verteilt Seriennummern auf Inventarnummern, und das merkt man erst Wochen
* später am Regal.
*/
/** Höchstwerte, damit ein versehentlich gewähltes 200-MB-Archiv nicht den
* Server beschäftigt. */
export const MAX_DATEIGROESSE = 10 * 1024 * 1024;
export const MAX_ZEILEN = 5_000;
export interface GeleseneTabelle {
spalten: string[];
zeilen: Record<string, string>[];
/** Wie viele Zeilen die Datei hatte, bevor gekürzt wurde. */
zeilenGesamt: number;
gekuerzt: boolean;
}
export interface ZellFehler {
zeile: number;
spalte: string;
feld: string;
meldung: string;
}
export interface VorschauZeile {
/** Zeilennummer in der Datei, 1-basiert ohne Überschrift. */
nummer: number;
werte: Record<string, unknown>;
rohwerte: Record<string, string>;
fehler: ZellFehler[];
/** true, wenn diese Zeile übernommen werden kann. */
uebernehmbar: boolean;
}
export interface ImportVorschau {
art: ImportArt;
felder: ImportFeld[];
spalten: string[];
zuordnung: Record<string, string | null>;
zeilen: VorschauZeile[];
zeilenGesamt: number;
gekuerzt: boolean;
/** Pflichtfelder, denen keine Spalte zugeordnet ist. */
fehlendePflichtfelder: string[];
anzahlUebernehmbar: number;
anzahlFehlerhaft: number;
}
// -----------------------------------------------------------------------------
// Schritt 1: Datei einlesen
// -----------------------------------------------------------------------------
/** Erkennt das Format an der Endung, ersatzweise am Inhalt. */
export function formatErkennen(dateiname: string): "csv" | "xlsx" | null {
const endung = dateiname.toLowerCase().split(".").pop();
if (endung === "csv" || endung === "txt") return "csv";
if (endung === "xlsx" || endung === "xlsm") return "xlsx";
return null;
}
/**
* CSV einlesen. Das Trennzeichen wird erraten — deutsche Tabellen kommen
* meistens mit Semikolon, weil Excel das im deutschen Sprachraum so schreibt.
*/
export function csvLesen(inhalt: string): GeleseneTabelle {
const ergebnis = Papa.parse<Record<string, string>>(inhalt, {
header: true,
skipEmptyLines: "greedy",
delimiter: "",
delimitersToGuess: [";", ",", "\t", "|"],
transformHeader: (kopf) => kopf.trim(),
});
const alleZeilen = (ergebnis.data ?? []).filter((zeile) =>
Object.values(zeile).some((wert) => (wert ?? "").toString().trim() !== ""),
);
const spalten = (ergebnis.meta.fields ?? []).filter((feld) => feld.trim() !== "");
return {
spalten,
zeilen: alleZeilen.slice(0, MAX_ZEILEN).map(zeileNormalisieren),
zeilenGesamt: alleZeilen.length,
gekuerzt: alleZeilen.length > MAX_ZEILEN,
};
}
/** Excel einlesen. Es wird immer das erste Arbeitsblatt genommen. */
export async function excelLesen(daten: Buffer): Promise<GeleseneTabelle> {
const mappe = new ExcelJS.Workbook();
await mappe.xlsx.load(daten as unknown as ArrayBuffer);
const blatt = mappe.worksheets[0];
if (!blatt) {
return { spalten: [], zeilen: [], zeilenGesamt: 0, gekuerzt: false };
}
const kopfzeile = blatt.getRow(1);
const spalten: string[] = [];
kopfzeile.eachCell({ includeEmpty: false }, (zelle, nummer) => {
const text = zellenText(zelle.value).trim();
spalten[nummer - 1] = text || `Spalte ${nummer}`;
});
const zeilen: Record<string, string>[] = [];
let gesamt = 0;
blatt.eachRow({ includeEmpty: false }, (zeile, nummer) => {
if (nummer === 1) return;
const datensatz: Record<string, string> = {};
let hatInhalt = false;
spalten.forEach((spalte, index) => {
if (!spalte) return;
const wert = zellenText(zeile.getCell(index + 1).value).trim();
datensatz[spalte] = wert;
if (wert) hatInhalt = true;
});
if (!hatInhalt) return;
gesamt += 1;
if (zeilen.length < MAX_ZEILEN) zeilen.push(datensatz);
});
return {
spalten: spalten.filter(Boolean),
zeilen,
zeilenGesamt: gesamt,
gekuerzt: gesamt > MAX_ZEILEN,
};
}
/** Excel liefert je nach Zelle Zahlen, Daten, Formelergebnisse oder Rich Text. */
function zellenText(wert: ExcelJS.CellValue): string {
if (wert === null || wert === undefined) return "";
if (wert instanceof Date) {
const tag = String(wert.getUTCDate()).padStart(2, "0");
const monat = String(wert.getUTCMonth() + 1).padStart(2, "0");
return `${tag}.${monat}.${wert.getUTCFullYear()}`;
}
if (typeof wert === "object") {
if ("text" in wert && typeof wert.text === "string") return wert.text;
if ("result" in wert) return zellenText(wert.result as ExcelJS.CellValue);
if ("richText" in wert && Array.isArray(wert.richText)) {
return wert.richText.map((teil) => teil.text).join("");
}
if ("hyperlink" in wert && typeof wert.text === "string") return wert.text;
return "";
}
return String(wert);
}
function zeileNormalisieren(zeile: Record<string, unknown>): Record<string, string> {
const ergebnis: Record<string, string> = {};
for (const [schluessel, wert] of Object.entries(zeile)) {
ergebnis[schluessel.trim()] = (wert ?? "").toString().trim();
}
return ergebnis;
}
/** Liest eine hochgeladene Datei, unabhängig vom Format. */
export async function tabelleLesen(
daten: Buffer,
dateiname: string,
): Promise<GeleseneTabelle> {
if (daten.length > MAX_DATEIGROESSE) {
throw new Error(
`Die Datei ist zu groß (${Math.round(daten.length / 1024 / 1024)} MB, erlaubt sind ${MAX_DATEIGROESSE / 1024 / 1024} MB).`,
);
}
const format = formatErkennen(dateiname);
if (format === "xlsx") return excelLesen(daten);
if (format === "csv") return csvLesen(textAusPuffer(daten));
throw new Error(
`Das Format von "${dateiname}" wird nicht unterstützt. Erlaubt sind CSV und Excel (.xlsx).`,
);
}
/**
* Text aus dem Puffer. Deutsche Exporte kommen oft noch als Windows-1252 —
* eine BOM oder ungültige UTF-8-Folgen verraten das.
*/
export function textAusPuffer(daten: Buffer): string {
// UTF-8 mit BOM
if (daten.length >= 3 && daten[0] === 0xef && daten[1] === 0xbb && daten[2] === 0xbf) {
return daten.subarray(3).toString("utf8");
}
const alsUtf8 = daten.toString("utf8");
// Das Ersetzungszeichen zeigt an, dass es kein gültiges UTF-8 war.
if (alsUtf8.includes("�")) {
return daten.toString("latin1");
}
return alsUtf8;
}
// -----------------------------------------------------------------------------
// Schritt 2: Zuordnung und Vorschau
// -----------------------------------------------------------------------------
/**
* Erzeugt die Vorschau. Schreibt nichts — das ist der ganze Sinn.
*
* @param zuordnung optional; ohne Angabe wird automatisch zugeordnet
*/
export function vorschauErstellen(
tabelle: GeleseneTabelle,
art: ImportArt,
zuordnung?: Record<string, string | null>,
): ImportVorschau {
const felder = IMPORT_ARTEN[art].felder;
const zuord = zuordnung ?? spaltenZuordnen(tabelle.spalten, felder);
const zugeordneteFelder = new Set(
Object.values(zuord).filter((wert): wert is string => Boolean(wert)),
);
const fehlendePflichtfelder = felder
.filter((feld) => feld.pflicht && !zugeordneteFelder.has(feld.schluessel))
.map((feld) => feld.bezeichnung);
const zeilen: VorschauZeile[] = tabelle.zeilen.map((rohzeile, index) => {
const werte: Record<string, unknown> = {};
const fehler: ZellFehler[] = [];
for (const [spalte, feldSchluessel] of Object.entries(zuord)) {
if (!feldSchluessel) continue;
const feld = felder.find((f) => f.schluessel === feldSchluessel);
if (!feld) continue;
const ergebnis = wertLesen(feld, rohzeile[spalte] ?? "");
if (!ergebnis.ok) {
fehler.push({
zeile: index + 1,
spalte,
feld: feld.bezeichnung,
meldung: ergebnis.fehler ?? "Ungültiger Wert",
});
continue;
}
if (ergebnis.wert !== undefined) werte[feldSchluessel] = ergebnis.wert;
}
// Pflichtfelder, für die es gar keine Spalte gibt, melden wir je Zeile —
// sonst sieht man in der Vorschau nicht, dass die Zeile unbrauchbar ist.
for (const feld of felder) {
if (!feld.pflicht) continue;
if (werte[feld.schluessel] !== undefined) continue;
if (fehler.some((f) => f.feld === feld.bezeichnung)) continue;
fehler.push({
zeile: index + 1,
spalte: "—",
feld: feld.bezeichnung,
meldung: `${feld.bezeichnung} fehlt`,
});
}
return {
nummer: index + 1,
werte,
rohwerte: rohzeile,
fehler,
uebernehmbar: fehler.length === 0,
};
});
// Dubletten innerhalb der Datei aufspüren — die fallen sonst erst beim
// Schreiben auf, und dann ist die halbe Datei schon drin.
markiereDubletten(zeilen, art);
return {
art,
felder,
spalten: tabelle.spalten,
zuordnung: zuord,
zeilen,
zeilenGesamt: tabelle.zeilenGesamt,
gekuerzt: tabelle.gekuerzt,
fehlendePflichtfelder,
anzahlUebernehmbar: zeilen.filter((zeile) => zeile.uebernehmbar).length,
anzahlFehlerhaft: zeilen.filter((zeile) => !zeile.uebernehmbar).length,
};
}
/** Welches Feld eine Zeile innerhalb der Datei eindeutig macht. */
const SCHLUESSELFELD: Record<ImportArt, string> = {
geraet: "inventarnummer",
person: "mitgliedsnummer",
fahrzeug: "funkrufname",
};
function markiereDubletten(zeilen: VorschauZeile[], art: ImportArt): void {
const feld = SCHLUESSELFELD[art];
const gesehen = new Map<string, number>();
for (const zeile of zeilen) {
const wert = zeile.werte[feld];
if (typeof wert !== "string" || !wert) continue;
const schluessel = wert.toLowerCase();
const zuerst = gesehen.get(schluessel);
if (zuerst !== undefined) {
zeile.fehler.push({
zeile: zeile.nummer,
spalte: feld,
feld,
meldung: `Kommt in dieser Datei schon in Zeile ${zuerst} vor`,
});
zeile.uebernehmbar = false;
} else {
gesehen.set(schluessel, zeile.nummer);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Auswertung gescannter Codes.
*
* Bewusst ohne "server-only" und ohne Abhängigkeiten: Diese Funktion läuft im
* Browser, direkt am Kamerabild. Die Erzeugung von QR-Codes steht dagegen in
* qr.ts und bleibt auf dem Server.
*/
/**
* Liest die Inventarnummer aus dem, was ein Scanner geliefert hat.
*
* Akzeptiert wird alles, was plausibel ist: die volle URL aus unserem eigenen
* Code, ein Pfad, oder die blanke Inventarnummer — manche Wehr hat schon
* Etiketten mit reinen Nummern im Bestand, und die sollen weiter funktionieren.
* Fremde Codes (etwa der Hersteller-Barcode auf dem Gerät) geben null zurück.
*/
export function inventarnummerAusScan(eingabe: string): string | null {
const text = eingabe.trim();
if (!text) return null;
// Volle URL oder Pfad auf /g/<nr>
const treffer = text.match(/\/g\/([^/?#\s]+)/i);
if (treffer?.[1]) {
try {
return decodeURIComponent(treffer[1]);
} catch {
return treffer[1];
}
}
// Eine fremde URL ist keine Inventarnummer.
if (/^https?:\/\//i.test(text)) return null;
// Blanke Nummer: Buchstaben, Ziffern, Bindestrich, Schrägstrich, Punkt.
if (/^[A-Za-z0-9][A-Za-z0-9._/-]{0,59}$/.test(text)) return text;
return null;
}
+64
View File
@@ -0,0 +1,64 @@
import "server-only";
import QRCode from "qrcode";
import { env } from "@/lib/env";
/**
* QR-Codes für Geräte.
*
* Der Code enthält eine kurze URL auf die eigene Installation:
*
* https://<florian>/g/<inventarnummer>
*
* Bewusst die Inventarnummer und nicht die UUID: Die Nummer steht ohnehin
* lesbar auf dem Etikett, ist kurz genug für einen groben Code mit dicken
* Modulen, und wer den Code mit einem beliebigen Scanner liest, sieht sofort,
* um welches Gerät es geht. Eine UUID wäre 36 Zeichen lang und für niemanden
* lesbar.
*
* Die URL ist keine Berechtigung: /g/<nr> leitet nur weiter, und die Zielseite
* verlangt eine Anmeldung wie jede andere auch. Wer den Aufkleber fotografiert,
* bekommt dadurch keinen Zugang.
*/
/** Fehlerkorrektur. "M" verträgt rund 15 % Verlust — genug für ein Etikett,
* das im Geräteraum Staub und Schrammen abbekommt. */
const FEHLERKORREKTUR = "M" as const;
export function qrZiel(inventarnummer: string): string {
const basis = env.APP_URL.replace(/\/+$/, "");
return `${basis}/g/${encodeURIComponent(inventarnummer)}`;
}
/**
* QR-Code als SVG. SVG statt PNG, weil der Etikettenbogen als PDF gerendert
* wird — dort skaliert eine Vektorgrafik ohne Treppenkanten.
*/
export async function qrAlsSvg(inventarnummer: string, groesseMm = 20): Promise<string> {
const svg = await QRCode.toString(qrZiel(inventarnummer), {
type: "svg",
errorCorrectionLevel: FEHLERKORREKTUR,
margin: 0,
width: Math.round(groesseMm * 8),
});
// qrcode setzt feste Pixelmaße; für das PDF sollen Millimeter gelten.
return svg
.replace(/width="[^"]+"/, `width="${groesseMm}mm"`)
.replace(/height="[^"]+"/, `height="${groesseMm}mm"`);
}
/** QR-Code als Data-URI. Für die Anzeige in der Gerätedetailseite. */
export async function qrAlsDataUri(inventarnummer: string, groessePx = 256): Promise<string> {
return QRCode.toDataURL(qrZiel(inventarnummer), {
errorCorrectionLevel: FEHLERKORREKTUR,
margin: 1,
width: groessePx,
color: { dark: "#000000ff", light: "#ffffffff" },
});
}
/**
* Die Auswertung gescannter Codes steht in qr-parser.ts — sie läuft im
* Browser und darf deshalb nicht in einem "server-only"-Modul liegen.
*/
export { inventarnummerAusScan } from "@/lib/stammdaten/qr-parser";
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
/**
* tRPC im Browser.
*
* Über dieses Objekt laufen alle Aufrufe aus Client-Komponenten. Die Typen
* kommen direkt aus dem Server-Router — ändert sich dort ein Feld, meckert
* hier der Compiler, und zwar bevor jemand die Seite öffnet.
*/
export const trpc = createTRPCReact<AppRouter>();
+59
View File
@@ -0,0 +1,59 @@
"use client";
import * as React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import { trpc } from "@/lib/trpc/client";
/**
* Hängt tRPC und React Query in den Baum.
*
* Die Einstellungen sind auf eine Verwaltungsanwendung zugeschnitten: Daten
* bleiben eine halbe Minute frisch, und beim Fensterwechsel wird nicht neu
* geladen. Wer eine Geräteliste offen hat und kurz ins Mailprogramm wechselt,
* soll nicht bei jeder Rückkehr ein Nachladen sehen.
*
* Ausnahme wird ab Phase 5 die Atemschutzüberwachung — dort zählt jede
* Sekunde, und die Ansicht bekommt eigene Einstellungen.
*/
export function TrpcProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: (versuche, fehler) => {
// Bei fehlender Berechtigung oder unbekanntem Datensatz bringt
// ein zweiter Versuch nichts.
const code = (fehler as { data?: { code?: string } })?.data?.code;
if (code === "FORBIDDEN" || code === "UNAUTHORIZED" || code === "NOT_FOUND") {
return false;
}
return versuche < 2;
},
},
},
}),
);
const [trpcClient] = React.useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
transformer: superjson,
maxURLLength: 2000,
}),
],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
+49 -11
View File
@@ -3,11 +3,18 @@ import { router, oeffentlich, angemeldet, mitRecht } from "@/server/trpc";
import { ROLLEN_BEZEICHNUNG, rechteVon } from "@/lib/authz";
import { datenbankErreichbar } from "@/lib/db";
import { speicherErreichbar } from "@/lib/storage";
import { geraetRouter } from "@/server/routers/stammdaten/geraet";
import { fahrzeugRouter } from "@/server/routers/stammdaten/fahrzeug";
import { standortRouter, geraeteraumRouter } from "@/server/routers/stammdaten/orte";
import { geraetetypRouter, qualifikationstypRouter } from "@/server/routers/stammdaten/katalog";
import { personRouter, qualifikationRouter } from "@/server/routers/stammdaten/person";
import { importRouter } from "@/server/routers/stammdaten/import";
/**
* Wurzel-Router. Ab Phase 1 kommen hier die Fachbereiche als Unter-Router
* dazu (stammdaten, wartung, atemschutz, einsatz, ausbildung, kasse).
* Phase 0 liefert nur, was zum Prüfen des Fundaments nötig ist.
* Wurzel-Router.
*
* Aufbau nach Fachbereichen, nicht nach Tabellen: wer die Geräteliste sucht,
* findet sie unter geraet, nicht unter irgendeinem technischen Namen.
*/
const systemRouter = router({
@@ -68,13 +75,25 @@ const organisationRouter = router({
/** Kennzahlen für die Startseite. */
kennzahlen: angemeldet.query(async ({ ctx }) =>
ctx.db(async (db) => {
const [mitglieder, fahrzeuge, geraete, pruefungFaellig] = await Promise.all([
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 } }),
]);
return { mitglieder, fahrzeuge, geraete, pruefungFaellig };
const in90Tagen = new Date(Date.now() + 90 * 864e5);
const [mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt, qualiAblauf] =
await Promise.all([
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 } }),
db.qualifikation.count({
where: {
geloeschtAm: null,
gueltigBis: { not: null, lte: in90Tagen },
person: { aktiv: true, geloeschtAm: null },
},
}),
]);
return { mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt, qualiAblauf };
}),
),
});
@@ -82,10 +101,18 @@ const organisationRouter = router({
const auditRouter = router({
/** Die letzten Einträge im Protokoll — nur mit dem entsprechenden Recht. */
letzte: mitRecht("system.auditlog")
.input(z.object({ anzahl: z.number().int().min(1).max(200).default(50) }).optional())
.input(
z
.object({
anzahl: z.number().int().min(1).max(200).default(50),
entitaet: z.string().max(80).optional(),
})
.optional(),
)
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.auditLog.findMany({
where: input?.entitaet ? { entitaet: input.entitaet } : {},
orderBy: { zeitpunkt: "desc" },
take: input?.anzahl ?? 50,
include: {
@@ -101,6 +128,17 @@ export const appRouter = router({
sitzung: sitzungRouter,
organisation: organisationRouter,
audit: auditRouter,
// Phase 1 — Stammdaten
standort: standortRouter,
fahrzeug: fahrzeugRouter,
geraeteraum: geraeteraumRouter,
geraetetyp: geraetetypRouter,
geraet: geraetRouter,
person: personRouter,
qualifikationstyp: qualifikationstypRouter,
qualifikation: qualifikationRouter,
datenImport: importRouter,
});
export type AppRouter = typeof appRouter;
+272
View File
@@ -0,0 +1,272 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { FahrzeugArt, Prisma } from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
/**
* Fahrzeuge.
*
* Der Funkrufname ist der Schlüssel, unter dem eine Wehr ihr Fahrzeug kennt —
* nicht das Kennzeichen. Danach wird sortiert, gesucht und im Einsatz gerufen.
*/
const SORTIERFELDER = [
"funkrufname",
"bezeichnung",
"dinTyp",
"kennzeichen",
"baujahr",
"tuevBis",
"erstelltAm",
] as const;
const fahrzeugDaten = z.object({
funkrufname: z.string().trim().min(1, "Der Funkrufname fehlt.").max(120),
bezeichnung: z.string().trim().min(1, "Die Bezeichnung fehlt.").max(120),
art: z.nativeEnum(FahrzeugArt).default(FahrzeugArt.LOESCHFAHRZEUG),
dinTyp: z.string().trim().max(40).nullish(),
kennzeichen: z.string().trim().max(20).nullish(),
hersteller: z.string().trim().max(120).nullish(),
aufbauhersteller: z.string().trim().max(120).nullish(),
fahrgestellnummer: z.string().trim().max(30).nullish(),
baujahr: z.number().int().min(1900).max(2100).nullish(),
erstzulassung: z.coerce.date().nullish(),
indienststellung: z.coerce.date().nullish(),
sitzplaetze: z.number().int().min(1).max(30).nullish(),
tankinhalt: z.number().int().min(0).max(50_000).nullish(),
gesamtmasse: z.number().int().min(0).max(100_000).nullish(),
kilometerstand: z.number().int().min(0).max(2_000_000).nullish(),
tuevBis: z.coerce.date().nullish(),
uvvBis: z.coerce.date().nullish(),
naechsteWartung: z.coerce.date().nullish(),
standortId: z.string().uuid().nullish(),
aktiv: z.boolean().default(true),
bemerkung: z.string().trim().max(1000).nullish(),
});
/** Räume, die bei einem neuen Fahrzeug fast immer gebraucht werden. */
const STANDARDRAEUME = [
{ bezeichnung: "G1", beschreibung: "Geräteraum 1, links vorn", sortierung: 10 },
{ bezeichnung: "G2", beschreibung: "Geräteraum 2, links Mitte", sortierung: 20 },
{ bezeichnung: "G3", beschreibung: "Geräteraum 3, links hinten", sortierung: 30 },
{ bezeichnung: "G4", beschreibung: "Geräteraum 4, rechts vorn", sortierung: 40 },
{ bezeichnung: "G5", beschreibung: "Geräteraum 5, rechts Mitte", sortierung: 50 },
{ bezeichnung: "G6", beschreibung: "Geräteraum 6, rechts hinten", sortierung: 60 },
{ bezeichnung: "GR", beschreibung: "Geräteraum Heck", sortierung: 70 },
{ bezeichnung: "Dach", beschreibung: "Dachkasten", sortierung: 80 },
{ bezeichnung: "MR", beschreibung: "Mannschaftsraum", sortierung: 90 },
];
export const fahrzeugRouter = router({
liste: angemeldet
.input(
listenEingabe(SORTIERFELDER).extend({
art: z.array(z.nativeEnum(FahrzeugArt)).optional(),
standortId: z.string().uuid().optional(),
nurAktive: z.boolean().default(true),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.FahrzeugWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.nurAktive ? { aktiv: true } : {}),
...(input.art?.length ? { art: { in: input.art } } : {}),
...(input.standortId ? { standortId: input.standortId } : {}),
...(sucheUeber(
["funkrufname", "bezeichnung", "kennzeichen", "dinTyp", "hersteller"],
input.suche,
) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.fahrzeug.findMany({
where: wo,
orderBy: sortierenNach(input.sortierung, input.richtung, {
funkrufname: "asc",
}) as Prisma.FahrzeugOrderByWithRelationInput,
include: {
standort: { select: { id: true, name: true } },
_count: { select: { geraete: true, geraeteraeume: true } },
},
...blaettern(input),
}),
db.fahrzeug.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
auswahl: angemeldet.query(async ({ ctx }) =>
ctx.db((db) =>
db.fahrzeug.findMany({
where: { geloeschtAm: null, aktiv: true },
select: { id: true, funkrufname: true, bezeichnung: true, dinTyp: true },
orderBy: { funkrufname: "asc" },
}),
),
),
einzeln: angemeldet.input(idEingabe).query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const fahrzeug = await db.fahrzeug.findUnique({
where: { id: input.id },
include: {
standort: { select: { id: true, name: true } },
geraeteraeume: {
where: { geloeschtAm: null },
orderBy: [{ sortierung: "asc" }, { bezeichnung: "asc" }],
include: { _count: { select: { geraete: true } } },
},
_count: { select: { geraete: true } },
},
});
if (!fahrzeug) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Fahrzeug gibt es nicht." });
}
return fahrzeug;
}),
),
anlegen: mitRecht("fahrzeug.bearbeiten")
.input(
fahrzeugDaten.extend({
/** Legt G1 bis G6, Heck, Dach und Mannschaftsraum gleich mit an. */
standardraeumeAnlegen: z.boolean().default(true),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { standardraeumeAnlegen, ...daten } = input;
const fahrzeug = await db.fahrzeug.create({
data: { organisationId: ctx.sitzung.organisationId, ...daten },
});
if (standardraeumeAnlegen) {
await db.geraeteraum.createMany({
data: STANDARDRAEUME.map((raum) => ({
organisationId: ctx.sitzung.organisationId,
fahrzeugId: fahrzeug.id,
...raum,
})),
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Fahrzeug",
entitaetId: fahrzeug.id,
beschreibung: `Fahrzeug ${fahrzeug.funkrufname} angelegt`,
sitzung: ctx.sitzung,
nachher: fahrzeug,
kontext: { standardraeume: standardraeumeAnlegen },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return fahrzeug;
}),
),
aendern: mitRecht("fahrzeug.bearbeiten")
.input(fahrzeugDaten.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.fahrzeug.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Fahrzeug gibt es nicht." });
}
const nachher = await db.fahrzeug.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Fahrzeug",
entitaetId: id,
beschreibung: `Fahrzeug ${nachher.funkrufname} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
loeschen: mitRecht("fahrzeug.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.fahrzeug.findUnique({
where: { id: input.id },
include: { _count: { select: { geraete: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Fahrzeug gibt es nicht." });
}
if (vorher._count.geraete > 0) {
throw new TRPCError({
code: "CONFLICT",
message: `Auf diesem Fahrzeug sind noch ${vorher._count.geraete} Geräte verlastet. Bitte zuerst umlagern.`,
});
}
await db.fahrzeug.update({
where: { id: input.id },
data: { geloeschtAm: new Date(), aktiv: false },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Fahrzeug",
entitaetId: input.id,
beschreibung: `Fahrzeug ${vorher.funkrufname} außer Dienst gestellt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
verlauf: angemeldet
.input(idEingabe.extend({ anzahl: z.number().int().min(1).max(200).default(50) }))
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.auditLog.findMany({
where: { entitaet: "Fahrzeug", entitaetId: input.id },
orderBy: { zeitpunkt: "desc" },
take: input.anzahl,
select: {
id: true,
zeitpunkt: true,
aktion: true,
beschreibung: true,
vorher: true,
nachher: true,
akteurName: true,
},
}),
),
),
});
+105
View File
@@ -0,0 +1,105 @@
import { z } from "zod";
/**
* Bausteine, die alle Stammdatenlisten teilen.
*
* Jede Liste in FLORIAN kann dasselbe: sortieren, filtern, durchsuchen,
* blättern. Damit sich das nicht in jedem Router wiederholt — und vor allem,
* damit es sich überall gleich verhält — steht die Form der Abfrage hier.
*/
/** Höchstzahl je Seite. Mehr als das braucht keine Ansicht, und es schützt
* davor, dass ein Aufruf versehentlich zehntausend Zeilen zieht. */
export const MAX_SEITENGROESSE = 200;
export const sortierrichtung = z.enum(["asc", "desc"]);
/**
* Standardform einer Listenabfrage.
*
* @param sortierfelder erlaubte Sortierspalten — als Aufzählung, damit kein
* beliebiger Feldname in die Abfrage wandern kann
*/
export function listenEingabe<T extends readonly [string, ...string[]]>(sortierfelder: T) {
return z.object({
/** Volltextsuche über die Felder, die der jeweilige Router festlegt. */
suche: z.string().trim().max(200).optional(),
seite: z.number().int().min(1).default(1),
seitengroesse: z.number().int().min(1).max(MAX_SEITENGROESSE).default(50),
sortierung: z.enum(sortierfelder as unknown as [string, ...string[]]).optional(),
richtung: sortierrichtung.default("asc"),
/** Gelöschte mit anzeigen. Nur für die Wiederherstellung gedacht. */
mitGeloeschten: z.boolean().default(false),
});
}
export type ListenEingabe = z.infer<ReturnType<typeof listenEingabe<readonly ["id"]>>>;
/** Antwortform jeder Liste. */
export interface ListenAntwort<T> {
eintraege: T[];
gesamt: number;
seite: number;
seitengroesse: number;
seitenGesamt: number;
}
export function listenAntwort<T>(
eintraege: T[],
gesamt: number,
eingabe: { seite: number; seitengroesse: number },
): ListenAntwort<T> {
return {
eintraege,
gesamt,
seite: eingabe.seite,
seitengroesse: eingabe.seitengroesse,
seitenGesamt: Math.max(1, Math.ceil(gesamt / eingabe.seitengroesse)),
};
}
/** Übersetzt Seite und Seitengröße in skip/take. */
export function blaettern(eingabe: { seite: number; seitengroesse: number }) {
return {
skip: (eingabe.seite - 1) * eingabe.seitengroesse,
take: eingabe.seitengroesse,
};
}
/**
* Baut aus einem Suchbegriff eine ODER-Bedingung über mehrere Textfelder.
* Leere Suche gibt undefined zurück — dann bleibt die Bedingung ganz weg.
*/
export function sucheUeber(felder: string[], begriff?: string) {
const text = begriff?.trim();
if (!text) return undefined;
return {
OR: felder.map((feld) => ({
[feld]: { contains: text, mode: "insensitive" as const },
})),
};
}
/** Sortierung mit einem verlässlichen Standard. */
export function sortierenNach(
feld: string | undefined,
richtung: "asc" | "desc",
standard: Record<string, "asc" | "desc">,
): Record<string, unknown> {
if (!feld) return standard;
return { [feld]: richtung };
}
/** Blendet Soft-Deletes aus, sofern nicht ausdrücklich gewünscht. */
export function ohneGeloeschte(mitGeloeschten: boolean) {
return mitGeloeschten ? {} : { geloeschtAm: null };
}
/** Eingabe für alles, was über eine Kennung angesprochen wird. */
export const idEingabe = z.object({ id: z.string().uuid() });
/** Eingabe für die Massenbearbeitung. */
export const idsEingabe = z.object({
ids: z.array(z.string().uuid()).min(1).max(500),
});
+582
View File
@@ -0,0 +1,582 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { GeraetStatus, LebensdauerKategorie, Prisma } from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
idsEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
import {
abgeleiteterGeraetStatus,
effektiverWert,
lebensdauerEnde,
naechstePruefung,
} from "@/lib/stammdaten/fristen";
/**
* Geräte — das Herzstück der Stammdaten.
*
* Zwei Dinge passieren hier automatisch, damit sie nicht vergessen werden:
* - Prüftermin und Lebensdauerende werden bei jedem Speichern neu gerechnet,
* aus den Angaben am Gerät und, wo diese fehlen, aus dem Gerätetyp.
* - Der Status folgt daraus, solange ihn niemand von Hand auf gesperrt,
* ausgesondert oder in Reparatur gesetzt hat.
*/
const SORTIERFELDER = [
"inventarnummer",
"bezeichnung",
"hersteller",
"status",
"naechstePruefung",
"lebensdauerBis",
"indienststellung",
"erstelltAm",
] as const;
const SUCHFELDER = ["inventarnummer", "bezeichnung", "hersteller", "modell", "seriennummer"];
/** Was in der Liste angezeigt wird — bewusst schmal gehalten. */
const LISTEN_AUSWAHL = {
id: true,
inventarnummer: true,
bezeichnung: true,
hersteller: true,
modell: true,
seriennummer: true,
status: true,
indienststellung: true,
letztePruefung: true,
naechstePruefung: true,
lebensdauerBis: true,
lagerort: true,
geloeschtAm: true,
geraetetyp: { select: { id: true, name: true, kategorie: true } },
standort: { select: { id: true, name: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
geraeteraum: { select: { id: true, bezeichnung: true } },
} satisfies Prisma.GeraetSelect;
/**
* Wo ein Gerät liegt. Entweder Standort oder Fahrzeug — die Datenbank setzt
* das ebenfalls durch, aber hier kommt eine verständliche Meldung heraus
* statt eines Constraint-Fehlers.
*/
const lagerung = z
.object({
standortId: z.string().uuid().nullish(),
fahrzeugId: z.string().uuid().nullish(),
geraeteraumId: z.string().uuid().nullish(),
})
.refine(
(wert) => Boolean(wert.standortId) !== Boolean(wert.fahrzeugId),
"Ein Gerät steht entweder an einem Standort oder ist auf einem Fahrzeug verlastet — nicht beides und nicht keines von beidem.",
)
.refine(
(wert) => !wert.geraeteraumId || Boolean(wert.fahrzeugId),
"Ein Geräteraum lässt sich nur zusammen mit einem Fahrzeug angeben.",
);
const stammdaten = z.object({
inventarnummer: z.string().trim().min(1, "Die Inventarnummer fehlt.").max(60),
bezeichnung: z.string().trim().min(1, "Die Bezeichnung fehlt.").max(160),
geraetetypId: z.string().uuid().nullish(),
hersteller: z.string().trim().max(120).nullish(),
modell: z.string().trim().max(120).nullish(),
seriennummer: z.string().trim().max(120).nullish(),
herstellungsdatum: z.coerce.date().nullish(),
indienststellung: z.coerce.date().nullish(),
anschaffungspreis: z.number().nonnegative().max(99_999_999).nullish(),
status: z.nativeEnum(GeraetStatus).default(GeraetStatus.EINSATZBEREIT),
pruefintervallMonate: z.number().int().min(1).max(600).nullish(),
letztePruefung: z.coerce.date().nullish(),
lebensdauerKategorie: z.nativeEnum(LebensdauerKategorie).nullish(),
lebensdauerJahre: z.number().int().min(1).max(100).nullish(),
lagerort: z.string().trim().max(160).nullish(),
bemerkung: z.string().trim().max(1000).nullish(),
});
const anlegenEingabe = z.intersection(stammdaten, lagerung);
const aendernEingabe = z.intersection(stammdaten.partial().extend({ id: z.string().uuid() }), lagerung);
type Vorgabewerte = {
pruefintervallMonate: number | null;
lebensdauerJahre: number | null;
lebensdauerBezug: "HERSTELLUNG" | "INDIENSTSTELLUNG" | null;
};
/**
* Rechnet die abgeleiteten Felder aus. Läuft bei jedem Schreibvorgang, damit
* Termin und Status nie auseinanderlaufen können.
*/
function abgeleiteteFelder(
geraet: {
herstellungsdatum?: Date | null;
indienststellung?: Date | null;
letztePruefung?: Date | null;
pruefintervallMonate?: number | null;
lebensdauerJahre?: number | null;
status: GeraetStatus;
},
typ: Vorgabewerte | null,
) {
const intervall = effektiverWert(geraet.pruefintervallMonate, typ?.pruefintervallMonate);
const jahre = effektiverWert(geraet.lebensdauerJahre, typ?.lebensdauerJahre);
const termin = naechstePruefung({
letztePruefung: geraet.letztePruefung,
indienststellung: geraet.indienststellung,
intervallMonate: intervall,
});
const ende = lebensdauerEnde({
herstellungsdatum: geraet.herstellungsdatum,
indienststellung: geraet.indienststellung,
lebensdauerJahre: jahre,
bezug: typ?.lebensdauerBezug ?? "INDIENSTSTELLUNG",
});
return {
naechstePruefung: termin,
lebensdauerBis: ende,
status: abgeleiteterGeraetStatus({
aktuellerStatus: geraet.status,
naechstePruefung: termin,
lebensdauerBis: ende,
}),
};
}
export const geraetRouter = router({
/** Liste mit Suche, Filter, Sortierung und Blättern. */
liste: angemeldet
.input(
listenEingabe(SORTIERFELDER).extend({
status: z.array(z.nativeEnum(GeraetStatus)).optional(),
geraetetypId: z.string().uuid().optional(),
standortId: z.string().uuid().optional(),
fahrzeugId: z.string().uuid().optional(),
/** Nur Geräte, deren Prüfung fällig ist oder demnächst wird. */
nurFaellig: z.boolean().optional(),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.GeraetWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.status?.length ? { status: { in: input.status } } : {}),
...(input.geraetetypId ? { geraetetypId: input.geraetetypId } : {}),
...(input.standortId ? { standortId: input.standortId } : {}),
...(input.fahrzeugId ? { fahrzeugId: input.fahrzeugId } : {}),
...(input.nurFaellig
? { naechstePruefung: { lte: new Date(Date.now() + 30 * 864e5) } }
: {}),
...(sucheUeber(SUCHFELDER, input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.geraet.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy: sortierenNach(input.sortierung, input.richtung, {
inventarnummer: "asc",
}) as Prisma.GeraetOrderByWithRelationInput,
...blaettern(input),
}),
db.geraet.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
/** Ein Gerät mit allem, was die Detailseite braucht. */
einzeln: angemeldet.input(idEingabe).query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const geraet = await db.geraet.findUnique({
where: { id: input.id },
include: {
geraetetyp: true,
standort: { select: { id: true, name: true, art: true } },
fahrzeug: { select: { id: true, funkrufname: true, bezeichnung: true } },
geraeteraum: { select: { id: true, bezeichnung: true } },
},
});
if (!geraet) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
return geraet;
}),
),
/** Nachschlagen über die Inventarnummer — der Weg vom QR-Code her. */
ueberInventarnummer: angemeldet
.input(z.object({ inventarnummer: z.string().trim().min(1).max(60) }))
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const geraet = await db.geraet.findFirst({
where: { inventarnummer: input.inventarnummer, geloeschtAm: null },
select: { id: true, inventarnummer: true, bezeichnung: true, status: true },
});
if (!geraet) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Zur Inventarnummer "${input.inventarnummer}" gibt es kein Gerät.`,
});
}
return geraet;
}),
),
anlegen: mitRecht("geraet.bearbeiten")
.input(anlegenEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const typ = input.geraetetypId
? await db.geraetetyp.findUnique({
where: { id: input.geraetetypId },
select: {
pruefintervallMonate: true,
lebensdauerJahre: true,
lebensdauerBezug: true,
},
})
: null;
const abgeleitet = abgeleiteteFelder({ ...input, status: input.status }, typ);
const geraet = await db.geraet.create({
data: {
organisationId: ctx.sitzung.organisationId,
...input,
anschaffungspreis: input.anschaffungspreis ?? null,
...abgeleitet,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Geraet",
entitaetId: geraet.id,
beschreibung: `Gerät ${geraet.inventarnummer} — ${geraet.bezeichnung} angelegt`,
sitzung: ctx.sitzung,
nachher: geraet,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return geraet;
}),
),
aendern: mitRecht("geraet.bearbeiten")
.input(aendernEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.geraet.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
const typId = aenderungen.geraetetypId ?? vorher.geraetetypId;
const typ = typId
? await db.geraetetyp.findUnique({
where: { id: typId },
select: {
pruefintervallMonate: true,
lebensdauerJahre: true,
lebensdauerBezug: true,
},
})
: null;
const zusammengefuehrt = { ...vorher, ...aenderungen };
const abgeleitet = abgeleiteteFelder(
{ ...zusammengefuehrt, status: zusammengefuehrt.status },
typ,
);
const nachher = await db.geraet.update({
where: { id },
data: { ...aenderungen, ...abgeleitet },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraet",
entitaetId: id,
beschreibung: `Gerät ${nachher.inventarnummer} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/**
* Massenbearbeitung des Status. Der häufigste Handgriff überhaupt: nach
* einer Sammelprüfung stehen zwanzig Geräte auf einmal wieder auf
* einsatzbereit.
*/
statusSetzen: mitRecht("geraet.bearbeiten")
.input(
idsEingabe.extend({
status: z.nativeEnum(GeraetStatus),
grund: z.string().trim().max(500).optional(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.geraet.findMany({
where: { id: { in: input.ids }, geloeschtAm: null },
select: { id: true, inventarnummer: true, status: true },
});
if (vorher.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Keines dieser Geräte gibt es." });
}
await db.geraet.updateMany({
where: { id: { in: vorher.map((g) => g.id) } },
data: {
status: input.status,
...(input.status === GeraetStatus.AUSGESONDERT
? { ausgesondertAm: new Date(), ausgesondertGrund: input.grund ?? null }
: {}),
},
});
// Je Gerät ein Eintrag — der Verlauf soll am einzelnen Gerät hängen,
// nicht an einem Sammelvorgang, den später niemand mehr zuordnen kann.
for (const geraet of vorher) {
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraet",
entitaetId: geraet.id,
beschreibung: `Status von ${geraet.inventarnummer}: ${geraet.status} → ${input.status}${
input.grund ? ` (${input.grund})` : ""
}`,
sitzung: ctx.sitzung,
vorher: { status: geraet.status },
nachher: { status: input.status },
kontext: { sammelvorgang: true, anzahl: vorher.length },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
}
return { geaendert: vorher.length };
}),
),
/** Geräte auf ein Fahrzeug oder an einen Standort umsetzen. */
umlagern: mitRecht("geraet.bearbeiten")
.input(idsEingabe.and(lagerung))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { ids, ...ziel } = input;
const vorher = await db.geraet.findMany({
where: { id: { in: ids }, geloeschtAm: null },
select: {
id: true,
inventarnummer: true,
standortId: true,
fahrzeugId: true,
geraeteraumId: true,
},
});
for (const geraet of vorher) {
await db.geraet.update({
where: { id: geraet.id },
data: {
standortId: ziel.standortId ?? null,
fahrzeugId: ziel.fahrzeugId ?? null,
geraeteraumId: ziel.geraeteraumId ?? null,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraet",
entitaetId: geraet.id,
beschreibung: `Gerät ${geraet.inventarnummer} umgelagert`,
sitzung: ctx.sitzung,
vorher: {
standortId: geraet.standortId,
fahrzeugId: geraet.fahrzeugId,
geraeteraumId: geraet.geraeteraumId,
},
nachher: ziel,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
}
return { umgelagert: vorher.length };
}),
),
/**
* Soft-Delete. Prüf- und Wartungsdaten werden nie hart gelöscht — das
* verlangen die Projektregeln und die Nachweispflicht gegenüber der
* Unfallkasse.
*/
loeschen: mitRecht("geraet.aussondern")
.input(idEingabe.extend({ grund: z.string().trim().max(500).optional() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.geraet.findUnique({ where: { id: input.id } });
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
const nachher = await db.geraet.update({
where: { id: input.id },
data: {
geloeschtAm: new Date(),
status: GeraetStatus.AUSGESONDERT,
ausgesondertAm: new Date(),
ausgesondertGrund: input.grund ?? null,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Geraet",
entitaetId: input.id,
beschreibung: `Gerät ${vorher.inventarnummer} ausgesondert${
input.grund ? `: ${input.grund}` : ""
}`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
/** Rücknahme eines Soft-Deletes. */
wiederherstellen: mitRecht("geraet.aussondern")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.geraet.findUnique({ where: { id: input.id } });
if (!vorher?.geloeschtAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Dieses Gerät ist gar nicht ausgesondert.",
});
}
const nachher = await db.geraet.update({
where: { id: input.id },
data: {
geloeschtAm: null,
ausgesondertAm: null,
ausgesondertGrund: null,
status: GeraetStatus.PRUEFUNG_FAELLIG,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.WIEDERHERGESTELLT,
entitaet: "Geraet",
entitaetId: input.id,
beschreibung: `Gerät ${vorher.inventarnummer} wieder in Dienst genommen`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/**
* Verlauf eines Geräts, gerendert aus dem Audit-Log. Es gibt bewusst keine
* eigene Historientabelle — der Audit-Log ist die Wahrheit, und zwei Quellen
* könnten auseinanderlaufen.
*/
verlauf: angemeldet
.input(idEingabe.extend({ anzahl: z.number().int().min(1).max(200).default(50) }))
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.auditLog.findMany({
where: { entitaet: "Geraet", entitaetId: input.id },
orderBy: { zeitpunkt: "desc" },
take: input.anzahl,
select: {
id: true,
zeitpunkt: true,
aktion: true,
beschreibung: true,
vorher: true,
nachher: true,
kontext: true,
akteurName: true,
akteur: { select: { id: true, vorname: true, nachname: true } },
},
}),
),
),
/** Zahlen für die Übersicht und die Filterleiste. */
kennzahlen: angemeldet.query(async ({ ctx }) =>
ctx.db(async (db) => {
const nachStatus = await db.geraet.groupBy({
by: ["status"],
where: { geloeschtAm: null },
_count: { _all: true },
});
const in30Tagen = new Date(Date.now() + 30 * 864e5);
const [gesamt, faelligBald, lebensdauerBald] = await Promise.all([
db.geraet.count({ where: { geloeschtAm: null } }),
db.geraet.count({
where: { geloeschtAm: null, naechstePruefung: { lte: in30Tagen } },
}),
db.geraet.count({
where: { geloeschtAm: null, lebensdauerBis: { lte: in30Tagen } },
}),
]);
return {
gesamt,
faelligBald,
lebensdauerBald,
nachStatus: Object.fromEntries(
nachStatus.map((eintrag) => [eintrag.status, eintrag._count._all]),
) as Record<GeraetStatus, number>,
};
}),
),
});
+421
View File
@@ -0,0 +1,421 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { StandortArt } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import type { MandantenClient } from "@/lib/db";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { tabelleLesen, vorschauErstellen, MAX_DATEIGROESSE } from "@/lib/stammdaten/import";
import { IMPORT_ARTEN } from "@/lib/stammdaten/import-felder";
import {
abgeleiteterGeraetStatus,
lebensdauerEnde,
naechstePruefung,
} from "@/lib/stammdaten/fristen";
/**
* Import aus CSV und Excel.
*
* Der Ablauf ist zweistufig und das ist der Punkt: erst Vorschau, dann
* Übernahme. Beide Schritte lesen die Datei neu ein und rechnen serverseitig —
* es werden nie Werte übernommen, die der Browser geschickt hat. Sonst könnte
* jemand die Vorschau umgehen und beliebige Daten hineinschreiben.
*
* Der Preis dafür ist, dass die Datei zweimal übertragen wird. Bei den Größen,
* um die es hier geht (eine Wehr hat selten mehr als ein paar hundert Geräte),
* ist das kein Thema.
*/
const dateiEingabe = z.object({
dateiname: z.string().trim().min(1).max(300),
/** Dateiinhalt als Base64. */
inhalt: z.string().max(Math.ceil((MAX_DATEIGROESSE * 4) / 3) + 1024),
art: z.enum(["geraet", "person", "fahrzeug"]),
/** Spalte der Datei -> Feldname. Ohne Angabe wird automatisch zugeordnet. */
zuordnung: z.record(z.string(), z.string().nullable()).optional(),
});
function pufferAus(base64: string): Buffer {
const daten = Buffer.from(base64, "base64");
if (daten.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Die Datei ist leer." });
}
if (daten.length > MAX_DATEIGROESSE) {
throw new TRPCError({
code: "PAYLOAD_TOO_LARGE",
message: `Die Datei ist zu groß. Erlaubt sind ${MAX_DATEIGROESSE / 1024 / 1024} MB.`,
});
}
return daten;
}
export const importRouter = router({
/** Welche Felder es je Datenart gibt — für die Zuordnungsoberfläche. */
felder: mitRecht("geraet.bearbeiten")
.input(z.object({ art: z.enum(["geraet", "person", "fahrzeug"]) }))
.query(({ input }) => ({
art: input.art,
bezeichnung: IMPORT_ARTEN[input.art].bezeichnung,
felder: IMPORT_ARTEN[input.art].felder,
})),
/**
* Schritt 1 und 2: Datei lesen, zuordnen, prüfen. Schreibt nichts.
*/
vorschau: mitRecht("geraet.bearbeiten")
.input(dateiEingabe)
.mutation(async ({ input }) => {
const daten = pufferAus(input.inhalt);
let tabelle;
try {
tabelle = await tabelleLesen(daten, input.dateiname);
} catch (fehler) {
throw new TRPCError({
code: "BAD_REQUEST",
message: fehler instanceof Error ? fehler.message : "Die Datei ließ sich nicht lesen.",
});
}
if (tabelle.spalten.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"In der Datei wurden keine Spaltenüberschriften gefunden. Die erste Zeile muss die Überschriften enthalten.",
});
}
const vorschau = vorschauErstellen(tabelle, input.art, input.zuordnung);
// Nur die ersten 100 Zeilen zurückgeben — die Vorschau soll zeigen, ob
// die Zuordnung stimmt, nicht die ganze Datei durch den Browser schicken.
return {
...vorschau,
zeilen: vorschau.zeilen.slice(0, 100),
vorschauGekuerzt: vorschau.zeilen.length > 100,
};
}),
/**
* Schritt 3: Übernehmen. Liest die Datei erneut, rechnet erneut, und
* schreibt nur, was fehlerfrei durchgeht.
*/
uebernehmen: mitRecht("geraet.bearbeiten")
.input(
dateiEingabe.extend({
/** Vorhandene Datensätze aktualisieren statt zu überspringen. */
vorhandeneAktualisieren: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
const daten = pufferAus(input.inhalt);
const tabelle = await tabelleLesen(daten, input.dateiname);
const vorschau = vorschauErstellen(tabelle, input.art, input.zuordnung);
if (vorschau.fehlendePflichtfelder.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Diesen Pflichtfeldern ist keine Spalte zugeordnet: ${vorschau.fehlendePflichtfelder.join(", ")}`,
});
}
const uebernehmbare = vorschau.zeilen.filter((zeile) => zeile.uebernehmbar);
const ergebnis = await ctx.db(async (db) => {
const bilanz = { angelegt: 0, aktualisiert: 0, uebersprungen: 0, fehler: [] as string[] };
// Nachschlagewerke einmal laden statt je Zeile — bei 500 Zeilen macht
// das den Unterschied zwischen zwei Sekunden und zwei Minuten.
const [standorte, fahrzeuge] = await Promise.all([
db.standort.findMany({
where: { geloeschtAm: null },
select: { id: true, name: true },
}),
db.fahrzeug.findMany({
where: { geloeschtAm: null },
select: { id: true, funkrufname: true, kennzeichen: true },
}),
]);
const standortNach = new Map(standorte.map((s) => [s.name.toLowerCase(), s.id]));
const fahrzeugNach = new Map<string, string>();
for (const fahrzeug of fahrzeuge) {
fahrzeugNach.set(fahrzeug.funkrufname.toLowerCase(), fahrzeug.id);
if (fahrzeug.kennzeichen) {
fahrzeugNach.set(fahrzeug.kennzeichen.toLowerCase(), fahrzeug.id);
}
}
let standardStandort =
standorte.find((s) => s.name.toLowerCase().includes("gerätehaus"))?.id ??
standorte[0]?.id;
if (!standardStandort && input.art === "geraet") {
const neu = await db.standort.create({
data: {
organisationId: ctx.sitzung.organisationId,
name: "Gerätehaus",
kuerzel: "GH",
art: StandortArt.GERAETEHAUS,
},
});
standardStandort = neu.id;
standortNach.set("gerätehaus", neu.id);
}
for (const zeile of uebernehmbare) {
try {
if (input.art === "geraet") {
await geraetUebernehmen(db, {
organisationId: ctx.sitzung.organisationId,
werte: zeile.werte,
standortNach,
fahrzeugNach,
standardStandort: standardStandort!,
aktualisieren: input.vorhandeneAktualisieren,
bilanz,
});
} else if (input.art === "fahrzeug") {
await fahrzeugUebernehmen(db, {
organisationId: ctx.sitzung.organisationId,
werte: zeile.werte,
aktualisieren: input.vorhandeneAktualisieren,
bilanz,
});
} else {
await personUebernehmen(db, {
organisationId: ctx.sitzung.organisationId,
werte: zeile.werte,
aktualisieren: input.vorhandeneAktualisieren,
bilanz,
});
}
} catch (fehler) {
bilanz.fehler.push(
`Zeile ${zeile.nummer}: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
);
}
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: IMPORT_ARTEN[input.art].bezeichnung,
beschreibung: `Import aus "${input.dateiname}": ${bilanz.angelegt} angelegt, ${bilanz.aktualisiert} aktualisiert, ${bilanz.uebersprungen} übersprungen`,
sitzung: ctx.sitzung,
kontext: {
dateiname: input.dateiname,
art: input.art,
zeilenInDatei: vorschau.zeilenGesamt,
uebernehmbar: uebernehmbare.length,
fehlerhaft: vorschau.anzahlFehlerhaft,
zuordnung: vorschau.zuordnung,
},
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return bilanz;
});
return {
...ergebnis,
nichtUebernommen: vorschau.anzahlFehlerhaft,
zeilenInDatei: vorschau.zeilenGesamt,
};
}),
});
// -----------------------------------------------------------------------------
// Übernahme je Datenart
// -----------------------------------------------------------------------------
type Bilanz = { angelegt: number; aktualisiert: number; uebersprungen: number; fehler: string[] };
type Db = MandantenClient;
async function geraetUebernehmen(
db: Db,
angaben: {
organisationId: string;
werte: Record<string, unknown>;
standortNach: Map<string, string>;
fahrzeugNach: Map<string, string>;
standardStandort: string;
aktualisieren: boolean;
bilanz: Bilanz;
},
): Promise<void> {
const { werte, bilanz } = angaben;
const inventarnummer = String(werte.inventarnummer);
const vorhanden = await db.geraet.findFirst({
where: { inventarnummer },
select: { id: true },
});
if (vorhanden && !angaben.aktualisieren) {
bilanz.uebersprungen += 1;
return;
}
// Lagerung auflösen: Fahrzeug hat Vorrang, sonst Standort, sonst Gerätehaus.
const fahrzeugName = typeof werte.fahrzeug === "string" ? werte.fahrzeug.toLowerCase() : null;
const standortName = typeof werte.standort === "string" ? werte.standort.toLowerCase() : null;
const fahrzeugId = fahrzeugName ? (angaben.fahrzeugNach.get(fahrzeugName) ?? null) : null;
let standortId: string | null = null;
if (!fahrzeugId) {
standortId = standortName
? (angaben.standortNach.get(standortName) ?? angaben.standardStandort)
: angaben.standardStandort;
}
const indienststellung = werte.indienststellung as Date | undefined;
const letztePruefung = werte.letztePruefung as Date | undefined;
const intervall = werte.pruefintervallMonate as number | undefined;
const jahre = werte.lebensdauerJahre as number | undefined;
const termin =
(werte.naechstePruefung as Date | undefined) ??
naechstePruefung({ letztePruefung, indienststellung, intervallMonate: intervall }) ??
undefined;
const ende =
lebensdauerEnde({
herstellungsdatum: werte.herstellungsdatum as Date | undefined,
indienststellung,
lebensdauerJahre: jahre,
bezug: "INDIENSTSTELLUNG",
}) ?? undefined;
const daten = {
organisationId: angaben.organisationId,
inventarnummer,
bezeichnung: String(werte.bezeichnung),
hersteller: (werte.hersteller as string) ?? null,
modell: (werte.modell as string) ?? null,
seriennummer: (werte.seriennummer as string) ?? null,
herstellungsdatum: (werte.herstellungsdatum as Date) ?? null,
indienststellung: indienststellung ?? null,
anschaffungspreis: (werte.anschaffungspreis as number) ?? null,
pruefintervallMonate: intervall ?? null,
letztePruefung: letztePruefung ?? null,
naechstePruefung: termin ?? null,
lebensdauerJahre: jahre ?? null,
lebensdauerBis: ende ?? null,
lagerort: (werte.lagerort as string) ?? null,
bemerkung: (werte.bemerkung as string) ?? null,
standortId,
fahrzeugId,
status: abgeleiteterGeraetStatus({
aktuellerStatus:
(werte.status as "EINSATZBEREIT" | undefined) ?? "EINSATZBEREIT",
naechstePruefung: termin,
lebensdauerBis: ende,
}),
};
if (vorhanden) {
await db.geraet.update({ where: { id: vorhanden.id }, data: daten });
bilanz.aktualisiert += 1;
} else {
await db.geraet.create({ data: daten });
bilanz.angelegt += 1;
}
}
async function fahrzeugUebernehmen(
db: Db,
angaben: {
organisationId: string;
werte: Record<string, unknown>;
aktualisieren: boolean;
bilanz: Bilanz;
},
): Promise<void> {
const { werte, bilanz } = angaben;
const funkrufname = String(werte.funkrufname);
const vorhanden = await db.fahrzeug.findFirst({
where: { funkrufname },
select: { id: true },
});
if (vorhanden && !angaben.aktualisieren) {
bilanz.uebersprungen += 1;
return;
}
const daten = {
organisationId: angaben.organisationId,
funkrufname,
bezeichnung: String(werte.bezeichnung),
dinTyp: (werte.dinTyp as string) ?? null,
kennzeichen: (werte.kennzeichen as string) ?? null,
hersteller: (werte.hersteller as string) ?? null,
aufbauhersteller: (werte.aufbauhersteller as string) ?? null,
fahrgestellnummer: (werte.fahrgestellnummer as string) ?? null,
baujahr: (werte.baujahr as number) ?? null,
erstzulassung: (werte.erstzulassung as Date) ?? null,
tuevBis: (werte.tuevBis as Date) ?? null,
sitzplaetze: (werte.sitzplaetze as number) ?? null,
tankinhalt: (werte.tankinhalt as number) ?? null,
};
if (vorhanden) {
await db.fahrzeug.update({ where: { id: vorhanden.id }, data: daten });
bilanz.aktualisiert += 1;
} else {
await db.fahrzeug.create({ data: daten });
bilanz.angelegt += 1;
}
}
async function personUebernehmen(
db: Db,
angaben: {
organisationId: string;
werte: Record<string, unknown>;
aktualisieren: boolean;
bilanz: Bilanz;
},
): Promise<void> {
const { werte, bilanz } = angaben;
const mitgliedsnummer = (werte.mitgliedsnummer as string) ?? null;
const email = typeof werte.email === "string" ? werte.email.toLowerCase() : null;
const vorhanden = mitgliedsnummer
? await db.person.findFirst({ where: { mitgliedsnummer }, select: { id: true } })
: email
? await db.person.findFirst({ where: { email }, select: { id: true } })
: null;
if (vorhanden && !angaben.aktualisieren) {
bilanz.uebersprungen += 1;
return;
}
const daten = {
organisationId: angaben.organisationId,
vorname: String(werte.vorname),
nachname: String(werte.nachname),
mitgliedsnummer,
geburtsdatum: (werte.geburtsdatum as Date) ?? null,
email,
telefon: (werte.telefon as string) ?? null,
mobil: (werte.mobil as string) ?? null,
strasse: (werte.strasse as string) ?? null,
plz: (werte.plz as string) ?? null,
ort: (werte.ort as string) ?? null,
eintrittsdatum: (werte.eintrittsdatum as Date) ?? null,
abteilung: (werte.abteilung as string) ?? null,
};
if (vorhanden) {
await db.person.update({ where: { id: vorhanden.id }, data: daten });
bilanz.aktualisiert += 1;
} else {
await db.person.create({ data: daten });
bilanz.angelegt += 1;
}
}
+313
View File
@@ -0,0 +1,313 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import {
GeraeteKategorie,
LebensdauerBezug,
LebensdauerKategorie,
Prisma,
QualifikationsArt,
} from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
/**
* Gerätetypen und Qualifikationstypen — der Katalog, aus dem sich die
* eigentlichen Daten bedienen.
*
* Was hier steht, ist Vorgabe: das Prüfintervall eines Gerätetyps gilt für
* jedes Gerät dieser Bauart, solange am einzelnen Gerät nichts anderes
* eingetragen ist. Das erspart es, bei vierzig Pressluftatmern vierzigmal
* dasselbe Intervall zu pflegen.
*/
const geraetetypDaten = z.object({
name: z.string().trim().min(1, "Der Name fehlt.").max(160),
kategorie: z.nativeEnum(GeraeteKategorie).default(GeraeteKategorie.SONSTIGES),
hersteller: z.string().trim().max(120).nullish(),
modell: z.string().trim().max(120).nullish(),
pruefintervallMonate: z.number().int().min(1).max(600).nullish(),
pruefvorschrift: z.string().trim().max(200).nullish(),
lebensdauerKategorie: z.nativeEnum(LebensdauerKategorie).default(LebensdauerKategorie.KEINE),
lebensdauerJahre: z.number().int().min(1).max(100).nullish(),
lebensdauerBezug: z.nativeEnum(LebensdauerBezug).default(LebensdauerBezug.INDIENSTSTELLUNG),
vorwarnzeitMonate: z.number().int().min(0).max(60).default(3),
beschreibung: z.string().trim().max(1000).nullish(),
aktiv: z.boolean().default(true),
});
export const geraetetypRouter = router({
liste: angemeldet
.input(
listenEingabe(["name", "kategorie", "hersteller", "pruefintervallMonate", "erstelltAm"]).extend({
kategorie: z.array(z.nativeEnum(GeraeteKategorie)).optional(),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.GeraetetypWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.kategorie?.length ? { kategorie: { in: input.kategorie } } : {}),
...(sucheUeber(["name", "hersteller", "modell", "pruefvorschrift"], input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.geraetetyp.findMany({
where: wo,
orderBy: sortierenNach(input.sortierung, input.richtung, {
name: "asc",
}) as Prisma.GeraetetypOrderByWithRelationInput,
include: { _count: { select: { geraete: true } } },
...blaettern(input),
}),
db.geraetetyp.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
auswahl: angemeldet.query(async ({ ctx }) =>
ctx.db((db) =>
db.geraetetyp.findMany({
where: { geloeschtAm: null, aktiv: true },
select: {
id: true,
name: true,
kategorie: true,
pruefintervallMonate: true,
lebensdauerJahre: true,
lebensdauerBezug: true,
},
orderBy: [{ kategorie: "asc" }, { name: "asc" }],
}),
),
),
einzeln: angemeldet.input(idEingabe).query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const typ = await db.geraetetyp.findUnique({
where: { id: input.id },
include: { _count: { select: { geraete: true } } },
});
if (!typ) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Gerätetyp gibt es nicht." });
}
return typ;
}),
),
anlegen: mitRecht("geraet.bearbeiten")
.input(geraetetypDaten)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const typ = await db.geraetetyp.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Geraetetyp",
entitaetId: typ.id,
beschreibung: `Gerätetyp ${typ.name} angelegt`,
sitzung: ctx.sitzung,
nachher: typ,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return typ;
}),
),
aendern: mitRecht("geraet.bearbeiten")
.input(geraetetypDaten.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.geraetetyp.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Gerätetyp gibt es nicht." });
}
const nachher = await db.geraetetyp.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraetetyp",
entitaetId: id,
beschreibung: `Gerätetyp ${nachher.name} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
loeschen: mitRecht("geraet.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.geraetetyp.findUnique({
where: { id: input.id },
include: { _count: { select: { geraete: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Gerätetyp gibt es nicht." });
}
if (vorher._count.geraete > 0) {
throw new TRPCError({
code: "CONFLICT",
message: `Dieser Gerätetyp ist noch ${vorher._count.geraete} Geräten zugeordnet. Bitte zuerst umtragen.`,
});
}
await db.geraetetyp.update({
where: { id: input.id },
data: { geloeschtAm: new Date(), aktiv: false },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Geraetetyp",
entitaetId: input.id,
beschreibung: `Gerätetyp ${vorher.name} entfernt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
});
// -----------------------------------------------------------------------------
const qualifikationstypDaten = z.object({
name: z.string().trim().min(1, "Der Name fehlt.").max(160),
kuerzel: z.string().trim().max(20).nullish(),
art: z.nativeEnum(QualifikationsArt).default(QualifikationsArt.LEHRGANG),
gueltigkeitMonate: z.number().int().min(1).max(600).nullish(),
vorwarnzeitTage: z.number().int().min(0).max(730).default(90),
einsatzrelevant: z.boolean().default(false),
beschreibung: z.string().trim().max(1000).nullish(),
aktiv: z.boolean().default(true),
});
export const qualifikationstypRouter = router({
liste: angemeldet
.input(listenEingabe(["name", "art", "gueltigkeitMonate", "erstelltAm"]))
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.QualifikationstypWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(sucheUeber(["name", "kuerzel", "beschreibung"], input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.qualifikationstyp.findMany({
where: wo,
orderBy: sortierenNach(input.sortierung, input.richtung, {
name: "asc",
}) as Prisma.QualifikationstypOrderByWithRelationInput,
include: { _count: { select: { qualifikationen: true } } },
...blaettern(input),
}),
db.qualifikationstyp.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
auswahl: angemeldet.query(async ({ ctx }) =>
ctx.db((db) =>
db.qualifikationstyp.findMany({
where: { geloeschtAm: null, aktiv: true },
select: {
id: true,
name: true,
kuerzel: true,
art: true,
gueltigkeitMonate: true,
vorwarnzeitTage: true,
einsatzrelevant: true,
},
orderBy: [{ art: "asc" }, { name: "asc" }],
}),
),
),
anlegen: mitRecht("qualifikation.bearbeiten")
.input(qualifikationstypDaten)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const typ = await db.qualifikationstyp.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Qualifikationstyp",
entitaetId: typ.id,
beschreibung: `Qualifikationstyp ${typ.name} angelegt`,
sitzung: ctx.sitzung,
nachher: typ,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return typ;
}),
),
aendern: mitRecht("qualifikation.bearbeiten")
.input(qualifikationstypDaten.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.qualifikationstyp.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Diesen Qualifikationstyp gibt es nicht.",
});
}
const nachher = await db.qualifikationstyp.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Qualifikationstyp",
entitaetId: id,
beschreibung: `Qualifikationstyp ${nachher.name} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
});
+305
View File
@@ -0,0 +1,305 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { Prisma, StandortArt } from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
/**
* Standorte und Geräteräume.
*
* Ein Standort ist ein Ort auf der Landkarte: das Gerätehaus, das Lager, die
* Außenstelle. Ein Geräteraum ist ein Fach am Fahrzeug. Beides zusammen
* beantwortet die Frage, die im Einsatz zählt: wo liegt das Ding?
*/
const standortDaten = z.object({
name: z.string().trim().min(1, "Der Name fehlt.").max(160),
kuerzel: z.string().trim().max(20).nullish(),
art: z.nativeEnum(StandortArt).default(StandortArt.GERAETEHAUS),
strasse: z.string().trim().max(200).nullish(),
plz: z.string().trim().max(10).nullish(),
ort: z.string().trim().max(120).nullish(),
bemerkung: z.string().trim().max(1000).nullish(),
aktiv: z.boolean().default(true),
});
export const standortRouter = router({
liste: angemeldet
.input(listenEingabe(["name", "art", "ort", "erstelltAm"]))
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.StandortWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(sucheUeber(["name", "kuerzel", "ort", "strasse"], input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.standort.findMany({
where: wo,
orderBy: sortierenNach(input.sortierung, input.richtung, {
name: "asc",
}) as Prisma.StandortOrderByWithRelationInput,
include: {
_count: { select: { geraete: true, fahrzeuge: true } },
},
...blaettern(input),
}),
db.standort.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
/** Kurzliste für Auswahlfelder. */
auswahl: angemeldet.query(async ({ ctx }) =>
ctx.db((db) =>
db.standort.findMany({
where: { geloeschtAm: null, aktiv: true },
select: { id: true, name: true, kuerzel: true, art: true },
orderBy: { name: "asc" },
}),
),
),
einzeln: angemeldet.input(idEingabe).query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const standort = await db.standort.findUnique({
where: { id: input.id },
include: { _count: { select: { geraete: true, fahrzeuge: true } } },
});
if (!standort) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Standort gibt es nicht." });
}
return standort;
}),
),
anlegen: mitRecht("organisation.bearbeiten")
.input(standortDaten)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const standort = await db.standort.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Standort",
entitaetId: standort.id,
beschreibung: `Standort ${standort.name} angelegt`,
sitzung: ctx.sitzung,
nachher: standort,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return standort;
}),
),
aendern: mitRecht("organisation.bearbeiten")
.input(standortDaten.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.standort.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Standort gibt es nicht." });
}
const nachher = await db.standort.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Standort",
entitaetId: id,
beschreibung: `Standort ${nachher.name} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
loeschen: mitRecht("organisation.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.standort.findUnique({
where: { id: input.id },
include: { _count: { select: { geraete: true, fahrzeuge: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Standort gibt es nicht." });
}
// Ein Standort, an dem noch etwas steht, lässt sich nicht entfernen —
// sonst hätten die Geräte plötzlich keinen Ort mehr.
if (vorher._count.geraete > 0 || vorher._count.fahrzeuge > 0) {
throw new TRPCError({
code: "CONFLICT",
message: `An diesem Standort stehen noch ${vorher._count.geraete} Geräte und ${vorher._count.fahrzeuge} Fahrzeuge. Bitte zuerst umlagern.`,
});
}
await db.standort.update({
where: { id: input.id },
data: { geloeschtAm: new Date(), aktiv: false },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Standort",
entitaetId: input.id,
beschreibung: `Standort ${vorher.name} entfernt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
});
// -----------------------------------------------------------------------------
const raumDaten = z.object({
fahrzeugId: z.string().uuid(),
bezeichnung: z.string().trim().min(1, "Die Bezeichnung fehlt.").max(40),
beschreibung: z.string().trim().max(200).nullish(),
sortierung: z.number().int().min(0).max(999).default(0),
});
export const geraeteraumRouter = router({
/** Alle Räume eines Fahrzeugs, in Beladereihenfolge. */
zuFahrzeug: angemeldet
.input(z.object({ fahrzeugId: z.string().uuid() }))
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.geraeteraum.findMany({
where: { fahrzeugId: input.fahrzeugId, geloeschtAm: null },
orderBy: [{ sortierung: "asc" }, { bezeichnung: "asc" }],
include: { _count: { select: { geraete: true } } },
}),
),
),
anlegen: mitRecht("fahrzeug.bearbeiten")
.input(raumDaten)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const fahrzeug = await db.fahrzeug.findUnique({
where: { id: input.fahrzeugId },
select: { id: true, funkrufname: true },
});
if (!fahrzeug) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Fahrzeug gibt es nicht." });
}
const raum = await db.geraeteraum.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Geraeteraum",
entitaetId: raum.id,
beschreibung: `Geräteraum ${raum.bezeichnung} an ${fahrzeug.funkrufname} angelegt`,
sitzung: ctx.sitzung,
nachher: raum,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return raum;
}),
),
aendern: mitRecht("fahrzeug.bearbeiten")
.input(raumDaten.partial().omit({ fahrzeugId: true }).extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.geraeteraum.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Geräteraum gibt es nicht." });
}
const nachher = await db.geraeteraum.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraeteraum",
entitaetId: id,
beschreibung: `Geräteraum ${nachher.bezeichnung} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
loeschen: mitRecht("fahrzeug.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.geraeteraum.findUnique({
where: { id: input.id },
include: { _count: { select: { geraete: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Geräteraum gibt es nicht." });
}
if (vorher._count.geraete > 0) {
throw new TRPCError({
code: "CONFLICT",
message: `In diesem Geräteraum liegen noch ${vorher._count.geraete} Geräte. Bitte zuerst umlagern.`,
});
}
await db.geraeteraum.update({
where: { id: input.id },
data: { geloeschtAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Geraeteraum",
entitaetId: input.id,
beschreibung: `Geräteraum ${vorher.bezeichnung} entfernt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
});
+480
View File
@@ -0,0 +1,480 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { Dienstgrad, Prisma } from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { darfPersonSehen, KeinZugriffFehler } from "@/lib/authz";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
import { qualifikationGueltigBis, qualifikationZustand } from "@/lib/stammdaten/fristen";
/**
* Mitglieder und ihre Qualifikationen.
*
* Personendaten sind der heikelste Teil der Anwendung. Zwei Regeln greifen
* hier durchgängig:
* - Die eigenen Daten darf jeder sehen, fremde nur mit dem Recht
* person.lesen. Das prüft darfPersonSehen() aus lib/authz.
* - Gelöscht wird nie hart. Ein ausgetretenes Mitglied bleibt bestehen,
* bis der Aufbewahrungsjob es nach PERSON_ANONYMISIERUNG_TAGE anonymisiert.
*/
const SORTIERFELDER = [
"nachname",
"vorname",
"mitgliedsnummer",
"dienstgrad",
"eintrittsdatum",
"abteilung",
"erstelltAm",
] as const;
const personDaten = z.object({
vorname: z.string().trim().min(1, "Der Vorname fehlt.").max(100),
nachname: z.string().trim().min(1, "Der Nachname fehlt.").max(100),
mitgliedsnummer: z.string().trim().max(30).nullish(),
geburtsdatum: z.coerce.date().nullish(),
dienstgrad: z.nativeEnum(Dienstgrad).default(Dienstgrad.KEIN),
email: z.string().trim().email("Das ist keine gültige E-Mail-Adresse.").max(200).nullish(),
telefon: z.string().trim().max(40).nullish(),
mobil: z.string().trim().max(40).nullish(),
strasse: z.string().trim().max(200).nullish(),
plz: z.string().trim().max(10).nullish(),
ort: z.string().trim().max(120).nullish(),
eintrittsdatum: z.coerce.date().nullish(),
austrittsdatum: z.coerce.date().nullish(),
abteilung: z.string().trim().max(60).nullish(),
aktiv: z.boolean().default(true),
});
export const personRouter = router({
liste: mitRecht("person.lesen")
.input(
listenEingabe(SORTIERFELDER).extend({
abteilung: z.string().trim().max(60).optional(),
nurAktive: z.boolean().default(true),
/** Nur Personen mit diesem Qualifikationstyp. */
qualifikationstypId: z.string().uuid().optional(),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.PersonWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.nurAktive ? { aktiv: true } : {}),
...(input.abteilung ? { abteilung: input.abteilung } : {}),
...(input.qualifikationstypId
? {
qualifikationen: {
some: { typId: input.qualifikationstypId, geloeschtAm: null },
},
}
: {}),
...(sucheUeber(
["nachname", "vorname", "mitgliedsnummer", "email", "ort"],
input.suche,
) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.person.findMany({
where: wo,
select: {
id: true,
vorname: true,
nachname: true,
mitgliedsnummer: true,
dienstgrad: true,
abteilung: true,
email: true,
mobil: true,
eintrittsdatum: true,
aktiv: true,
geloeschtAm: true,
rollen: { select: { rolle: { select: { schluessel: true, name: true } } } },
_count: { select: { qualifikationen: true } },
},
orderBy: sortierenNach(input.sortierung, input.richtung, {
nachname: "asc",
}) as Prisma.PersonOrderByWithRelationInput,
...blaettern(input),
}),
db.person.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
auswahl: mitRecht("person.lesen").query(async ({ ctx }) =>
ctx.db((db) =>
db.person.findMany({
where: { geloeschtAm: null, aktiv: true },
select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true },
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
}),
),
),
/** Eine Person. Die eigenen Daten darf jeder sehen, fremde nur mit Recht. */
einzeln: angemeldet.input(idEingabe).query(async ({ ctx, input }) => {
if (!darfPersonSehen(ctx.sitzung, input.id)) {
throw new KeinZugriffFehler("person.lesen", "Diese Personendaten dürfen Sie nicht sehen.");
}
return ctx.db(async (db) => {
const person = await db.person.findUnique({
where: { id: input.id },
include: {
rollen: { include: { rolle: true } },
qualifikationen: {
where: { geloeschtAm: null },
include: { typ: true },
orderBy: { erworbenAm: "desc" },
},
},
});
if (!person) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." });
}
// Der Hash gehört auch dann nicht nach draußen, wenn er nur im Speicher
// liegt — das Feld wird hier ausdrücklich entfernt.
const { passwortHash: _hash, ...ohneGeheimnis } = person;
return ohneGeheimnis;
});
}),
anlegen: mitRecht("person.bearbeiten")
.input(personDaten)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const person = await db.person.create({
data: {
organisationId: ctx.sitzung.organisationId,
...input,
email: input.email?.toLowerCase() ?? null,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Person",
entitaetId: person.id,
beschreibung: `Mitglied ${person.nachname}, ${person.vorname} angelegt`,
sitzung: ctx.sitzung,
nachher: person,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
const { passwortHash: _hash, ...ohneGeheimnis } = person;
return ohneGeheimnis;
}),
),
aendern: mitRecht("person.bearbeiten")
.input(personDaten.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.person.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." });
}
const nachher = await db.person.update({
where: { id },
data: {
...aenderungen,
...(aenderungen.email !== undefined
? { email: aenderungen.email?.toLowerCase() ?? null }
: {}),
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Person",
entitaetId: id,
beschreibung: `Mitglied ${nachher.nachname}, ${nachher.vorname} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
const { passwortHash: _hash, ...ohneGeheimnis } = nachher;
return ohneGeheimnis;
}),
),
/** Austragen. Soft-Delete, wie bei allem mit Nachweischarakter. */
austragen: mitRecht("person.loeschen")
.input(idEingabe.extend({ austrittsdatum: z.coerce.date().optional() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
if (input.id === ctx.sitzung.personId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Sie können sich nicht selbst austragen.",
});
}
const vorher = await db.person.findUnique({ where: { id: input.id } });
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." });
}
const nachher = await db.person.update({
where: { id: input.id },
data: {
aktiv: false,
austrittsdatum: input.austrittsdatum ?? new Date(),
geloeschtAm: new Date(),
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Person",
entitaetId: input.id,
beschreibung: `Mitglied ${vorher.nachname}, ${vorher.vorname} ausgetragen`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
/** Welche Abteilungen es gibt — für die Filterleiste. */
abteilungen: mitRecht("person.lesen").query(async ({ ctx }) =>
ctx.db(async (db) => {
const gruppen = await db.person.groupBy({
by: ["abteilung"],
where: { geloeschtAm: null, abteilung: { not: null } },
_count: { _all: true },
});
return gruppen
.filter((gruppe) => gruppe.abteilung)
.map((gruppe) => ({ name: gruppe.abteilung as string, anzahl: gruppe._count._all }))
.sort((a, b) => a.name.localeCompare(b.name, "de"));
}),
),
});
// -----------------------------------------------------------------------------
const qualifikationDaten = z.object({
personId: z.string().uuid(),
typId: z.string().uuid(),
erworbenAm: z.coerce.date(),
/** Leer lassen: dann wird aus der Gültigkeitsdauer des Typs gerechnet. */
gueltigBis: z.coerce.date().nullish(),
ausstellendeStelle: z.string().trim().max(200).nullish(),
nachweisnummer: z.string().trim().max(80).nullish(),
bemerkung: z.string().trim().max(1000).nullish(),
});
export const qualifikationRouter = router({
/** Qualifikationen einer Person. */
zuPerson: angemeldet
.input(z.object({ personId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
if (!darfPersonSehen(ctx.sitzung, input.personId)) {
throw new KeinZugriffFehler("person.lesen");
}
return ctx.db((db) =>
db.qualifikation.findMany({
where: { personId: input.personId, geloeschtAm: null },
include: { typ: true },
orderBy: [{ gueltigBis: "asc" }, { erworbenAm: "desc" }],
}),
);
}),
/**
* Was demnächst abläuft. Speist die Warnung auf der Übersicht.
* Gerechnet wird mit der Vorwarnzeit des jeweiligen Typs, nicht mit einer
* pauschalen Frist — eine G26.3-Untersuchung braucht mehr Vorlauf als eine
* Unterweisung.
*/
ablaufwarnungen: mitRecht("qualifikation.lesen")
.input(z.object({ tage: z.number().int().min(1).max(730).default(90) }).optional())
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const fenster = new Date(Date.now() + (input?.tage ?? 90) * 864e5);
const eintraege = await db.qualifikation.findMany({
where: {
geloeschtAm: null,
gueltigBis: { not: null, lte: fenster },
person: { aktiv: true, geloeschtAm: null },
},
include: {
typ: { select: { id: true, name: true, kuerzel: true, vorwarnzeitTage: true, einsatzrelevant: true } },
person: { select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true } },
},
orderBy: { gueltigBis: "asc" },
take: 200,
});
return eintraege.map((eintrag) => ({
...eintrag,
zustand: qualifikationZustand(eintrag.gueltigBis, eintrag.typ.vorwarnzeitTage),
}));
}),
),
anlegen: mitRecht("qualifikation.bearbeiten")
.input(qualifikationDaten)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const typ = await db.qualifikationstyp.findUnique({
where: { id: input.typId },
select: { id: true, name: true, gueltigkeitMonate: true },
});
if (!typ) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Diesen Qualifikationstyp gibt es nicht.",
});
}
const person = await db.person.findUnique({
where: { id: input.personId },
select: { id: true, vorname: true, nachname: true },
});
if (!person) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." });
}
const qualifikation = await db.qualifikation.create({
data: {
organisationId: ctx.sitzung.organisationId,
...input,
// Ausdrücklich gesetztes Ablaufdatum gewinnt: auf einer Urkunde
// steht manchmal etwas anderes, als die Regel hergäbe.
gueltigBis:
input.gueltigBis ??
qualifikationGueltigBis(input.erworbenAm, typ.gueltigkeitMonate),
bestaetigtVonId: ctx.sitzung.personId,
bestaetigtAm: new Date(),
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Qualifikation",
entitaetId: qualifikation.id,
beschreibung: `${typ.name} für ${person.nachname}, ${person.vorname} eingetragen`,
sitzung: ctx.sitzung,
nachher: qualifikation,
kontext: { personId: person.id },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return qualifikation;
}),
),
aendern: mitRecht("qualifikation.bearbeiten")
.input(qualifikationDaten.partial().omit({ personId: true }).extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.qualifikation.findUnique({
where: { id },
include: { typ: { select: { name: true, gueltigkeitMonate: true } } },
});
if (!vorher) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Diese Qualifikation gibt es nicht.",
});
}
const erworben = aenderungen.erworbenAm ?? vorher.erworbenAm;
const nachher = await db.qualifikation.update({
where: { id },
data: {
...aenderungen,
gueltigBis:
aenderungen.gueltigBis !== undefined
? aenderungen.gueltigBis
: qualifikationGueltigBis(erworben, vorher.typ.gueltigkeitMonate),
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Qualifikation",
entitaetId: id,
beschreibung: `${vorher.typ.name} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
loeschen: mitRecht("qualifikation.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.qualifikation.findUnique({
where: { id: input.id },
include: { typ: { select: { name: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Diese Qualifikation gibt es nicht.",
});
}
await db.qualifikation.update({
where: { id: input.id },
data: { geloeschtAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Qualifikation",
entitaetId: input.id,
beschreibung: `${vorher.typ.name} entfernt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
});
+42 -44
View File
@@ -2,8 +2,8 @@ import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import type { Kontext } from "@/server/context";
import { KeinZugriffFehler, hatRecht, type Recht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { KeinZugriffFehler, hatRecht, type Recht, type Sitzung } from "@/lib/authz";
import { fuerOrganisation, type MandantenClient } from "@/lib/db";
/**
* tRPC-Grundgerüst.
@@ -41,10 +41,37 @@ export const createCallerFactory = t.createCallerFactory;
/** Ohne Anmeldung erreichbar. */
export const oeffentlich = t.procedure;
/** Übersetzt KeinZugriffFehler in ein sauberes tRPC-FORBIDDEN. */
const fehlerUebersetzen = middleware(async ({ next }) => {
/** Abfrage im Mandantenkontext der Sitzung. */
export type MandantenAbfrage = <T>(fn: (db: MandantenClient) => Promise<T>) => Promise<T>;
/**
* Verlangt eine Sitzung, übersetzt Berechtigungsfehler und stellt den
* mandantengebundenen Zugriff bereit.
*
* Die Middleware wird bewusst als Funktion an .use() übergeben und nicht über
* t.middleware() gebaut: nur so reicht tRPC den erweiterten Kontext an die
* folgenden Prozeduren weiter, und ctx.sitzung ist dort nicht mehr nullbar.
*/
export const angemeldet = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.sitzung) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Bitte zuerst anmelden." });
}
const sitzung: Sitzung = ctx.sitzung;
try {
return await next();
return await next({
ctx: {
...ctx,
sitzung,
/**
* Führt eine Abfrage im Mandantenkontext der Sitzung aus.
* @example const personen = await ctx.db((db) => db.person.findMany());
*/
db: (<T>(fn: (db: MandantenClient) => Promise<T>) =>
fuerOrganisation(sitzung.organisationId, fn)) as MandantenAbfrage,
},
});
} catch (fehler) {
if (fehler instanceof KeinZugriffFehler) {
throw new TRPCError({ code: "FORBIDDEN", message: fehler.message, cause: fehler });
@@ -53,47 +80,18 @@ const fehlerUebersetzen = middleware(async ({ next }) => {
}
});
/** Verlangt eine Sitzung und stellt den mandantengebundenen Zugriff bereit. */
const angemeldetMiddleware = middleware(async ({ ctx, next }) => {
if (!ctx.sitzung) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Bitte zuerst anmelden.",
});
}
const sitzung = ctx.sitzung;
return next({
ctx: {
...ctx,
sitzung,
/**
* Führt eine Abfrage im Mandantenkontext der Sitzung aus.
* @example const personen = await ctx.db((db) => db.person.findMany());
*/
db: <T>(fn: Parameters<typeof fuerOrganisation<T>>[1]) =>
fuerOrganisation(sitzung.organisationId, fn),
},
});
});
export const angemeldet = t.procedure.use(fehlerUebersetzen).use(angemeldetMiddleware);
/**
* Prozedur, die ein bestimmtes Recht verlangt.
* @example export const geraeteAendern = mitRecht("geraet.bearbeiten").mutation(...)
* @example export const geraetAendern = mitRecht("geraet.bearbeiten").mutation(...)
*/
export function mitRecht(recht: Recht) {
return angemeldet.use(
middleware(async ({ ctx, next }) => {
if (!hatRecht(ctx.sitzung, recht)) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Für diesen Vorgang fehlt die Berechtigung "${recht}".`,
});
}
return next({ ctx });
}),
);
return angemeldet.use(async ({ ctx, next }) => {
if (!hatRecht(ctx.sitzung, recht)) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Für diesen Vorgang fehlt die Berechtigung "${recht}".`,
});
}
return next();
});
}