Phase 2: Wartungs- und Pruefmanagement

Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf,
Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche
Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung.

Elf neue Tabellen, alle mit organisationId und Row Level Security. Die
Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger
durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung
mit Verweis auf die alte, beide bleiben lesbar.

79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 12:07:11 +02:00
co-authored by Claude Opus 5
parent 7f866ac35c
commit e503aafc18
97 changed files with 13367 additions and 133 deletions
+186 -11
View File
@@ -1,7 +1,7 @@
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { ArrowLeft, Pencil, Printer } from "lucide-react";
import { ArrowLeft, ClipboardCheck, Pencil, Printer, TriangleAlert } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
@@ -10,6 +10,7 @@ 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";
import { ErgebnisAbzeichen, MangelStatusAbzeichen, PrioritaetAbzeichen } from "@/components/wartung/anzeige";
export const metadata = { title: "Gerät" };
@@ -51,12 +52,56 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
},
});
return { geraet, verlauf };
// Phase 2: was am Gerät geprüft, gemeldet und geplant ist.
const [pruefungen, maengel, termine] = await Promise.all([
db.pruefung.findMany({
where: { geraetId: id, geloeschtAm: null },
orderBy: { durchgefuehrtAm: "desc" },
take: 10,
select: {
id: true,
protokollNummer: true,
durchgefuehrtAm: true,
ergebnis: true,
status: true,
art: true,
prueferName: true,
vorlage: { select: { name: true } },
},
}),
db.mangel.findMany({
where: {
geraetId: id,
geloeschtAm: null,
status: { notIn: ["BEHOBEN", "VERWORFEN"] },
},
orderBy: { gemeldetAm: "desc" },
select: {
id: true,
beschreibung: true,
prioritaet: true,
status: true,
gemeldetAm: true,
melderName: true,
},
}),
db.wartungstermin.findMany({
where: { geraetId: id, status: "OFFEN", geloeschtAm: null },
orderBy: { faelligAm: "asc" },
select: {
id: true,
faelligAm: true,
vorlage: { select: { id: true, name: true } },
},
}),
]);
return { geraet, verlauf, pruefungen, maengel, termine };
});
if (!daten) notFound();
const { geraet, verlauf } = daten;
const { geraet, verlauf, pruefungen, maengel, termine } = daten;
const qr = await qrAlsDataUri(geraet.inventarnummer, 320);
const angaben: Array<[string, React.ReactNode]> = [
@@ -102,14 +147,29 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
</p>
</div>
{hatRecht(sitzung, "geraet.bearbeiten") && (
<Button asChild>
<Link href={`/geraete/${geraet.id}/bearbeiten`}>
<Pencil aria-hidden />
Bearbeiten
</Link>
</Button>
)}
<div className="flex flex-wrap gap-2">
{hatRecht(sitzung, "pruefung.durchfuehren") && geraet.status !== "AUSGESONDERT" && (
<Button asChild>
<Link
href={`/pruefungen/neu?geraetId=${geraet.id}${
termine[0] ? `&terminId=${termine[0].id}` : ""
}`}
>
<ClipboardCheck aria-hidden />
Prüfung durchführen
</Link>
</Button>
)}
{hatRecht(sitzung, "geraet.bearbeiten") && (
<Button variant="outline" asChild>
<Link href={`/geraete/${geraet.id}/bearbeiten`}>
<Pencil aria-hidden />
Bearbeiten
</Link>
</Button>
)}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
@@ -195,6 +255,121 @@ export default async function GeraetSeite({ params }: { params: Promise<{ id: st
</Card>
</div>
{(termine.length > 0 || maengel.length > 0) && (
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Anstehende Prüfungen</CardTitle>
<CardDescription>
Offene Termine aus den Wartungsvorlagen. Die früheste Frist steht oben in den
Angaben.
</CardDescription>
</CardHeader>
<CardContent>
{termine.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
Kein offener Termin. Termine entstehen nachts aus den Wartungsvorlagen des
Gerätetyps.
</p>
) : (
<ul className="space-y-2">
{termine.map((termin) => (
<li
key={termin.id}
className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--border)] pb-2 text-sm last:border-0"
>
<span>{termin.vorlage?.name ?? "Prüfung ohne Vorlage"}</span>
<FristAnzeige datum={termin.faelligAm} />
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TriangleAlert className="size-4" aria-hidden />
Offene Mängel
</CardTitle>
<CardDescription>
Was an diesem Gerät noch behoben werden muss.
</CardDescription>
</CardHeader>
<CardContent>
{maengel.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Kein offener Mangel.</p>
) : (
<ul className="space-y-3">
{maengel.map((mangel) => (
<li key={mangel.id} className="text-sm">
<div className="flex flex-wrap items-center gap-2">
<PrioritaetAbzeichen prioritaet={mangel.prioritaet} />
<MangelStatusAbzeichen status={mangel.status} />
<span className="text-xs text-[var(--muted-foreground)]">
{alsDatum(mangel.gemeldetAm)}
{mangel.melderName && ` · ${mangel.melderName}`}
</span>
</div>
<p className="mt-1">{mangel.beschreibung}</p>
</li>
))}
</ul>
)}
<Button variant="outline" size="sm" asChild className="mt-4">
<Link href="/maengel">Zur Mängelverwaltung</Link>
</Button>
</CardContent>
</Card>
</div>
)}
<Card>
<CardHeader>
<CardTitle>Prüfhistorie</CardTitle>
<CardDescription>
Abgeschlossene Prüfungen mit Ergebnis. Das PDF liegt am jeweiligen Protokoll.
</CardDescription>
</CardHeader>
<CardContent>
{pruefungen.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
Zu diesem Gerät ist noch keine Prüfung dokumentiert.
</p>
) : (
<ul className="space-y-2">
{pruefungen.map((pruefung) => (
<li
key={pruefung.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-[var(--border)] pb-2 text-sm last:border-0"
>
<Link
href={`/pruefungen/${pruefung.id}`}
className="tabellenzahlen font-medium underline-offset-4 hover:underline"
>
{pruefung.protokollNummer ?? "Entwurf"}
</Link>
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
{alsDatum(pruefung.durchgefuehrtAm)}
</span>
<span>{pruefung.vorlage?.name ?? "—"}</span>
<ErgebnisAbzeichen ergebnis={pruefung.ergebnis} />
{pruefung.art === "EXTERN" && (
<span className="text-xs text-[var(--muted-foreground)]">extern</span>
)}
{pruefung.prueferName && (
<span className="text-xs text-[var(--muted-foreground)]">
— {pruefung.prueferName}
</span>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Verlauf</CardTitle>
+453
View File
@@ -0,0 +1,453 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { Check, Play, Plus, Search, X } from "lucide-react";
import { MangelPrioritaet, MangelStatus } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { MangelStatusAbzeichen, PrioritaetAbzeichen } from "@/components/wartung/anzeige";
import { DateiFeld } from "@/components/wartung/datei-feld";
import { MANGEL_STATUS_TEXT, PRIORITAET_TEXT } from "@/lib/wartung/texte";
import { alsDatumZeit } from "@/lib/utils";
/**
* Mängelliste mit dem vollständigen Weg: melden → zugewiesen → in Arbeit →
* behoben.
*
* Die Schaltflächen zeigen immer nur den nächsten sinnvollen Schritt. Ein
* Formular, in dem man jeden Zustand frei wählen kann, führt dazu, dass Mängel
* auf „behoben" springen, ohne dass jemand daran gearbeitet hat.
*/
export function MaengelListe({
darfBearbeiten,
darfZuweisen,
}: {
darfBearbeiten: boolean;
darfZuweisen: boolean;
}) {
const [nurOffene, setNurOffene] = React.useState(true);
const [suche, setSuche] = React.useState("");
const [meldenOffen, setMeldenOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
// Formular zum Melden
const [geraetSuche, setGeraetSuche] = React.useState("");
const [geraetId, setGeraetId] = React.useState("");
const [beschreibung, setBeschreibung] = React.useState("");
const [prioritaet, setPrioritaet] = React.useState<MangelPrioritaet>(MangelPrioritaet.MITTEL);
const [fotoDateiId, setFotoDateiId] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const maengel = trpc.mangel.liste.useQuery({
seite: 1,
seitengroesse: 100,
richtung: "desc",
nurOffene,
suche: suche || undefined,
});
const kennzahlen = trpc.mangel.kennzahlen.useQuery();
const geraete = trpc.geraet.liste.useQuery(
{ seite: 1, seitengroesse: 25, richtung: "asc", suche: geraetSuche || undefined },
{ enabled: meldenOffen },
);
const mitglieder = trpc.person.liste.useQuery(
{ seite: 1, seitengroesse: 200, richtung: "asc" },
{ enabled: darfZuweisen },
);
const aktualisieren = () => {
void werkzeuge.mangel.invalidate();
void werkzeuge.geraet.invalidate();
};
const melden = trpc.mangel.melden.useMutation({
onSuccess: () => {
setMeldenOffen(false);
setGeraetId("");
setBeschreibung("");
setFotoDateiId(null);
setPrioritaet(MangelPrioritaet.MITTEL);
aktualisieren();
},
onError: (fehler) => setMeldung(fehler.message),
});
const zuweisen = trpc.mangel.zuweisen.useMutation({
onSuccess: aktualisieren,
onError: (fehler) => setMeldung(fehler.message),
});
const inArbeit = trpc.mangel.inArbeit.useMutation({
onSuccess: aktualisieren,
onError: (fehler) => setMeldung(fehler.message),
});
const beheben = trpc.mangel.beheben.useMutation({
onSuccess: (ergebnis) => {
setMeldung(
ergebnis.geraetFreigegeben
? "Mangel behoben — das Gerät ist wieder einsatzbereit."
: "Mangel behoben.",
);
aktualisieren();
},
onError: (fehler) => setMeldung(fehler.message),
});
const verwerfen = trpc.mangel.verwerfen.useMutation({
onSuccess: aktualisieren,
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = maengel.data?.eintraege ?? [];
return (
<div className="space-y-4">
<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={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Beschreibung oder Inventarnummer …"
className="pl-8"
aria-label="Mängel durchsuchen"
/>
</div>
<Select
value={nurOffene ? "offen" : "alle"}
onValueChange={(wert) => setNurOffene(wert === "offen")}
>
<SelectTrigger className="w-48" aria-label="Nach Stand filtern">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="offen">nur offene</SelectItem>
<SelectItem value="alle">alle</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setMeldenOffen(true)}>
<Plus aria-hidden />
Mangel melden
</Button>
</div>
{kennzahlen.data && (
<p className="text-sm text-[var(--muted-foreground)]">
{kennzahlen.data.offen} offen
{kennzahlen.data.kritisch > 0 && (
<span className="font-medium text-[var(--gesperrt)]">
{" "}
· {kennzahlen.data.kritisch} kritisch
</span>
)}
</p>
)}
{meldung && <Fehlermeldung meldung={meldung} kennung="mangelmeldung" />}
{maengel.error && <Fehlermeldung meldung={maengel.error.message} />}
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Gerät</TableHead>
<TableHead>Mangel</TableHead>
<TableHead>Dringlichkeit</TableHead>
<TableHead>Stand</TableHead>
<TableHead>Zuständig</TableHead>
<TableHead className="w-56" aria-label="Aktionen" />
</TableRow>
</TableHeader>
<TableBody>
{maengel.isLoading && <LadeZeilen spalten={6} />}
{eintraege.map((mangel) => (
<TableRow key={mangel.id}>
<TableCell>
<Link
href={`/geraete/${mangel.geraet.id}`}
className="font-medium underline-offset-4 hover:underline"
>
<span className="tabellenzahlen">{mangel.geraet.inventarnummer}</span>
</Link>
<div className="text-xs text-[var(--muted-foreground)]">
{mangel.geraet.bezeichnung}
</div>
{mangel.geraetGesperrt && (
<div className="text-xs font-medium text-[var(--gesperrt)]">
wegen dieses Mangels gesperrt
</div>
)}
</TableCell>
<TableCell className="max-w-md">
<p className="text-sm">{mangel.beschreibung}</p>
<p className="text-xs text-[var(--muted-foreground)]">
gemeldet {alsDatumZeit(mangel.gemeldetAm)}
{mangel.melderName && ` von ${mangel.melderName}`}
{mangel.pruefung?.protokollNummer &&
` · aus Protokoll ${mangel.pruefung.protokollNummer}`}
</p>
{mangel.behebung && (
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
Behebung: {mangel.behebung}
</p>
)}
</TableCell>
<TableCell>
<PrioritaetAbzeichen prioritaet={mangel.prioritaet} />
</TableCell>
<TableCell>
<MangelStatusAbzeichen status={mangel.status} />
</TableCell>
<TableCell className="text-sm">
{darfZuweisen ? (
<Select
value={mangel.zugewiesenAn?.id ?? "niemand"}
onValueChange={(wert) =>
zuweisen.mutate({
id: mangel.id,
zugewiesenAnId: wert === "niemand" ? null : wert,
})
}
>
<SelectTrigger
className="h-8 w-44"
aria-label={`Zuständigkeit für Mangel an ${mangel.geraet.inventarnummer}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="niemand">niemand</SelectItem>
{mitglieder.data?.eintraege.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
</SelectItem>
))}
</SelectContent>
</Select>
) : mangel.zugewiesenAn ? (
`${mangel.zugewiesenAn.nachname}, ${mangel.zugewiesenAn.vorname}`
) : (
"—"
)}
</TableCell>
<TableCell>
{darfBearbeiten &&
mangel.status !== MangelStatus.BEHOBEN &&
mangel.status !== MangelStatus.VERWORFEN && (
<div className="flex flex-wrap gap-1">
{mangel.status !== MangelStatus.IN_ARBEIT && (
<Button
variant="outline"
size="sm"
onClick={() => inArbeit.mutate({ id: mangel.id })}
>
<Play aria-hidden />
In Arbeit
</Button>
)}
<Button
size="sm"
onClick={() => {
const behebung = window.prompt("Was wurde gemacht?");
if (behebung === null) return;
beheben.mutate({ id: mangel.id, behebung: behebung || null });
}}
>
<Check aria-hidden />
Behoben
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
const grund = window.prompt("Warum ist das kein Mangel?");
if (grund === null) return;
verwerfen.mutate({ id: mangel.id, grund: grund || undefined });
}}
aria-label="Meldung verwerfen"
>
<X aria-hidden />
</Button>
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!maengel.isLoading && eintraege.length === 0 && (
<div className="p-6">
<NichtsGefunden
titel={nurOffene ? "Kein offener Mangel" : "Noch kein Mangel gemeldet"}
hinweis="Mängel entstehen bei einer Prüfung oder werden hier von Hand gemeldet."
aktion={
<Button onClick={() => setMeldenOffen(true)}>
<Plus aria-hidden />
Mangel melden
</Button>
}
/>
</div>
)}
</div>
<Dialog open={meldenOffen} onOpenChange={setMeldenOffen}>
<DialogContent>
<form
onSubmit={(ereignis) => {
ereignis.preventDefault();
setMeldung(null);
melden.mutate({
geraetId,
beschreibung,
prioritaet,
fotoDateiId,
});
}}
className="space-y-4"
>
<DialogHeader>
<DialogTitle>Mangel melden</DialogTitle>
<DialogDescription>
Ein kritischer Mangel sperrt das Gerät sofort — ohne Rückfrage.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="geraetsuche">Gerät suchen</Label>
<Input
id="geraetsuche"
value={geraetSuche}
onChange={(ereignis) => setGeraetSuche(ereignis.target.value)}
placeholder="Inventarnummer oder Bezeichnung"
/>
<Select value={geraetId} onValueChange={setGeraetId}>
<SelectTrigger aria-label="Gerät auswählen">
<SelectValue placeholder="Gerät wählen" />
</SelectTrigger>
<SelectContent>
{geraete.data?.eintraege.map((geraet) => (
<SelectItem key={geraet.id} value={geraet.id}>
{geraet.inventarnummer} — {geraet.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="mangelbeschreibung">Was ist der Mangel?</Label>
<Textarea
id="mangelbeschreibung"
required
rows={3}
value={beschreibung}
onChange={(ereignis) => setBeschreibung(ereignis.target.value)}
placeholder="Riss im Schlauch, etwa 20 cm von der Kupplung"
/>
</div>
<div className="space-y-2">
<Label htmlFor="mangelprio">Dringlichkeit</Label>
<Select
value={prioritaet}
onValueChange={(wert) => setPrioritaet(wert as MangelPrioritaet)}
>
<SelectTrigger id="mangelprio">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(PRIORITAET_TEXT) as MangelPrioritaet[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{PRIORITAET_TEXT[wert]}
{wert === MangelPrioritaet.KRITISCH && " — sperrt das Gerät"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Foto</Label>
<DateiFeld
entitaet="Mangel"
kategorie="foto"
dateiId={fotoDateiId}
onHochgeladen={setFotoDateiId}
/>
</div>
{melden.error && <Fehlermeldung meldung={melden.error.message} />}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setMeldenOffen(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={melden.isPending || !geraetId}>
{melden.isPending ? "wird gemeldet …" : "Melden"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<p className="text-xs text-[var(--muted-foreground)]">
Stand der Meldungen:{" "}
{Object.entries(kennzahlen.data?.nachStatus ?? {})
.map(
([status, anzahl]) =>
`${MANGEL_STATUS_TEXT[status as MangelStatus] ?? status}: ${anzahl}`,
)
.join(" · ")}
</p>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { MaengelListe } from "@/app/(app)/maengel/maengel-liste";
export const metadata = { title: "Mängel" };
/**
* Mängelverwaltung.
*
* Melden darf jedes Mitglied — der Mangel fällt dem auf, der das Gerät
* benutzt. Bearbeiten und beheben bleibt beim Gerätewart.
*/
export default async function MaengelSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mängel</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Von der Meldung bis zur Behebung — mit Foto, Zuständigkeit und Gerätesperre.
</p>
</div>
<MaengelListe
darfBearbeiten={hatRecht(sitzung, "pruefung.durchfuehren")}
darfZuweisen={hatRecht(sitzung, "pruefung.planen")}
/>
</div>
);
}
@@ -0,0 +1,91 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { FileDown, PencilLine } from "lucide-react";
import type { PruefungStatus } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
/**
* Die beiden Dinge, die man mit einem fertigen Protokoll tut: herunterladen
* oder korrigieren.
*
* Korrigieren heißt hier nicht ändern. Es entsteht ein neuer Entwurf mit den
* bisherigen Antworten und einem Verweis auf dieses Protokoll; erst mit seinem
* Abschluss gilt das alte als ersetzt. So bleibt nachvollziehbar, was zuerst
* dastand — genau darum geht es bei einem Nachweis.
*/
export function ProtokollAktionen({
pruefungId,
status,
hatProtokoll,
darfKorrigieren,
}: {
pruefungId: string;
status: PruefungStatus;
hatProtokoll: boolean;
darfKorrigieren: boolean;
}) {
const router = useRouter();
const [meldung, setMeldung] = React.useState<string | null>(null);
const link = trpc.pruefung.protokollLink.useMutation({
onSuccess: (ergebnis) => {
if (ergebnis.bereit && ergebnis.adresse) {
window.open(ergebnis.adresse, "_blank", "noopener");
setMeldung(null);
} else {
setMeldung(
"Das PDF wird gerade erzeugt. In ein paar Sekunden noch einmal versuchen — Gotenberg braucht einen Moment.",
);
}
},
onError: (fehler) => setMeldung(fehler.message),
});
const korrigieren = trpc.pruefung.korrigieren.useMutation({
// Der Wizard nimmt den frischen Entwurf auf: er sucht zu Gerät, Vorlage
// und Prüfer nach einer angefangenen Prüfung und setzt sie fort — samt der
// übernommenen Antworten aus dem alten Protokoll.
onSuccess: (neu) =>
router.push(
`/pruefungen/neu?geraetId=${neu.geraetId}${neu.vorlageId ? `&vorlageId=${neu.vorlageId}` : ""}`,
),
onError: (fehler) => setMeldung(fehler.message),
});
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => link.mutate({ id: pruefungId })}
disabled={link.isPending || status === "ENTWURF"}
>
<FileDown aria-hidden />
{hatProtokoll ? "Protokoll als PDF" : "PDF erzeugen"}
</Button>
{darfKorrigieren && status !== "ENTWURF" && (
<Button
variant="outline"
onClick={() => {
const grund = window.prompt(
"Warum wird korrigiert? Der Grund steht später im neuen Protokoll.",
);
if (grund?.trim()) korrigieren.mutate({ id: pruefungId, grund: grund.trim() });
}}
disabled={korrigieren.isPending}
>
<PencilLine aria-hidden />
Korrektur anlegen
</Button>
)}
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="protokollmeldung" />}
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
import { notFound, 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 { fuerOrganisation } from "@/lib/db";
import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ProtokollAktionen } from "@/app/(app)/pruefungen/[id]/aktionen";
import { ErgebnisAbzeichen, PruefungStatusAbzeichen } from "@/components/wartung/anzeige";
import { PRIORITAET_TEXT, PUNKTTYP_TEXT } from "@/lib/wartung/texte";
export const metadata = { title: "Prüfprotokoll" };
/**
* Ein Prüfprotokoll auf dem Bildschirm.
*
* Zeigt dasselbe wie das PDF, nur ohne Papierlayout. Das PDF bleibt der
* Nachweis; diese Seite ist der schnelle Blick, ohne erst eine Datei öffnen zu
* müssen.
*/
export default async function PruefungSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
const pruefung = await fuerOrganisation(sitzung.organisationId, (db) =>
db.pruefung.findUnique({
where: { id },
include: {
geraet: {
select: { id: true, inventarnummer: true, bezeichnung: true, seriennummer: true },
},
vorlage: { select: { id: true, name: true, pruefvorschrift: true, intervallMonate: true } },
pruefer: { select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true } },
ergebnisse: { include: { punkt: true } },
signaturen: true,
maengel: { where: { geloeschtAm: null } },
ersetzt: { select: { id: true, protokollNummer: true } },
ersetztDurch: { select: { id: true, protokollNummer: true, status: true } },
},
}),
);
if (!pruefung) notFound();
const ergebnisseSortiert = [...pruefung.ergebnisse].sort(
(a, b) => a.punkt.sortierung - b.punkt.sortierung,
);
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="/pruefungen/verlauf">
<ArrowLeft aria-hidden />
Zurück zu den Protokollen
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">
Prüfprotokoll {pruefung.protokollNummer ?? ""}
</h1>
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
<Link href={`/geraete/${pruefung.geraet.id}`} className="underline underline-offset-4">
{pruefung.geraet.inventarnummer} — {pruefung.geraet.bezeichnung}
</Link>
<ErgebnisAbzeichen ergebnis={pruefung.ergebnis} />
<PruefungStatusAbzeichen status={pruefung.status} />
</p>
</div>
<ProtokollAktionen
pruefungId={pruefung.id}
status={pruefung.status}
hatProtokoll={Boolean(pruefung.protokollDateiId)}
darfKorrigieren={hatRecht(sitzung, "pruefung.durchfuehren")}
/>
</div>
{pruefung.ersetzt && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
Dieses Protokoll ist eine Korrektur zu{" "}
<Link href={`/pruefungen/${pruefung.ersetzt.id}`} className="underline underline-offset-4">
{pruefung.ersetzt.protokollNummer ?? "einem früheren Protokoll"}
</Link>
{pruefung.korrekturGrund && `. Grund: ${pruefung.korrekturGrund}`}
</div>
)}
{pruefung.ersetztDurch.length > 0 && (
<div className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
Dieses Protokoll wurde ersetzt durch{" "}
{pruefung.ersetztDurch.map((nachfolger) => (
<Link
key={nachfolger.id}
href={`/pruefungen/${nachfolger.id}`}
className="underline underline-offset-4"
>
{nachfolger.protokollNummer ?? "eine Korrektur"}
</Link>
))}
.
</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">
{(
[
["Prüfdatum", alsDatum(pruefung.durchgefuehrtAm)],
[
"Prüfer",
pruefung.pruefer
? `${pruefung.pruefer.vorname} ${pruefung.pruefer.nachname}`
: (pruefung.prueferName ?? "—"),
],
["Prüfer-Kennung", pruefung.pruefer?.mitgliedsnummer ?? "—"],
["Art", pruefung.art === "EXTERN" ? "extern durch Fachfirma" : "intern"],
["Fachfirma", pruefung.externeFirma ?? "—"],
["Kosten", pruefung.kosten ? alsEuro(Number(pruefung.kosten)) : "—"],
["Vorlage", pruefung.vorlage?.name ?? "—"],
["Prüfvorschrift", pruefung.vorlage?.pruefvorschrift ?? "—"],
["Dauer", pruefung.dauerMinuten ? `${pruefung.dauerMinuten} Minuten` : "—"],
["Nächste Prüfung", alsDatum(pruefung.naechstePruefungAm)],
["Abgeschlossen", alsDatumZeit(pruefung.abgeschlossenAm)],
] as Array<[string, string]>
).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>
{pruefung.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">{pruefung.bemerkung}</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Unterschriften</CardTitle>
<CardDescription>
Die Bilder liegen in MinIO und sind im PDF eingebettet.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{pruefung.signaturen.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Keine Unterschrift erfasst.</p>
) : (
pruefung.signaturen.map((signatur) => (
<div key={signatur.id} className="text-sm">
<p className="font-medium">{signatur.name}</p>
<p className="text-xs text-[var(--muted-foreground)]">
{signatur.rolle} · {alsDatumZeit(signatur.unterzeichnetAm)}
</p>
</div>
))
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Checkliste</CardTitle>
<CardDescription>
{ergebnisseSortiert.filter((eintrag) => !eintrag.inOrdnung).length} von{" "}
{ergebnisseSortiert.length} Punkten beanstandet.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Prüfpunkt</TableHead>
<TableHead>Art</TableHead>
<TableHead>Soll</TableHead>
<TableHead>Ist</TableHead>
<TableHead>Befund</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ergebnisseSortiert.map((eintrag) => (
<TableRow key={eintrag.id}>
<TableCell>
{eintrag.punkt.text}
{eintrag.punkt.pflicht && (
<span className="ml-1 text-[var(--gesperrt)]">*</span>
)}
{eintrag.bemerkung && (
<div className="text-xs text-[var(--muted-foreground)]">
{eintrag.bemerkung}
</div>
)}
</TableCell>
<TableCell className="text-sm text-[var(--muted-foreground)]">
{PUNKTTYP_TEXT[eintrag.punkt.typ]}
</TableCell>
<TableCell className="text-sm">
{eintrag.punkt.sollwert ?? "—"}
{eintrag.punkt.einheit ? ` ${eintrag.punkt.einheit}` : ""}
</TableCell>
<TableCell className="text-sm tabellenzahlen">
{eintrag.punkt.typ === "JA_NEIN"
? eintrag.jaNein === true
? "ja"
: eintrag.jaNein === false
? "nein"
: "—"
: eintrag.punkt.typ === "MESSWERT"
? eintrag.zahlwert
? `${Number(eintrag.zahlwert).toLocaleString("de-DE")}${
eintrag.punkt.einheit ? ` ${eintrag.punkt.einheit}` : ""
}`
: "—"
: eintrag.punkt.typ === "FOTO"
? eintrag.fotoDateiId
? "Foto liegt bei"
: "—"
: (eintrag.text ?? "—")}
</TableCell>
<TableCell
className={
eintrag.inOrdnung ? "text-sm" : "text-sm font-medium text-[var(--gesperrt)]"
}
>
{eintrag.inOrdnung ? "i. O." : "nicht i. O."}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{pruefung.maengel.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Festgestellte Mängel</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{pruefung.maengel.map((mangel) => (
<li key={mangel.id} className="text-sm">
<Link href="/maengel" className="underline underline-offset-4">
{PRIORITAET_TEXT[mangel.prioritaet]}
</Link>
: {mangel.beschreibung}
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,439 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { CalendarClock, ClipboardCheck, Hourglass, Users } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import {
Fehlermeldung,
FristAnzeige,
LadeZeilen,
NichtsGefunden,
StatusAbzeichen,
} from "@/components/stammdaten/anzeige";
import { FensterAbzeichen, Kennzahl } from "@/components/wartung/anzeige";
import { alsDatum } from "@/lib/utils";
import type { Fristfenster } from "@/lib/wartung/fristen";
/**
* Fristen-Dashboard.
*
* Aufbau von oben nach unten in der Reihenfolge, in der ein Gerätewart denkt:
*
* 1. Wie viel ist überfällig? (die vier Zahlen, gleichzeitig Filter)
* 2. Wo steckt es? (gebündelt nach Fahrzeug, Standort, Typ oder Zuständigem)
* 3. Was genau ist zu tun? (die Terminliste, aus der die Prüfung startet)
*
* Die Bündelung ist kein Zierrat: wer einen Prüftag ansetzt, arbeitet ein
* Fahrzeug ab. Eine alphabetische Gesamtliste hilft dabei nicht.
*/
const GRUPPIERUNGEN = [
{ wert: "fahrzeug", text: "nach Fahrzeug" },
{ wert: "standort", text: "nach Standort" },
{ wert: "geraetetyp", text: "nach Gerätetyp" },
{ wert: "zustaendig", text: "nach Zuständigem" },
] as const;
type Gruppierung = (typeof GRUPPIERUNGEN)[number]["wert"];
type Zeitfenster = "ueberfaellig" | "diese_woche" | "dieser_monat" | "naechste_90_tage";
export function FristenDashboard({
darfPlanen,
darfPruefen,
}: {
darfPlanen: boolean;
darfPruefen: boolean;
}) {
const [gruppierung, setGruppierung] = React.useState<Gruppierung>("fahrzeug");
const [fenster, setFenster] = React.useState<Zeitfenster | null>(null);
const [ausgewaehlt, setAusgewaehlt] = React.useState<Set<string>>(new Set());
const [meldung, setMeldung] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const dashboard = trpc.wartungstermin.dashboard.useQuery({ gruppierung });
const termine = trpc.wartungstermin.liste.useQuery({
seite: 1,
seitengroesse: 100,
richtung: "asc",
fenster: fenster ?? undefined,
});
const mitglieder = trpc.person.liste.useQuery(
{ seite: 1, seitengroesse: 200, richtung: "asc" },
{ enabled: darfPlanen },
);
const zuweisen = trpc.wartungstermin.zuweisen.useMutation({
onSuccess: (ergebnis) => {
setMeldung(`${ergebnis.zugewiesen} Termine zugewiesen.`);
setAusgewaehlt(new Set());
void werkzeuge.wartungstermin.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const zahlen = dashboard.data?.fenster;
const eintraege = termine.data?.eintraege ?? [];
function auswahlUmschalten(id: string) {
setAusgewaehlt((alt) => {
const neu = new Set(alt);
if (neu.has(id)) neu.delete(id);
else neu.add(id);
return neu;
});
}
return (
<div className="space-y-6">
{/* Die vier Zahlen. Ein Klick filtert die Liste darunter. */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kennzahl
wert={zahlen?.ueberfaellig ?? 0}
bezeichnung="überfällig"
hinweis="hätte längst geprüft sein müssen"
dringend
aktiv={fenster === "ueberfaellig"}
onClick={() => setFenster(fenster === "ueberfaellig" ? null : "ueberfaellig")}
/>
<Kennzahl
wert={zahlen?.diese_woche ?? 0}
bezeichnung="diese Woche"
hinweis="in den nächsten sieben Tagen"
aktiv={fenster === "diese_woche"}
onClick={() => setFenster(fenster === "diese_woche" ? null : "diese_woche")}
/>
<Kennzahl
wert={zahlen?.dieser_monat ?? 0}
bezeichnung="diesen Monat"
hinweis="in den nächsten 30 Tagen"
aktiv={fenster === "dieser_monat"}
onClick={() => setFenster(fenster === "dieser_monat" ? null : "dieser_monat")}
/>
<Kennzahl
wert={zahlen?.naechste_90_tage ?? 0}
bezeichnung="nächste 90 Tage"
hinweis="mittelfristig zu planen"
aktiv={fenster === "naechste_90_tage"}
onClick={() => setFenster(fenster === "naechste_90_tage" ? null : "naechste_90_tage")}
/>
</div>
{/* Was sonst noch Aufmerksamkeit braucht. */}
<div className="grid gap-3 sm:grid-cols-3">
<Link
href="/pruefungen/lebensdauer"
className="flex items-center gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-colors hover:border-[var(--primary)]"
>
<Hourglass className="size-5 text-[var(--muted-foreground)]" aria-hidden />
<div>
<p className="text-sm font-medium">
Lebensdauer: {dashboard.data?.lebensdauer.abgelaufen ?? 0} abgelaufen
</p>
<p className="text-xs text-[var(--muted-foreground)]">
{dashboard.data?.lebensdauer.bald ?? 0} laufen in den nächsten 90 Tagen ab
</p>
</div>
</Link>
<Link
href="/maengel"
className="flex items-center gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-colors hover:border-[var(--primary)]"
>
<ClipboardCheck className="size-5 text-[var(--muted-foreground)]" aria-hidden />
<div>
<p className="text-sm font-medium">{dashboard.data?.maengelOffen ?? 0} offene Mängel</p>
<p className="text-xs text-[var(--muted-foreground)]">gemeldet, zugewiesen, in Arbeit</p>
</div>
</Link>
<Link
href="/rueckrufe"
className="flex items-center gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-colors hover:border-[var(--primary)]"
>
<CalendarClock className="size-5 text-[var(--muted-foreground)]" aria-hidden />
<div>
<p className="text-sm font-medium">
{dashboard.data?.rueckrufeOffen ?? 0} offene Rückrufe
</p>
<p className="text-xs text-[var(--muted-foreground)]">Herstellerwarnungen in Arbeit</p>
</div>
</Link>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="dashboardmeldung" />}
{dashboard.error && <Fehlermeldung meldung={dashboard.error.message} />}
{/* Bündelung */}
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Verteilung</CardTitle>
<CardDescription>Wo die offenen Termine hängen.</CardDescription>
</div>
<Select
value={gruppierung}
onValueChange={(wert) => setGruppierung(wert as Gruppierung)}
>
<SelectTrigger className="w-52" aria-label="Bündelung wählen">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GRUPPIERUNGEN.map((eintrag) => (
<SelectItem key={eintrag.wert} value={eintrag.wert}>
{eintrag.text}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Bezeichnung</TableHead>
<TableHead className="text-right">überfällig</TableHead>
<TableHead className="text-right">diese Woche</TableHead>
<TableHead className="text-right">diesen Monat</TableHead>
<TableHead className="text-right">gesamt offen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboard.isLoading && <LadeZeilen spalten={5} zeilen={4} />}
{dashboard.data?.gruppen.map((gruppe) => (
<TableRow key={gruppe.schluessel}>
<TableCell className="font-medium">{gruppe.bezeichnung}</TableCell>
<TableCell className="text-right tabellenzahlen">
{gruppe.fenster.ueberfaellig > 0 ? (
<span className="font-medium text-[var(--gesperrt)]">
{gruppe.fenster.ueberfaellig}
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{gruppe.fenster.diese_woche || "—"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{gruppe.fenster.dieser_monat || "—"}
</TableCell>
<TableCell className="text-right tabellenzahlen font-medium">
{gruppe.gesamt}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!dashboard.isLoading && (dashboard.data?.gruppen.length ?? 0) === 0 && (
<div className="p-4">
<NichtsGefunden
titel="Keine offenen Prüftermine"
hinweis={
darfPlanen
? "Termine entstehen aus den Wartungsvorlagen — der nächtliche Lauf legt sie an. Ohne Vorlage gibt es keine Termine."
: "Sobald Termine anstehen, erscheinen sie hier."
}
aktion={
darfPlanen ? (
<Button asChild variant="outline">
<Link href="/pruefungen/vorlagen">Vorlagen einrichten</Link>
</Button>
) : undefined
}
/>
</div>
)}
</CardContent>
</Card>
{/* Terminliste */}
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>
Offene Termine
{fenster && (
<span className="ml-2 text-sm font-normal text-[var(--muted-foreground)]">
gefiltert auf „{fenster.replace(/_/g, " ")}"
</span>
)}
</CardTitle>
<CardDescription>
{termine.data?.gesamt ?? 0} Termine · nach Fälligkeit sortiert
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
{fenster && (
<Button variant="ghost" size="sm" onClick={() => setFenster(null)}>
Filter aufheben
</Button>
)}
{darfPlanen && ausgewaehlt.size > 0 && (
<Select
onValueChange={(wert) =>
zuweisen.mutate({
ids: [...ausgewaehlt],
zugewiesenAnId: wert === "niemand" ? null : wert,
})
}
>
<SelectTrigger className="h-9 w-64" aria-label="Ausgewählte Termine zuweisen">
<SelectValue placeholder={`${ausgewaehlt.size} Termine zuweisen an …`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="niemand">niemandem</SelectItem>
{mitglieder.data?.eintraege.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
{darfPlanen && <TableHead className="w-10" aria-label="Auswahl" />}
<TableHead>Gerät</TableHead>
<TableHead>Prüfung</TableHead>
<TableHead>Lagerung</TableHead>
<TableHead>Zuständig</TableHead>
<TableHead className="text-right">fällig</TableHead>
<TableHead className="w-32" aria-label="Aktion" />
</TableRow>
</TableHeader>
<TableBody>
{termine.isLoading && <LadeZeilen spalten={darfPlanen ? 7 : 6} />}
{eintraege.map((termin) => (
<TableRow key={termin.id}>
{darfPlanen && (
<TableCell>
<Checkbox
checked={ausgewaehlt.has(termin.id)}
onCheckedChange={() => auswahlUmschalten(termin.id)}
aria-label={`Termin für ${termin.geraet.inventarnummer} auswählen`}
/>
</TableCell>
)}
<TableCell>
<Link
href={`/geraete/${termin.geraet.id}`}
className="font-medium underline-offset-4 hover:underline"
>
<span className="tabellenzahlen">{termin.geraet.inventarnummer}</span>
</Link>
<div className="text-xs text-[var(--muted-foreground)]">
{termin.geraet.bezeichnung}
</div>
<div className="mt-1">
<StatusAbzeichen status={termin.geraet.status} />
</div>
</TableCell>
<TableCell className="text-sm">
{termin.vorlage?.name ?? "ohne Vorlage"}
{termin.vorlage?.pruefvorschrift && (
<div className="text-xs text-[var(--muted-foreground)]">
{termin.vorlage.pruefvorschrift}
</div>
)}
</TableCell>
<TableCell className="text-sm">
{termin.geraet.fahrzeug
? `${termin.geraet.fahrzeug.funkrufname}${
termin.geraet.geraeteraum
? ` · ${termin.geraet.geraeteraum.bezeichnung}`
: ""
}`
: (termin.geraet.standort?.name ?? "—")}
</TableCell>
<TableCell className="text-sm">
{termin.zugewiesenAn ? (
`${termin.zugewiesenAn.nachname}, ${termin.zugewiesenAn.vorname}`
) : (
<span className="inline-flex items-center gap-1 text-[var(--muted-foreground)]">
<Users className="size-3.5" aria-hidden />
offen
</span>
)}
</TableCell>
<TableCell className="text-right">
<FristAnzeige datum={termin.faelligAm} vorwarnTage={30} />
<div className="mt-1">
<FensterAbzeichen fenster={termin.fenster as Fristfenster} />
</div>
</TableCell>
<TableCell>
{darfPruefen && (
<Button size="sm" asChild>
<Link
href={`/pruefungen/neu?geraetId=${termin.geraet.id}&terminId=${termin.id}`}
>
Prüfen
</Link>
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!termine.isLoading && eintraege.length === 0 && (
<div className="p-4">
<NichtsGefunden
titel={fenster ? "In diesem Zeitraum steht nichts an" : "Keine offenen Termine"}
hinweis={
fenster
? "Ein anderer Zeitraum zeigt vielleicht mehr."
: "Alles geprüft — oder es sind noch keine Vorlagen eingerichtet."
}
/>
</div>
)}
<p className="mt-3 text-xs text-[var(--muted-foreground)]">
Stand: {alsDatum(dashboard.data?.stand ?? new Date())}. Termine entstehen nachts aus den
Wartungsvorlagen; überfällig wird jeden Tag neu gerechnet, nicht gespeichert.
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,171 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { Archive } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
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 {
Fehlermeldung,
FristAnzeige,
LadeZeilen,
NichtsGefunden,
StatusAbzeichen,
} from "@/components/stammdaten/anzeige";
/**
* Geräte am Ende der Nutzungsdauer.
*
* Der Vorschlag zur Aussonderung ist bewusst ein Knopf und kein Automatismus:
* ein abgelaufener Helm wird gesperrt (das macht der nächtliche Lauf), aber
* ausgesondert wird er von einem Menschen, der weiß, ob Ersatz da ist.
*/
export function LebensdauerListe({ darfAussondern }: { darfAussondern: boolean }) {
const [vorwarnTage, setVorwarnTage] = React.useState(180);
const [meldung, setMeldung] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const geraete = trpc.wartungstermin.lebensdauer.useQuery({ vorwarnTage });
const aussondern = trpc.geraet.loeschen.useMutation({
onSuccess: () => {
setMeldung("Gerät ausgesondert.");
void werkzeuge.wartungstermin.lebensdauer.invalidate();
void werkzeuge.geraet.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = geraete.data ?? [];
const abgelaufen = eintraege.filter((geraet) => geraet.abgelaufen).length;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="space-y-1">
<Label htmlFor="vorwarn">Zeitraum</Label>
<Select
value={String(vorwarnTage)}
onValueChange={(wert) => setVorwarnTage(Number(wert))}
>
<SelectTrigger id="vorwarn" className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">nur bereits abgelaufen</SelectItem>
<SelectItem value="90">nächste 90 Tage</SelectItem>
<SelectItem value="180">nächste 6 Monate</SelectItem>
<SelectItem value="365">nächstes Jahr</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
{abgelaufen} abgelaufen · {eintraege.length - abgelaufen} laufen demnächst ab
</p>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="lebensdauermeldung" />}
{geraete.error && <Fehlermeldung meldung={geraete.error.message} />}
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Gerät</TableHead>
<TableHead>Kategorie</TableHead>
<TableHead>Lagerung</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">nutzbar bis</TableHead>
<TableHead className="w-40" aria-label="Aktion" />
</TableRow>
</TableHeader>
<TableBody>
{geraete.isLoading && <LadeZeilen spalten={6} />}
{eintraege.map((geraet) => (
<TableRow key={geraet.id}>
<TableCell>
<Link
href={`/geraete/${geraet.id}`}
className="font-medium underline-offset-4 hover:underline"
>
<span className="tabellenzahlen">{geraet.inventarnummer}</span>
</Link>
<div className="text-xs text-[var(--muted-foreground)]">{geraet.bezeichnung}</div>
</TableCell>
<TableCell className="text-sm">
{geraet.lebensdauerKategorie ??
geraet.geraetetyp?.lebensdauerKategorie ??
"—"}
</TableCell>
<TableCell className="text-sm">
{geraet.fahrzeug?.funkrufname ?? geraet.standort?.name ?? "—"}
</TableCell>
<TableCell>
<StatusAbzeichen status={geraet.status} />
</TableCell>
<TableCell className="text-right">
<FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={vorwarnTage || 90} />
</TableCell>
<TableCell>
{darfAussondern && geraet.abgelaufen && geraet.status !== "AUSGESONDERT" && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (
window.confirm(
`${geraet.inventarnummer} aussondern? Das Gerät bleibt mit allen Prüfungen erhalten, verschwindet aber aus dem Bestand.`,
)
) {
aussondern.mutate({
id: geraet.id,
grund: "Nutzungsdauer abgelaufen",
});
}
}}
>
<Archive aria-hidden />
Aussondern
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!geraete.isLoading && eintraege.length === 0 && (
<div className="p-6">
<NichtsGefunden
titel="Nichts läuft ab"
hinweis="In diesem Zeitraum erreicht kein Gerät das Ende seiner Nutzungsdauer. Voraussetzung dafür ist, dass am Gerätetyp eine Lebensdauer hinterlegt ist."
/>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,41 @@
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 { LebensdauerListe } from "@/app/(app)/pruefungen/lebensdauer/lebensdauer-liste";
export const metadata = { title: "Lebensdauer" };
/**
* Lebensdauerüberwachung.
*
* Helme, Seile, Schutzkleidung und Pressluftatmer sind ab einem Stichtag nicht
* mehr zugelassen — unabhängig davon, wie gut sie aussehen. Diese Seite zeigt,
* was abgelaufen ist und was demnächst abläuft, und schlägt die Aussonderung
* vor.
*/
export default async function LebensdauerSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/pruefungen">
<ArrowLeft aria-hidden />
Zurück zu den Fristen
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Lebensdauer</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Geräte am Ende ihrer zulässigen Nutzungsdauer — unabhängig vom Zustand.
</p>
</div>
<LebensdauerListe darfAussondern={hatRecht(sitzung, "geraet.aussondern")} />
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { PruefWizard } from "@/app/(app)/pruefungen/neu/pruef-wizard";
export const metadata = { title: "Prüfung durchführen" };
/**
* Der Prüfwizard.
*
* Aufgerufen wird er aus dem Fristen-Dashboard (mit Termin) oder von der
* Gerätedetailseite (nur mit Gerät). Beides kommt als Suchparameter herein —
* so lässt sich der Vorgang auch über einen QR-Scan am Regal starten.
*/
export default async function NeuePruefungSeite({
searchParams,
}: {
searchParams: Promise<{ geraetId?: string; terminId?: string; vorlageId?: string }>;
}) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "pruefung.durchfuehren")) {
redirect("/pruefungen");
}
const { geraetId, terminId, vorlageId } = await searchParams;
if (!geraetId) redirect("/pruefungen");
return (
<PruefWizard
geraetId={geraetId}
terminId={terminId ?? null}
vorlageId={vorlageId ?? null}
prueferName={sitzung.name}
prueferId={sitzung.personId}
/>
);
}
@@ -0,0 +1,711 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import {
Check,
ChevronLeft,
ChevronRight,
Clock,
TriangleAlert,
X,
} from "lucide-react";
import { ChecklistenpunktTyp, MangelPrioritaet, PruefErgebnis, PruefungArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { DateiFeld } from "@/components/wartung/datei-feld";
import { UnterschriftFeld } from "@/components/wartung/unterschrift";
import { ERGEBNIS_TEXT } from "@/lib/wartung/texte";
/**
* Prüfwizard — Schritt für Schritt durch die Checkliste.
*
* Der Ablauf ist auf das Tablet im Gerätehaus zugeschnitten:
*
* * Ein Punkt je Bildschirm, große Schaltflächen. Wer Handschuhe trägt,
* trifft keine 24-Pixel-Kästchen.
* * Jede Antwort geht sofort zum Server. Bricht das WLAN weg, ist höchstens
* die letzte Antwort verloren, nicht die ganze Prüfung.
* * Die Zeit läuft im Hintergrund mit und landet im Protokoll — sie ist
* Bestandteil des Nachweises, wie gründlich geprüft wurde.
*
* Am Ende steht der Abschluss: Ergebnis, Mängel, Unterschrift. Danach ist das
* Protokoll festgeschrieben; Korrekturen erzeugen ein neues.
*/
interface Antwortstand {
jaNein?: boolean | null;
zahlwert?: string;
text?: string;
fotoDateiId?: string | null;
bemerkung?: string;
inOrdnung?: boolean | null;
}
interface MangelEntwurf {
beschreibung: string;
prioritaet: MangelPrioritaet;
fotoDateiId?: string | null;
}
function verstricheneZeit(seit: Date | null): string {
if (!seit) return "0:00";
const sekunden = Math.max(0, Math.floor((Date.now() - seit.getTime()) / 1000));
const minuten = Math.floor(sekunden / 60);
return `${minuten}:${String(sekunden % 60).padStart(2, "0")}`;
}
export function PruefWizard({
geraetId,
terminId,
vorlageId,
prueferName,
prueferId,
}: {
geraetId: string;
terminId: string | null;
vorlageId: string | null;
prueferName: string;
prueferId: string;
}) {
const router = useRouter();
const [pruefungId, setPruefungId] = React.useState<string | null>(null);
const [schritt, setSchritt] = React.useState(0);
const [antworten, setAntworten] = React.useState<Record<string, Antwortstand>>({});
const [fehler, setFehler] = React.useState<string | null>(null);
// Abschlussangaben
const [ergebnis, setErgebnis] = React.useState<PruefErgebnis | null>(null);
const [bemerkung, setBemerkung] = React.useState("");
const [maengel, setMaengel] = React.useState<MangelEntwurf[]>([]);
const [signaturDateiId, setSignaturDateiId] = React.useState<string | null>(null);
const [art, setArt] = React.useState<PruefungArt>(PruefungArt.INTERN);
const [externeFirma, setExterneFirma] = React.useState("");
const [kosten, setKosten] = React.useState("");
const [fremdprotokollId, setFremdprotokollId] = React.useState<string | null>(null);
const [uhr, setUhr] = React.useState(0);
const gestartet = React.useRef(false);
const werkzeuge = trpc.useUtils();
const starten = trpc.pruefung.starten.useMutation({
onSuccess: (ergebnisDaten) => {
const pruefung = ergebnisDaten.pruefung;
setPruefungId(pruefung.id);
setArt(pruefung.art);
// Fortgesetzter Entwurf: bereits gegebene Antworten übernehmen.
const vorhandene: Record<string, Antwortstand> = {};
for (const eintrag of pruefung.ergebnisse) {
vorhandene[eintrag.punktId] = {
jaNein: eintrag.jaNein,
zahlwert: eintrag.zahlwert ? String(eintrag.zahlwert) : "",
text: eintrag.text ?? "",
fotoDateiId: eintrag.fotoDateiId,
bemerkung: eintrag.bemerkung ?? "",
};
}
setAntworten(vorhandene);
},
onError: (problem) => setFehler(problem.message),
});
// Genau einmal starten — React ruft Effekte in der Entwicklung doppelt auf.
React.useEffect(() => {
if (gestartet.current) return;
gestartet.current = true;
starten.mutate({
geraetId,
terminId,
vorlageId,
art: PruefungArt.INTERN,
});
}, [geraetId, terminId, vorlageId, starten]);
const pruefung = starten.data?.pruefung;
const punkte = React.useMemo(() => pruefung?.vorlage?.punkte ?? [], [pruefung]);
const begonnenUm = pruefung?.begonnenUm ? new Date(pruefung.begonnenUm) : null;
React.useEffect(() => {
const zeitgeber = setInterval(() => setUhr((wert) => wert + 1), 1000);
return () => clearInterval(zeitgeber);
}, []);
const speichern = trpc.pruefung.ergebnisSpeichern.useMutation({
onError: (problem) => setFehler(problem.message),
});
const abschliessen = trpc.pruefung.abschliessen.useMutation({
onSuccess: (fertig) => {
void werkzeuge.wartungstermin.invalidate();
void werkzeuge.geraet.invalidate();
router.push(`/pruefungen/${fertig.id}`);
},
onError: (problem) => setFehler(problem.message),
});
function antwortSetzen(punktId: string, teil: Partial<Antwortstand>) {
setAntworten((alt) => ({ ...alt, [punktId]: { ...alt[punktId], ...teil } }));
}
/** Antwort zum aktuellen Punkt zum Server schicken. */
async function punktSpeichern(punktId: string) {
if (!pruefungId) return;
const antwort = antworten[punktId];
if (!antwort) return;
await speichern.mutateAsync({
pruefungId,
punktId,
jaNein: antwort.jaNein ?? null,
zahlwert:
antwort.zahlwert !== undefined && antwort.zahlwert !== ""
? Number(antwort.zahlwert)
: null,
text: antwort.text || null,
fotoDateiId: antwort.fotoDateiId ?? null,
inOrdnung: antwort.inOrdnung ?? null,
bemerkung: antwort.bemerkung || null,
});
}
async function weiter() {
setFehler(null);
const punkt = punkte[schritt];
if (punkt) await punktSpeichern(punkt.id);
setSchritt((wert) => Math.min(wert + 1, punkte.length));
}
function zurueck() {
setFehler(null);
setSchritt((wert) => Math.max(0, wert - 1));
}
/** Mängel aus den beanstandeten Punkten vorschlagen. */
function maengelVorschlagen() {
const vorschlaege: MangelEntwurf[] = [];
for (const punkt of punkte) {
const antwort = antworten[punkt.id];
if (!antwort) continue;
const nichtInOrdnung =
antwort.inOrdnung === false ||
(punkt.typ === ChecklistenpunktTyp.JA_NEIN && antwort.jaNein === false);
if (!nichtInOrdnung) continue;
vorschlaege.push({
beschreibung: `${punkt.text}${antwort.bemerkung ? `: ${antwort.bemerkung}` : ""}`,
prioritaet: punkt.pflicht ? MangelPrioritaet.KRITISCH : MangelPrioritaet.MITTEL,
fotoDateiId: antwort.fotoDateiId ?? null,
});
}
setMaengel((alt) => [...alt, ...vorschlaege]);
}
const amAbschluss = schritt >= punkte.length;
// Vorschlag für das Gesamtergebnis, sobald der Abschluss erreicht ist.
const stand = trpc.pruefung.stand.useQuery(
{ id: pruefungId ?? "" },
{ enabled: Boolean(pruefungId) && amAbschluss },
);
React.useEffect(() => {
if (amAbschluss && stand.data && ergebnis === null) {
setErgebnis(stand.data.vorschlag as PruefErgebnis);
if (stand.data.vorschlag !== "BESTANDEN" && maengel.length === 0) maengelVorschlagen();
}
// maengelVorschlagen hängt nur an bereits gesetztem Zustand.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [amAbschluss, stand.data]);
if (starten.isPending || !pruefung) {
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold tracking-tight">Prüfung wird vorbereitet …</h1>
{fehler && <Fehlermeldung meldung={fehler} />}
</div>
);
}
const punkt = punkte[schritt];
const antwort = punkt ? (antworten[punkt.id] ?? {}) : {};
const fortschritt = punkte.length === 0 ? 100 : Math.round((schritt / punkte.length) * 100);
return (
<div className="mx-auto max-w-3xl space-y-6">
{/* Kopf: Gerät, Uhr, Fortschritt */}
<div>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h1 className="text-2xl font-semibold tracking-tight">
{pruefung.geraet.bezeichnung}
</h1>
<span className="inline-flex items-center gap-1.5 text-sm text-[var(--muted-foreground)]">
<Clock className="size-4" aria-hidden />
<span className="tabellenzahlen" aria-live="off">
{verstricheneZeit(begonnenUm)}
</span>
<span className="sr-only">seit Beginn der Prüfung ({uhr} Sekunden aktualisiert)</span>
</span>
</div>
<p className="text-sm text-[var(--muted-foreground)]">
<span className="tabellenzahlen">{pruefung.geraet.inventarnummer}</span>
{pruefung.vorlage && ` · ${pruefung.vorlage.name}`}
{starten.data?.fortgesetzt && " · angefangene Prüfung wird fortgesetzt"}
</p>
<div
className="mt-3 h-2 overflow-hidden rounded-full bg-[var(--muted)]"
role="progressbar"
aria-valuenow={fortschritt}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Fortschritt der Prüfung"
>
<div
className="h-full bg-[var(--primary)] transition-all"
style={{ width: `${fortschritt}%` }}
/>
</div>
</div>
{fehler && <Fehlermeldung meldung={fehler} kennung="wizardfehler" />}
{punkte.length === 0 && (
<Fehlermeldung
meldung="Zu dieser Prüfung gibt es keine Checkliste. Ohne Prüfpunkte lässt sich nichts dokumentieren — bitte erst eine Wartungsvorlage mit Punkten anlegen."
kennung="ohnecheckliste"
/>
)}
{/* Ein Prüfpunkt */}
{!amAbschluss && punkt && (
<Card>
<CardHeader>
<CardDescription>
Schritt {schritt + 1} von {punkte.length}
{punkt.pflicht && " · Pflichtpunkt"}
</CardDescription>
<CardTitle className="text-xl">{punkt.text}</CardTitle>
{punkt.hinweis && (
<CardDescription>{punkt.hinweis}</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-5">
{punkt.sollwert && (
<p className="rounded-md bg-[var(--muted)] px-3 py-2 text-sm">
Soll: <strong>{punkt.sollwert}</strong>
</p>
)}
{punkt.typ === ChecklistenpunktTyp.JA_NEIN && (
<div className="grid grid-cols-2 gap-3">
<Button
type="button"
variant={antwort.jaNein === true ? "default" : "outline"}
className="h-16 text-base"
onClick={() => antwortSetzen(punkt.id, { jaNein: true, inOrdnung: null })}
>
<Check aria-hidden />
in Ordnung
</Button>
<Button
type="button"
variant={antwort.jaNein === false ? "destructive" : "outline"}
className="h-16 text-base"
onClick={() => antwortSetzen(punkt.id, { jaNein: false, inOrdnung: null })}
>
<X aria-hidden />
nicht in Ordnung
</Button>
</div>
)}
{punkt.typ === ChecklistenpunktTyp.MESSWERT && (
<div className="space-y-2">
<Label htmlFor="messwert">
Messwert{punkt.einheit ? ` in ${punkt.einheit}` : ""}
</Label>
<Input
id="messwert"
type="number"
step="any"
inputMode="decimal"
className="h-14 text-lg"
value={antwort.zahlwert ?? ""}
onChange={(ereignis) =>
antwortSetzen(punkt.id, { zahlwert: ereignis.target.value })
}
/>
{(punkt.minWert !== null || punkt.maxWert !== null) && (
<p className="text-xs text-[var(--muted-foreground)]">
zulässig {punkt.minWert !== null ? String(punkt.minWert) : "…"} bis{" "}
{punkt.maxWert !== null ? String(punkt.maxWert) : "…"}
{punkt.einheit ? ` ${punkt.einheit}` : ""} — außerhalb gilt der Punkt als
beanstandet.
</p>
)}
</div>
)}
{punkt.typ === ChecklistenpunktTyp.TEXT && (
<div className="space-y-2">
<Label htmlFor="freitext">Befund</Label>
<Textarea
id="freitext"
rows={4}
value={antwort.text ?? ""}
onChange={(ereignis) => antwortSetzen(punkt.id, { text: ereignis.target.value })}
/>
</div>
)}
{punkt.typ === ChecklistenpunktTyp.FOTO && (
<div className="space-y-2">
<Label>Foto</Label>
<DateiFeld
entitaet="Pruefung"
entitaetId={pruefungId ?? undefined}
kategorie="foto"
dateiId={antwort.fotoDateiId ?? null}
onHochgeladen={(dateiId) => antwortSetzen(punkt.id, { fotoDateiId: dateiId })}
/>
</div>
)}
{/* Foto und Bemerkung sind zu jedem Punkt möglich, nicht nur beim Fototyp. */}
{punkt.typ !== ChecklistenpunktTyp.FOTO && (
<div className="space-y-2">
<Label>Foto (freiwillig)</Label>
<DateiFeld
entitaet="Pruefung"
entitaetId={pruefungId ?? undefined}
kategorie="foto"
dateiId={antwort.fotoDateiId ?? null}
onHochgeladen={(dateiId) => antwortSetzen(punkt.id, { fotoDateiId: dateiId })}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="punktbemerkung">Bemerkung</Label>
<Input
id="punktbemerkung"
value={antwort.bemerkung ?? ""}
onChange={(ereignis) =>
antwortSetzen(punkt.id, { bemerkung: ereignis.target.value })
}
placeholder="Was aufgefallen ist"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={antwort.inOrdnung === false}
onCheckedChange={(an) =>
antwortSetzen(punkt.id, { inOrdnung: an === true ? false : null })
}
/>
Trotz Wert beanstanden — der Prüfer sieht mehr als die Zahl
</label>
</CardContent>
</Card>
)}
{/* Abschluss */}
{amAbschluss && (
<div className="space-y-6">
{stand.data && stand.data.offenePflichtpunkte.length > 0 && (
<Fehlermeldung
kennung="offenepflichtpunkte"
meldung={`Es fehlen noch Pflichtangaben: ${stand.data.offenePflichtpunkte
.map((offen) => offen.text)
.join(", ")}`}
/>
)}
<Card>
<CardHeader>
<CardTitle>Ergebnis</CardTitle>
<CardDescription>
Vorgeschlagen ist {stand.data ? ERGEBNIS_TEXT[stand.data.vorschlag] : "…"} —
{" "}
{stand.data?.beantwortet ?? 0} von {stand.data?.gesamt ?? punkte.length} Punkten
beantwortet. Die Entscheidung trifft der Prüfer.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 sm:grid-cols-3">
{(Object.keys(ERGEBNIS_TEXT) as PruefErgebnis[]).map((wert) => (
<Button
key={wert}
type="button"
variant={ergebnis === wert ? "default" : "outline"}
className="h-14"
onClick={() => setErgebnis(wert)}
>
{ERGEBNIS_TEXT[wert]}
</Button>
))}
</div>
<div className="space-y-2">
<Label htmlFor="abschlussbemerkung">Bemerkung zum Protokoll</Label>
<Textarea
id="abschlussbemerkung"
rows={3}
value={bemerkung}
onChange={(ereignis) => setBemerkung(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="art">Durchgeführt</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as PruefungArt)}>
<SelectTrigger id="art">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={PruefungArt.INTERN}>intern durch die Wehr</SelectItem>
<SelectItem value={PruefungArt.EXTERN}>extern durch eine Fachfirma</SelectItem>
</SelectContent>
</Select>
</div>
{art === PruefungArt.EXTERN && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="firma">Fachfirma</Label>
<Input
id="firma"
value={externeFirma}
onChange={(ereignis) => setExterneFirma(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="kosten">Kosten in Euro</Label>
<Input
id="kosten"
type="number"
step="0.01"
min={0}
value={kosten}
onChange={(ereignis) => setKosten(ereignis.target.value)}
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Fremdprotokoll als PDF</Label>
<DateiFeld
entitaet="Pruefung"
entitaetId={pruefungId ?? undefined}
kategorie="fremdprotokoll"
nurBilder={false}
beschriftung="Protokoll der Fachfirma anhängen"
dateiId={fremdprotokollId}
onHochgeladen={setFremdprotokollId}
/>
</div>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
<div>
<CardTitle>Mängel</CardTitle>
<CardDescription>
Was aufgefallen ist und noch behoben werden muss. Kritische Mängel sperren das
Gerät.
</CardDescription>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setMaengel((alt) => [
...alt,
{ beschreibung: "", prioritaet: MangelPrioritaet.MITTEL },
])
}
>
<TriangleAlert aria-hidden />
Mangel ergänzen
</Button>
</CardHeader>
<CardContent className="space-y-3">
{maengel.length === 0 && (
<p className="text-sm text-[var(--muted-foreground)]">
Kein Mangel erfasst.
</p>
)}
{maengel.map((mangel, index) => (
<div
key={index}
className="space-y-2 rounded-md border border-[var(--border)] p-3"
>
<Textarea
rows={2}
value={mangel.beschreibung}
onChange={(ereignis) =>
setMaengel((alt) =>
alt.map((eintrag, position) =>
position === index
? { ...eintrag, beschreibung: ereignis.target.value }
: eintrag,
),
)
}
placeholder="Was ist beschädigt, fehlt oder funktioniert nicht?"
aria-label={`Beschreibung des Mangels ${index + 1}`}
/>
<div className="flex flex-wrap items-center gap-2">
<Select
value={mangel.prioritaet}
onValueChange={(wert) =>
setMaengel((alt) =>
alt.map((eintrag, position) =>
position === index
? { ...eintrag, prioritaet: wert as MangelPrioritaet }
: eintrag,
),
)
}
>
<SelectTrigger
className="w-44"
aria-label={`Dringlichkeit des Mangels ${index + 1}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={MangelPrioritaet.NIEDRIG}>niedrig</SelectItem>
<SelectItem value={MangelPrioritaet.MITTEL}>mittel</SelectItem>
<SelectItem value={MangelPrioritaet.HOCH}>hoch</SelectItem>
<SelectItem value={MangelPrioritaet.KRITISCH}>
kritisch — sperrt das Gerät
</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setMaengel((alt) => alt.filter((_, position) => position !== index))
}
>
<X aria-hidden />
Entfernen
</Button>
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Unterschrift</CardTitle>
<CardDescription>
Ohne Unterschrift ist ein Protokoll kein Nachweis. Mit dem Abschluss wird es
festgeschrieben — Korrekturen erzeugen danach ein neues Protokoll.
</CardDescription>
</CardHeader>
<CardContent>
<UnterschriftFeld
name={prueferName}
rolle="Prüfer"
pruefungId={pruefungId ?? undefined}
onFertig={setSignaturDateiId}
uebernommen={Boolean(signaturDateiId)}
/>
</CardContent>
</Card>
</div>
)}
{/* Steuerung */}
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-[var(--border)] pt-4">
<Button type="button" variant="outline" onClick={zurueck} disabled={schritt === 0}>
<ChevronLeft aria-hidden />
Zurück
</Button>
{!amAbschluss ? (
<Button type="button" onClick={weiter} disabled={speichern.isPending}>
{speichern.isPending ? "wird gespeichert …" : "Weiter"}
<ChevronRight aria-hidden />
</Button>
) : (
<Button
type="button"
disabled={
abschliessen.isPending ||
!ergebnis ||
!signaturDateiId ||
(stand.data?.offenePflichtpunkte.length ?? 0) > 0
}
onClick={() => {
if (!pruefungId || !ergebnis || !signaturDateiId) return;
setFehler(null);
abschliessen.mutate({
id: pruefungId,
ergebnis,
bemerkung: bemerkung || null,
externeFirma: art === PruefungArt.EXTERN ? externeFirma || null : null,
externeProtokollDateiId: art === PruefungArt.EXTERN ? fremdprotokollId : null,
kosten: art === PruefungArt.EXTERN && kosten ? Number(kosten) : null,
signaturen: [
{
rolle: "PRUEFER",
name: prueferName,
personId: prueferId,
bildDateiId: signaturDateiId,
},
],
maengel: maengel
.filter((mangel) => mangel.beschreibung.trim().length > 0)
.map((mangel) => ({
beschreibung: mangel.beschreibung,
prioritaet: mangel.prioritaet,
fotoDateiId: mangel.fotoDateiId ?? null,
})),
});
}}
>
<Check aria-hidden />
{abschliessen.isPending ? "wird abgeschlossen …" : "Prüfung abschließen"}
</Button>
)}
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ClipboardList, ListChecks, ShieldAlert, TriangleAlert } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button";
import { FristenDashboard } from "@/app/(app)/pruefungen/fristen-dashboard";
export const metadata = { title: "Prüfungen" };
/**
* Das Fristen-Dashboard — die Startseite des Gerätewarts.
*
* Die Rechteprüfung passiert hier auf dem Server; die Client-Komponente bekommt
* nur das Ergebnis. So bleibt die Regel aus CLAUDE.md gewahrt: keine
* Rollenprüfung in Komponenten.
*/
export default async function PruefungenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const darfPlanen = hatRecht(sitzung, "pruefung.planen");
const darfPruefen = hatRecht(sitzung, "pruefung.durchfuehren");
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Prüfungen und Fristen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Was ansteht, was überfällig ist und was demnächst aus der Nutzungsdauer läuft.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href="/maengel">
<TriangleAlert aria-hidden />
Mängel
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/rueckrufe">
<ShieldAlert aria-hidden />
Rückrufe
</Link>
</Button>
{darfPlanen && (
<Button variant="outline" asChild>
<Link href="/pruefungen/vorlagen">
<ListChecks aria-hidden />
Vorlagen
</Link>
</Button>
)}
{darfPruefen && (
<Button asChild>
<Link href="/pruefungen/verlauf">
<ClipboardList aria-hidden />
Protokolle
</Link>
</Button>
)}
</div>
</div>
<FristenDashboard darfPlanen={darfPlanen} darfPruefen={darfPruefen} />
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { ProtokollListe } from "@/app/(app)/pruefungen/verlauf/protokoll-liste";
export const metadata = { title: "Prüfprotokolle" };
/**
* Alle Prüfprotokolle der Wehr.
*
* Der Nachweis nach außen: wenn die Unfallkasse fragt, wird hier gesucht —
* nach Gerät, nach Zeitraum, nach Ergebnis.
*/
export default async function VerlaufSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/pruefungen">
<ArrowLeft aria-hidden />
Zurück zu den Fristen
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Prüfprotokolle</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Abgeschlossene Prüfungen mit Ergebnis, Prüfer und PDF.
</p>
</div>
<ProtokollListe />
</div>
);
}
@@ -0,0 +1,186 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { PruefErgebnis, PruefungStatus } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { ErgebnisAbzeichen, PruefungStatusAbzeichen } from "@/components/wartung/anzeige";
import { ERGEBNIS_TEXT } from "@/lib/wartung/texte";
import { alsDatum, alsEuro } from "@/lib/utils";
/**
* Liste der Protokolle.
*
* Entwürfe stehen mit drin, aber gekennzeichnet: eine angefangene Prüfung ist
* kein Nachweis, und wer eine sucht, um sie zu Ende zu bringen, findet sie hier.
*/
export function ProtokollListe() {
const [ergebnis, setErgebnis] = React.useState<PruefErgebnis | "alle">("alle");
const [vonAm, setVonAm] = React.useState("");
const [bisAm, setBisAm] = React.useState("");
const pruefungen = trpc.pruefung.liste.useQuery({
anzahl: 200,
ergebnis: ergebnis === "alle" ? undefined : [ergebnis],
vonAm: vonAm ? new Date(vonAm) : undefined,
bisAm: bisAm ? new Date(bisAm) : undefined,
});
const eintraege = pruefungen.data ?? [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label htmlFor="ergebnisfilter">Ergebnis</Label>
<Select
value={ergebnis}
onValueChange={(wert) => setErgebnis(wert as PruefErgebnis | "alle")}
>
<SelectTrigger id="ergebnisfilter" className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">alle Ergebnisse</SelectItem>
{(Object.keys(ERGEBNIS_TEXT) as PruefErgebnis[]).map((wert) => (
<SelectItem key={wert} value={wert}>
{ERGEBNIS_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="von">geprüft ab</Label>
<Input
id="von"
type="date"
value={vonAm}
onChange={(ereignis) => setVonAm(ereignis.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="bis">geprüft bis</Label>
<Input
id="bis"
type="date"
value={bisAm}
onChange={(ereignis) => setBisAm(ereignis.target.value)}
/>
</div>
</div>
{pruefungen.error && <Fehlermeldung meldung={pruefungen.error.message} />}
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Protokoll</TableHead>
<TableHead>Gerät</TableHead>
<TableHead>Prüfung</TableHead>
<TableHead>Prüfer</TableHead>
<TableHead>Ergebnis</TableHead>
<TableHead className="text-right">geprüft am</TableHead>
<TableHead className="text-right">Kosten</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pruefungen.isLoading && <LadeZeilen spalten={7} />}
{eintraege.map((pruefung) => (
<TableRow key={pruefung.id}>
<TableCell className="tabellenzahlen font-medium">
<Link
href={`/pruefungen/${pruefung.id}`}
className="underline-offset-4 hover:underline"
>
{pruefung.protokollNummer ?? "Entwurf"}
</Link>
{pruefung.status !== PruefungStatus.ABGESCHLOSSEN && (
<div className="mt-1">
<PruefungStatusAbzeichen status={pruefung.status} />
</div>
)}
</TableCell>
<TableCell>
<Link
href={`/geraete/${pruefung.geraet.id}`}
className="underline-offset-4 hover:underline"
>
<span className="tabellenzahlen">{pruefung.geraet.inventarnummer}</span>
</Link>
<div className="text-xs text-[var(--muted-foreground)]">
{pruefung.geraet.bezeichnung}
</div>
</TableCell>
<TableCell className="text-sm">
{pruefung.vorlage?.name ?? "—"}
{pruefung.art === "EXTERN" && (
<div className="text-xs text-[var(--muted-foreground)]">
extern: {pruefung.externeFirma ?? "Fachfirma"}
</div>
)}
</TableCell>
<TableCell className="text-sm">
{pruefung.pruefer
? `${pruefung.pruefer.nachname}, ${pruefung.pruefer.vorname}`
: (pruefung.prueferName ?? "—")}
</TableCell>
<TableCell>
<ErgebnisAbzeichen ergebnis={pruefung.ergebnis} />
{pruefung._count.maengel > 0 && (
<div className="mt-1 text-xs text-[var(--muted-foreground)]">
{pruefung._count.maengel} Mängel
</div>
)}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{alsDatum(pruefung.durchgefuehrtAm)}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{pruefung.kosten ? alsEuro(Number(pruefung.kosten)) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!pruefungen.isLoading && eintraege.length === 0 && (
<div className="p-6">
<NichtsGefunden
titel="Noch kein Protokoll"
hinweis="Sobald eine Prüfung abgeschlossen ist, steht sie hier — mit PDF, Unterschrift und Ergebnis."
/>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,340 @@
"use client";
import * as React from "react";
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import { ChecklistenpunktTyp } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { PUNKTTYP_TEXT } from "@/lib/wartung/texte";
/**
* Checklisten-Editor.
*
* Die Reihenfolge ist fachlich wichtig: der Prüfwizard arbeitet die Punkte
* genau so ab, wie sie hier stehen. Ein Gerätewart legt sie in der Reihenfolge
* an, in der er das Gerät in die Hand nimmt — erst außen, dann innen, zuletzt
* die Messwerte.
*
* Umsortiert wird mit Pfeiltasten statt Ziehen und Fallenlassen: das
* funktioniert auch mit Handschuhen auf einem Tablet und mit der Tastatur.
*/
const LEERER_PUNKT = {
text: "",
typ: ChecklistenpunktTyp.JA_NEIN as ChecklistenpunktTyp,
pflicht: true,
sollwert: "",
einheit: "",
minWert: "",
maxWert: "",
hinweis: "",
};
export function ChecklistenEditor({
vorlageId,
darfBearbeiten,
}: {
vorlageId: string;
darfBearbeiten: boolean;
}) {
const [neu, setNeu] = React.useState(LEERER_PUNKT);
const [fehler, setFehler] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const vorlage = trpc.wartungsvorlage.einzeln.useQuery({ id: vorlageId });
const aktualisieren = () => void werkzeuge.wartungsvorlage.einzeln.invalidate({ id: vorlageId });
const punktAnlegen = trpc.wartungsvorlage.punktAnlegen.useMutation({
onSuccess: () => {
setNeu(LEERER_PUNKT);
aktualisieren();
},
onError: (problem) => setFehler(problem.message),
});
const punktLoeschen = trpc.wartungsvorlage.punktLoeschen.useMutation({
onSuccess: aktualisieren,
onError: (problem) => setFehler(problem.message),
});
const sortieren = trpc.wartungsvorlage.punkteSortieren.useMutation({
onSuccess: aktualisieren,
onError: (problem) => setFehler(problem.message),
});
const punkte = vorlage.data?.punkte ?? [];
function verschieben(index: number, richtung: -1 | 1) {
const ziel = index + richtung;
if (ziel < 0 || ziel >= punkte.length) return;
const reihenfolge = punkte.map((punkt) => punkt.id);
[reihenfolge[index], reihenfolge[ziel]] = [reihenfolge[ziel]!, reihenfolge[index]!];
sortieren.mutate({ vorlageId, reihenfolge });
}
function punktHinzufuegen(ereignis: React.FormEvent) {
ereignis.preventDefault();
setFehler(null);
punktAnlegen.mutate({
vorlageId,
text: neu.text,
typ: neu.typ,
pflicht: neu.pflicht,
sollwert: neu.sollwert || null,
einheit: neu.einheit || null,
minWert: neu.minWert === "" ? null : Number(neu.minWert),
maxWert: neu.maxWert === "" ? null : Number(neu.maxWert),
hinweis: neu.hinweis || null,
sortierung: 0,
});
}
if (vorlage.isLoading) {
return <p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>;
}
if (vorlage.error) return <Fehlermeldung meldung={vorlage.error.message} />;
if (!vorlage.data) return null;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{vorlage.data.name}</h1>
<p className="text-sm text-[var(--muted-foreground)]">
alle {vorlage.data.intervallMonate} Monate
{vorlage.data.geraetetyp && ` · ${vorlage.data.geraetetyp.name}`}
{vorlage.data.pruefvorschrift && ` · ${vorlage.data.pruefvorschrift}`}
{" · "}
{vorlage.data._count.pruefungen} Prüfungen bisher
</p>
</div>
{fehler && <Fehlermeldung meldung={fehler} />}
<Card>
<CardHeader>
<CardTitle>Checkliste</CardTitle>
<CardDescription>
Der Prüfwizard fragt die Punkte in dieser Reihenfolge ab. Pflichtpunkte müssen
beantwortet sein, bevor sich eine Prüfung abschließen lässt — und ein durchgefallener
Pflichtpunkt bedeutet „nicht bestanden".
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{punkte.length === 0 && (
<NichtsGefunden
titel="Noch kein Prüfpunkt"
hinweis="Eine Vorlage ohne Punkte erzeugt Termine, aber keine sinnvolle Prüfung."
/>
)}
<ol className="space-y-2">
{punkte.map((punkt, index) => (
<li
key={punkt.id}
className="flex flex-wrap items-start gap-3 rounded-md border border-[var(--border)] p-3"
>
<span className="tabellenzahlen w-6 shrink-0 pt-1 text-sm text-[var(--muted-foreground)]">
{index + 1}
</span>
<div className="min-w-48 flex-1">
<p className="text-sm font-medium">
{punkt.text}
{punkt.pflicht && (
<span className="ml-1 text-[var(--gesperrt)]" title="Pflichtpunkt">
*
</span>
)}
</p>
<p className="text-xs text-[var(--muted-foreground)]">
{PUNKTTYP_TEXT[punkt.typ]}
{punkt.sollwert && ` · Soll: ${punkt.sollwert}`}
{(punkt.minWert !== null || punkt.maxWert !== null) &&
` · zulässig ${punkt.minWert ?? "…"} bis ${punkt.maxWert ?? "…"}${
punkt.einheit ? ` ${punkt.einheit}` : ""
}`}
</p>
{punkt.hinweis && (
<p className="mt-1 text-xs text-[var(--muted-foreground)]">{punkt.hinweis}</p>
)}
</div>
{darfBearbeiten && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => verschieben(index, -1)}
disabled={index === 0}
aria-label={`${punkt.text} nach oben`}
>
<ArrowUp aria-hidden />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => verschieben(index, 1)}
disabled={index === punkte.length - 1}
aria-label={`${punkt.text} nach unten`}
>
<ArrowDown aria-hidden />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (window.confirm(`Prüfpunkt "${punkt.text}" entfernen?`)) {
punktLoeschen.mutate({ id: punkt.id });
}
}}
aria-label={`${punkt.text} entfernen`}
>
<Trash2 aria-hidden />
</Button>
</div>
)}
</li>
))}
</ol>
</CardContent>
</Card>
{darfBearbeiten && (
<Card>
<CardHeader>
<CardTitle>Prüfpunkt ergänzen</CardTitle>
<CardDescription>
Bei einem Messwert entscheidet der zulässige Bereich über „in Ordnung" — gerechnet
wird das auf dem Server, nicht im Browser.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={punktHinzufuegen} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="punkttext">Frage oder Prüfschritt</Label>
<Input
id="punkttext"
required
value={neu.text}
onChange={(ereignis) => setNeu({ ...neu, text: ereignis.target.value })}
placeholder="Maskenkörper auf Risse und Verformung prüfen"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="punkttyp">Art der Antwort</Label>
<Select
value={neu.typ}
onValueChange={(wert) =>
setNeu({ ...neu, typ: wert as ChecklistenpunktTyp })
}
>
<SelectTrigger id="punkttyp">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PUNKTTYP_TEXT).map(([wert, text]) => (
<SelectItem key={wert} value={wert}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="sollwert">Sollwert im Klartext</Label>
<Input
id="sollwert"
value={neu.sollwert}
onChange={(ereignis) => setNeu({ ...neu, sollwert: ereignis.target.value })}
placeholder="mindestens 270 bar"
/>
</div>
</div>
{neu.typ === ChecklistenpunktTyp.MESSWERT && (
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="minwert">kleinster zulässiger Wert</Label>
<Input
id="minwert"
type="number"
step="any"
value={neu.minWert}
onChange={(ereignis) => setNeu({ ...neu, minWert: ereignis.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxwert">größter zulässiger Wert</Label>
<Input
id="maxwert"
type="number"
step="any"
value={neu.maxWert}
onChange={(ereignis) => setNeu({ ...neu, maxWert: ereignis.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="einheit">Einheit</Label>
<Input
id="einheit"
value={neu.einheit}
onChange={(ereignis) => setNeu({ ...neu, einheit: ereignis.target.value })}
placeholder="bar"
/>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="hinweis">Hinweis für den Prüfer</Label>
<Input
id="hinweis"
value={neu.hinweis}
onChange={(ereignis) => setNeu({ ...neu, hinweis: ereignis.target.value })}
placeholder="Sichtprüfung bei gutem Licht, Maske dabei dehnen"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={neu.pflicht}
onCheckedChange={(an) => setNeu({ ...neu, pflicht: an === true })}
/>
Pflichtpunkt — davon hängt die Einsatzbereitschaft ab
</label>
{punktAnlegen.error && <Fehlermeldung meldung={punktAnlegen.error.message} />}
<Button type="submit" disabled={punktAnlegen.isPending}>
<Plus aria-hidden />
{punktAnlegen.isPending ? "wird ergänzt …" : "Punkt ergänzen"}
</Button>
</form>
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,37 @@
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 { ChecklistenEditor } from "@/app/(app)/pruefungen/vorlagen/[id]/checkliste";
export const metadata = { title: "Checkliste" };
/**
* Eine Vorlage mit ihrer Checkliste.
*
* Der Editor ist eine Client-Komponente: Punkte werden hinzugefügt, umsortiert
* und geändert, ohne dass die Seite dazwischen neu lädt.
*/
export default async function VorlageSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/pruefungen/vorlagen">
<ArrowLeft aria-hidden />
Zurück zu den Vorlagen
</Link>
</Button>
</div>
<ChecklistenEditor vorlageId={id} darfBearbeiten={hatRecht(sitzung, "pruefung.planen")} />
</div>
);
}
@@ -0,0 +1,39 @@
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 { VorlagenVerwaltung } from "@/app/(app)/pruefungen/vorlagen/vorlagen-verwaltung";
export const metadata = { title: "Wartungsvorlagen" };
/**
* Wartungsvorlagen.
*
* Hier steht, was in welchem Takt geprüft wird. Ohne Vorlage entstehen keine
* Termine — deshalb ist das die erste Seite, die eine Wehr in Phase 2 ausfüllt.
*/
export default async function VorlagenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/pruefungen">
<ArrowLeft aria-hidden />
Zurück zu den Fristen
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Wartungsvorlagen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Was an einem Gerätetyp regelmäßig zu prüfen ist — Intervall, Vorschrift und Checkliste.
</p>
</div>
<VorlagenVerwaltung darfBearbeiten={hatRecht(sitzung, "pruefung.planen")} />
</div>
);
}
@@ -0,0 +1,381 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { Plus, Search, Trash2 } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
/**
* Verwaltung der Wartungsvorlagen.
*
* Die Liste zeigt, was jede Vorlage kostet und bringt: Intervall, Zahl der
* Prüfpunkte, Zahl der offenen Termine. Die Checkliste selbst wird auf der
* Detailseite gepflegt — sie ist zu umfangreich für eine Tabellenzeile.
*/
const LEER = {
name: "",
geraetetypId: "",
intervallMonate: 12,
vorwarnzeitTage: 30,
pruefvorschrift: "",
standardDauerMinuten: "",
standardExtern: false,
automatisch: true,
beschreibung: "",
};
export function VorlagenVerwaltung({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const [suche, setSuche] = React.useState("");
const [offen, setOffen] = React.useState(false);
const [formular, setFormular] = React.useState(LEER);
const [fehler, setFehler] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const vorlagen = trpc.wartungsvorlage.liste.useQuery({
seite: 1,
seitengroesse: 100,
richtung: "asc",
suche: suche || undefined,
});
const geraetetypen = trpc.geraetetyp.liste.useQuery({
seite: 1,
seitengroesse: 200,
richtung: "asc",
});
const anlegen = trpc.wartungsvorlage.anlegen.useMutation({
onSuccess: () => {
setOffen(false);
setFormular(LEER);
void werkzeuge.wartungsvorlage.liste.invalidate();
},
onError: (problem) => setFehler(problem.message),
});
const loeschen = trpc.wartungsvorlage.loeschen.useMutation({
onSuccess: () => void werkzeuge.wartungsvorlage.liste.invalidate(),
onError: (problem) => setFehler(problem.message),
});
function absenden(ereignis: React.FormEvent) {
ereignis.preventDefault();
setFehler(null);
anlegen.mutate({
name: formular.name,
geraetetypId: formular.geraetetypId || null,
intervallMonate: Number(formular.intervallMonate),
vorwarnzeitTage: Number(formular.vorwarnzeitTage),
pruefvorschrift: formular.pruefvorschrift || null,
standardDauerMinuten: formular.standardDauerMinuten
? Number(formular.standardDauerMinuten)
: null,
standardExtern: formular.standardExtern,
automatisch: formular.automatisch,
beschreibung: formular.beschreibung || null,
aktiv: true,
});
}
const eintraege = vorlagen.data?.eintraege ?? [];
return (
<div className="space-y-4">
<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={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Name oder Prüfvorschrift …"
className="pl-8"
aria-label="Vorlagen durchsuchen"
/>
</div>
{darfBearbeiten && (
<Button onClick={() => setOffen(true)}>
<Plus aria-hidden />
Neue Vorlage
</Button>
)}
</div>
{fehler && <Fehlermeldung meldung={fehler} />}
{vorlagen.error && <Fehlermeldung meldung={vorlagen.error.message} />}
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Bezeichnung</TableHead>
<TableHead>Gerätetyp</TableHead>
<TableHead>Vorschrift</TableHead>
<TableHead className="text-right">Intervall</TableHead>
<TableHead className="text-right">Punkte</TableHead>
<TableHead className="text-right">offene Termine</TableHead>
<TableHead className="w-24" aria-label="Aktionen" />
</TableRow>
</TableHeader>
<TableBody>
{vorlagen.isLoading && <LadeZeilen spalten={7} />}
{eintraege.map((vorlage) => (
<TableRow key={vorlage.id}>
<TableCell className="font-medium">
<Link
href={`/pruefungen/vorlagen/${vorlage.id}`}
className="underline-offset-4 hover:underline"
>
{vorlage.name}
</Link>
{!vorlage.aktiv && (
<span className="ml-2 text-xs text-[var(--muted-foreground)]">(inaktiv)</span>
)}
{!vorlage.automatisch && (
<div className="text-xs text-[var(--muted-foreground)]">
keine automatische Planung
</div>
)}
</TableCell>
<TableCell className="text-sm">{vorlage.geraetetyp?.name ?? "—"}</TableCell>
<TableCell className="text-sm text-[var(--muted-foreground)]">
{vorlage.pruefvorschrift ?? "—"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{vorlage.intervallMonate} Mon.
</TableCell>
<TableCell className="text-right tabellenzahlen">{vorlage._count.punkte}</TableCell>
<TableCell className="text-right tabellenzahlen">{vorlage._count.termine}</TableCell>
<TableCell>
{darfBearbeiten && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (
window.confirm(
`Vorlage "${vorlage.name}" löschen? Offene Termine dazu entfallen.`,
)
) {
loeschen.mutate({ id: vorlage.id });
}
}}
aria-label={`Vorlage ${vorlage.name} löschen`}
>
<Trash2 aria-hidden />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!vorlagen.isLoading && eintraege.length === 0 && (
<div className="p-6">
<NichtsGefunden
titel="Noch keine Wartungsvorlage"
hinweis="Eine Vorlage sagt, was an einem Gerätetyp in welchem Takt zu prüfen ist. Ohne sie entstehen keine Termine — und ohne Termine kein Fristen-Dashboard."
aktion={
darfBearbeiten ? (
<Button onClick={() => setOffen(true)}>
<Plus aria-hidden />
Erste Vorlage anlegen
</Button>
) : undefined
}
/>
</div>
)}
</div>
<Dialog open={offen} onOpenChange={setOffen}>
<DialogContent>
<form onSubmit={absenden} className="space-y-4">
<DialogHeader>
<DialogTitle>Neue Wartungsvorlage</DialogTitle>
<DialogDescription>
Die Prüfpunkte kommen im nächsten Schritt dazu — erst die Regel, dann die Fragen.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="name">Bezeichnung</Label>
<Input
id="name"
required
value={formular.name}
onChange={(ereignis) =>
setFormular({ ...formular, name: ereignis.target.value })
}
placeholder="Sichtprüfung Pressluftatmer"
/>
</div>
<div className="space-y-2">
<Label htmlFor="geraetetyp">Gilt für Gerätetyp</Label>
<Select
value={formular.geraetetypId || "keiner"}
onValueChange={(wert) =>
setFormular({ ...formular, geraetetypId: wert === "keiner" ? "" : wert })
}
>
<SelectTrigger id="geraetetyp">
<SelectValue placeholder="Gerätetyp wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="keiner">keiner — nur von Hand zuweisbar</SelectItem>
{geraetetypen.data?.eintraege.map((typ) => (
<SelectItem key={typ.id} value={typ.id}>
{typ.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-[var(--muted-foreground)]">
Ohne Gerätetyp plant der nächtliche Lauf keine Termine.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="intervall">Intervall in Monaten</Label>
<Input
id="intervall"
type="number"
min={1}
max={600}
required
value={formular.intervallMonate}
onChange={(ereignis) =>
setFormular({ ...formular, intervallMonate: Number(ereignis.target.value) })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="vorwarn">Vorwarnzeit in Tagen</Label>
<Input
id="vorwarn"
type="number"
min={0}
max={365}
value={formular.vorwarnzeitTage}
onChange={(ereignis) =>
setFormular({ ...formular, vorwarnzeitTage: Number(ereignis.target.value) })
}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="vorschrift">Prüfvorschrift</Label>
<Input
id="vorschrift"
value={formular.pruefvorschrift}
onChange={(ereignis) =>
setFormular({ ...formular, pruefvorschrift: ereignis.target.value })
}
placeholder="DGUV Vorschrift 49"
/>
</div>
<div className="space-y-2">
<Label htmlFor="dauer">Übliche Dauer in Minuten</Label>
<Input
id="dauer"
type="number"
min={1}
value={formular.standardDauerMinuten}
onChange={(ereignis) =>
setFormular({ ...formular, standardDauerMinuten: ereignis.target.value })
}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="beschreibung">Beschreibung</Label>
<Textarea
id="beschreibung"
rows={2}
value={formular.beschreibung}
onChange={(ereignis) =>
setFormular({ ...formular, beschreibung: ereignis.target.value })
}
/>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={formular.automatisch}
onCheckedChange={(an) => setFormular({ ...formular, automatisch: an === true })}
/>
Termine nachts automatisch planen
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={formular.standardExtern}
onCheckedChange={(an) =>
setFormular({ ...formular, standardExtern: an === true })
}
/>
Wird üblicherweise von einer Fachfirma durchgeführt
</label>
</div>
{anlegen.error && <Fehlermeldung meldung={anlegen.error.message} />}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={anlegen.isPending}>
{anlegen.isPending ? "wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,363 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { Check, Search } from "lucide-react";
import { BetroffenheitStatus } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Fehlermeldung, NichtsGefunden, StatusAbzeichen } from "@/components/stammdaten/anzeige";
import { BetroffenheitAbzeichen, RueckrufStatusAbzeichen } from "@/components/wartung/anzeige";
import { BETROFFENHEIT_TEXT } from "@/lib/wartung/texte";
import { alsDatum } from "@/lib/utils";
/**
* Rückruf bearbeiten: suchen, zuordnen, abarbeiten.
*
* Der Ablauf entspricht dem, was eine Wehr wirklich tut, wenn ein
* Sicherheitshinweis eintrifft: nachsehen, was man davon hat, es aus dem Dienst
* nehmen, die Maßnahme durchführen, wieder freigeben.
*/
export function RueckrufBearbeitung({
rueckrufId,
darfBearbeiten,
}: {
rueckrufId: string;
darfBearbeiten: boolean;
}) {
const [ausgewaehlt, setAusgewaehlt] = React.useState<Set<string>>(new Set());
const [meldung, setMeldung] = React.useState<string | null>(null);
const werkzeuge = trpc.useUtils();
const rueckruf = trpc.rueckruf.einzeln.useQuery({ id: rueckrufId });
const treffer = trpc.rueckruf.geraeteSuchen.useQuery({ id: rueckrufId });
const aktualisieren = () => {
void werkzeuge.rueckruf.invalidate();
void werkzeuge.geraet.invalidate();
};
const zuordnen = trpc.rueckruf.zuordnen.useMutation({
onSuccess: (ergebnis) => {
setMeldung(
`${ergebnis.zugeordnet} Geräte zugeordnet, ${ergebnis.gesperrt} davon gesperrt.`,
);
setAusgewaehlt(new Set());
aktualisieren();
},
onError: (fehler) => setMeldung(fehler.message),
});
const betroffenheitSetzen = trpc.rueckruf.betroffenheitSetzen.useMutation({
onSuccess: (ergebnis) => {
if (ergebnis.geraetFreigegeben) setMeldung("Gerät wieder einsatzbereit.");
aktualisieren();
},
onError: (fehler) => setMeldung(fehler.message),
});
const abschliessen = trpc.rueckruf.abschliessen.useMutation({
onSuccess: () => {
setMeldung("Rückruf abgeschlossen.");
aktualisieren();
},
onError: (fehler) => setMeldung(fehler.message),
});
if (rueckruf.isLoading) {
return <p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>;
}
if (rueckruf.error) return <Fehlermeldung meldung={rueckruf.error.message} />;
if (!rueckruf.data) return null;
const daten = rueckruf.data;
const offeneTreffer = (treffer.data?.treffer ?? []).filter(
(geraet) => !geraet.bereitsZugeordnet,
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{daten.titel}</h1>
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
{daten.hersteller}
{daten.referenz && ` · ${daten.referenz}`}
{daten.veroeffentlichtAm && ` · ${alsDatum(daten.veroeffentlichtAm)}`}
<RueckrufStatusAbzeichen status={daten.status} />
</p>
</div>
{darfBearbeiten && daten.status !== "ERLEDIGT" && (
<Button
variant="outline"
onClick={() => abschliessen.mutate({ id: rueckrufId })}
disabled={abschliessen.isPending}
>
<Check aria-hidden />
Rückruf abschließen
</Button>
)}
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="rueckrufmeldung" />}
{(daten.beschreibung || daten.massnahme) && (
<Card>
<CardHeader>
<CardTitle>Warnung des Herstellers</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{daten.beschreibung && <p className="whitespace-pre-line">{daten.beschreibung}</p>}
{daten.massnahme && (
<div>
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Maßnahme
</p>
<p className="whitespace-pre-line">{daten.massnahme}</p>
</div>
)}
{daten.typen.length > 0 && (
<div>
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Betroffene Bauart
</p>
<ul className="list-inside list-disc">
{daten.typen.map((typ) => (
<li key={typ.id}>
{typ.geraetetyp?.name ?? typ.modell ?? "ohne nähere Eingrenzung"}
{(typ.seriennummerVon || typ.seriennummerBis) &&
` · Seriennummern ${typ.seriennummerVon ?? "…"} bis ${typ.seriennummerBis ?? "…"}`}
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)}
{/* Vorschlag aus dem Bestand */}
{darfBearbeiten && (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
<div>
<CardTitle>Mögliche Treffer im Bestand</CardTitle>
<CardDescription>
Vorschlag anhand von Bauart, Modell und Seriennummer. Zugeordnet wird nur, was hier
bestätigt wird.
</CardDescription>
</div>
{ausgewaehlt.size > 0 && (
<Button
onClick={() =>
zuordnen.mutate({
rueckrufId,
geraetIds: [...ausgewaehlt],
})
}
disabled={zuordnen.isPending}
>
{ausgewaehlt.size} Geräte zuordnen
{daten.sperrtGeraete && " und sperren"}
</Button>
)}
</CardHeader>
<CardContent>
{treffer.data?.hinweis && (
<p className="text-sm text-[var(--muted-foreground)]">{treffer.data.hinweis}</p>
)}
{offeneTreffer.length === 0 && !treffer.data?.hinweis && (
<NichtsGefunden
titel="Kein weiteres Gerät passt auf die Beschreibung"
hinweis="Entweder ist der Bestand nicht betroffen, oder alle Treffer sind bereits zugeordnet."
/>
)}
{offeneTreffer.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" aria-label="Auswahl" />
<TableHead>Gerät</TableHead>
<TableHead>Modell</TableHead>
<TableHead>Seriennummer</TableHead>
<TableHead>Lagerung</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{offeneTreffer.map((geraet) => (
<TableRow key={geraet.id}>
<TableCell>
<Checkbox
checked={ausgewaehlt.has(geraet.id)}
onCheckedChange={() =>
setAusgewaehlt((alt) => {
const neu = new Set(alt);
if (neu.has(geraet.id)) neu.delete(geraet.id);
else neu.add(geraet.id);
return neu;
})
}
aria-label={`${geraet.inventarnummer} auswählen`}
/>
</TableCell>
<TableCell>
<Link
href={`/geraete/${geraet.id}`}
className="font-medium underline-offset-4 hover:underline"
>
<span className="tabellenzahlen">{geraet.inventarnummer}</span>
</Link>
<div className="text-xs text-[var(--muted-foreground)]">
{geraet.bezeichnung}
</div>
</TableCell>
<TableCell className="text-sm">{geraet.modell ?? "—"}</TableCell>
<TableCell className="text-sm tabellenzahlen">
{geraet.seriennummer ?? "—"}
</TableCell>
<TableCell className="text-sm">
{geraet.fahrzeug?.funkrufname ?? geraet.standort?.name ?? "—"}
</TableCell>
<TableCell>
<StatusAbzeichen status={geraet.status} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
{/* Bereits zugeordnete Geräte */}
<Card>
<CardHeader>
<CardTitle>Betroffene Geräte</CardTitle>
<CardDescription>
{daten.betroffenheiten.length} zugeordnet ·{" "}
{
daten.betroffenheiten.filter(
(eintrag) =>
eintrag.status === "OFFEN" || eintrag.status === "MASSNAHME_LAEUFT",
).length
}{" "}
noch offen
</CardDescription>
</CardHeader>
<CardContent>
{daten.betroffenheiten.length === 0 ? (
<NichtsGefunden
titel="Noch kein Gerät zugeordnet"
hinweis="Solange kein Gerät zugeordnet ist, hat der Rückruf keine Wirkung auf den Bestand."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Gerät</TableHead>
<TableHead>Seriennummer</TableHead>
<TableHead>Lagerung</TableHead>
<TableHead>Gerätestatus</TableHead>
<TableHead>Stand</TableHead>
<TableHead className="w-56" aria-label="Aktion" />
</TableRow>
</TableHeader>
<TableBody>
{daten.betroffenheiten.map((eintrag) => (
<TableRow key={eintrag.id}>
<TableCell>
<Link
href={`/geraete/${eintrag.geraet.id}`}
className="font-medium underline-offset-4 hover:underline"
>
<span className="tabellenzahlen">{eintrag.geraet.inventarnummer}</span>
</Link>
<div className="text-xs text-[var(--muted-foreground)]">
{eintrag.geraet.bezeichnung}
</div>
</TableCell>
<TableCell className="text-sm tabellenzahlen">
{eintrag.geraet.seriennummer ?? "—"}
</TableCell>
<TableCell className="text-sm">
{eintrag.geraet.fahrzeug?.funkrufname ??
eintrag.geraet.standort?.name ??
"—"}
</TableCell>
<TableCell>
<StatusAbzeichen status={eintrag.geraet.status} />
</TableCell>
<TableCell>
<BetroffenheitAbzeichen status={eintrag.status} />
</TableCell>
<TableCell>
{darfBearbeiten && (
<Select
value={eintrag.status}
onValueChange={(wert) =>
betroffenheitSetzen.mutate({
id: eintrag.id,
status: wert as BetroffenheitStatus,
})
}
>
<SelectTrigger
className="h-8 w-52"
aria-label={`Stand für ${eintrag.geraet.inventarnummer}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(BETROFFENHEIT_TEXT) as BetroffenheitStatus[]).map(
(wert) => (
<SelectItem key={wert} value={wert}>
{BETROFFENHEIT_TEXT[wert]}
</SelectItem>
),
)}
</SelectContent>
</Select>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<p className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
<Search className="size-3.5" aria-hidden />
Die Suche vergleicht Seriennummern als Text. Wo Hersteller reine Zahlen vergeben, stimmt
das nur bei gleicher Stellenzahl — deshalb ist das Ergebnis ein Vorschlag und keine
automatische Sperre.
</p>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
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 { RueckrufBearbeitung } from "@/app/(app)/rueckrufe/[id]/bearbeitung";
export const metadata = { title: "Rückruf" };
/**
* Ein Rückruf mit Gerätesuche und Bearbeitungsstand.
*
* Die Suche schlägt vor, was auf die Beschreibung passt; zugeordnet wird nur,
* was der Gerätewart bestätigt. Eine Chargenangabe des Herstellers passt selten
* eins zu eins auf den eigenen Bestand.
*/
export default async function RueckrufSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/rueckrufe">
<ArrowLeft aria-hidden />
Zurück zu den Rückrufen
</Link>
</Button>
</div>
<RueckrufBearbeitung
rueckrufId={id}
darfBearbeiten={hatRecht(sitzung, "pruefung.planen")}
/>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { RueckrufListe } from "@/app/(app)/rueckrufe/rueckruf-liste";
export const metadata = { title: "Rückrufe" };
/**
* Herstellerrückrufe.
*
* Kommt eine Warnung des Herstellers, muss die Wehr wissen, ob sie betroffene
* Stücke im Bestand hat. Genau das ist der Zweck dieser Seite.
*/
export default async function RueckrufeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Rückrufe</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Herstellerwarnungen, betroffene Geräte und die Maßnahmen dazu.
</p>
</div>
<RueckrufListe darfBearbeiten={hatRecht(sitzung, "pruefung.planen")} />
</div>
);
}
+349
View File
@@ -0,0 +1,349 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { Plus } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { RueckrufStatusAbzeichen } from "@/components/wartung/anzeige";
import { alsDatum } from "@/lib/utils";
/**
* Liste der Rückrufe.
*
* Beim Anlegen wird gleich die betroffene Bauart mit erfasst — ohne sie kann
* die Suche nach betroffenen Geräten nichts finden, und der Rückruf bliebe ein
* Zettel ohne Wirkung.
*/
export function RueckrufListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const [offen, setOffen] = React.useState(false);
const [fehler, setFehler] = React.useState<string | null>(null);
const [hersteller, setHersteller] = React.useState("");
const [titel, setTitel] = React.useState("");
const [referenz, setReferenz] = React.useState("");
const [beschreibung, setBeschreibung] = React.useState("");
const [massnahme, setMassnahme] = React.useState("");
const [veroeffentlichtAm, setVeroeffentlichtAm] = React.useState("");
const [sperrtGeraete, setSperrtGeraete] = React.useState(true);
const [geraetetypId, setGeraetetypId] = React.useState("");
const [modell, setModell] = React.useState("");
const [seriennummerVon, setSeriennummerVon] = React.useState("");
const [seriennummerBis, setSeriennummerBis] = React.useState("");
const werkzeuge = trpc.useUtils();
const rueckrufe = trpc.rueckruf.liste.useQuery({
seite: 1,
seitengroesse: 100,
richtung: "desc",
});
const geraetetypen = trpc.geraetetyp.liste.useQuery(
{ seite: 1, seitengroesse: 200, richtung: "asc" },
{ enabled: offen },
);
const anlegen = trpc.rueckruf.anlegen.useMutation({
onSuccess: () => {
setOffen(false);
setHersteller("");
setTitel("");
setReferenz("");
setBeschreibung("");
setMassnahme("");
setGeraetetypId("");
setModell("");
setSeriennummerVon("");
setSeriennummerBis("");
void werkzeuge.rueckruf.liste.invalidate();
},
onError: (problem) => setFehler(problem.message),
});
const eintraege = rueckrufe.data?.eintraege ?? [];
return (
<div className="space-y-4">
{darfBearbeiten && (
<div className="flex justify-end">
<Button onClick={() => setOffen(true)}>
<Plus aria-hidden />
Rückruf erfassen
</Button>
</div>
)}
{fehler && <Fehlermeldung meldung={fehler} />}
{rueckrufe.error && <Fehlermeldung meldung={rueckrufe.error.message} />}
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Titel</TableHead>
<TableHead>Hersteller</TableHead>
<TableHead>Referenz</TableHead>
<TableHead>Stand</TableHead>
<TableHead className="text-right">betroffene Geräte</TableHead>
<TableHead className="text-right">veröffentlicht</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rueckrufe.isLoading && <LadeZeilen spalten={6} />}
{eintraege.map((rueckruf) => (
<TableRow key={rueckruf.id}>
<TableCell className="font-medium">
<Link
href={`/rueckrufe/${rueckruf.id}`}
className="underline-offset-4 hover:underline"
>
{rueckruf.titel}
</Link>
{rueckruf.sperrtGeraete && (
<div className="text-xs text-[var(--muted-foreground)]">
sperrt betroffene Geräte
</div>
)}
</TableCell>
<TableCell className="text-sm">{rueckruf.hersteller}</TableCell>
<TableCell className="text-sm tabellenzahlen">
{rueckruf.referenz ?? "—"}
</TableCell>
<TableCell>
<RueckrufStatusAbzeichen status={rueckruf.status} />
</TableCell>
<TableCell className="text-right tabellenzahlen">
{rueckruf._count.betroffenheiten}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{alsDatum(rueckruf.veroeffentlichtAm)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!rueckrufe.isLoading && eintraege.length === 0 && (
<div className="p-6">
<NichtsGefunden
titel="Kein Rückruf erfasst"
hinweis="Kommt eine Herstellerwarnung, wird sie hier eingetragen. FLORIAN sucht dann die betroffenen Geräte im Bestand."
aktion={
darfBearbeiten ? (
<Button onClick={() => setOffen(true)}>
<Plus aria-hidden />
Rückruf erfassen
</Button>
) : undefined
}
/>
</div>
)}
</div>
<Dialog open={offen} onOpenChange={setOffen}>
<DialogContent className="max-w-2xl">
<form
onSubmit={(ereignis) => {
ereignis.preventDefault();
setFehler(null);
anlegen.mutate({
hersteller,
titel,
referenz: referenz || null,
beschreibung: beschreibung || null,
massnahme: massnahme || null,
veroeffentlichtAm: veroeffentlichtAm ? new Date(veroeffentlichtAm) : null,
quelle: null,
sperrtGeraete,
typen:
geraetetypId || modell || seriennummerVon || seriennummerBis
? [
{
geraetetypId: geraetetypId || null,
modell: modell || null,
seriennummerVon: seriennummerVon || null,
seriennummerBis: seriennummerBis || null,
herstellungVon: null,
herstellungBis: null,
},
]
: [],
});
}}
className="space-y-4"
>
<DialogHeader>
<DialogTitle>Rückruf erfassen</DialogTitle>
<DialogDescription>
Die betroffene Bauart gleich mit angeben — danach sucht FLORIAN im Bestand.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hersteller">Hersteller</Label>
<Input
id="hersteller"
required
value={hersteller}
onChange={(ereignis) => setHersteller(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="referenz">Referenz des Herstellers</Label>
<Input
id="referenz"
value={referenz}
onChange={(ereignis) => setReferenz(ereignis.target.value)}
placeholder="SI-2026-04"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="titel">Titel</Label>
<Input
id="titel"
required
value={titel}
onChange={(ereignis) => setTitel(ereignis.target.value)}
placeholder="Sicherheitshinweis Lungenautomat Baureihe X"
/>
</div>
<div className="space-y-2">
<Label htmlFor="beschreibung">Beschreibung</Label>
<Textarea
id="beschreibung"
rows={3}
value={beschreibung}
onChange={(ereignis) => setBeschreibung(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="massnahme">Maßnahme</Label>
<Textarea
id="massnahme"
rows={2}
value={massnahme}
onChange={(ereignis) => setMassnahme(ereignis.target.value)}
placeholder="Zur Überprüfung an den Hersteller einsenden"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="veroeffentlicht">Veröffentlicht am</Label>
<Input
id="veroeffentlicht"
type="date"
value={veroeffentlichtAm}
onChange={(ereignis) => setVeroeffentlichtAm(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="typ">Betroffener Gerätetyp</Label>
<Select
value={geraetetypId || "keiner"}
onValueChange={(wert) => setGeraetetypId(wert === "keiner" ? "" : wert)}
>
<SelectTrigger id="typ">
<SelectValue placeholder="Gerätetyp" />
</SelectTrigger>
<SelectContent>
<SelectItem value="keiner">nicht über den Typ eingrenzen</SelectItem>
{geraetetypen.data?.eintraege.map((typ) => (
<SelectItem key={typ.id} value={typ.id}>
{typ.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="modell">Modell enthält</Label>
<Input
id="modell"
value={modell}
onChange={(ereignis) => setModell(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="serievon">Seriennummer von</Label>
<Input
id="serievon"
value={seriennummerVon}
onChange={(ereignis) => setSeriennummerVon(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="seriebis">Seriennummer bis</Label>
<Input
id="seriebis"
value={seriennummerBis}
onChange={(ereignis) => setSeriennummerBis(ereignis.target.value)}
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={sperrtGeraete}
onCheckedChange={(an) => setSperrtGeraete(an === true)}
/>
Betroffene Geräte beim Zuordnen sperren
</label>
{anlegen.error && <Fehlermeldung meldung={anlegen.error.message} />}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
<Button type="submit" disabled={anlegen.isPending}>
{anlegen.isPending ? "wird erfasst …" : "Erfassen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
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 { hochladen, objektSchluessel } from "@/lib/storage";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
/**
* Datei-Upload.
*
* Eigener Endpunkt statt einer tRPC-Prozedur, aus demselben Grund wie beim
* Etikettenbogen: Dateien sind keine JSON-Daten. Über tRPC müssten sie
* Base64-kodiert durch die Leitung — ein Drittel mehr Bytes, alles im
* Arbeitsspeicher, und die Fortschrittsanzeige des Browsers wäre weg.
*
* Verwendet für die Fotos im Prüfwizard, die Unterschrift aus dem Zeichenfeld
* und das Fremdprotokoll einer Fachfirma.
*
* Die Datei geht ausschließlich über lib/storage.ts nach MinIO — Projektregel.
* Was hier entsteht, ist der Datei-Datensatz mit der Kennung, die der Aufrufer
* anschließend an Prüfergebnis, Signatur oder Mangel hängt.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Was hochgeladen werden darf. Alles andere hat in einem Protokoll nichts verloren. */
const ERLAUBTE_TYPEN = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"application/pdf",
]);
/** 20 MB. Ein Handyfoto liegt darunter, ein Fremdprotokoll erst recht. */
const MAX_BYTES = 20 * 1024 * 1024;
const felder = z.object({
/** Woran die Datei hängt: "Pruefung", "Mangel", "Signatur". */
entitaet: z.string().trim().min(1).max(80),
entitaetId: z.string().uuid().optional(),
/** Einordnung: "foto", "signatur", "fremdprotokoll". */
kategorie: z.string().trim().max(60).default("foto"),
});
export async function POST(anfrage: Request) {
const sitzung = await aktuelleSitzung();
if (!sitzung) {
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
}
if (!hatRecht(sitzung, "datei.hochladen")) {
return NextResponse.json({ fehler: "Fehlende Berechtigung zum Hochladen." }, { status: 403 });
}
const formular = await anfrage.formData().catch(() => null);
if (!formular) {
return NextResponse.json(
{ fehler: "Die Anfrage enthält kein Formular mit einer Datei." },
{ status: 400 },
);
}
const datei = formular.get("datei");
if (!(datei instanceof File)) {
return NextResponse.json({ fehler: "Es wurde keine Datei übergeben." }, { status: 400 });
}
const geprueft = felder.safeParse({
entitaet: formular.get("entitaet") ?? "",
entitaetId: formular.get("entitaetId") || undefined,
kategorie: formular.get("kategorie") || undefined,
});
if (!geprueft.success) {
return NextResponse.json(
{ fehler: "Es fehlt die Angabe, wozu die Datei gehört." },
{ status: 400 },
);
}
const mimeTyp = datei.type || "application/octet-stream";
if (!ERLAUBTE_TYPEN.has(mimeTyp)) {
return NextResponse.json(
{
fehler: `Dateien vom Typ ${mimeTyp} werden nicht angenommen. Erlaubt sind Bilder und PDF.`,
},
{ status: 415 },
);
}
if (datei.size > MAX_BYTES) {
return NextResponse.json(
{ fehler: `Die Datei ist zu groß (${Math.round(datei.size / 1024 / 1024)} MB, erlaubt sind 20 MB).` },
{ status: 413 },
);
}
const inhalt = Buffer.from(await datei.arrayBuffer());
try {
const organisation = await fuerOrganisation(sitzung.organisationId, (db) =>
db.organisation.findUnique({
where: { id: sitzung.organisationId },
select: { slug: true },
}),
);
const schluessel = objektSchluessel({
organisationSlug: organisation?.slug ?? sitzung.organisationId,
entitaet: geprueft.data.entitaet,
entitaetId: geprueft.data.entitaetId,
dateiname: datei.name || "datei",
});
const abgelegt = await hochladen(schluessel, inhalt, mimeTyp, {
kategorie: geprueft.data.kategorie,
hochgeladenvon: sitzung.personId,
});
const datensatz = await fuerOrganisation(sitzung.organisationId, async (db) => {
const angelegt = await db.datei.create({
data: {
organisationId: sitzung.organisationId,
bucket: abgelegt.bucket,
objektSchluessel: abgelegt.objektSchluessel,
dateiname: datei.name || "datei",
mimeTyp,
groesseBytes: BigInt(abgelegt.groesseBytes),
pruefsumme: abgelegt.pruefsumme,
entitaet: geprueft.data.entitaet,
entitaetId: geprueft.data.entitaetId ?? null,
kategorie: geprueft.data.kategorie,
hochgeladenVonId: sitzung.personId,
},
});
await protokolliereIn(db, {
organisationId: sitzung.organisationId,
aktion: AuditAktion.DATEI_HOCHGELADEN,
entitaet: geprueft.data.entitaet,
entitaetId: geprueft.data.entitaetId ?? null,
beschreibung: `Datei "${angelegt.dateiname}" hochgeladen (${geprueft.data.kategorie})`,
sitzung,
nachher: { dateiId: angelegt.id, mimeTyp, groesseBytes: abgelegt.groesseBytes },
});
return angelegt;
});
return NextResponse.json({
id: datensatz.id,
dateiname: datensatz.dateiname,
mimeTyp: datensatz.mimeTyp,
groesseBytes: abgelegt.groesseBytes,
});
} catch (fehler) {
console.error("[dateien] Upload fehlgeschlagen:", fehler);
return NextResponse.json(
{
fehler:
"Die Datei ließ sich nicht ablegen. Läuft der MinIO-Container? (docker compose ps)",
},
{ status: 502 },
);
}
}
+17 -1
View File
@@ -13,7 +13,9 @@ import {
Map,
Menu,
MonitorSmartphone,
ShieldAlert,
Siren,
TriangleAlert,
Truck,
Wallet,
Users,
@@ -64,6 +66,20 @@ const EINTRAEGE: Eintrag[] = [
rechte: ["pruefung.lesen"],
abPhase: 2,
},
{
titel: "Mängel",
pfad: "/maengel",
symbol: TriangleAlert,
rechte: ["pruefung.lesen"],
abPhase: 2,
},
{
titel: "Rückrufe",
pfad: "/rueckrufe",
symbol: ShieldAlert,
rechte: ["pruefung.lesen"],
abPhase: 2,
},
{ titel: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 },
{
titel: "Terminal",
@@ -99,7 +115,7 @@ const EINTRAEGE: Eintrag[] = [
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
const AKTUELLE_PHASE = 1;
const AKTUELLE_PHASE = 2;
export interface SeitenleisteProps {
rechte: Recht[];
+167
View File
@@ -0,0 +1,167 @@
"use client";
import type {
BetroffenheitStatus,
MangelPrioritaet,
MangelStatus,
PruefErgebnis,
PruefungStatus,
RueckrufStatus,
WartungsterminStatus,
} from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import type { Fristfenster } from "@/lib/wartung/fristen";
import {
BETROFFENHEIT_TEXT,
ERGEBNIS_TEXT,
FENSTER_TEXT,
MANGEL_STATUS_TEXT,
PRIORITAET_TEXT,
PRUEFUNG_STATUS_TEXT,
RUECKRUF_STATUS_TEXT,
TERMIN_STATUS_TEXT,
} from "@/lib/wartung/texte";
/**
* Anzeigebausteine für Wartung und Prüfung.
*
* Dieselbe Farblehre wie bei den Stammdaten: grün in Ordnung, gelb es eilt,
* rot es steht still, grau erledigt. Und wie dort trägt jede Farbe zusätzlich
* Klartext — im Gerätehaus steht kein kalibrierter Bildschirm, und
* Rot-Grün-Sehschwäche ist häufig.
*
* Die Beschriftungen selbst stehen in lib/wartung/texte.ts, damit auch
* Server-Komponenten sie verwenden können.
*/
export function ErgebnisAbzeichen({ ergebnis }: { ergebnis: PruefErgebnis | null }) {
if (!ergebnis) return <Badge variant="secondary">offen</Badge>;
const variante =
ergebnis === "BESTANDEN"
? "einsatzbereit"
: ergebnis === "BESTANDEN_MIT_MAENGELN"
? "pruefungFaellig"
: "gesperrt";
return <Badge variant={variante}>{ERGEBNIS_TEXT[ergebnis]}</Badge>;
}
export function PruefungStatusAbzeichen({ status }: { status: PruefungStatus }) {
const variante =
status === "ABGESCHLOSSEN" ? "outline" : status === "ENTWURF" ? "pruefungFaellig" : "ausgesondert";
return <Badge variant={variante}>{PRUEFUNG_STATUS_TEXT[status]}</Badge>;
}
export function TerminStatusAbzeichen({ status }: { status: WartungsterminStatus }) {
const variante =
status === "OFFEN" ? "outline" : status === "ERLEDIGT" ? "einsatzbereit" : "ausgesondert";
return <Badge variant={variante}>{TERMIN_STATUS_TEXT[status]}</Badge>;
}
export function FensterAbzeichen({ fenster }: { fenster: Fristfenster }) {
const variante =
fenster === "ueberfaellig"
? "gesperrt"
: fenster === "diese_woche"
? "pruefungFaellig"
: "outline";
return <Badge variant={variante}>{FENSTER_TEXT[fenster]}</Badge>;
}
export function MangelStatusAbzeichen({ status }: { status: MangelStatus }) {
const variante =
status === "BEHOBEN"
? "einsatzbereit"
: status === "VERWORFEN"
? "ausgesondert"
: status === "IN_ARBEIT"
? "pruefungFaellig"
: "outline";
return <Badge variant={variante}>{MANGEL_STATUS_TEXT[status]}</Badge>;
}
export function PrioritaetAbzeichen({ prioritaet }: { prioritaet: MangelPrioritaet }) {
const variante =
prioritaet === "KRITISCH"
? "gesperrt"
: prioritaet === "HOCH"
? "pruefungFaellig"
: prioritaet === "NIEDRIG"
? "ausgesondert"
: "secondary";
return <Badge variant={variante}>{PRIORITAET_TEXT[prioritaet]}</Badge>;
}
export function RueckrufStatusAbzeichen({ status }: { status: RueckrufStatus }) {
const variante =
status === "OFFEN" ? "gesperrt" : status === "IN_BEARBEITUNG" ? "pruefungFaellig" : "einsatzbereit";
return <Badge variant={variante}>{RUECKRUF_STATUS_TEXT[status]}</Badge>;
}
export function BetroffenheitAbzeichen({ status }: { status: BetroffenheitStatus }) {
const variante =
status === "ERLEDIGT"
? "einsatzbereit"
: status === "NICHT_BETROFFEN"
? "ausgesondert"
: status === "MASSNAHME_LAEUFT"
? "pruefungFaellig"
: "gesperrt";
return <Badge variant={variante}>{BETROFFENHEIT_TEXT[status]}</Badge>;
}
/**
* Kennzahl mit Klartext darunter. Die Zahl ist groß, weil sie aus zwei Metern
* Abstand lesbar sein soll — das Tablet liegt beim Prüfen auf der Werkbank.
*/
export function Kennzahl({
wert,
bezeichnung,
hinweis,
dringend,
aktiv,
onClick,
}: {
wert: number;
bezeichnung: string;
hinweis?: string;
dringend?: boolean;
aktiv?: boolean;
onClick?: () => void;
}) {
const inhalt = (
<>
<span
className={`tabellenzahlen text-3xl font-semibold ${
dringend && wert > 0 ? "text-[var(--gesperrt)]" : ""
}`}
>
{wert}
</span>
<span className="text-sm font-medium">{bezeichnung}</span>
{hinweis && <span className="text-xs text-[var(--muted-foreground)]">{hinweis}</span>}
</>
);
const klassen = `flex flex-col gap-0.5 rounded-lg border p-4 text-left transition-colors ${
aktiv
? "border-[var(--primary)] bg-[var(--accent)]"
: "border-[var(--border)] bg-[var(--card)]"
} ${onClick ? "hover:border-[var(--primary)]" : ""}`;
if (!onClick) return <div className={klassen}>{inhalt}</div>;
return (
<button type="button" onClick={onClick} className={klassen} aria-pressed={aktiv}>
{inhalt}
</button>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import * as React from "react";
import { Camera, FileUp, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { dateiHochladen } from "@/lib/hochladen";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
/**
* Foto- und Dateifeld.
*
* Auf dem Tablet öffnet es unmittelbar die Kamera (capture="environment"),
* denn genau dafür ist es da: der Prüfer fotografiert den Riss im Schlauch,
* während er davorsteht. Am Rechner ist es ein gewöhnliches Dateifeld.
*
* Hochgeladen wird sofort — nicht erst beim Speichern des Formulars. Wer eine
* halbe Stunde an einer Prüfung sitzt, soll seine Fotos nicht verlieren, weil
* am Ende etwas schiefgeht.
*/
export interface DateiFeldProps {
entitaet: string;
entitaetId?: string | null;
kategorie?: string;
/** Nur Bilder annehmen und die Kamera anbieten. */
nurBilder?: boolean;
beschriftung?: string;
dateiId?: string | null;
onHochgeladen: (dateiId: string | null, dateiname?: string) => void;
}
export function DateiFeld({
entitaet,
entitaetId,
kategorie = "foto",
nurBilder = true,
beschriftung,
dateiId,
onHochgeladen,
}: DateiFeldProps) {
const eingabeRef = React.useRef<HTMLInputElement | null>(null);
const [laeuft, setLaeuft] = React.useState(false);
const [fehler, setFehler] = React.useState<string | null>(null);
const [name, setName] = React.useState<string | null>(null);
const [vorschau, setVorschau] = React.useState<string | null>(null);
// Die Vorschau ist eine Objekt-URL auf die lokale Datei — sie wird beim
// Austausch wieder freigegeben, sonst hält der Browser sie bis zum Neuladen.
React.useEffect(() => {
return () => {
if (vorschau) URL.revokeObjectURL(vorschau);
};
}, [vorschau]);
async function auswaehlen(ereignis: React.ChangeEvent<HTMLInputElement>) {
const datei = ereignis.target.files?.[0];
if (!datei) return;
setLaeuft(true);
setFehler(null);
try {
const hochgeladen = await dateiHochladen(datei, entitaet, entitaetId, kategorie);
setName(hochgeladen.dateiname);
if (datei.type.startsWith("image/")) {
setVorschau(URL.createObjectURL(datei));
}
onHochgeladen(hochgeladen.id, hochgeladen.dateiname);
} catch (problem) {
setFehler(problem instanceof Error ? problem.message : String(problem));
} finally {
setLaeuft(false);
// Damit dieselbe Datei erneut gewählt werden kann.
if (eingabeRef.current) eingabeRef.current.value = "";
}
}
function entfernen() {
setName(null);
if (vorschau) URL.revokeObjectURL(vorschau);
setVorschau(null);
onHochgeladen(null);
}
return (
<div className="space-y-2">
<input
ref={eingabeRef}
type="file"
accept={nurBilder ? "image/*" : "image/*,application/pdf"}
{...(nurBilder ? { capture: "environment" as const } : {})}
onChange={auswaehlen}
className="hidden"
aria-label={beschriftung ?? "Datei auswählen"}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => eingabeRef.current?.click()}
disabled={laeuft}
>
{nurBilder ? <Camera aria-hidden /> : <FileUp aria-hidden />}
{laeuft
? "wird hochgeladen …"
: dateiId
? "Ersetzen"
: (beschriftung ?? (nurBilder ? "Foto aufnehmen" : "Datei wählen"))}
</Button>
{dateiId && (
<>
<span className="text-sm text-[var(--muted-foreground)]">{name ?? "Datei liegt bei"}</span>
<Button type="button" variant="ghost" size="sm" onClick={entfernen}>
<X aria-hidden />
Entfernen
</Button>
</>
)}
</div>
{vorschau && (
// Bewusst kein next/image: die Quelle ist eine lokale Objekt-URL, die
// der Bildoptimierer weder kennt noch abrufen kann.
// eslint-disable-next-line @next/next/no-img-element
<img
src={vorschau}
alt="Vorschau der ausgewählten Datei"
className="max-h-40 rounded border border-[var(--border)]"
/>
)}
{fehler && <Fehlermeldung meldung={fehler} kennung="dateifehler" />}
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import * as React from "react";
import { Check, Eraser } from "lucide-react";
import { Button } from "@/components/ui/button";
import { canvasAlsPng, dateiHochladen } from "@/lib/hochladen";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
/**
* Unterschriftenfeld.
*
* Ein Zeichenfeld, das mit Finger, Stift und Maus funktioniert — deshalb
* Pointer-Events und nicht getrennt Maus und Touch: die Browser fassen beides
* darunter zusammen, und ein Tablet mit Stift liefert je nach Gerät mal das
* eine, mal das andere.
*
* Zwei Feinheiten, die im Gerätehaus den Unterschied machen:
*
* * Die Zeichenfläche wird auf die tatsächliche Pixeldichte skaliert. Ohne
* das ist die Unterschrift auf einem Tablet ein verwaschener Klotz.
* * touch-action: none verhindert, dass das Wischen beim Unterschreiben die
* Seite scrollt.
*
* Das Ergebnis ist ein PNG in MinIO; zurück kommt dessen Kennung, die an die
* Signatur der Prüfung gehängt wird.
*/
export interface UnterschriftFeldProps {
/** Wer unterschreibt — steht unter dem Feld und später im Protokoll. */
name: string;
/** Rolle als Klartext, nur zur Beschriftung. */
rolle?: string;
/** Prüfung, an der die Datei hängt. */
pruefungId?: string;
onFertig: (dateiId: string) => void;
/** Bereits übernommen — dann ist das Feld gesperrt. */
uebernommen?: boolean;
}
export function UnterschriftFeld({
name,
rolle,
pruefungId,
onFertig,
uebernommen = false,
}: UnterschriftFeldProps) {
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const zeichnet = React.useRef(false);
const [leer, setLeer] = React.useState(true);
const [laeuft, setLaeuft] = React.useState(false);
const [fehler, setFehler] = React.useState<string | null>(null);
// Zeichenfläche an die Anzeigegröße und die Pixeldichte anpassen.
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const anpassen = () => {
const dichte = window.devicePixelRatio || 1;
const breite = canvas.clientWidth;
const hoehe = canvas.clientHeight;
canvas.width = Math.round(breite * dichte);
canvas.height = Math.round(hoehe * dichte);
const stift = canvas.getContext("2d");
if (!stift) return;
stift.scale(dichte, dichte);
stift.lineWidth = 2;
stift.lineCap = "round";
stift.lineJoin = "round";
stift.strokeStyle = "#111111";
// Weißer Grund: das PNG landet später auf einem weißen Blatt Papier.
stift.fillStyle = "#ffffff";
stift.fillRect(0, 0, breite, hoehe);
};
anpassen();
window.addEventListener("resize", anpassen);
return () => window.removeEventListener("resize", anpassen);
}, []);
function position(ereignis: React.PointerEvent<HTMLCanvasElement>) {
const canvas = canvasRef.current!;
const rahmen = canvas.getBoundingClientRect();
return { x: ereignis.clientX - rahmen.left, y: ereignis.clientY - rahmen.top };
}
function beginnen(ereignis: React.PointerEvent<HTMLCanvasElement>) {
if (uebernommen) return;
const stift = canvasRef.current?.getContext("2d");
if (!stift) return;
canvasRef.current?.setPointerCapture(ereignis.pointerId);
zeichnet.current = true;
const { x, y } = position(ereignis);
stift.beginPath();
stift.moveTo(x, y);
setLeer(false);
}
function ziehen(ereignis: React.PointerEvent<HTMLCanvasElement>) {
if (!zeichnet.current || uebernommen) return;
const stift = canvasRef.current?.getContext("2d");
if (!stift) return;
const { x, y } = position(ereignis);
stift.lineTo(x, y);
stift.stroke();
}
function beenden(ereignis: React.PointerEvent<HTMLCanvasElement>) {
if (!zeichnet.current) return;
zeichnet.current = false;
canvasRef.current?.releasePointerCapture(ereignis.pointerId);
}
function leeren() {
const canvas = canvasRef.current;
const stift = canvas?.getContext("2d");
if (!canvas || !stift) return;
stift.fillStyle = "#ffffff";
stift.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
setLeer(true);
}
async function uebernehmen() {
const canvas = canvasRef.current;
if (!canvas || leer) return;
setLaeuft(true);
setFehler(null);
try {
const png = await canvasAlsPng(canvas);
const datei = await dateiHochladen(
png,
"Pruefung",
pruefungId,
"signatur",
`unterschrift-${Date.now()}.png`,
);
onFertig(datei.id);
} catch (problem) {
setFehler(problem instanceof Error ? problem.message : String(problem));
} finally {
setLaeuft(false);
}
}
return (
<div className="space-y-2">
<canvas
ref={canvasRef}
onPointerDown={beginnen}
onPointerMove={ziehen}
onPointerUp={beenden}
onPointerLeave={beenden}
className="h-40 w-full touch-none rounded-md border border-[var(--border)] bg-white"
aria-label={`Unterschriftenfeld für ${name}`}
style={{ cursor: uebernommen ? "not-allowed" : "crosshair" }}
/>
<div className="flex flex-wrap items-center gap-2">
<div className="mr-auto text-sm">
<span className="font-medium">{name}</span>
{rolle && <span className="text-[var(--muted-foreground)]"> · {rolle}</span>}
</div>
<Button type="button" variant="ghost" size="sm" onClick={leeren} disabled={uebernommen || leer}>
<Eraser aria-hidden />
Löschen
</Button>
<Button
type="button"
size="sm"
onClick={uebernehmen}
disabled={uebernommen || leer || laeuft}
>
<Check aria-hidden />
{uebernommen ? "übernommen" : laeuft ? "wird gespeichert …" : "Übernehmen"}
</Button>
</div>
{fehler && <Fehlermeldung meldung={fehler} kennung="unterschriftfehler" />}
</div>
);
}
+8
View File
@@ -106,6 +106,14 @@ const schema = z.object({
LLM_API_KEY: z.string().optional(),
LLM_MODELL: z.string().optional(),
// Wartung und Prüfung (Phase 2)
/** Wann der nächtliche Lauf Termine plant. Cron-Syntax, Europe/Berlin. */
WARTUNG_NACHTLAUF: z.string().default("15 3 * * *"),
/** Wann Erinnerungen verschickt werden — nach der Planung, nicht davor. */
WARTUNG_ERINNERUNG_LAUF: z.string().default("45 3 * * *"),
/** Wie weit im Voraus Termine angelegt werden. */
WARTUNG_PLANUNGSHORIZONT_TAGE: zahl(180),
// Aufbewahrung
AUFBEWAHRUNG_JAHRE: zahl(10),
AUDIT_AUFBEWAHRUNG_TAGE: zahl(3650),
+63
View File
@@ -0,0 +1,63 @@
/**
* Datei-Upload aus dem Browser.
*
* Gegenstück zu src/app/api/dateien/route.ts. Bewusst kein tRPC: Dateien
* gehören als Multipart durch die Leitung, nicht Base64-kodiert durch eine
* JSON-Antwort.
*
* Diese Datei läuft im Browser — hier darf nichts aus lib/storage oder lib/db
* hineingeraten.
*/
export interface HochgeladeneDatei {
id: string;
dateiname: string;
mimeTyp: string;
groesseBytes: number;
}
export class UploadFehler extends Error {}
/**
* Lädt eine Datei hoch und gibt den angelegten Datensatz zurück.
*
* @param entitaet woran die Datei hängt, z. B. "Pruefung"
* @param entitaetId der zugehörige Datensatz, sofern schon vorhanden
* @param kategorie "foto", "signatur" oder "fremdprotokoll"
*/
export async function dateiHochladen(
datei: File | Blob,
entitaet: string,
entitaetId?: string | null,
kategorie = "foto",
dateiname?: string,
): Promise<HochgeladeneDatei> {
const formular = new FormData();
formular.append(
"datei",
datei,
dateiname ?? (datei instanceof File ? datei.name : "datei.png"),
);
formular.append("entitaet", entitaet);
if (entitaetId) formular.append("entitaetId", entitaetId);
formular.append("kategorie", kategorie);
const antwort = await fetch("/api/dateien", { method: "POST", body: formular });
if (!antwort.ok) {
const inhalt = (await antwort.json().catch(() => null)) as { fehler?: string } | null;
throw new UploadFehler(inhalt?.fehler ?? `Der Upload schlug fehl (${antwort.status}).`);
}
return (await antwort.json()) as HochgeladeneDatei;
}
/** Wandelt den Inhalt eines Zeichenfelds in eine PNG-Datei um. */
export async function canvasAlsPng(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((auflösen, ablehnen) => {
canvas.toBlob((blob) => {
if (blob) auflösen(blob);
else ablehnen(new UploadFehler("Die Unterschrift ließ sich nicht in ein Bild umwandeln."));
}, "image/png");
});
}
+8 -4
View File
@@ -23,10 +23,14 @@ import { formatInTimeZone } from "date-fns-tz";
*
* Deshalb hier eigene Arithmetik auf den UTC-Bestandteilen. Sie ist
* unabhängig davon, wo der Server steht.
*
* Die vier Grundfunktionen sind ab Phase 2 exportiert: die Wartungsplanung
* rechnet mit denselben Kalendertagen, und zwei Umsetzungen derselben
* Datumsarithmetik würden früher oder später um einen Tag auseinanderlaufen.
*/
/** Setzt ein Datum auf UTC-Mitternacht seines eigenen Kalendertages. */
function utcTag(wert: Date): Date {
export function utcTag(wert: Date): Date {
return new Date(Date.UTC(wert.getUTCFullYear(), wert.getUTCMonth(), wert.getUTCDate()));
}
@@ -35,7 +39,7 @@ function utcTag(wert: Date): Date {
* geklemmt: der 31. Januar plus einen Monat ist der 28. Februar, nicht der
* 3. März.
*/
function monateAddieren(basis: Date, monate: number): Date {
export function monateAddieren(basis: Date, monate: number): Date {
const jahr = basis.getUTCFullYear();
const monat = basis.getUTCMonth() + monate;
const tag = basis.getUTCDate();
@@ -56,7 +60,7 @@ function jahreAddieren(basis: Date, jahre: number): Date {
* nach UTC noch der Vortag. Eine Frist, die "heute" abläuft, darf davon nicht
* abhängen.
*/
function heutigerTag(jetzt: Date): Date {
export function heutigerTag(jetzt: Date): Date {
const [jahr, monat, tag] = formatInTimeZone(jetzt, "Europe/Berlin", "yyyy-MM-dd")
.split("-")
.map(Number);
@@ -64,7 +68,7 @@ function heutigerTag(jetzt: Date): Date {
}
/** Kalendertage zwischen zwei UTC-Tagen. */
function tagesabstand(von: Date, bis: Date): number {
export function tagesabstand(von: Date, bis: Date): number {
return Math.round((utcTag(bis).getTime() - utcTag(von).getTime()) / 86_400_000);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Bewertung einer Prüfung.
*
* Die Frage, ob ein Gerät bestanden hat, wird hier beantwortet und nirgends
* sonst. Weder der Browser noch die Datenbank entscheiden das mit: der Browser
* nicht, weil man ihm nicht trauen kann, und die Datenbank nicht, weil die
* Regel fachlich ist und sich ändern kann.
*
* Die Regel in einem Satz: ein nicht erfüllter Pflichtpunkt bedeutet "nicht
* bestanden", ein nicht erfüllter Kann-Punkt "bestanden mit Mängeln".
*
* Dahinter steckt die Festlegung, dass Pflichtpunkte genau die sind, an denen
* die Einsatzbereitschaft hängt — deshalb heißen sie so. Wer einen Punkt als
* Pflicht anlegt, sagt damit: fällt der durch, geht das Gerät aus dem Dienst.
*/
export type PunktTyp = "JA_NEIN" | "MESSWERT" | "TEXT" | "FOTO";
export type Ergebnis = "BESTANDEN" | "BESTANDEN_MIT_MAENGELN" | "NICHT_BESTANDEN";
export const ERGEBNIS_BEZEICHNUNG: Record<Ergebnis, string> = {
BESTANDEN: "bestanden",
BESTANDEN_MIT_MAENGELN: "bestanden mit Mängeln",
NICHT_BESTANDEN: "nicht bestanden",
};
/** Ein Checklistenpunkt, so weit die Bewertung ihn braucht. */
export interface Punkt {
id: string;
text: string;
typ: PunktTyp;
pflicht: boolean;
minWert?: number | null;
maxWert?: number | null;
}
/** Die Antwort darauf. */
export interface Antwort {
punktId: string;
jaNein?: boolean | null;
zahlwert?: number | null;
text?: string | null;
/** Kennung der hochgeladenen Datei, wenn ein Foto verlangt war. */
fotoDateiId?: string | null;
/** Vom Prüfer übersteuert. Fehlt die Angabe, rechnet der Server sie aus. */
inOrdnung?: boolean | null;
}
/**
* Liegt ein Messwert im zulässigen Bereich?
*
* Fehlt eine Grenze, schränkt sie nicht ein — "mindestens 270 bar" ist ein
* Bereich mit Untergrenze und ohne Obergrenze. Fehlt der Wert selbst, ist die
* Frage unbeantwortet; das fängt vollstaendig() ab, nicht diese Funktion.
*/
export function messwertInOrdnung(
wert: number | null | undefined,
minWert?: number | null,
maxWert?: number | null,
): boolean {
if (wert === null || wert === undefined || Number.isNaN(wert)) return false;
if (minWert !== null && minWert !== undefined && wert < minWert) return false;
if (maxWert !== null && maxWert !== undefined && wert > maxWert) return false;
return true;
}
/** Ist der Punkt überhaupt beantwortet? */
export function beantwortet(punkt: Punkt, antwort: Antwort | undefined): boolean {
if (!antwort) return false;
switch (punkt.typ) {
case "JA_NEIN":
return antwort.jaNein === true || antwort.jaNein === false;
case "MESSWERT":
return (
antwort.zahlwert !== null &&
antwort.zahlwert !== undefined &&
!Number.isNaN(antwort.zahlwert)
);
case "TEXT":
return Boolean(antwort.text?.trim());
case "FOTO":
return Boolean(antwort.fotoDateiId);
}
}
/**
* Ob ein einzelner Punkt in Ordnung ist.
*
* Die Angabe des Prüfers hat Vorrang: bei einem Messwert innerhalb der Toleranz
* kann trotzdem etwas nicht stimmen, und wer davorsteht, sieht mehr als die
* Zahl. Ohne ausdrückliche Angabe entscheidet die Art des Punktes:
*
* ja_nein die Antwort selbst
* messwert der zulässige Bereich
* text/foto beantwortet heißt in Ordnung — hier gibt es nichts zu rechnen
*/
export function punktInOrdnung(punkt: Punkt, antwort: Antwort | undefined): boolean {
if (!antwort) return false;
if (antwort.inOrdnung === true || antwort.inOrdnung === false) return antwort.inOrdnung;
switch (punkt.typ) {
case "JA_NEIN":
return antwort.jaNein === true;
case "MESSWERT":
return messwertInOrdnung(antwort.zahlwert, punkt.minWert, punkt.maxWert);
case "TEXT":
case "FOTO":
return beantwortet(punkt, antwort);
}
}
/** Pflichtpunkte, die noch offen sind. Solange welche übrig sind, ist die
* Prüfung nicht abschließbar. */
export function offenePflichtpunkte(punkte: Punkt[], antworten: Antwort[]): Punkt[] {
const nachId = new Map(antworten.map((antwort) => [antwort.punktId, antwort]));
return punkte.filter((punkt) => punkt.pflicht && !beantwortet(punkt, nachId.get(punkt.id)));
}
/** Punkte, die nicht in Ordnung sind — die Vorlage für die Mängelmeldung. */
export function beanstandetePunkte(punkte: Punkt[], antworten: Antwort[]): Punkt[] {
const nachId = new Map(antworten.map((antwort) => [antwort.punktId, antwort]));
return punkte.filter((punkt) => {
const antwort = nachId.get(punkt.id);
if (!beantwortet(punkt, antwort)) return false;
return !punktInOrdnung(punkt, antwort);
});
}
/**
* Das Gesamtergebnis.
*
* Es ist ein Vorschlag, kein Urteil: der Prüfer darf es übersteuern, denn er
* trägt die Verantwortung. Was er wählt, steht im Protokoll — was hier
* herauskommt, ist die Vorbelegung und der Abgleich dazu.
*/
export function ergebnisVorschlag(punkte: Punkt[], antworten: Antwort[]): Ergebnis {
const beanstandet = beanstandetePunkte(punkte, antworten);
if (beanstandet.length === 0) return "BESTANDEN";
if (beanstandet.some((punkt) => punkt.pflicht)) return "NICHT_BESTANDEN";
return "BESTANDEN_MIT_MAENGELN";
}
/**
* Welchen Status das Gerät nach dieser Prüfung haben soll.
*
* Nicht bestanden heißt gesperrt — ein Gerät, das die Prüfung nicht besteht,
* gehört nicht auf das Fahrzeug. Mit Mängeln bestanden bleibt einsatzbereit;
* der Mangel wird verfolgt, aber er hindert den Einsatz nicht.
*/
export function geraetStatusNachPruefung(
ergebnis: Ergebnis,
): "EINSATZBEREIT" | "GESPERRT" {
return ergebnis === "NICHT_BESTANDEN" ? "GESPERRT" : "EINSATZBEREIT";
}
/** Wie lange die Prüfung gedauert hat, in vollen Minuten. */
export function dauerInMinuten(begonnenUm: Date | null | undefined, beendetUm: Date): number | null {
if (!begonnenUm) return null;
const minuten = Math.round((beendetUm.getTime() - begonnenUm.getTime()) / 60_000);
return minuten >= 0 ? minuten : null;
}
+166
View File
@@ -0,0 +1,166 @@
import {
heutigerTag,
monateAddieren,
tagesabstand,
utcTag,
} from "@/lib/stammdaten/fristen";
/**
* Fristenlogik der Wartung.
*
* Wie in Phase 1: reine Funktionen ohne Datenbankzugriff. Was hier entschieden
* wird — wann ein Termin fällig ist, in welchen Topf er im Dashboard fällt, ob
* heute erinnert wird — muss ohne laufenden Stack durchtestbar sein.
*
* Alle Angaben sind Kalendertage in Europe/Berlin, gerechnet auf UTC-Basis.
* Die Uhrzeit spielt bei Prüffristen keine Rolle.
*/
/** Die Töpfe des Fristen-Dashboards. */
export type Fristfenster =
| "ueberfaellig"
| "diese_woche"
| "dieser_monat"
| "naechste_90_tage"
| "spaeter";
export const FRISTFENSTER_BEZEICHNUNG: Record<Fristfenster, string> = {
ueberfaellig: "überfällig",
diese_woche: "diese Woche",
dieser_monat: "diesen Monat",
naechste_90_tage: "nächste 90 Tage",
spaeter: "später",
};
/** Reihenfolge der Anzeige — dringend zuerst. */
export const FRISTFENSTER_REIHENFOLGE: Fristfenster[] = [
"ueberfaellig",
"diese_woche",
"dieser_monat",
"naechste_90_tage",
"spaeter",
];
/**
* Die Grenzen der Töpfe, als UTC-Kalendertage.
*
* "Diese Woche" heißt bewusst "innerhalb von sieben Tagen" und nicht "bis
* Sonntag": am Freitag interessiert den Gerätewart, was bis nächsten Freitag
* ansteht, nicht was das Kalenderblatt sagt. Dasselbe gilt für den Monat.
*/
export function fensterGrenzen(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),
};
}
/** In welchen Topf ein Fälligkeitsdatum fällt. */
export function terminFenster(faelligAm: Date, heute: Date = new Date()): Fristfenster {
const tage = tagesabstand(heutigerTag(heute), faelligAm);
if (tage < 0) return "ueberfaellig";
if (tage <= 7) return "diese_woche";
if (tage <= 30) return "dieser_monat";
if (tage <= 90) return "naechste_90_tage";
return "spaeter";
}
/**
* Nächste Fälligkeit nach einer Prüfung.
*
* Basis ist das Datum der letzten durchgeführten Prüfung. Gibt es keine, zählt
* die Indienststellung des Geräts — eine Wehr, die ein Gerät vor drei Jahren in
* Dienst gestellt und nie geprüft hat, soll das im Dashboard sehen und nicht
* durch einen fehlenden Termin verdeckt bekommen.
*
* Ohne beides gibt es keinen Termin. Das ist kein Fehler: bei einem Gerät ohne
* jede Datumsangabe wäre jedes errechnete Datum geraten.
*/
export function naechsteFaelligkeit(angaben: {
letztePruefung?: Date | null;
indienststellung?: Date | null;
intervallMonate: number;
}): 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);
}
/** Vorwarnstufen der Erinnerung, wie sie im Schema stehen. */
export type Erinnerungsstufe = "T_30" | "T_7" | "T_0" | "UEBERFAELLIG";
/**
* Welche Erinnerung heute ansteht — oder keine.
*
* Der nächtliche Lauf ruft das für jeden offenen Termin auf. Dass eine Stufe
* nicht zweimal verschickt wird, entscheidet nicht diese Funktion, sondern der
* Erinnerungs-Eintrag in der Datenbank: je Termin und Stufe gibt es ihn nur
* einmal.
*
* Deshalb steht hier "kleiner oder gleich" und nicht "genau": ein Termin, der
* erst 20 Tage vor Fälligkeit angelegt wird, soll seine erste Erinnerung sofort
* bekommen und nicht durchrutschen, weil der Tag T-30 nie eingetreten ist.
*
* @param vorwarnzeitTage Vorwarnzeit der Vorlage. Sie ersetzt die 30 Tage der
* ersten Stufe; die Stufen T-7 und T-0 bleiben fest.
*/
export function erinnerungsstufe(
faelligAm: Date,
vorwarnzeitTage = 30,
heute: Date = new Date(),
): Erinnerungsstufe | null {
const tage = tagesabstand(heutigerTag(heute), faelligAm);
if (tage < 0) return "UEBERFAELLIG";
if (tage === 0) return "T_0";
if (tage <= 7) return "T_7";
if (tage <= Math.max(vorwarnzeitTage, 7)) return "T_30";
return null;
}
/**
* Bis wann im Voraus geplant wird.
*
* Der nächtliche Lauf legt Termine nicht bis in alle Ewigkeit an, sondern nur
* innerhalb dieses Horizonts. Sonst stünden im Dashboard Termine für 2031, und
* jede Änderung an einer Vorlage müsste hunderte Zukunftstermine nachziehen.
*/
export function innerhalbPlanungshorizont(
faelligAm: Date,
horizontTage: number,
heute: Date = new Date(),
): boolean {
return tagesabstand(heutigerTag(heute), faelligAm) <= horizontTage;
}
/**
* Laufende Protokollnummer im Format "JJJJ-NNNN", z. B. "2026-0042".
*
* Die Nummer ist je Wehr und Jahr fortlaufend. Sie steht auf dem PDF und ist
* das, was in einer Rückfrage der Unfallkasse genannt wird — deshalb kurz,
* sprechbar und ohne Bindestrichwüste.
*/
export function protokollNummer(jahr: number, laufend: number): string {
return `${jahr}-${String(laufend).padStart(4, "0")}`;
}
/** Zerlegt eine Protokollnummer wieder. Gibt null zurück, wenn sie nicht passt. */
export function protokollNummerLesen(
nummer: string | null | undefined,
): { jahr: number; laufend: number } | null {
if (!nummer) return null;
const treffer = /^(\d{4})-(\d{1,6})$/.exec(nummer.trim());
if (!treffer) return null;
return { jahr: Number(treffer[1]), laufend: Number(treffer[2]) };
}
+119
View File
@@ -0,0 +1,119 @@
import { heutigerTag } from "@/lib/stammdaten/fristen";
import { innerhalbPlanungshorizont, naechsteFaelligkeit } from "@/lib/wartung/fristen";
/**
* Terminplanung.
*
* Der nächtliche Lauf beantwortet für jedes Gerät und jede Vorlage dieselbe
* Frage: steht ein Termin an, den es noch nicht gibt? Die Antwort steht hier
* als reine Funktion — der Job in src/worker holt die Daten und schreibt das
* Ergebnis, entscheiden tut er nichts.
*
* Das ist Absicht: die Planung ist die Stelle, an der eine Wehr merkt, dass ein
* Gerät seit vier Jahren nicht geprüft wurde. Solche Logik gehört testbar, ohne
* dass eine Datenbank, ein Redis und eine Uhr richtig stehen müssen.
*/
/** Ein Gerät, so weit die Planung es braucht. */
export interface PlanungsGeraet {
id: string;
inventarnummer: string;
status: string;
indienststellung?: Date | null;
/** Datum der letzten abgeschlossenen Prüfung nach dieser Vorlage. */
letztePruefungZurVorlage?: Date | null;
/** Ersatzweise das Prüfdatum am Gerät selbst (Phase 1, aus dem Import). */
letztePruefung?: Date | null;
/** Es gibt bereits einen offenen Termin für diese Vorlage. */
hatOffenenTermin: boolean;
}
export interface Planungsvorlage {
id: string;
intervallMonate: number;
}
export interface GeplanterTermin {
geraetId: string;
vorlageId: string;
faelligAm: Date;
/** Es gab keinen Anhaltspunkt für ein Datum — der Termin ist sofort fällig. */
ohneBasisdatum: boolean;
}
/** Geräte, für die grundsätzlich nicht geplant wird. */
const NICHT_PLANEN = new Set(["AUSGESONDERT"]);
/**
* Welche Termine anzulegen sind.
*
* Vier Fälle, in dieser Reihenfolge:
*
* 1. Ausgesonderte Geräte fallen raus. Ein ausgesondertes Gerät wird nicht
* mehr geprüft, und ein Termin darauf wäre nur Rauschen im Dashboard.
* 2. Gibt es schon einen offenen Termin, kommt kein zweiter dazu. Erst wenn
* der erledigt ist, wird der nächste geplant.
* 3. Fehlt jeder Anhaltspunkt — keine frühere Prüfung, keine Indienststellung
* —, wird der Termin auf heute gesetzt. Ein Gerät ohne jedes Datum ist
* nicht "in Ordnung", sondern ungeklärt, und genau so soll es auffallen.
* 4. Sonst: letzte Prüfung plus Intervall. Liegt das Ergebnis jenseits des
* Planungshorizonts, wird noch nicht geplant.
*/
export function zuPlanendeTermine(
vorlage: Planungsvorlage,
geraete: PlanungsGeraet[],
optionen: { horizontTage: number; heute?: Date },
): GeplanterTermin[] {
const heute = optionen.heute ?? new Date();
const heutigerStichtag = heutigerTag(heute);
const geplant: GeplanterTermin[] = [];
for (const geraet of geraete) {
if (NICHT_PLANEN.has(geraet.status)) continue;
if (geraet.hatOffenenTermin) continue;
const basis = geraet.letztePruefungZurVorlage ?? geraet.letztePruefung ?? null;
const faellig = naechsteFaelligkeit({
letztePruefung: basis,
indienststellung: geraet.indienststellung,
intervallMonate: vorlage.intervallMonate,
});
if (!faellig) {
geplant.push({
geraetId: geraet.id,
vorlageId: vorlage.id,
faelligAm: heutigerStichtag,
ohneBasisdatum: true,
});
continue;
}
if (!innerhalbPlanungshorizont(faellig, optionen.horizontTage, heute)) continue;
geplant.push({
geraetId: geraet.id,
vorlageId: vorlage.id,
faelligAm: faellig,
ohneBasisdatum: false,
});
}
return geplant;
}
/**
* Der Prüftermin, der am Gerät steht.
*
* Ein Gerät kann mehreren Vorlagen unterliegen — ein Pressluftatmer wird
* jährlich sichtgeprüft und alle sechs Jahre grundüberholt. Am Gerät selbst
* steht die nächste Frist, und das ist immer die früheste offene.
*/
export function fruehesteFaelligkeit(termine: Array<{ faelligAm: Date }>): Date | null {
if (termine.length === 0) return null;
return termine.reduce(
(frueheste, termin) => (termin.faelligAm < frueheste ? termin.faelligAm : frueheste),
termine[0]!.faelligAm,
);
}
+308
View File
@@ -0,0 +1,308 @@
import "server-only";
import { fuerOrganisation } from "@/lib/db";
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
import { herunterladen, hochladen, objektSchluessel } from "@/lib/storage";
import {
alsDataUri,
protokollPdf,
type ProtokollDaten,
type ProtokollPunkt,
} from "@/lib/wartung/protokoll";
import type { Ergebnis, PunktTyp } from "@/lib/wartung/bewertung";
/**
* Vom Datensatz zum abgelegten PDF.
*
* Läuft im Hintergrundprozess, nicht in der Anfrage: Gotenberg braucht ein paar
* Sekunden, und die Fotos wollen aus MinIO geholt und eingebettet werden. Wer
* gerade eine Prüfung abgeschlossen hat, soll dabei nicht warten.
*
* Der Ablauf ist bewusst dreigeteilt — lesen, rendern, schreiben. Zwischen
* Lesen und Schreiben liegen Netzaufrufe zu MinIO und Gotenberg; die dürfen
* dauern. Eine offene Datenbanktransaktion dürfte das nicht, sie liefe in ihr
* Zeitlimit und gäbe eine Verbindung nicht frei.
*
* Der Vorgang ist wiederholbar: gibt es das Protokoll schon, passiert nichts.
* So darf der Auftrag zweimal laufen, ohne dass zwei PDFs entstehen.
*/
/** Mehr Fotos passen nicht sinnvoll in ein Protokoll — und nicht in den Speicher. */
const MAX_FOTOS = 24;
/** Ein einzelnes Bild größer als das wird nicht eingebettet. */
const MAX_BILD_BYTES = 8 * 1024 * 1024;
const ROLLE_TEXT: Record<string, string> = {
PRUEFER: "Prüfer",
GERAETEWART: "Gerätewart",
FREMDFIRMA: "Fachfirma",
};
const PRIORITAET_TEXT: Record<string, string> = {
NIEDRIG: "niedrig",
MITTEL: "mittel",
HOCH: "hoch",
KRITISCH: "kritisch",
};
/** Antwort in Klartext für die Protokollspalte "Ist". */
function istwert(
typ: PunktTyp,
ergebnis: {
jaNein: boolean | null;
zahlwert: { toNumber(): number } | null;
text: string | null;
fotoDateiId: string | null;
} | null,
einheit?: string | null,
): string {
if (!ergebnis) return "nicht geprüft";
switch (typ) {
case "JA_NEIN":
return ergebnis.jaNein === true ? "ja" : ergebnis.jaNein === false ? "nein" : "—";
case "MESSWERT": {
if (!ergebnis.zahlwert) return "—";
const zahl = ergebnis.zahlwert.toNumber();
return `${zahl.toLocaleString("de-DE", { maximumFractionDigits: 3 })}${
einheit ? ` ${einheit}` : ""
}`;
}
case "TEXT":
return ergebnis.text ?? "—";
case "FOTO":
return ergebnis.fotoDateiId ? "Foto liegt bei" : "—";
}
}
/**
* Erzeugt das Protokoll-PDF und legt es in MinIO ab.
*
* @returns die Kennung der Datei, oder null wenn es zu dieser Prüfung kein
* Protokoll geben kann (unbekannt oder noch Entwurf)
*/
export async function protokollErzeugen(
organisationId: string,
pruefungId: string,
): Promise<{ dateiId: string; neu: boolean } | null> {
// ---------------------------------------------------------------- lesen
const geladen = await fuerOrganisation(organisationId, async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: pruefungId },
include: {
organisation: { select: { name: true, slug: true, strasse: true, plz: true, ort: true } },
geraet: {
include: {
geraetetyp: { select: { name: true } },
fahrzeug: { select: { funkrufname: true } },
standort: { select: { name: true } },
geraeteraum: { select: { bezeichnung: true } },
},
},
vorlage: {
select: {
name: true,
pruefvorschrift: true,
intervallMonate: true,
punkte: { orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }] },
},
},
pruefer: { select: { mitgliedsnummer: true } },
ergebnisse: true,
signaturen: { orderBy: { unterzeichnetAm: "asc" } },
maengel: { where: { geloeschtAm: null } },
ersetzt: { select: { protokollNummer: true } },
},
});
if (!pruefung) return null;
if (pruefung.status === "ENTWURF") return null;
const bildIds = [
...pruefung.ergebnisse.map((ergebnis) => ergebnis.fotoDateiId),
...pruefung.signaturen.map((signatur) => signatur.bildDateiId),
].filter((id): id is string => Boolean(id));
const dateien = bildIds.length
? await db.datei.findMany({
where: { id: { in: bildIds } },
select: { id: true, objektSchluessel: true, mimeTyp: true, groesseBytes: true },
})
: [];
return { pruefung, dateien };
});
if (!geladen) return null;
const { pruefung, dateien } = geladen;
if (pruefung.protokollDateiId) {
return { dateiId: pruefung.protokollDateiId, neu: false };
}
// --------------------------------------------------------------- rendern
const bilder = new Map<string, string>();
let eingebettet = 0;
for (const datei of dateien) {
if (eingebettet >= MAX_FOTOS) break;
if (Number(datei.groesseBytes) > MAX_BILD_BYTES) continue;
try {
const inhalt = await herunterladen(datei.objektSchluessel);
bilder.set(datei.id, alsDataUri(inhalt, datei.mimeTyp));
eingebettet += 1;
} catch (fehler) {
// Ein fehlendes Bild darf das Protokoll nicht verhindern — der Nachweis
// steckt in den Werten, nicht im Foto.
console.error(`[protokoll] Bild ${datei.id} nicht ladbar:`, fehler);
}
}
const ergebnisNachPunkt = new Map(
pruefung.ergebnisse.map((ergebnis) => [ergebnis.punktId, ergebnis]),
);
const punkte: ProtokollPunkt[] = (pruefung.vorlage?.punkte ?? []).map((punkt) => {
const ergebnis = ergebnisNachPunkt.get(punkt.id) ?? null;
return {
text: punkt.text,
typ: punkt.typ,
pflicht: punkt.pflicht,
sollwert: punkt.sollwert,
einheit: punkt.einheit,
istwert: istwert(punkt.typ, ergebnis, punkt.einheit),
inOrdnung: ergebnis?.inOrdnung ?? false,
bemerkung: ergebnis?.bemerkung ?? null,
bild: ergebnis?.fotoDateiId ? (bilder.get(ergebnis.fotoDateiId) ?? null) : null,
};
});
const lagerung = pruefung.geraet.fahrzeug
? [pruefung.geraet.fahrzeug.funkrufname, pruefung.geraet.geraeteraum?.bezeichnung]
.filter(Boolean)
.join(" · ")
: (pruefung.geraet.standort?.name ?? null);
const daten: ProtokollDaten = {
organisation: {
name: pruefung.organisation.name,
strasse: pruefung.organisation.strasse,
plz: pruefung.organisation.plz,
ort: pruefung.organisation.ort,
},
geraet: {
inventarnummer: pruefung.geraet.inventarnummer,
bezeichnung: pruefung.geraet.bezeichnung,
hersteller: pruefung.geraet.hersteller,
modell: pruefung.geraet.modell,
seriennummer: pruefung.geraet.seriennummer,
typName: pruefung.geraet.geraetetyp?.name ?? null,
lagerung,
baujahr: pruefung.geraet.herstellungsdatum,
indienststellung: pruefung.geraet.indienststellung,
},
pruefung: {
protokollNummer: pruefung.protokollNummer,
durchgefuehrtAm: pruefung.durchgefuehrtAm,
art: pruefung.art,
ergebnis: (pruefung.ergebnis ?? "BESTANDEN") as Ergebnis,
dauerMinuten: pruefung.dauerMinuten,
prueferName: pruefung.prueferName,
prueferKennung: pruefung.pruefer?.mitgliedsnummer ?? null,
externeFirma: pruefung.externeFirma,
kosten: pruefung.kosten ? pruefung.kosten.toNumber() : null,
naechstePruefungAm: pruefung.naechstePruefungAm,
bemerkung: pruefung.bemerkung,
ersetztProtokollNummer: pruefung.ersetzt?.protokollNummer ?? null,
korrekturGrund: pruefung.korrekturGrund,
},
vorlage: pruefung.vorlage
? {
name: pruefung.vorlage.name,
pruefvorschrift: pruefung.vorlage.pruefvorschrift,
intervallMonate: pruefung.vorlage.intervallMonate,
}
: null,
punkte,
maengel: pruefung.maengel.map((mangel) => ({
beschreibung: mangel.beschreibung,
prioritaet: PRIORITAET_TEXT[mangel.prioritaet] ?? mangel.prioritaet,
})),
signaturen: pruefung.signaturen.map((signatur) => ({
name: signatur.name,
rolle: ROLLE_TEXT[signatur.rolle] ?? signatur.rolle,
unterzeichnetAm: signatur.unterzeichnetAm,
bild: signatur.bildDateiId ? (bilder.get(signatur.bildDateiId) ?? null) : null,
})),
erzeugtAm: new Date(),
};
const pdf = await protokollPdf(daten);
const dateiname = `Pruefprotokoll-${pruefung.protokollNummer ?? pruefung.id}.pdf`;
const schluessel = objektSchluessel({
organisationSlug: pruefung.organisation.slug,
entitaet: "Pruefung",
entitaetId: pruefung.id,
dateiname,
});
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
pruefung: pruefung.id,
protokoll: pruefung.protokollNummer ?? "",
});
// -------------------------------------------------------------- schreiben
return fuerOrganisation(organisationId, async (db) => {
// Zwischenzeitlich könnte ein zweiter Lauf schneller gewesen sein.
const stand = await db.pruefung.findUnique({
where: { id: pruefung.id },
select: { protokollDateiId: true },
});
if (stand?.protokollDateiId) {
return { dateiId: stand.protokollDateiId, neu: false };
}
const datei = await db.datei.create({
data: {
organisationId,
bucket: abgelegt.bucket,
objektSchluessel: abgelegt.objektSchluessel,
dateiname,
mimeTyp: "application/pdf",
groesseBytes: BigInt(abgelegt.groesseBytes),
pruefsumme: abgelegt.pruefsumme,
entitaet: "Pruefung",
entitaetId: pruefung.id,
kategorie: "pruefprotokoll",
hochgeladenVonId: pruefung.prueferId,
},
});
// Erlaubt trotz Änderungssperre: das Protokollfeld war leer (siehe Trigger
// pruefung_unveraenderlich in Migration 20260903000300).
await db.pruefung.update({
where: { id: pruefung.id },
data: { protokollDateiId: datei.id },
});
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.DATEI_HOCHGELADEN,
entitaet: "Pruefung",
entitaetId: pruefung.id,
beschreibung: `Prüfprotokoll ${pruefung.protokollNummer ?? ""} erzeugt (${Math.round(
abgelegt.groesseBytes / 1024,
)} kB)`,
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
nachher: { dateiId: datei.id, pruefsumme: abgelegt.pruefsumme },
});
return { dateiId: datei.id, neu: true };
});
}
+479
View File
@@ -0,0 +1,479 @@
import "server-only";
import { htmlZuPdf, html } from "@/lib/pdf";
import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
import { ERGEBNIS_BEZEICHNUNG, type Ergebnis, type PunktTyp } from "@/lib/wartung/bewertung";
/**
* Das Prüfprotokoll als PDF.
*
* Es ist der Nachweis, den die Wehr bei einer Prüfung durch die Unfallkasse
* vorlegt. Danach richtet sich der Aufbau:
*
* Kopf wer geprüft hat, welche Wehr, welches Gerät, welche Vorschrift
* Mitte jeder Checklistenpunkt mit Soll, Ist und Befund
* Fotos eingebettet, nicht verlinkt — ein Link ins Nichts ist kein Beleg
* Mängel was aufgefallen ist
* Fuß Unterschriften, Prüfvorschrift, Prüfdatum, Seitenzahl
*
* Alles ist eingebettet: keine externen Schriften, keine nachgeladenen Bilder.
* Ein Protokoll muss in zehn Jahren noch genauso aussehen wie heute, auch wenn
* es dann keinen Server mehr gibt, der irgendetwas nachliefert.
*/
/** Bild als data-URI, damit es fest im PDF steckt. */
export function alsDataUri(inhalt: Buffer, mimeTyp: string): string {
return `data:${mimeTyp};base64,${inhalt.toString("base64")}`;
}
export interface ProtokollPunkt {
text: string;
typ: PunktTyp;
pflicht: boolean;
sollwert?: string | null;
einheit?: string | null;
/** Die Antwort, schon für die Anzeige aufbereitet. */
istwert: string;
inOrdnung: boolean;
bemerkung?: string | null;
/** Foto zum Punkt, bereits als data-URI. */
bild?: string | null;
}
export interface ProtokollSignatur {
name: string;
rolle: string;
unterzeichnetAm: Date;
/** PNG des Zeichenfelds als data-URI. */
bild?: string | null;
}
export interface ProtokollDaten {
organisation: {
name: string;
strasse?: string | null;
plz?: string | null;
ort?: string | null;
};
geraet: {
inventarnummer: string;
bezeichnung: string;
hersteller?: string | null;
modell?: string | null;
seriennummer?: string | null;
typName?: string | null;
lagerung?: string | null;
baujahr?: Date | null;
indienststellung?: Date | null;
};
pruefung: {
protokollNummer?: string | null;
durchgefuehrtAm: Date;
art: "INTERN" | "EXTERN";
ergebnis: Ergebnis;
dauerMinuten?: number | null;
prueferName?: string | null;
/** Mitgliedsnummer oder Kennung des Prüfers — die "Prüfer-ID" im Kopf. */
prueferKennung?: string | null;
externeFirma?: string | null;
kosten?: number | null;
naechstePruefungAm?: Date | null;
bemerkung?: string | null;
/** Bei einer Korrektur: Nummer des ersetzten Protokolls. */
ersetztProtokollNummer?: string | null;
korrekturGrund?: string | null;
};
vorlage?: {
name: string;
pruefvorschrift?: string | null;
intervallMonate: number;
} | null;
punkte: ProtokollPunkt[];
maengel: Array<{ beschreibung: string; prioritaet: string }>;
signaturen: ProtokollSignatur[];
erzeugtAm: Date;
}
const ERGEBNIS_FARBE: Record<Ergebnis, string> = {
BESTANDEN: "#15803d",
BESTANDEN_MIT_MAENGELN: "#b45309",
NICHT_BESTANDEN: "#b91c1c",
};
function zeile(bezeichnung: string, wert: string | null | undefined): string {
return `
<div class="feld">
<div class="beschriftung">${html(bezeichnung)}</div>
<div class="wert">${html(wert && wert !== "" ? wert : "—")}</div>
</div>`;
}
function punktZeile(punkt: ProtokollPunkt, nummer: number): string {
const soll = [punkt.sollwert, punkt.einheit].filter(Boolean).join(" ");
return `
<tr class="${punkt.inOrdnung ? "" : "beanstandet"}">
<td class="nummer">${nummer}</td>
<td>
${html(punkt.text)}
${punkt.pflicht ? '<span class="pflicht" title="Pflichtpunkt">*</span>' : ""}
${punkt.bemerkung ? `<div class="bemerkung">${html(punkt.bemerkung)}</div>` : ""}
</td>
<td class="soll">${html(soll || "—")}</td>
<td class="ist">${html(punkt.istwert || "—")}</td>
<td class="befund">${punkt.inOrdnung ? "i. O." : "nicht i. O."}</td>
</tr>`;
}
function fotoBlock(punkte: ProtokollPunkt[]): string {
const mitBild = punkte.filter((punkt) => punkt.bild);
if (mitBild.length === 0) return "";
return `
<section>
<h2>Fotos</h2>
<div class="fotos">
${mitBild
.map(
(punkt) => `
<figure>
<img src="${punkt.bild}" alt="">
<figcaption>${html(punkt.text)}</figcaption>
</figure>`,
)
.join("")}
</div>
</section>`;
}
/** Baut das eigenständige HTML des Protokolls. */
export function protokollHtml(daten: ProtokollDaten): string {
const { organisation, geraet, pruefung, vorlage, punkte, maengel, signaturen } = daten;
const anschrift = [
organisation.strasse,
[organisation.plz, organisation.ort].filter(Boolean).join(" "),
]
.filter(Boolean)
.join(", ");
const geraetetext = [geraet.hersteller, geraet.modell].filter(Boolean).join(" ");
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Prüfprotokoll ${html(pruefung.protokollNummer ?? geraet.inventarnummer)}</title>
<style>
/* Ränder kommen von Gotenberg (siehe protokollPdf) — hier keine setzen,
sonst bleibt kein Platz für Kopf- und Fußzeile. */
@page { size: A4; }
* { box-sizing: border-box; }
html, body {
margin: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 9.5pt;
color: #111;
-webkit-print-color-adjust: exact;
}
h1 { font-size: 15pt; margin: 0 0 1mm; }
h2 {
font-size: 10.5pt;
margin: 6mm 0 2mm;
padding-bottom: 1mm;
border-bottom: 0.4mm solid #111;
}
.kopf {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8mm;
padding-bottom: 3mm;
border-bottom: 0.6mm solid #111;
}
.kopf .wehr { font-size: 8.5pt; color: #333; }
.ergebnis {
text-align: right;
white-space: nowrap;
}
.ergebnis .marke {
display: inline-block;
padding: 1.5mm 3mm;
border: 0.5mm solid currentColor;
border-radius: 1.5mm;
font-weight: 700;
font-size: 11pt;
}
.ergebnis .nummer { font-size: 8.5pt; color: #333; margin-top: 1.5mm; }
.felder {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2mm 6mm;
margin-top: 3mm;
}
.feld .beschriftung {
font-size: 7.5pt;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #555;
}
.feld .wert { font-size: 9.5pt; }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
font-size: 7.5pt;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #555;
border-bottom: 0.3mm solid #999;
padding: 1.5mm 2mm;
}
td {
padding: 1.8mm 2mm;
border-bottom: 0.2mm solid #ddd;
vertical-align: top;
}
tr { break-inside: avoid; }
td.nummer { width: 8mm; color: #666; }
td.soll, td.ist { width: 28mm; }
td.befund { width: 24mm; font-weight: 600; }
tr.beanstandet td.befund { color: #b91c1c; }
tr.beanstandet td { background: #fdf2f2; }
.pflicht { color: #b91c1c; font-weight: 700; }
.bemerkung { font-size: 8pt; color: #444; margin-top: 0.8mm; }
.fotos {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4mm;
}
.fotos figure { margin: 0; break-inside: avoid; }
.fotos img {
width: 100%;
height: 45mm;
object-fit: cover;
border: 0.2mm solid #bbb;
}
.fotos figcaption { font-size: 7.5pt; color: #444; margin-top: 1mm; }
.maengel li { margin-bottom: 1.5mm; }
.unterschriften {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8mm;
margin-top: 4mm;
}
.unterschrift { break-inside: avoid; }
.unterschrift .bild {
height: 22mm;
border-bottom: 0.3mm solid #111;
display: flex;
align-items: flex-end;
}
.unterschrift .bild img { max-height: 21mm; max-width: 100%; }
.unterschrift .name { font-size: 8.5pt; margin-top: 1.5mm; }
.unterschrift .rolle { font-size: 7.5pt; color: #555; }
.hinweis {
margin-top: 6mm;
padding: 2.5mm 3mm;
background: #f4f4f5;
border-left: 1mm solid #999;
font-size: 8pt;
color: #333;
}
.korrektur {
margin-top: 3mm;
padding: 2.5mm 3mm;
border: 0.4mm solid #b45309;
color: #7c2d12;
font-size: 8.5pt;
}
</style>
</head>
<body>
<div class="kopf">
<div>
<h1>Prüfprotokoll</h1>
<div class="wehr">
${html(organisation.name)}${anschrift ? ` · ${html(anschrift)}` : ""}
</div>
</div>
<div class="ergebnis" style="color: ${ERGEBNIS_FARBE[pruefung.ergebnis]}">
<div class="marke">${html(ERGEBNIS_BEZEICHNUNG[pruefung.ergebnis])}</div>
<div class="nummer">
${pruefung.protokollNummer ? `Protokoll ${html(pruefung.protokollNummer)}` : "ohne Nummer"}
</div>
</div>
</div>
${
pruefung.ersetztProtokollNummer
? `<div class="korrektur">
<strong>Korrekturprotokoll.</strong> Dieses Protokoll ersetzt
${html(pruefung.ersetztProtokollNummer)}.
${pruefung.korrekturGrund ? ` Grund: ${html(pruefung.korrekturGrund)}` : ""}
</div>`
: ""
}
<section>
<h2>Gerät</h2>
<div class="felder">
${zeile("Inventarnummer", geraet.inventarnummer)}
${zeile("Bezeichnung", geraet.bezeichnung)}
${zeile("Gerätetyp", geraet.typName)}
${zeile("Hersteller / Modell", geraetetext)}
${zeile("Seriennummer", geraet.seriennummer)}
${zeile("Lagerung", geraet.lagerung)}
${zeile("Baujahr", geraet.baujahr ? alsDatum(geraet.baujahr) : null)}
${zeile("Indienststellung", geraet.indienststellung ? alsDatum(geraet.indienststellung) : null)}
</div>
</section>
<section>
<h2>Prüfung</h2>
<div class="felder">
${zeile("Prüfdatum", alsDatum(pruefung.durchgefuehrtAm))}
${zeile("Prüfer", pruefung.prueferName)}
${zeile("Prüfer-Kennung", pruefung.prueferKennung)}
${zeile("Art", pruefung.art === "EXTERN" ? "extern durch Fachfirma" : "intern")}
${zeile("Fachfirma", pruefung.externeFirma)}
${zeile("Kosten", pruefung.kosten !== null && pruefung.kosten !== undefined ? alsEuro(pruefung.kosten) : null)}
${zeile("Vorlage", vorlage?.name)}
${zeile("Prüfvorschrift", vorlage?.pruefvorschrift)}
${zeile("Intervall", vorlage ? `${vorlage.intervallMonate} Monate` : null)}
${zeile("Dauer", pruefung.dauerMinuten ? `${pruefung.dauerMinuten} Minuten` : null)}
${zeile("Nächste Prüfung", pruefung.naechstePruefungAm ? alsDatum(pruefung.naechstePruefungAm) : null)}
</div>
${pruefung.bemerkung ? `<p style="margin-top:3mm">${html(pruefung.bemerkung)}</p>` : ""}
</section>
<section>
<h2>Checkliste</h2>
<table>
<thead>
<tr>
<th>Nr.</th>
<th>Prüfpunkt</th>
<th>Soll</th>
<th>Ist</th>
<th>Befund</th>
</tr>
</thead>
<tbody>
${punkte.map((punkt, index) => punktZeile(punkt, index + 1)).join("")}
</tbody>
</table>
<p style="font-size:7.5pt;color:#555;margin-top:2mm">
<span class="pflicht">*</span> Pflichtpunkt — davon hängt die
Einsatzbereitschaft ab.
</p>
</section>
${fotoBlock(punkte)}
${
maengel.length > 0
? `<section>
<h2>Festgestellte Mängel</h2>
<ul class="maengel">
${maengel
.map(
(mangel) =>
`<li><strong>${html(mangel.prioritaet)}:</strong> ${html(mangel.beschreibung)}</li>`,
)
.join("")}
</ul>
</section>`
: ""
}
<section>
<h2>Unterschriften</h2>
<div class="unterschriften">
${signaturen
.map(
(signatur) => `
<div class="unterschrift">
<div class="bild">${signatur.bild ? `<img src="${signatur.bild}" alt="">` : ""}</div>
<div class="name">${html(signatur.name)}</div>
<div class="rolle">${html(signatur.rolle)} · ${html(alsDatumZeit(signatur.unterzeichnetAm))}</div>
</div>`,
)
.join("")}
</div>
</section>
<div class="hinweis">
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Dieses
Protokoll ist nach dem Abschluss unveränderbar gespeichert. Korrekturen
erscheinen als eigenes Protokoll mit Verweis auf dieses.
</div>
</body>
</html>`;
}
/** Kopfzeile des PDF — sie wiederholt sich auf jeder Seite. */
export function kopfzeileHtml(daten: ProtokollDaten): string {
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
width: 100%; margin: 0; padding: 0 12mm; }
.zeile { display: flex; justify-content: space-between; }
</style></head><body>
<div class="zeile">
<span>${html(daten.organisation.name)}</span>
<span>Prüfprotokoll ${html(daten.pruefung.protokollNummer ?? "")} · ${html(daten.geraet.inventarnummer)}</span>
</div>
</body></html>`;
}
/** Fußzeile mit Prüfvorschrift, Prüfdatum und Seitenzahl. */
export function fusszeileHtml(daten: ProtokollDaten): string {
const vorschrift = daten.vorlage?.pruefvorschrift ?? "ohne Angabe der Vorschrift";
return `<!doctype html><html><head><meta charset="utf-8"><style>
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
width: 100%; margin: 0; padding: 0 12mm; }
.zeile { display: flex; justify-content: space-between; }
</style></head><body>
<div class="zeile">
<span>${html(vorschrift)} · geprüft am ${html(alsDatum(daten.pruefung.durchgefuehrtAm))}</span>
<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
</div>
</body></html>`;
}
/** Erzeugt das Protokoll als PDF über Gotenberg. */
export async function protokollPdf(daten: ProtokollDaten): Promise<Buffer> {
return htmlZuPdf(protokollHtml(daten), {
randOben: 0.55,
randUnten: 0.55,
randLinks: 0.5,
randRechts: 0.5,
hintergrundDrucken: true,
kopfzeileHtml: kopfzeileHtml(daten),
fusszeileHtml: fusszeileHtml(daten),
});
}
+81
View File
@@ -0,0 +1,81 @@
import type {
BetroffenheitStatus,
ChecklistenpunktTyp,
MangelPrioritaet,
MangelStatus,
PruefErgebnis,
PruefungStatus,
RueckrufStatus,
WartungsterminStatus,
} from "@prisma/client";
import type { Fristfenster } from "@/lib/wartung/fristen";
/**
* Klartext zu den Aufzählungen der Wartung.
*
* Steht bewusst in einer Datei ohne "use client": Server-Komponenten und
* Client-Komponenten brauchen dieselben Beschriftungen. Läge das in einem
* Client-Modul, bekäme der Server statt der Texte nur Platzhalter — Next.js
* ersetzt alle Exporte eines "use client"-Moduls durch Verweise.
*/
export const ERGEBNIS_TEXT: Record<PruefErgebnis, string> = {
BESTANDEN: "bestanden",
BESTANDEN_MIT_MAENGELN: "mit Mängeln bestanden",
NICHT_BESTANDEN: "nicht bestanden",
};
export const PRUEFUNG_STATUS_TEXT: Record<PruefungStatus, string> = {
ENTWURF: "Entwurf",
ABGESCHLOSSEN: "abgeschlossen",
ERSETZT: "ersetzt",
};
export const TERMIN_STATUS_TEXT: Record<WartungsterminStatus, string> = {
OFFEN: "offen",
ERLEDIGT: "erledigt",
ENTFALLEN: "entfallen",
};
export const MANGEL_STATUS_TEXT: Record<MangelStatus, string> = {
GEMELDET: "gemeldet",
ZUGEWIESEN: "zugewiesen",
IN_ARBEIT: "in Arbeit",
BEHOBEN: "behoben",
VERWORFEN: "verworfen",
};
export const PRIORITAET_TEXT: Record<MangelPrioritaet, string> = {
NIEDRIG: "niedrig",
MITTEL: "mittel",
HOCH: "hoch",
KRITISCH: "kritisch",
};
export const RUECKRUF_STATUS_TEXT: Record<RueckrufStatus, string> = {
OFFEN: "offen",
IN_BEARBEITUNG: "in Bearbeitung",
ERLEDIGT: "erledigt",
};
export const BETROFFENHEIT_TEXT: Record<BetroffenheitStatus, string> = {
OFFEN: "offen",
NICHT_BETROFFEN: "nicht betroffen",
MASSNAHME_LAEUFT: "Maßnahme läuft",
ERLEDIGT: "erledigt",
};
export const PUNKTTYP_TEXT: Record<ChecklistenpunktTyp, string> = {
JA_NEIN: "Ja / Nein",
MESSWERT: "Messwert",
TEXT: "Text",
FOTO: "Foto",
};
export const FENSTER_TEXT: Record<Fristfenster, string> = {
ueberfaellig: "überfällig",
diese_woche: "diese Woche",
dieser_monat: "diesen Monat",
naechste_90_tage: "nächste 90 Tage",
spaeter: "später",
};
+12
View File
@@ -9,6 +9,11 @@ import { standortRouter, geraeteraumRouter } from "@/server/routers/stammdaten/o
import { geraetetypRouter, qualifikationstypRouter } from "@/server/routers/stammdaten/katalog";
import { personRouter, qualifikationRouter } from "@/server/routers/stammdaten/person";
import { importRouter } from "@/server/routers/stammdaten/import";
import { wartungsvorlageRouter } from "@/server/routers/wartung/vorlage";
import { wartungsterminRouter } from "@/server/routers/wartung/termin";
import { pruefungRouter } from "@/server/routers/wartung/pruefung";
import { mangelRouter } from "@/server/routers/wartung/mangel";
import { rueckrufRouter } from "@/server/routers/wartung/rueckruf";
/**
* Wurzel-Router.
@@ -139,6 +144,13 @@ export const appRouter = router({
qualifikationstyp: qualifikationstypRouter,
qualifikation: qualifikationRouter,
datenImport: importRouter,
// Phase 2 — Wartung und Prüfung
wartungsvorlage: wartungsvorlageRouter,
wartungstermin: wartungsterminRouter,
pruefung: pruefungRouter,
mangel: mangelRouter,
rueckruf: rueckrufRouter,
});
export type AppRouter = typeof appRouter;
+476
View File
@@ -0,0 +1,476 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { MangelPrioritaet, MangelStatus, Prisma } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
} from "@/server/routers/stammdaten/gemeinsam";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
/**
* Mängel.
*
* Der Weg ist immer derselbe: melden → zugewiesen → in Arbeit → behoben. Jeder
* Schritt landet im Audit-Log, und wer zuständig ist, bekommt eine Nachricht.
*
* Melden darf jedes Mitglied. Das ist Absicht: der Mangel fällt demjenigen auf,
* der das Gerät benutzt, nicht dem Gerätewart am Schreibtisch. Eine Hürde beim
* Melden führt dazu, dass gar nicht gemeldet wird — und ein nicht gemeldeter
* Riss im Schlauch ist gefährlicher als eine Fehlmeldung, die jemand wieder
* verwirft.
*
* Bearbeiten, zuweisen und beheben bleibt beim Gerätewart.
*/
const SUCHFELDER = ["beschreibung", "behebung"];
const LISTEN_AUSWAHL = {
id: true,
beschreibung: true,
prioritaet: true,
status: true,
gemeldetAm: true,
behobenAm: true,
behebung: true,
geraetGesperrt: true,
melderName: true,
fotoDateiId: true,
geloeschtAm: true,
geraet: {
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
fahrzeug: { select: { id: true, funkrufname: true } },
standort: { select: { id: true, name: true } },
},
},
melder: { select: { id: true, vorname: true, nachname: true } },
zugewiesenAn: { select: { id: true, vorname: true, nachname: true } },
behobenVon: { select: { id: true, vorname: true, nachname: true } },
pruefung: { select: { id: true, protokollNummer: true, durchgefuehrtAm: true } },
} satisfies Prisma.MangelSelect;
/** Zustände, die als erledigt gelten. */
const ERLEDIGT: MangelStatus[] = [MangelStatus.BEHOBEN, MangelStatus.VERWORFEN];
export const mangelRouter = router({
liste: mitRecht("pruefung.lesen")
.input(
listenEingabe(["gemeldetAm", "prioritaet", "status"] as const).extend({
status: z.array(z.nativeEnum(MangelStatus)).optional(),
prioritaet: z.array(z.nativeEnum(MangelPrioritaet)).optional(),
geraetId: z.string().uuid().optional(),
zugewiesenAnId: z.string().uuid().optional(),
nurOffene: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.MangelWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.status?.length ? { status: { in: input.status } } : {}),
...(input.nurOffene ? { status: { notIn: ERLEDIGT } } : {}),
...(input.prioritaet?.length ? { prioritaet: { in: input.prioritaet } } : {}),
...(input.geraetId ? { geraetId: input.geraetId } : {}),
...(input.zugewiesenAnId ? { zugewiesenAnId: input.zugewiesenAnId } : {}),
...(input.suche
? {
OR: [
...SUCHFELDER.map((feld) => ({
[feld]: { contains: input.suche, mode: "insensitive" as const },
})),
{ geraet: { inventarnummer: { contains: input.suche, mode: "insensitive" } } },
],
}
: {}),
};
const [eintraege, gesamt] = await Promise.all([
db.mangel.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy:
input.sortierung === "prioritaet"
? [{ prioritaet: input.richtung }, { gemeldetAm: "desc" }]
: input.sortierung === "status"
? [{ status: input.richtung }, { gemeldetAm: "desc" }]
: { gemeldetAm: input.sortierung ? input.richtung : "desc" },
...blaettern(input),
}),
db.mangel.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
einzeln: mitRecht("pruefung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const mangel = await db.mangel.findUnique({
where: { id: input.id },
select: LISTEN_AUSWAHL,
});
if (!mangel) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
}
return mangel;
}),
),
/** Zahlen für Übersicht und Filterleiste. */
kennzahlen: mitRecht("pruefung.lesen").query(async ({ ctx }) =>
ctx.db(async (db) => {
const nachStatus = await db.mangel.groupBy({
by: ["status"],
where: { geloeschtAm: null },
_count: { _all: true },
});
const [offen, kritisch] = await Promise.all([
db.mangel.count({ where: { geloeschtAm: null, status: { notIn: ERLEDIGT } } }),
db.mangel.count({
where: {
geloeschtAm: null,
status: { notIn: ERLEDIGT },
prioritaet: MangelPrioritaet.KRITISCH,
},
}),
]);
return {
offen,
kritisch,
nachStatus: Object.fromEntries(
nachStatus.map((eintrag) => [eintrag.status, eintrag._count._all]),
) as Record<MangelStatus, number>,
};
}),
),
/**
* Mangel melden.
*
* Ein kritischer Mangel sperrt das Gerät sofort — ohne Rückfrage. Wer die
* höchste Stufe wählt, sagt damit: das Ding darf nicht mehr aufs Fahrzeug.
* Dass die Sperre zu diesem Mangel gehört, wird vermerkt; beim Beheben wird
* sie wieder aufgehoben.
*/
melden: mitRecht("pruefung.lesen")
.input(
z.object({
geraetId: z.string().uuid(),
beschreibung: z.string().trim().min(1, "Ohne Beschreibung nützt die Meldung nichts.").max(2000),
prioritaet: z.nativeEnum(MangelPrioritaet).default(MangelPrioritaet.MITTEL),
fotoDateiId: z.string().uuid().nullish(),
geraetSperren: z.boolean().optional(),
zugewiesenAnId: z.string().uuid().nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const geraet = await db.geraet.findUnique({
where: { id: input.geraetId },
select: { id: true, inventarnummer: true, bezeichnung: true, status: true },
});
if (!geraet) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
const sperren =
input.geraetSperren ?? input.prioritaet === MangelPrioritaet.KRITISCH;
const wirdGesperrt = sperren && geraet.status !== "GESPERRT";
const mangel = await db.mangel.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: input.geraetId,
beschreibung: input.beschreibung,
prioritaet: input.prioritaet,
fotoDateiId: input.fotoDateiId ?? null,
melderId: ctx.sitzung.personId,
melderName: ctx.sitzung.name,
zugewiesenAnId: input.zugewiesenAnId ?? null,
status: input.zugewiesenAnId ? MangelStatus.ZUGEWIESEN : MangelStatus.GEMELDET,
geraetGesperrt: wirdGesperrt,
},
});
if (wirdGesperrt) {
await db.geraet.update({
where: { id: input.geraetId },
data: { status: "GESPERRT" },
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Mangel",
entitaetId: mangel.id,
beschreibung: `Mangel an ${geraet.inventarnummer} gemeldet (${input.prioritaet})${
wirdGesperrt ? ", Gerät gesperrt" : ""
}`,
sitzung: ctx.sitzung,
nachher: mangel,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
await beauftrage(AUFTRAG.ERINNERUNG_SENDEN, {
organisationId: ctx.sitzung.organisationId,
anlass: "mangel_gemeldet",
mangelId: mangel.id,
}).catch((fehler) => {
console.error("[mangel] Benachrichtigung ließ sich nicht einstellen:", fehler);
});
return mangel;
}),
),
/** Zuständigkeit setzen. Der Status springt dabei auf "zugewiesen". */
zuweisen: mitRecht("pruefung.planen")
.input(idEingabe.extend({ zugewiesenAnId: z.string().uuid().nullish() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.mangel.findUnique({
where: { id: input.id },
include: { geraet: { select: { inventarnummer: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
}
const nachher = await db.mangel.update({
where: { id: input.id },
data: {
zugewiesenAnId: input.zugewiesenAnId ?? null,
status: input.zugewiesenAnId
? vorher.status === MangelStatus.GEMELDET
? MangelStatus.ZUGEWIESEN
: vorher.status
: MangelStatus.GEMELDET,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Mangel",
entitaetId: input.id,
beschreibung: `Mangel an ${vorher.geraet.inventarnummer} zugewiesen`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
await beauftrage(AUFTRAG.ERINNERUNG_SENDEN, {
organisationId: ctx.sitzung.organisationId,
anlass: "mangel_zugewiesen",
mangelId: input.id,
}).catch(() => undefined);
return nachher;
}),
),
/** In Arbeit nehmen. */
inArbeit: mitRecht("pruefung.durchfuehren")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.mangel.findUnique({
where: { id: input.id },
include: { geraet: { select: { inventarnummer: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
}
if (ERLEDIGT.includes(vorher.status)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Dieser Mangel ist bereits abgeschlossen.",
});
}
const nachher = await db.mangel.update({
where: { id: input.id },
data: {
status: MangelStatus.IN_ARBEIT,
zugewiesenAnId: vorher.zugewiesenAnId ?? ctx.sitzung.personId,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Mangel",
entitaetId: input.id,
beschreibung: `Mangel an ${vorher.geraet.inventarnummer} in Arbeit`,
sitzung: ctx.sitzung,
vorher: { status: vorher.status },
nachher: { status: nachher.status },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/**
* Mangel beheben.
*
* Hat dieser Mangel das Gerät gesperrt, wird die Sperre hier aufgehoben —
* aber nur, wenn kein anderer offener Mangel das Gerät ebenfalls sperrt.
* Sonst gäbe ein behobener Kratzer ein Gerät frei, das wegen eines Risses
* gesperrt ist.
*/
beheben: mitRecht("pruefung.durchfuehren")
.input(
idEingabe.extend({
behebung: z.string().trim().max(2000).nullish(),
geraetFreigeben: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.mangel.findUnique({
where: { id: input.id },
include: { geraet: { select: { id: true, inventarnummer: true, status: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
}
const jetzt = new Date();
const nachher = await db.mangel.update({
where: { id: input.id },
data: {
status: MangelStatus.BEHOBEN,
behobenAm: jetzt,
behobenVonId: ctx.sitzung.personId,
behebung: input.behebung ?? null,
},
});
let freigegeben = false;
if (vorher.geraetGesperrt && vorher.geraet.status === "GESPERRT") {
const weitereSperren = await db.mangel.count({
where: {
geraetId: vorher.geraetId,
id: { not: input.id },
geraetGesperrt: true,
status: { notIn: ERLEDIGT },
geloeschtAm: null,
},
});
if (weitereSperren === 0 && (input.geraetFreigeben ?? true)) {
await db.geraet.update({
where: { id: vorher.geraetId },
data: { status: "EINSATZBEREIT" },
});
freigegeben = true;
}
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Mangel",
entitaetId: input.id,
beschreibung: `Mangel an ${vorher.geraet.inventarnummer} behoben${
freigegeben ? ", Gerät wieder einsatzbereit" : ""
}`,
sitzung: ctx.sitzung,
vorher,
nachher,
kontext: { geraetFreigegeben: freigegeben },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
await beauftrage(AUFTRAG.ERINNERUNG_SENDEN, {
organisationId: ctx.sitzung.organisationId,
anlass: "mangel_behoben",
mangelId: input.id,
}).catch(() => undefined);
return { ...nachher, geraetFreigegeben: freigegeben };
}),
),
/** Fehlmeldung. Bleibt stehen, damit nachvollziehbar ist, was gemeldet war. */
verwerfen: mitRecht("pruefung.durchfuehren")
.input(idEingabe.extend({ grund: z.string().trim().max(500).optional() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.mangel.findUnique({
where: { id: input.id },
include: { geraet: { select: { id: true, inventarnummer: true, status: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
}
const nachher = await db.mangel.update({
where: { id: input.id },
data: {
status: MangelStatus.VERWORFEN,
behebung: input.grund ?? null,
behobenAm: new Date(),
behobenVonId: ctx.sitzung.personId,
},
});
if (vorher.geraetGesperrt && vorher.geraet.status === "GESPERRT") {
const weitereSperren = await db.mangel.count({
where: {
geraetId: vorher.geraetId,
id: { not: input.id },
geraetGesperrt: true,
status: { notIn: ERLEDIGT },
geloeschtAm: null,
},
});
if (weitereSperren === 0) {
await db.geraet.update({
where: { id: vorher.geraetId },
data: { status: "EINSATZBEREIT" },
});
}
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Mangel",
entitaetId: input.id,
beschreibung: `Mangelmeldung zu ${vorher.geraet.inventarnummer} verworfen${
input.grund ? `: ${input.grund}` : ""
}`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
});
+883
View File
@@ -0,0 +1,883 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import {
ChecklistenpunktTyp,
MangelPrioritaet,
Prisma,
PruefErgebnis,
PruefungArt,
PruefungStatus,
SignaturRolle,
WartungsterminStatus,
} from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
import { downloadLink } from "@/lib/storage";
import {
dauerInMinuten,
ergebnisVorschlag,
offenePflichtpunkte,
punktInOrdnung,
type Antwort,
type Punkt,
} from "@/lib/wartung/bewertung";
import {
naechsteFaelligkeit,
protokollNummer,
protokollNummerLesen,
} from "@/lib/wartung/fristen";
import { utcTag } from "@/lib/stammdaten/fristen";
/**
* Prüfungen — der Wizard von der ersten Frage bis zum unterschriebenen PDF.
*
* Der Ablauf in vier Schritten:
*
* starten legt einen Entwurf an und liefert die Checkliste
* ergebnisSpeichern je Punkt, so oft wie nötig — der Wizard speichert laufend
* abschliessen prüft auf Vollständigkeit, unterschreibt, schreibt fest
* (Hintergrund) das PDF entsteht im Worker und wird nachgetragen
*
* Warum laufend gespeichert wird: eine Atemschutzprüfung dauert eine
* Dreiviertelstunde, und ein Tablet im Gerätehaus verliert schon mal das WLAN.
* Wer dann von vorn anfangen muss, macht die Prüfung beim nächsten Mal wieder
* auf Papier.
*
* Nach dem Abschluss ist Schluss: die Datenbank lässt keine Änderung mehr zu
* (siehe Migration 20260903000300). Korrekturen laufen über korrigieren() und
* erzeugen ein neues Protokoll mit Verweis auf das alte.
*/
/** Was die Detailansicht und der Wizard brauchen. */
const PRUEFUNG_EINSCHLUSS = {
geraet: {
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
seriennummer: true,
hersteller: true,
modell: true,
herstellungsdatum: true,
indienststellung: true,
geraetetyp: { select: { id: true, name: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
standort: { select: { id: true, name: true } },
},
},
vorlage: {
select: {
id: true,
name: true,
intervallMonate: true,
pruefvorschrift: true,
standardDauerMinuten: true,
punkte: {
where: { geloeschtAm: null },
orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }],
},
},
},
pruefer: { select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true } },
ergebnisse: { include: { punkt: true } },
signaturen: true,
maengel: { where: { geloeschtAm: null } },
termin: { select: { id: true, faelligAm: true, status: true } },
ersetzt: { select: { id: true, protokollNummer: true, durchgefuehrtAm: true } },
} satisfies Prisma.PruefungInclude;
/** Prisma-Punkt in die Form, mit der lib/wartung/bewertung rechnet. */
function alsPunkt(punkt: {
id: string;
text: string;
typ: ChecklistenpunktTyp;
pflicht: boolean;
minWert: Prisma.Decimal | null;
maxWert: Prisma.Decimal | null;
}): Punkt {
return {
id: punkt.id,
text: punkt.text,
typ: punkt.typ,
pflicht: punkt.pflicht,
minWert: punkt.minWert ? punkt.minWert.toNumber() : null,
maxWert: punkt.maxWert ? punkt.maxWert.toNumber() : null,
};
}
function alsAntwort(ergebnis: {
punktId: string;
jaNein: boolean | null;
zahlwert: Prisma.Decimal | null;
text: string | null;
fotoDateiId: string | null;
inOrdnung: boolean;
}): Antwort {
return {
punktId: ergebnis.punktId,
jaNein: ergebnis.jaNein,
zahlwert: ergebnis.zahlwert ? ergebnis.zahlwert.toNumber() : null,
text: ergebnis.text,
fotoDateiId: ergebnis.fotoDateiId,
inOrdnung: ergebnis.inOrdnung,
};
}
export const pruefungRouter = router({
/** Liste der Prüfungen, für die Historie am Gerät und die Gesamtübersicht. */
liste: mitRecht("pruefung.lesen")
.input(
z.object({
geraetId: z.string().uuid().optional(),
prueferId: z.string().uuid().optional(),
status: z.array(z.nativeEnum(PruefungStatus)).optional(),
ergebnis: z.array(z.nativeEnum(PruefErgebnis)).optional(),
vonAm: z.coerce.date().optional(),
bisAm: z.coerce.date().optional(),
anzahl: z.number().int().min(1).max(200).default(50),
}),
)
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.pruefung.findMany({
where: {
geloeschtAm: null,
...(input.geraetId ? { geraetId: input.geraetId } : {}),
...(input.prueferId ? { prueferId: input.prueferId } : {}),
...(input.status?.length ? { status: { in: input.status } } : {}),
...(input.ergebnis?.length ? { ergebnis: { in: input.ergebnis } } : {}),
...(input.vonAm || input.bisAm
? {
durchgefuehrtAm: {
...(input.vonAm ? { gte: input.vonAm } : {}),
...(input.bisAm ? { lte: input.bisAm } : {}),
},
}
: {}),
},
orderBy: { durchgefuehrtAm: "desc" },
take: input.anzahl,
select: {
id: true,
protokollNummer: true,
durchgefuehrtAm: true,
art: true,
status: true,
ergebnis: true,
dauerMinuten: true,
externeFirma: true,
kosten: true,
naechstePruefungAm: true,
protokollDateiId: true,
prueferName: true,
pruefer: { select: { id: true, vorname: true, nachname: true } },
vorlage: { select: { id: true, name: true } },
geraet: { select: { id: true, inventarnummer: true, bezeichnung: true } },
_count: { select: { maengel: true } },
},
}),
),
),
einzeln: mitRecht("pruefung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
include: PRUEFUNG_EINSCHLUSS,
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
return pruefung;
}),
),
/** Eigene Entwürfe — angefangene Prüfungen, die noch offen liegen. */
entwuerfe: mitRecht("pruefung.durchfuehren").query(async ({ ctx }) =>
ctx.db((db) =>
db.pruefung.findMany({
where: {
status: PruefungStatus.ENTWURF,
geloeschtAm: null,
prueferId: ctx.sitzung.personId,
},
orderBy: { erstelltAm: "desc" },
select: {
id: true,
begonnenUm: true,
durchgefuehrtAm: true,
geraet: { select: { id: true, inventarnummer: true, bezeichnung: true } },
vorlage: { select: { id: true, name: true } },
_count: { select: { ergebnisse: true } },
},
}),
),
),
/**
* Prüfung beginnen.
*
* Gibt es zu diesem Gerät und dieser Vorlage bereits einen eigenen Entwurf,
* wird der fortgesetzt statt ein zweiter angelegt. Sonst sammeln sich nach
* jedem verlorenen WLAN halbe Prüfungen an.
*/
starten: mitRecht("pruefung.durchfuehren")
.input(
z.object({
geraetId: z.string().uuid(),
terminId: z.string().uuid().nullish(),
vorlageId: z.string().uuid().nullish(),
art: z.nativeEnum(PruefungArt).default(PruefungArt.INTERN),
externeFirma: z.string().trim().max(200).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const geraet = await db.geraet.findUnique({
where: { id: input.geraetId },
select: { id: true, inventarnummer: true, bezeichnung: true, geraetetypId: true },
});
if (!geraet) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
// Vorlage: ausdrücklich gewählt, sonst über den Termin, sonst die des
// Gerätetyps — in dieser Reihenfolge.
let vorlageId = input.vorlageId ?? null;
if (!vorlageId && input.terminId) {
const termin = await db.wartungstermin.findUnique({
where: { id: input.terminId },
select: { vorlageId: true },
});
vorlageId = termin?.vorlageId ?? null;
}
if (!vorlageId && geraet.geraetetypId) {
const passende = await db.wartungsvorlage.findFirst({
where: { geraetetypId: geraet.geraetetypId, aktiv: true, geloeschtAm: null },
orderBy: { intervallMonate: "asc" },
select: { id: true },
});
vorlageId = passende?.id ?? null;
}
const laufender = await db.pruefung.findFirst({
where: {
geraetId: input.geraetId,
vorlageId,
status: PruefungStatus.ENTWURF,
prueferId: ctx.sitzung.personId,
geloeschtAm: null,
},
include: PRUEFUNG_EINSCHLUSS,
});
if (laufender) return { pruefung: laufender, fortgesetzt: true };
const person = await db.person.findUnique({
where: { id: ctx.sitzung.personId },
select: { vorname: true, nachname: true },
});
const pruefung = await db.pruefung.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: input.geraetId,
terminId: input.terminId ?? null,
vorlageId,
art: input.art,
externeFirma: input.externeFirma ?? null,
prueferId: ctx.sitzung.personId,
prueferName: person ? `${person.vorname} ${person.nachname}` : ctx.sitzung.name,
durchgefuehrtAm: utcTag(new Date()),
begonnenUm: new Date(),
},
include: PRUEFUNG_EINSCHLUSS,
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Pruefung",
entitaetId: pruefung.id,
beschreibung: `Prüfung an ${geraet.inventarnummer} begonnen`,
sitzung: ctx.sitzung,
nachher: { geraetId: geraet.id, vorlageId, art: input.art },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { pruefung, fortgesetzt: false };
}),
),
/**
* Eine Antwort speichern.
*
* Ob der Punkt in Ordnung ist, rechnet der Server — der Browser schickt nur,
* was gemessen oder angekreuzt wurde. Eine ausdrückliche Angabe des Prüfers
* hat Vorrang: er steht davor und sieht mehr als die Zahl.
*/
ergebnisSpeichern: mitRecht("pruefung.durchfuehren")
.input(
z.object({
pruefungId: z.string().uuid(),
punktId: z.string().uuid(),
jaNein: z.boolean().nullish(),
zahlwert: z.number().nullish(),
text: z.string().trim().max(1000).nullish(),
fotoDateiId: z.string().uuid().nullish(),
inOrdnung: z.boolean().nullish(),
bemerkung: z.string().trim().max(500).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.pruefungId },
select: { id: true, status: true, vorlageId: true },
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (pruefung.status !== PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Diese Prüfung ist abgeschlossen und lässt sich nicht mehr ändern. Für eine Korrektur eine neue Prüfung anlegen.",
});
}
const punkt = await db.checklistenpunkt.findUnique({ where: { id: input.punktId } });
if (!punkt) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
}
const bewertet = punktInOrdnung(alsPunkt(punkt), {
punktId: input.punktId,
jaNein: input.jaNein,
zahlwert: input.zahlwert,
text: input.text,
fotoDateiId: input.fotoDateiId,
inOrdnung: input.inOrdnung,
});
const daten = {
jaNein: input.jaNein ?? null,
zahlwert: input.zahlwert ?? null,
text: input.text ?? null,
fotoDateiId: input.fotoDateiId ?? null,
bemerkung: input.bemerkung ?? null,
inOrdnung: bewertet,
};
return db.pruefergebnis.upsert({
where: { pruefungId_punktId: { pruefungId: input.pruefungId, punktId: input.punktId } },
create: {
organisationId: ctx.sitzung.organisationId,
pruefungId: input.pruefungId,
punktId: input.punktId,
...daten,
},
update: daten,
});
}),
),
/** Zwischenstand: was fehlt noch, was ist beanstandet, was käme heraus. */
stand: mitRecht("pruefung.durchfuehren")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
include: {
vorlage: {
select: { punkte: { where: { geloeschtAm: null }, orderBy: { sortierung: "asc" } } },
},
ergebnisse: true,
},
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
const punkte = (pruefung.vorlage?.punkte ?? []).map(alsPunkt);
const antworten = pruefung.ergebnisse.map(alsAntwort);
return {
offenePflichtpunkte: offenePflichtpunkte(punkte, antworten).map((punkt) => ({
id: punkt.id,
text: punkt.text,
})),
beantwortet: antworten.length,
gesamt: punkte.length,
vorschlag: ergebnisVorschlag(punkte, antworten),
};
}),
),
/**
* Prüfung abschließen.
*
* Hier passiert alles, was zusammengehört, in einer Transaktion:
* Unterschriften anlegen, Mängel melden, Protokollnummer ziehen, Prüfung
* festschreiben, Termin erledigen, nächsten Termin planen und den Status des
* Geräts nachziehen. Ginge davon etwas schief, darf nichts davon gelten.
*
* Die Reihenfolge ist nicht beliebig: Unterschriften und Mängel müssen vor
* dem Statuswechsel entstehen, denn danach lässt die Datenbank am Protokoll
* nichts mehr zu.
*/
abschliessen: mitRecht("pruefung.durchfuehren")
.input(
z.object({
id: z.string().uuid(),
ergebnis: z.nativeEnum(PruefErgebnis).optional(),
durchgefuehrtAm: z.coerce.date().optional(),
bemerkung: z.string().trim().max(2000).nullish(),
naechstePruefungAm: z.coerce.date().nullish(),
externeFirma: z.string().trim().max(200).nullish(),
externeProtokollDateiId: z.string().uuid().nullish(),
kosten: z.number().nonnegative().max(9_999_999).nullish(),
signaturen: z
.array(
z.object({
rolle: z.nativeEnum(SignaturRolle).default(SignaturRolle.PRUEFER),
name: z.string().trim().min(1).max(200),
personId: z.string().uuid().nullish(),
bildDateiId: z.string().uuid().nullish(),
}),
)
.min(1, "Ohne Unterschrift ist ein Protokoll kein Nachweis.")
.max(4),
maengel: z
.array(
z.object({
beschreibung: z.string().trim().min(1).max(2000),
prioritaet: z.nativeEnum(MangelPrioritaet).default(MangelPrioritaet.MITTEL),
fotoDateiId: z.string().uuid().nullish(),
}),
)
.max(50)
.default([]),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
include: {
geraet: {
select: {
id: true,
inventarnummer: true,
status: true,
indienststellung: true,
pruefintervallMonate: true,
},
},
vorlage: {
select: {
id: true,
intervallMonate: true,
punkte: { where: { geloeschtAm: null } },
},
},
ergebnisse: true,
},
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (pruefung.status !== PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Diese Prüfung ist bereits abgeschlossen.",
});
}
const punkte = (pruefung.vorlage?.punkte ?? []).map(alsPunkt);
const antworten = pruefung.ergebnisse.map(alsAntwort);
const offene = offenePflichtpunkte(punkte, antworten);
if (offene.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Es fehlen noch ${offene.length} Pflichtangaben: ${offene
.slice(0, 3)
.map((punkt) => punkt.text)
.join(", ")}${offene.length > 3 ? " …" : ""}`,
});
}
const ergebnis = input.ergebnis ?? ergebnisVorschlag(punkte, antworten);
const jetzt = new Date();
const durchgefuehrtAm = utcTag(input.durchgefuehrtAm ?? pruefung.durchgefuehrtAm);
// Nächster Termin: was der Prüfer angibt, sonst das Intervall der
// Vorlage, sonst das am Gerät hinterlegte.
const intervall = pruefung.vorlage?.intervallMonate ?? pruefung.geraet.pruefintervallMonate;
const naechste =
input.naechstePruefungAm ??
(intervall
? naechsteFaelligkeit({ letztePruefung: durchgefuehrtAm, intervallMonate: intervall })
: null);
// Unterschriften und Mängel zuerst — danach ist das Protokoll dicht.
for (const signatur of input.signaturen) {
await db.signatur.create({
data: {
organisationId: ctx.sitzung.organisationId,
pruefungId: pruefung.id,
rolle: signatur.rolle,
name: signatur.name,
personId: signatur.personId ?? null,
bildDateiId: signatur.bildDateiId ?? null,
unterzeichnetAm: jetzt,
},
});
}
for (const mangel of input.maengel) {
await db.mangel.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: pruefung.geraetId,
pruefungId: pruefung.id,
melderId: ctx.sitzung.personId,
melderName: ctx.sitzung.name,
beschreibung: mangel.beschreibung,
prioritaet: mangel.prioritaet,
fotoDateiId: mangel.fotoDateiId ?? null,
geraetGesperrt: ergebnis === PruefErgebnis.NICHT_BESTANDEN,
},
});
}
// Fortlaufende Protokollnummer je Wehr und Jahr.
//
// Zwei Prüfer, die gleichzeitig abschließen, würden ohne Sperre
// dieselbe Nummer ziehen — die eindeutige Bedingung in der Datenbank
// fängt das ab, aber der zweite bekäme einen Datenbankfehler statt
// eines Protokolls. Die Sperre gilt nur bis zum Ende dieser
// Transaktion und nur für diese Wehr und dieses Jahr.
const jahr = durchgefuehrtAm.getUTCFullYear();
const sperrschluessel = `pruefung:${ctx.sitzung.organisationId}:${jahr}`;
await db.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${sperrschluessel}))`;
// Höchste vergebene Nummer plus eins — nicht die Anzahl der Zeilen.
// Nummern werden nie wiederverwendet, auch wenn eine Prüfung im
// Aufbewahrungslauf verschwindet.
const letzte = await db.pruefung.findFirst({
where: { protokollNummer: { startsWith: `${jahr}-` } },
orderBy: { protokollNummer: "desc" },
select: { protokollNummer: true },
});
const nummer = protokollNummer(
jahr,
(protokollNummerLesen(letzte?.protokollNummer)?.laufend ?? 0) + 1,
);
const abgeschlossen = await db.pruefung.update({
where: { id: pruefung.id },
data: {
status: PruefungStatus.ABGESCHLOSSEN,
ergebnis,
durchgefuehrtAm,
bemerkung: input.bemerkung ?? pruefung.bemerkung,
naechstePruefungAm: naechste,
externeFirma: input.externeFirma ?? pruefung.externeFirma,
externeProtokollDateiId:
input.externeProtokollDateiId ?? pruefung.externeProtokollDateiId,
kosten: input.kosten ?? pruefung.kosten,
dauerMinuten: dauerInMinuten(pruefung.begonnenUm, jetzt),
protokollNummer: nummer,
abgeschlossenAm: jetzt,
},
});
// Eine Korrektur löst das alte Protokoll ab. Es bleibt lesbar.
if (pruefung.ersetztPruefungId) {
await db.pruefung.update({
where: { id: pruefung.ersetztPruefungId },
data: { status: PruefungStatus.ERSETZT },
});
}
// Termin erledigen und den nächsten planen.
if (pruefung.terminId) {
await db.wartungstermin.update({
where: { id: pruefung.terminId },
data: { status: WartungsterminStatus.ERLEDIGT, erledigtAm: jetzt },
});
}
if (naechste && pruefung.vorlageId) {
await db.wartungstermin.upsert({
where: {
geraetId_vorlageId_faelligAm: {
geraetId: pruefung.geraetId,
vorlageId: pruefung.vorlageId,
faelligAm: naechste,
},
},
create: {
organisationId: ctx.sitzung.organisationId,
geraetId: pruefung.geraetId,
vorlageId: pruefung.vorlageId,
faelligAm: naechste,
automatischErzeugt: true,
},
update: {},
});
}
// Der Status des Geräts folgt dem Ergebnis: nicht bestanden heißt
// gesperrt. Bestanden hebt eine frühere Sperre bewusst NICHT auf —
// wer gesperrt hat, hatte einen Grund, und der kann außerhalb dieser
// Prüfung liegen. Nur "Prüfung fällig" löst sich von selbst.
const neuerStatus =
ergebnis === PruefErgebnis.NICHT_BESTANDEN
? "GESPERRT"
: pruefung.geraet.status === "PRUEFUNG_FAELLIG"
? "EINSATZBEREIT"
: pruefung.geraet.status;
await db.geraet.update({
where: { id: pruefung.geraetId },
data: {
letztePruefung: durchgefuehrtAm,
naechstePruefung: naechste,
status: neuerStatus,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Pruefung",
entitaetId: pruefung.id,
beschreibung: `Prüfung ${nummer} an ${pruefung.geraet.inventarnummer} abgeschlossen: ${ergebnis}`,
sitzung: ctx.sitzung,
vorher: { status: pruefung.status },
nachher: {
status: PruefungStatus.ABGESCHLOSSEN,
ergebnis,
protokollNummer: nummer,
naechstePruefungAm: naechste,
},
kontext: {
maengel: input.maengel.length,
unterschriften: input.signaturen.length,
geraetStatus: neuerStatus,
},
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
// Das PDF entsteht im Hintergrund: Gotenberg braucht ein paar Sekunden,
// und der Prüfer soll währenddessen nicht auf einen Ladebalken sehen.
await beauftrage(AUFTRAG.PDF_ERZEUGEN, {
organisationId: ctx.sitzung.organisationId,
pruefungId: pruefung.id,
}).catch((fehler) => {
console.error("[pruefung] PDF-Auftrag ließ sich nicht einstellen:", fehler);
});
return abgeschlossen;
}),
),
/**
* Korrektur eines abgeschlossenen Protokolls.
*
* Es wird nichts überschrieben. Stattdessen entsteht ein neuer Entwurf mit
* denselben Antworten und einem Verweis auf das alte Protokoll. Erst mit
* seinem Abschluss gilt das alte als ersetzt.
*/
korrigieren: mitRecht("pruefung.durchfuehren")
.input(idEingabe.extend({ grund: z.string().trim().min(1, "Ein Grund gehört dazu.").max(500) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const alt = await db.pruefung.findUnique({
where: { id: input.id },
include: { ergebnisse: true, geraet: { select: { inventarnummer: true } } },
});
if (!alt) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (alt.status === PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ein Entwurf lässt sich unmittelbar ändern — dafür braucht es keine Korrektur.",
});
}
const person = await db.person.findUnique({
where: { id: ctx.sitzung.personId },
select: { vorname: true, nachname: true },
});
const neu = await db.pruefung.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: alt.geraetId,
terminId: alt.terminId,
vorlageId: alt.vorlageId,
art: alt.art,
externeFirma: alt.externeFirma,
kosten: alt.kosten,
prueferId: ctx.sitzung.personId,
prueferName: person ? `${person.vorname} ${person.nachname}` : ctx.sitzung.name,
durchgefuehrtAm: alt.durchgefuehrtAm,
begonnenUm: new Date(),
bemerkung: alt.bemerkung,
ersetztPruefungId: alt.id,
korrekturGrund: input.grund,
},
});
// Die bisherigen Antworten übernehmen, damit nur das Falsche geändert
// werden muss und nicht alles noch einmal einzutippen ist.
for (const ergebnis of alt.ergebnisse) {
await db.pruefergebnis.create({
data: {
organisationId: ctx.sitzung.organisationId,
pruefungId: neu.id,
punktId: ergebnis.punktId,
jaNein: ergebnis.jaNein,
zahlwert: ergebnis.zahlwert,
text: ergebnis.text,
fotoDateiId: ergebnis.fotoDateiId,
inOrdnung: ergebnis.inOrdnung,
bemerkung: ergebnis.bemerkung,
},
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Pruefung",
entitaetId: neu.id,
beschreibung: `Korrektur zu Protokoll ${alt.protokollNummer ?? alt.id} an ${alt.geraet.inventarnummer} begonnen: ${input.grund}`,
sitzung: ctx.sitzung,
nachher: { ersetztPruefungId: alt.id, korrekturGrund: input.grund },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return neu;
}),
),
/** Entwurf verwerfen. Abgeschlossene Prüfungen sind davon ausgenommen. */
entwurfVerwerfen: mitRecht("pruefung.durchfuehren")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
select: { id: true, status: true, prueferId: true, geraetId: true },
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (pruefung.status !== PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Abgeschlossene Protokolle lassen sich nicht verwerfen.",
});
}
await db.pruefung.delete({ where: { id: input.id } });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Pruefung",
entitaetId: input.id,
beschreibung: "Angefangene Prüfung verworfen",
sitzung: ctx.sitzung,
vorher: pruefung,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
/**
* Zeitlich begrenzter Link auf das Protokoll-PDF.
*
* Fehlt das PDF noch, wird die Erzeugung angestoßen und der Aufrufer
* gebeten, es gleich noch einmal zu versuchen — statt ihn mit einem Fehler
* stehen zu lassen.
*/
protokollLink: mitRecht("pruefung.lesen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const pruefung = await db.pruefung.findUnique({
where: { id: input.id },
select: {
id: true,
protokollNummer: true,
status: true,
protokoll: { select: { objektSchluessel: true, dateiname: true } },
geraet: { select: { inventarnummer: true } },
},
});
if (!pruefung) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
}
if (!pruefung.protokoll) {
if (pruefung.status === PruefungStatus.ENTWURF) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Ein Protokoll entsteht erst mit dem Abschluss der Prüfung.",
});
}
await beauftrage(AUFTRAG.PDF_ERZEUGEN, {
organisationId: ctx.sitzung.organisationId,
pruefungId: pruefung.id,
});
return { bereit: false as const, adresse: null };
}
const adresse = await downloadLink(
pruefung.protokoll.objektSchluessel,
pruefung.protokoll.dateiname,
);
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.DATEI_ABGERUFEN,
entitaet: "Pruefung",
entitaetId: pruefung.id,
beschreibung: `Protokoll ${pruefung.protokollNummer ?? ""} abgerufen`,
sitzung: ctx.sitzung,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { bereit: true as const, adresse };
}),
),
});
+524
View File
@@ -0,0 +1,524 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { BetroffenheitStatus, Prisma, RueckrufStatus } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
/**
* Rückrufe.
*
* Ein Hersteller meldet einen Fehler an einer Baureihe. Die Wehr muss dann
* genau zwei Dinge tun: herausfinden, ob sie betroffene Stücke hat, und sie aus
* dem Verkehr ziehen, bis die Maßnahme des Herstellers durch ist.
*
* Genau das bildet dieser Router ab:
* geraeteSuchen zeigt, welche Geräte auf die Beschreibung passen
* zuordnen hält fest, welche betroffen sind, und sperrt sie auf Wunsch
* erledigen hebt die Sperre wieder auf, wenn die Maßnahme durch ist
*
* Die Suche ist bewusst ein Vorschlag und keine automatische Sperre. Eine
* Chargenangabe des Herstellers passt selten eins zu eins auf die
* Seriennummern im eigenen Bestand — das letzte Wort hat der Gerätewart.
*/
const LISTEN_AUSWAHL = {
id: true,
hersteller: true,
titel: true,
referenz: true,
veroeffentlichtAm: true,
status: true,
sperrtGeraete: true,
erledigtAm: true,
geloeschtAm: true,
_count: { select: { typen: true, betroffenheiten: true } },
} satisfies Prisma.RueckrufSelect;
const rueckrufFelder = z.object({
hersteller: z.string().trim().min(1, "Der Hersteller fehlt.").max(160),
titel: z.string().trim().min(1, "Ein Titel gehört dazu.").max(200),
referenz: z.string().trim().max(80).nullish(),
beschreibung: z.string().trim().max(4000).nullish(),
massnahme: z.string().trim().max(2000).nullish(),
veroeffentlichtAm: z.coerce.date().nullish(),
quelle: z.string().trim().max(500).nullish(),
sperrtGeraete: z.boolean().default(false),
});
const typFelder = z.object({
geraetetypId: z.string().uuid().nullish(),
modell: z.string().trim().max(160).nullish(),
seriennummerVon: z.string().trim().max(120).nullish(),
seriennummerBis: z.string().trim().max(120).nullish(),
herstellungVon: z.coerce.date().nullish(),
herstellungBis: z.coerce.date().nullish(),
});
type TypEingrenzung = z.infer<typeof typFelder>;
/**
* Aus einer Eingrenzung eine Suchbedingung machen.
*
* Die Seriennummern werden als Text verglichen, nicht als Zahl. Das ist die
* einzige Variante, die immer funktioniert: Feuerwehrgerät trägt Nummern wie
* "PA-2019-0421" oder "A45X99". Wo Hersteller reine Zahlen vergeben, stimmt der
* Textvergleich, solange die Nummern gleich lang sind — und das sind sie in
* einer Charge fast immer. Deshalb ist das Ergebnis ein Vorschlag.
*/
function bedingungAusTyp(typ: TypEingrenzung): Prisma.GeraetWhereInput {
return {
geloeschtAm: null,
...(typ.geraetetypId ? { geraetetypId: typ.geraetetypId } : {}),
...(typ.modell ? { modell: { contains: typ.modell, mode: "insensitive" as const } } : {}),
...(typ.seriennummerVon || typ.seriennummerBis
? {
seriennummer: {
...(typ.seriennummerVon ? { gte: typ.seriennummerVon } : {}),
...(typ.seriennummerBis ? { lte: typ.seriennummerBis } : {}),
},
}
: {}),
...(typ.herstellungVon || typ.herstellungBis
? {
herstellungsdatum: {
...(typ.herstellungVon ? { gte: typ.herstellungVon } : {}),
...(typ.herstellungBis ? { lte: typ.herstellungBis } : {}),
},
}
: {}),
};
}
export const rueckrufRouter = router({
liste: mitRecht("pruefung.lesen")
.input(
listenEingabe(["veroeffentlichtAm", "hersteller", "titel"] as const).extend({
status: z.array(z.nativeEnum(RueckrufStatus)).optional(),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.RueckrufWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.status?.length ? { status: { in: input.status } } : {}),
...(sucheUeber(["titel", "hersteller", "referenz", "beschreibung"], input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.rueckruf.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy: input.sortierung
? { [input.sortierung]: input.richtung }
: [{ status: "asc" }, { veroeffentlichtAm: "desc" }],
...blaettern(input),
}),
db.rueckruf.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
einzeln: mitRecht("pruefung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const rueckruf = await db.rueckruf.findUnique({
where: { id: input.id },
include: {
typen: { include: { geraetetyp: { select: { id: true, name: true } } } },
betroffenheiten: {
include: {
geraet: {
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
seriennummer: true,
status: true,
fahrzeug: { select: { id: true, funkrufname: true } },
standort: { select: { id: true, name: true } },
},
},
},
orderBy: { festgestelltAm: "asc" },
},
},
});
if (!rueckruf) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
}
return rueckruf;
}),
),
anlegen: mitRecht("pruefung.planen")
.input(rueckrufFelder.extend({ typen: z.array(typFelder).max(20).default([]) }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { typen, ...felder } = input;
const rueckruf = await db.rueckruf.create({
data: {
organisationId: ctx.sitzung.organisationId,
...felder,
typen: {
create: typen.map((typ) => ({
organisationId: ctx.sitzung.organisationId,
...typ,
})),
},
},
include: { typen: true },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Rueckruf",
entitaetId: rueckruf.id,
beschreibung: `Rückruf "${rueckruf.titel}" von ${rueckruf.hersteller} erfasst`,
sitzung: ctx.sitzung,
nachher: rueckruf,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return rueckruf;
}),
),
aendern: mitRecht("pruefung.planen")
.input(rueckrufFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.rueckruf.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
}
const nachher = await db.rueckruf.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rueckruf",
entitaetId: id,
beschreibung: `Rückruf "${nachher.titel}" geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
typErgaenzen: mitRecht("pruefung.planen")
.input(z.object({ rueckrufId: z.string().uuid() }).and(typFelder))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { rueckrufId, ...felder } = input;
const typ = await db.rueckrufGeraetetyp.create({
data: { organisationId: ctx.sitzung.organisationId, rueckrufId, ...felder },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rueckruf",
entitaetId: rueckrufId,
beschreibung: "Betroffene Bauart zum Rückruf ergänzt",
sitzung: ctx.sitzung,
nachher: typ,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return typ;
}),
),
/**
* Welche Geräte passen auf die Beschreibung?
*
* Liefert einen Vorschlag samt Hinweis, welche davon schon zugeordnet sind.
* Zugeordnet wird erst im nächsten Schritt und nur, was der Gerätewart
* bestätigt.
*/
geraeteSuchen: mitRecht("pruefung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const rueckruf = await db.rueckruf.findUnique({
where: { id: input.id },
include: { typen: true, betroffenheiten: { select: { geraetId: true } } },
});
if (!rueckruf) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
}
if (rueckruf.typen.length === 0) {
return { treffer: [], hinweis: "Für diesen Rückruf ist noch keine Bauart hinterlegt." };
}
const bereitsZugeordnet = new Set(
rueckruf.betroffenheiten.map((eintrag) => eintrag.geraetId),
);
const treffer = await db.geraet.findMany({
where: { OR: rueckruf.typen.map(bedingungAusTyp) },
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
hersteller: true,
modell: true,
seriennummer: true,
herstellungsdatum: true,
status: true,
geraetetyp: { select: { id: true, name: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
standort: { select: { id: true, name: true } },
},
orderBy: { inventarnummer: "asc" },
take: 500,
});
return {
treffer: treffer.map((geraet) => ({
...geraet,
bereitsZugeordnet: bereitsZugeordnet.has(geraet.id),
})),
hinweis: null,
};
}),
),
/** Geräte als betroffen festhalten und auf Wunsch sperren. */
zuordnen: mitRecht("pruefung.planen")
.input(
z.object({
rueckrufId: z.string().uuid(),
geraetIds: z.array(z.string().uuid()).min(1).max(500),
sperren: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const rueckruf = await db.rueckruf.findUnique({ where: { id: input.rueckrufId } });
if (!rueckruf) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
}
const sperren = input.sperren ?? rueckruf.sperrtGeraete;
const geraete = await db.geraet.findMany({
where: { id: { in: input.geraetIds }, geloeschtAm: null },
select: { id: true, inventarnummer: true, status: true },
});
let gesperrt = 0;
for (const geraet of geraete) {
const sperrenJetzt = sperren && geraet.status !== "GESPERRT";
await db.rueckrufBetroffenheit.upsert({
where: {
rueckrufId_geraetId: { rueckrufId: input.rueckrufId, geraetId: geraet.id },
},
create: {
organisationId: ctx.sitzung.organisationId,
rueckrufId: input.rueckrufId,
geraetId: geraet.id,
geraetGesperrt: sperrenJetzt,
},
update: {},
});
if (sperrenJetzt) {
await db.geraet.update({ where: { id: geraet.id }, data: { status: "GESPERRT" } });
gesperrt += 1;
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraet",
entitaetId: geraet.id,
beschreibung: `Von Rückruf "${rueckruf.titel}" betroffen${
sperrenJetzt ? " und gesperrt" : ""
}`,
sitzung: ctx.sitzung,
vorher: { status: geraet.status },
nachher: { status: sperrenJetzt ? "GESPERRT" : geraet.status },
kontext: { rueckrufId: input.rueckrufId },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
}
if (rueckruf.status === RueckrufStatus.OFFEN && geraete.length > 0) {
await db.rueckruf.update({
where: { id: input.rueckrufId },
data: { status: RueckrufStatus.IN_BEARBEITUNG },
});
}
return { zugeordnet: geraete.length, gesperrt };
}),
),
/**
* Stand je Gerät fortschreiben. Mit "erledigt" oder "nicht betroffen" wird
* eine durch den Rückruf gesetzte Sperre wieder aufgehoben — sofern kein
* offener Mangel dasselbe Gerät sperrt.
*/
betroffenheitSetzen: mitRecht("pruefung.planen")
.input(
z.object({
id: z.string().uuid(),
status: z.nativeEnum(BetroffenheitStatus),
bemerkung: z.string().trim().max(1000).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.rueckrufBetroffenheit.findUnique({
where: { id: input.id },
include: {
geraet: { select: { id: true, inventarnummer: true, status: true } },
rueckruf: { select: { id: true, titel: true } },
},
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Eintrag gibt es nicht." });
}
const abgeschlossen =
input.status === BetroffenheitStatus.ERLEDIGT ||
input.status === BetroffenheitStatus.NICHT_BETROFFEN;
const nachher = await db.rueckrufBetroffenheit.update({
where: { id: input.id },
data: {
status: input.status,
bemerkung: input.bemerkung ?? vorher.bemerkung,
erledigtAm: abgeschlossen ? new Date() : null,
geraetGesperrt: abgeschlossen ? false : vorher.geraetGesperrt,
},
});
let freigegeben = false;
if (abgeschlossen && vorher.geraetGesperrt && vorher.geraet.status === "GESPERRT") {
const offeneMaengel = await db.mangel.count({
where: {
geraetId: vorher.geraetId,
geraetGesperrt: true,
status: { notIn: ["BEHOBEN", "VERWORFEN"] },
geloeschtAm: null,
},
});
const weitereRueckrufe = await db.rueckrufBetroffenheit.count({
where: {
geraetId: vorher.geraetId,
id: { not: input.id },
geraetGesperrt: true,
status: { in: [BetroffenheitStatus.OFFEN, BetroffenheitStatus.MASSNAHME_LAEUFT] },
},
});
if (offeneMaengel === 0 && weitereRueckrufe === 0) {
await db.geraet.update({
where: { id: vorher.geraetId },
data: { status: "EINSATZBEREIT" },
});
freigegeben = true;
}
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "RueckrufBetroffenheit",
entitaetId: input.id,
beschreibung: `${vorher.geraet.inventarnummer} im Rückruf "${vorher.rueckruf.titel}": ${input.status}${
freigegeben ? ", Gerät wieder einsatzbereit" : ""
}`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { ...nachher, geraetFreigegeben: freigegeben };
}),
),
/** Rückruf abschließen, wenn jedes betroffene Gerät abgearbeitet ist. */
abschliessen: mitRecht("pruefung.planen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const rueckruf = await db.rueckruf.findUnique({
where: { id: input.id },
include: { betroffenheiten: { select: { status: true } } },
});
if (!rueckruf) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
}
const offen = rueckruf.betroffenheiten.filter(
(eintrag) =>
eintrag.status === BetroffenheitStatus.OFFEN ||
eintrag.status === BetroffenheitStatus.MASSNAHME_LAEUFT,
).length;
if (offen > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Es sind noch ${offen} Geräte offen. Erst wenn zu jedem ein Ergebnis steht, lässt sich der Rückruf abschließen.`,
});
}
const nachher = await db.rueckruf.update({
where: { id: input.id },
data: { status: RueckrufStatus.ERLEDIGT, erledigtAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Rueckruf",
entitaetId: input.id,
beschreibung: `Rückruf "${rueckruf.titel}" abgeschlossen`,
sitzung: ctx.sitzung,
vorher: { status: rueckruf.status },
nachher: { status: nachher.status },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
});
+513
View File
@@ -0,0 +1,513 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { Prisma, WartungsterminStatus } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
idsEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
} from "@/server/routers/stammdaten/gemeinsam";
import {
FRISTFENSTER_REIHENFOLGE,
fensterGrenzen,
terminFenster,
type Fristfenster,
} from "@/lib/wartung/fristen";
/**
* Prüftermine und das Fristen-Dashboard.
*
* Das Dashboard ist die Ansicht, die der Gerätewart morgens aufmacht. Es
* beantwortet genau eine Frage — was ist zu tun — und ordnet die Antwort nach
* Dringlichkeit: überfällig, diese Woche, diesen Monat, die nächsten 90 Tage.
*
* Gruppieren lässt sich das nach Fahrzeug, Standort, Gerätetyp oder Zuständigem.
* Das ist kein Zierrat: wer einen Prüftag ansetzt, arbeitet ein Fahrzeug ab,
* nicht eine alphabetische Liste.
*/
/** Wonach sich die Termine bündeln lassen. */
const GRUPPIERUNG = ["fahrzeug", "standort", "geraetetyp", "zustaendig"] as const;
type Gruppierung = (typeof GRUPPIERUNG)[number];
const LISTEN_AUSWAHL = {
id: true,
faelligAm: true,
status: true,
automatischErzeugt: true,
bemerkung: true,
erledigtAm: true,
geloeschtAm: true,
geraet: {
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
geraetetyp: { select: { id: true, name: true } },
standort: { select: { id: true, name: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
geraeteraum: { select: { id: true, bezeichnung: true } },
},
},
vorlage: {
select: { id: true, name: true, intervallMonate: true, pruefvorschrift: true, standardExtern: true },
},
zugewiesenAn: { select: { id: true, vorname: true, nachname: true } },
} satisfies Prisma.WartungsterminSelect;
type TerminZeile = Prisma.WartungsterminGetPayload<{ select: typeof LISTEN_AUSWAHL }>;
/** Nach welchem Merkmal eine Zeile gebündelt wird. */
function gruppeVon(
termin: TerminZeile,
nach: Gruppierung,
): { schluessel: string; bezeichnung: string } {
switch (nach) {
case "fahrzeug":
return termin.geraet.fahrzeug
? { schluessel: termin.geraet.fahrzeug.id, bezeichnung: termin.geraet.fahrzeug.funkrufname }
: { schluessel: "ohne", bezeichnung: "nicht verlastet" };
case "standort":
return termin.geraet.standort
? { schluessel: termin.geraet.standort.id, bezeichnung: termin.geraet.standort.name }
: { schluessel: "ohne", bezeichnung: "ohne Standort" };
case "geraetetyp":
return termin.geraet.geraetetyp
? { schluessel: termin.geraet.geraetetyp.id, bezeichnung: termin.geraet.geraetetyp.name }
: { schluessel: "ohne", bezeichnung: "ohne Gerätetyp" };
case "zustaendig":
return termin.zugewiesenAn
? {
schluessel: termin.zugewiesenAn.id,
bezeichnung: `${termin.zugewiesenAn.nachname}, ${termin.zugewiesenAn.vorname}`,
}
: { schluessel: "ohne", bezeichnung: "niemandem zugewiesen" };
}
}
const terminFilter = z.object({
status: z.array(z.nativeEnum(WartungsterminStatus)).optional(),
fenster: z.enum(["ueberfaellig", "diese_woche", "dieser_monat", "naechste_90_tage"]).optional(),
fahrzeugId: z.string().uuid().optional(),
standortId: z.string().uuid().optional(),
geraetetypId: z.string().uuid().optional(),
geraetId: z.string().uuid().optional(),
vorlageId: z.string().uuid().optional(),
zugewiesenAnId: z.string().uuid().optional(),
});
/** Übersetzt Filter und Zeitfenster in eine Prisma-Bedingung. */
function bedingung(
filter: z.infer<typeof terminFilter> & { suche?: string; mitGeloeschten?: boolean },
heute = new Date(),
): Prisma.WartungsterminWhereInput {
const grenzen = fensterGrenzen(heute);
const zeitraum: Prisma.WartungsterminWhereInput =
filter.fenster === "ueberfaellig"
? { faelligAm: { lt: grenzen.heute } }
: filter.fenster === "diese_woche"
? { faelligAm: { gte: grenzen.heute, lte: grenzen.inSiebenTagen } }
: filter.fenster === "dieser_monat"
? { faelligAm: { gte: grenzen.heute, lte: grenzen.inDreissigTagen } }
: filter.fenster === "naechste_90_tage"
? { faelligAm: { gte: grenzen.heute, lte: grenzen.inNeunzigTagen } }
: {};
return {
...ohneGeloeschte(filter.mitGeloeschten ?? false),
...(filter.status?.length ? { status: { in: filter.status } } : {}),
...(filter.vorlageId ? { vorlageId: filter.vorlageId } : {}),
...(filter.geraetId ? { geraetId: filter.geraetId } : {}),
...(filter.zugewiesenAnId ? { zugewiesenAnId: filter.zugewiesenAnId } : {}),
...zeitraum,
geraet: {
geloeschtAm: null,
...(filter.fahrzeugId ? { fahrzeugId: filter.fahrzeugId } : {}),
...(filter.standortId ? { standortId: filter.standortId } : {}),
...(filter.geraetetypId ? { geraetetypId: filter.geraetetypId } : {}),
...(filter.suche
? {
OR: [
{ inventarnummer: { contains: filter.suche, mode: "insensitive" as const } },
{ bezeichnung: { contains: filter.suche, mode: "insensitive" as const } },
],
}
: {}),
},
};
}
export const wartungsterminRouter = router({
/** Terminliste mit allen Filtern des Dashboards. */
liste: mitRecht("pruefung.lesen")
.input(listenEingabe(["faelligAm", "erstelltAm"] as const).extend(terminFilter.shape))
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo = bedingung({
...input,
status: input.status ?? [WartungsterminStatus.OFFEN],
});
const [eintraege, gesamt] = await Promise.all([
db.wartungstermin.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy: { faelligAm: input.richtung },
...blaettern(input),
}),
db.wartungstermin.count({ where: wo }),
]);
return listenAntwort(
eintraege.map((termin) => ({
...termin,
fenster: terminFenster(termin.faelligAm),
})),
gesamt,
input,
);
}),
),
/**
* Das Fristen-Dashboard.
*
* Gezählt wird über die Datenbank, gebündelt in der Anwendung: die
* Gruppierung geht über zwei Beziehungen (Termin → Gerät → Fahrzeug), und
* das kann Prismas groupBy nicht. Die Menge ist überschaubar — eine Wehr hat
* offene Termine in der Größenordnung von hunderten, nicht von Millionen.
*/
dashboard: mitRecht("pruefung.lesen")
.input(
z
.object({
gruppierung: z.enum(GRUPPIERUNG).default("fahrzeug"),
fahrzeugId: z.string().uuid().optional(),
standortId: z.string().uuid().optional(),
zugewiesenAnId: z.string().uuid().optional(),
})
.default({ gruppierung: "fahrzeug" }),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const heute = new Date();
const grenzen = fensterGrenzen(heute);
const grundfilter = {
status: [WartungsterminStatus.OFFEN],
fahrzeugId: input.fahrzeugId,
standortId: input.standortId,
zugewiesenAnId: input.zugewiesenAnId,
};
const offene = await db.wartungstermin.findMany({
where: bedingung({ ...grundfilter }, heute),
select: LISTEN_AUSWAHL,
orderBy: { faelligAm: "asc" },
// Genug für jede Wehr; wird es je mehr, ist die Liste ohnehin
// unbrauchbar und es braucht einen Filter, keine längere Liste.
take: 2000,
});
const leer = (): Record<Fristfenster, number> => ({
ueberfaellig: 0,
diese_woche: 0,
dieser_monat: 0,
naechste_90_tage: 0,
spaeter: 0,
});
const gesamtFenster = leer();
const gruppen = new Map<
string,
{ schluessel: string; bezeichnung: string; fenster: Record<Fristfenster, number>; gesamt: number }
>();
for (const termin of offene) {
const fenster = terminFenster(termin.faelligAm, heute);
gesamtFenster[fenster] += 1;
const { schluessel, bezeichnung } = gruppeVon(termin, input.gruppierung);
const eintrag = gruppen.get(schluessel) ?? {
schluessel,
bezeichnung,
fenster: leer(),
gesamt: 0,
};
eintrag.fenster[fenster] += 1;
eintrag.gesamt += 1;
gruppen.set(schluessel, eintrag);
}
// Lebensdauerüberwachung: was läuft ab, was ist abgelaufen. Beides
// gehört neben die Prüffristen — für die Wehr ist es dieselbe Frage:
// welches Gerät darf nächsten Monat nicht mehr aufs Fahrzeug.
const [lebensdauerAbgelaufen, lebensdauerBald, maengelOffen, rueckrufeOffen] =
await Promise.all([
db.geraet.count({
where: { geloeschtAm: null, lebensdauerBis: { lt: grenzen.heute } },
}),
db.geraet.count({
where: {
geloeschtAm: null,
lebensdauerBis: { gte: grenzen.heute, lte: grenzen.inNeunzigTagen },
},
}),
db.mangel.count({
where: { geloeschtAm: null, status: { notIn: ["BEHOBEN", "VERWORFEN"] } },
}),
db.rueckruf.count({ where: { geloeschtAm: null, status: { not: "ERLEDIGT" } } }),
]);
return {
fenster: gesamtFenster,
reihenfolge: FRISTFENSTER_REIHENFOLGE,
gruppierung: input.gruppierung,
gruppen: [...gruppen.values()].sort((a, b) => {
// Wo es brennt, steht oben.
if (b.fenster.ueberfaellig !== a.fenster.ueberfaellig) {
return b.fenster.ueberfaellig - a.fenster.ueberfaellig;
}
return b.gesamt - a.gesamt;
}),
naechste: offene.slice(0, 25).map((termin) => ({
...termin,
fenster: terminFenster(termin.faelligAm, heute),
})),
lebensdauer: { abgelaufen: lebensdauerAbgelaufen, bald: lebensdauerBald },
maengelOffen,
rueckrufeOffen,
stand: heute,
};
}),
),
/**
* Geräte, deren Nutzungsdauer abläuft — mit Vorschlag zur Aussonderung.
* Helme, Seile und Schutzkleidung sind irgendwann unabhängig vom Zustand
* nicht mehr zugelassen; wer das übersieht, hat im Ernstfall ein Problem.
*/
lebensdauer: mitRecht("pruefung.lesen")
.input(
z.object({ vorwarnTage: z.number().int().min(0).max(1095).default(180) }).default({
vorwarnTage: 180,
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const heute = fensterGrenzen().heute;
const grenze = new Date(heute.getTime() + input.vorwarnTage * 86_400_000);
const geraete = await db.geraet.findMany({
where: {
geloeschtAm: null,
status: { not: "AUSGESONDERT" },
lebensdauerBis: { not: null, lte: grenze },
},
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
lebensdauerBis: true,
lebensdauerKategorie: true,
herstellungsdatum: true,
indienststellung: true,
geraetetyp: { select: { id: true, name: true, lebensdauerKategorie: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
standort: { select: { id: true, name: true } },
},
orderBy: { lebensdauerBis: "asc" },
take: 500,
});
return geraete.map((geraet) => ({
...geraet,
abgelaufen: Boolean(geraet.lebensdauerBis && geraet.lebensdauerBis < heute),
}));
}),
),
/** Termin von Hand anlegen — für alles, was keine Vorlage abdeckt. */
anlegen: mitRecht("pruefung.planen")
.input(
z.object({
geraetId: z.string().uuid(),
vorlageId: z.string().uuid().nullish(),
faelligAm: z.coerce.date(),
zugewiesenAnId: z.string().uuid().nullish(),
bemerkung: z.string().trim().max(1000).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const geraet = await db.geraet.findUnique({
where: { id: input.geraetId },
select: { id: true, inventarnummer: true, bezeichnung: true },
});
if (!geraet) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
const termin = await db.wartungstermin.create({
data: {
organisationId: ctx.sitzung.organisationId,
geraetId: input.geraetId,
vorlageId: input.vorlageId ?? null,
faelligAm: input.faelligAm,
zugewiesenAnId: input.zugewiesenAnId ?? null,
bemerkung: input.bemerkung ?? null,
automatischErzeugt: false,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Wartungstermin",
entitaetId: termin.id,
beschreibung: `Prüftermin für ${geraet.inventarnummer} angelegt`,
sitzung: ctx.sitzung,
nachher: termin,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return termin;
}),
),
aendern: mitRecht("pruefung.planen")
.input(
z.object({
id: z.string().uuid(),
faelligAm: z.coerce.date().optional(),
zugewiesenAnId: z.string().uuid().nullish(),
bemerkung: z.string().trim().max(1000).nullish(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.wartungstermin.findUnique({
where: { id },
include: { geraet: { select: { inventarnummer: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
}
if (vorher.status !== WartungsterminStatus.OFFEN) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Nur offene Termine lassen sich ändern.",
});
}
const nachher = await db.wartungstermin.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wartungstermin",
entitaetId: id,
beschreibung: `Prüftermin für ${vorher.geraet.inventarnummer} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/** Mehrere Termine auf einmal jemandem zuweisen — der Prüftag am Stück. */
zuweisen: mitRecht("pruefung.planen")
.input(idsEingabe.extend({ zugewiesenAnId: z.string().uuid().nullish() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const termine = await db.wartungstermin.findMany({
where: { id: { in: input.ids }, status: WartungsterminStatus.OFFEN, geloeschtAm: null },
select: { id: true, zugewiesenAnId: true, geraet: { select: { inventarnummer: true } } },
});
if (termine.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Keinen dieser Termine gibt es." });
}
await db.wartungstermin.updateMany({
where: { id: { in: termine.map((termin) => termin.id) } },
data: { zugewiesenAnId: input.zugewiesenAnId ?? null },
});
for (const termin of termine) {
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wartungstermin",
entitaetId: termin.id,
beschreibung: `Prüftermin für ${termin.geraet.inventarnummer} zugewiesen`,
sitzung: ctx.sitzung,
vorher: { zugewiesenAnId: termin.zugewiesenAnId },
nachher: { zugewiesenAnId: input.zugewiesenAnId ?? null },
kontext: { sammelvorgang: true, anzahl: termine.length },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
}
return { zugewiesen: termine.length };
}),
),
/**
* Termin für gegenstandslos erklären. Kein Löschen: dass ein Termin bestand
* und warum er entfiel, gehört zur Nachweiskette.
*/
entfallen: mitRecht("pruefung.planen")
.input(idEingabe.extend({ grund: z.string().trim().max(500).optional() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.wartungstermin.findUnique({
where: { id: input.id },
include: { geraet: { select: { inventarnummer: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
}
const nachher = await db.wartungstermin.update({
where: { id: input.id },
data: {
status: WartungsterminStatus.ENTFALLEN,
bemerkung: input.grund ?? vorher.bemerkung,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wartungstermin",
entitaetId: input.id,
beschreibung: `Prüftermin für ${vorher.geraet.inventarnummer} entfällt${
input.grund ? `: ${input.grund}` : ""
}`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
});
+429
View File
@@ -0,0 +1,429 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { ChecklistenpunktTyp, Prisma } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
/**
* Wartungsvorlagen und ihre Checklisten.
*
* Die Vorlage ist die Regel — "Pressluftatmer, alle 12 Monate, DGUV 49" — und
* die Checklistenpunkte sind die Fragen, die dabei zu beantworten sind. Beides
* pflegt der Gerätewart, beides ist Voraussetzung für alles Weitere: ohne
* Vorlage gibt es keine Termine, ohne Punkte keine Prüfung.
*
* Gelöscht wird nur weich. Eine Vorlage hängt an abgeschlossenen Protokollen,
* und die müssen in zehn Jahren noch erklärbar sein.
*/
const SORTIERFELDER = ["name", "intervallMonate", "erstelltAm"] as const;
const SUCHFELDER = ["name", "pruefvorschrift", "beschreibung"];
const LISTEN_AUSWAHL = {
id: true,
name: true,
intervallMonate: true,
vorwarnzeitTage: true,
pruefvorschrift: true,
standardDauerMinuten: true,
standardExtern: true,
automatisch: true,
aktiv: true,
geloeschtAm: true,
geraetetyp: { select: { id: true, name: true, kategorie: true } },
_count: { select: { punkte: true, termine: true, pruefungen: true } },
} satisfies Prisma.WartungsvorlageSelect;
const vorlageFelder = z.object({
name: z.string().trim().min(1, "Die Bezeichnung fehlt.").max(160),
geraetetypId: z.string().uuid().nullish(),
intervallMonate: z
.number()
.int()
.min(1, "Ein Intervall von null Monaten ergibt keinen Termin.")
.max(600),
vorwarnzeitTage: z.number().int().min(0).max(365).default(30),
pruefvorschrift: z.string().trim().max(200).nullish(),
standardDauerMinuten: z.number().int().min(1).max(10_000).nullish(),
standardExtern: z.boolean().default(false),
automatisch: z.boolean().default(true),
beschreibung: z.string().trim().max(1000).nullish(),
aktiv: z.boolean().default(true),
});
const punktBasis = z.object({
text: z.string().trim().min(1, "Der Prüfpunkt braucht einen Text.").max(300),
typ: z.nativeEnum(ChecklistenpunktTyp).default(ChecklistenpunktTyp.JA_NEIN),
pflicht: z.boolean().default(true),
sollwert: z.string().trim().max(120).nullish(),
einheit: z.string().trim().max(20).nullish(),
minWert: z.number().nullish(),
maxWert: z.number().nullish(),
hinweis: z.string().trim().max(500).nullish(),
sortierung: z.number().int().min(0).max(9999).default(0),
});
/** Ein Bereich, dessen Untergrenze über der Obergrenze liegt, ist kein Bereich. */
function bereichSinnvoll(wert: { minWert?: number | null; maxWert?: number | null }): boolean {
if (wert.minWert === null || wert.minWert === undefined) return true;
if (wert.maxWert === null || wert.maxWert === undefined) return true;
return wert.minWert <= wert.maxWert;
}
const BEREICH_FEHLER = {
message: "Der kleinste zulässige Wert darf nicht über dem größten liegen.",
path: ["minWert"],
};
const punktFelder = punktBasis.refine(bereichSinnvoll, BEREICH_FEHLER);
const punktAenderung = punktBasis.partial().refine(bereichSinnvoll, BEREICH_FEHLER);
export const wartungsvorlageRouter = router({
liste: mitRecht("pruefung.lesen")
.input(
listenEingabe(SORTIERFELDER).extend({
geraetetypId: z.string().uuid().optional(),
nurAktive: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.WartungsvorlageWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.geraetetypId ? { geraetetypId: input.geraetetypId } : {}),
...(input.nurAktive ? { aktiv: true } : {}),
...(sucheUeber(SUCHFELDER, input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.wartungsvorlage.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy: sortierenNach(input.sortierung, input.richtung, {
name: "asc",
}) as Prisma.WartungsvorlageOrderByWithRelationInput,
...blaettern(input),
}),
db.wartungsvorlage.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
/** Eine Vorlage mit ihrer vollständigen Checkliste. */
einzeln: mitRecht("pruefung.lesen")
.input(idEingabe)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorlage = await db.wartungsvorlage.findUnique({
where: { id: input.id },
include: {
geraetetyp: { select: { id: true, name: true, kategorie: true } },
punkte: {
where: { geloeschtAm: null },
orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }],
},
_count: { select: { termine: true, pruefungen: true } },
},
});
if (!vorlage) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
}
return vorlage;
}),
),
anlegen: mitRecht("pruefung.planen")
.input(vorlageFelder)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorlage = await db.wartungsvorlage.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Wartungsvorlage",
entitaetId: vorlage.id,
beschreibung: `Wartungsvorlage "${vorlage.name}" angelegt (alle ${vorlage.intervallMonate} Monate)`,
sitzung: ctx.sitzung,
nachher: vorlage,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return vorlage;
}),
),
aendern: mitRecht("pruefung.planen")
.input(vorlageFelder.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.wartungsvorlage.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
}
const nachher = await db.wartungsvorlage.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wartungsvorlage",
entitaetId: id,
beschreibung: `Wartungsvorlage "${nachher.name}" geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/**
* Weiches Löschen. Offene Termine dieser Vorlage werden gleich mit auf
* "entfallen" gesetzt — sonst bliebe im Dashboard eine Fälligkeit stehen,
* zu der es keine Regel mehr gibt.
*/
loeschen: mitRecht("pruefung.planen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.wartungsvorlage.findUnique({ where: { id: input.id } });
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
}
const nachher = await db.wartungsvorlage.update({
where: { id: input.id },
data: { geloeschtAm: new Date(), aktiv: false },
});
const entfallen = await db.wartungstermin.updateMany({
where: { vorlageId: input.id, status: "OFFEN", geloeschtAm: null },
data: { status: "ENTFALLEN" },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Wartungsvorlage",
entitaetId: input.id,
beschreibung: `Wartungsvorlage "${vorher.name}" gelöscht, ${entfallen.count} offene Termine entfallen`,
sitzung: ctx.sitzung,
vorher,
nachher,
kontext: { entfalleneTermine: entfallen.count },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true, entfalleneTermine: entfallen.count };
}),
),
wiederherstellen: mitRecht("pruefung.planen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.wartungsvorlage.findUnique({ where: { id: input.id } });
if (!vorher?.geloeschtAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Diese Vorlage ist gar nicht gelöscht.",
});
}
const nachher = await db.wartungsvorlage.update({
where: { id: input.id },
data: { geloeschtAm: null, aktiv: true },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.WIEDERHERGESTELLT,
entitaet: "Wartungsvorlage",
entitaetId: input.id,
beschreibung: `Wartungsvorlage "${nachher.name}" wiederhergestellt`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
// ---------------------------------------------------------------------------
// Checklistenpunkte
// ---------------------------------------------------------------------------
punktAnlegen: mitRecht("pruefung.planen")
.input(z.object({ vorlageId: z.string().uuid() }).and(punktFelder))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { vorlageId, ...felder } = input;
const vorlage = await db.wartungsvorlage.findUnique({
where: { id: vorlageId },
select: { id: true, name: true },
});
if (!vorlage) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
}
// Neue Punkte hängen sich hinten an, wenn keine Sortierung angegeben ist.
const sortierung =
felder.sortierung > 0
? felder.sortierung
: ((
await db.checklistenpunkt.aggregate({
where: { vorlageId, geloeschtAm: null },
_max: { sortierung: true },
})
)._max.sortierung ?? 0) + 10;
const punkt = await db.checklistenpunkt.create({
data: {
organisationId: ctx.sitzung.organisationId,
vorlageId,
...felder,
sortierung,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Checklistenpunkt",
entitaetId: punkt.id,
beschreibung: `Prüfpunkt "${punkt.text}" zu "${vorlage.name}" ergänzt`,
sitzung: ctx.sitzung,
nachher: punkt,
kontext: { vorlageId },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return punkt;
}),
),
punktAendern: mitRecht("pruefung.planen")
.input(z.object({ id: z.string().uuid() }).and(punktAenderung))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.checklistenpunkt.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
}
const nachher = await db.checklistenpunkt.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Checklistenpunkt",
entitaetId: id,
beschreibung: `Prüfpunkt "${nachher.text}" geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/**
* Weiches Löschen — ein Punkt kann in abgeschlossenen Protokollen stehen.
* Die Datenbank verhindert das harte Löschen ohnehin (Restrict auf
* pruefergebnis), aber die verständliche Fassung steht hier.
*/
punktLoeschen: mitRecht("pruefung.planen")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.checklistenpunkt.findUnique({ where: { id: input.id } });
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
}
await db.checklistenpunkt.update({
where: { id: input.id },
data: { geloeschtAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Checklistenpunkt",
entitaetId: input.id,
beschreibung: `Prüfpunkt "${vorher.text}" entfernt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
/** Reihenfolge der Checkliste festlegen — die Prüfung läuft sie so ab. */
punkteSortieren: mitRecht("pruefung.planen")
.input(
z.object({
vorlageId: z.string().uuid(),
reihenfolge: z.array(z.string().uuid()).min(1).max(500),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
for (const [index, punktId] of input.reihenfolge.entries()) {
await db.checklistenpunkt.updateMany({
where: { id: punktId, vorlageId: input.vorlageId },
data: { sortierung: (index + 1) * 10 },
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Wartungsvorlage",
entitaetId: input.vorlageId,
beschreibung: `Reihenfolge der Checkliste geändert (${input.reihenfolge.length} Punkte)`,
sitzung: ctx.sitzung,
kontext: { reihenfolge: input.reihenfolge },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { sortiert: input.reihenfolge.length };
}),
),
});
+107 -18
View File
@@ -1,38 +1,84 @@
// Im Container liefert Compose die Umgebung, lokal die .env. dotenv
// überschreibt nichts, was schon gesetzt ist — im Betrieb ändert die Zeile
// also nichts, macht aber "npm run worker" auf dem Entwicklungsrechner möglich.
import "dotenv/config";
import { Worker, type Job } from "bullmq";
import { env } from "@/lib/env";
import { AUFTRAG, WARTESCHLANGE, redis, type AuftragsDaten } from "@/lib/warteschlange";
import { verbindungenTrennen } from "@/lib/db";
import {
AUFTRAG,
WARTESCHLANGE,
beauftrageWiederkehrend,
redis,
type AuftragsDaten,
} from "@/lib/warteschlange";
import { ohneMandantenfilter, verbindungenTrennen } from "@/lib/db";
import { protokollErzeugen } from "@/lib/wartung/protokoll-erzeugen";
import { erinnerungenSenden, mangelBenachrichtigung, terminePlanen } from "@/worker/wartung";
/**
* Hintergrundprozess.
*
* Läuft als eigener Container neben der Anwendung und arbeitet die
* Warteschlange ab. In Phase 0 sind die Auftragsarten angelegt, aber fachlich
* noch leer — die Fristenberechnung kommt in Phase 2, die Erinnerungen in
* Phase 7. Was hier schon steht, ist das Gerüst: Aufnahme, Protokollierung,
* Fehlerbehandlung und ein sauberes Herunterfahren.
* Warteschlange ab. Seit Phase 2 steckt die eigentliche Arbeit drin:
* Terminplanung, Erinnerungen und die Erzeugung der Prüfprotokolle. Die
* Aufbewahrung folgt in Phase 8.
*
* Beim Start trägt sich der Prozess die wiederkehrenden Läufe je Wehr ein.
* Das ist bewusst hier und nicht in der Anwendung: der Worker ist der Einzige,
* der sie auch ausführt, und ein Zeitplan ohne Ausführenden ist nutzlos.
*/
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
console.info(`[worker] ${nachricht}`, ...weiteres);
}
async function fristenPruefen(auftrag: Job<AuftragsDaten>): Promise<{ geprueft: number }> {
protokoll(`Fristen prüfen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 2: Geräte mit überschrittener Frist auf PRUEFUNG_FAELLIG setzen.
return { geprueft: 0 };
async function fristenPruefen(auftrag: Job<AuftragsDaten>) {
const { organisationId } = auftrag.data;
const ergebnis = await terminePlanen(organisationId);
protokoll(
`Fristen geprüft für ${organisationId}: ${ergebnis.angelegt} Termine geplant, ` +
`${ergebnis.statusGeaendert} Geräte im Status geändert`,
);
return ergebnis;
}
async function erinnerungSenden(auftrag: Job<AuftragsDaten>): Promise<{ versendet: number }> {
protokoll(`Erinnerungen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 7: Mail und Telegram über lib/integrations.
return { versendet: 0 };
async function erinnerungSenden(auftrag: Job<AuftragsDaten>) {
const { organisationId, anlass, mangelId } = auftrag.data as AuftragsDaten & {
anlass?: string;
mangelId?: string;
};
// Mängel melden sich sofort, Termine im nächtlichen Sammellauf.
if (anlass?.startsWith("mangel") && mangelId) {
return mangelBenachrichtigung(organisationId, mangelId, anlass);
}
return erinnerungenSenden(organisationId);
}
async function pdfErzeugen(auftrag: Job<AuftragsDaten>): Promise<{ erzeugt: boolean }> {
protokoll(`PDF erzeugen für Organisation ${auftrag.data.organisationId}`);
// Ab Phase 2: HTML an Gotenberg, Ergebnis nach MinIO über lib/storage.
return { erzeugt: false };
async function pdfErzeugen(auftrag: Job<AuftragsDaten>) {
const { organisationId, pruefungId } = auftrag.data as AuftragsDaten & { pruefungId?: string };
if (!pruefungId) {
throw new Error("PDF-Auftrag ohne Angabe, wozu — pruefungId fehlt.");
}
const ergebnis = await protokollErzeugen(organisationId, pruefungId);
if (!ergebnis) {
protokoll(`Kein Protokoll für Prüfung ${pruefungId} — unbekannt oder noch Entwurf.`);
return { erzeugt: false };
}
protokoll(
ergebnis.neu
? `Protokoll für Prüfung ${pruefungId} erzeugt.`
: `Protokoll für Prüfung ${pruefungId} lag bereits vor.`,
);
return { erzeugt: ergebnis.neu, dateiId: ergebnis.dateiId };
}
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
@@ -49,6 +95,36 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
};
/**
* Trägt die nächtlichen Läufe für jede aktive Wehr ein.
*
* BullMQ hält wiederkehrende Aufträge in Redis; ein zweiter Eintrag mit
* derselben Auftragskennung ersetzt den ersten. Der Aufruf ist deshalb bei
* jedem Start wiederholbar, und eine neu angelegte Wehr kommt beim nächsten
* Neustart des Workers dazu.
*/
async function zeitplaeneEinrichten(): Promise<number> {
const organisationen = await ohneMandantenfilter(
"Wartungsjobs für alle Wehren einplanen",
(db) => db.organisation.findMany({ where: { aktiv: true, geloeschtAm: null }, select: { id: true, kurzname: true } }),
);
for (const organisation of organisationen) {
await beauftrageWiederkehrend(
AUFTRAG.FRISTEN_PRUEFEN,
{ organisationId: organisation.id },
env.WARTUNG_NACHTLAUF,
);
await beauftrageWiederkehrend(
AUFTRAG.ERINNERUNG_SENDEN,
{ organisationId: organisation.id },
env.WARTUNG_ERINNERUNG_LAUF,
);
}
return organisationen.length;
}
const worker = new Worker<AuftragsDaten>(
WARTESCHLANGE,
async (auftrag) => {
@@ -78,6 +154,19 @@ worker.on("ready", () => {
protokoll(
`bereit — Warteschlange "${WARTESCHLANGE}", ${env.WORKER_CONCURRENCY} parallele Aufträge`,
);
void zeitplaeneEinrichten()
.then((anzahl) =>
protokoll(
`Zeitpläne eingerichtet für ${anzahl} Wehren ` +
`(Planung ${env.WARTUNG_NACHTLAUF}, Erinnerungen ${env.WARTUNG_ERINNERUNG_LAUF})`,
),
)
.catch((fehler) => {
// Ein fehlgeschlagener Zeitplan darf den Worker nicht aufhalten: die
// Aufträge aus der Anwendung sollen weiter abgearbeitet werden.
console.error("[worker] Zeitpläne ließen sich nicht einrichten:", fehler);
});
});
/** Laufende Aufträge zu Ende bringen, bevor der Prozess endet. */
+450
View File
@@ -0,0 +1,450 @@
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
import { fuerOrganisation } from "@/lib/db";
import { env } from "@/lib/env";
import { mail } from "@/lib/integrations/mail";
import { abgeleiteterGeraetStatus } from "@/lib/stammdaten/fristen";
import { erinnerungsstufe, type Erinnerungsstufe } from "@/lib/wartung/fristen";
import { fruehesteFaelligkeit, zuPlanendeTermine, type PlanungsGeraet } from "@/lib/wartung/planung";
import { alsDatum } from "@/lib/utils";
/**
* Die nächtliche Arbeit hinter dem Prüfmanagement.
*
* Zwei Läufe, die getrennt bleiben, weil sie Verschiedenes tun und
* verschieden schiefgehen können:
*
* terminePlanen legt fällige Termine an und zieht den Gerätestatus nach
* erinnerungenSenden verschickt Vorwarnungen zu offenen Terminen
*
* Beide sind wiederholbar. Wer sie zweimal laufen lässt, bekommt keine
* doppelten Termine (Eindeutigkeit über Gerät, Vorlage und Tag) und keine
* doppelten Mails (ein Erinnerungseintrag je Termin und Stufe).
*/
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
console.info(`[wartung] ${nachricht}`, ...weiteres);
}
// -----------------------------------------------------------------------------
// Termine planen
// -----------------------------------------------------------------------------
export interface PlanungsErgebnis {
angelegt: number;
statusGeaendert: number;
vorlagen: number;
}
/**
* Plant Termine für eine Wehr und zieht danach den Status der Geräte nach.
*
* Der Status ist der eigentliche Zweck des Laufs: ein Gerät, dessen Prüfung
* heute fällig geworden ist, soll morgen früh nicht mehr als einsatzbereit in
* der Liste stehen. Von Hand gesetzte Zustände — gesperrt, ausgesondert, in
* Reparatur — bleiben unangetastet; darüber hat jemand bewusst entschieden.
*/
export async function terminePlanen(
organisationId: string,
heute = new Date(),
): Promise<PlanungsErgebnis> {
const horizontTage = env.WARTUNG_PLANUNGSHORIZONT_TAGE;
const angelegt = await fuerOrganisation(organisationId, async (db) => {
const vorlagen = await db.wartungsvorlage.findMany({
where: { aktiv: true, automatisch: true, geloeschtAm: null, geraetetypId: { not: null } },
select: { id: true, name: true, intervallMonate: true, geraetetypId: true },
});
let summe = 0;
for (const vorlage of vorlagen) {
const geraete = await db.geraet.findMany({
where: {
geraetetypId: vorlage.geraetetypId,
geloeschtAm: null,
status: { not: "AUSGESONDERT" },
},
select: {
id: true,
inventarnummer: true,
status: true,
indienststellung: true,
letztePruefung: true,
wartungstermine: {
where: { vorlageId: vorlage.id, status: "OFFEN", geloeschtAm: null },
select: { id: true },
take: 1,
},
pruefungen: {
where: { vorlageId: vorlage.id, status: "ABGESCHLOSSEN", geloeschtAm: null },
orderBy: { durchgefuehrtAm: "desc" },
select: { durchgefuehrtAm: true },
take: 1,
},
},
});
const planungsdaten: PlanungsGeraet[] = geraete.map((geraet) => ({
id: geraet.id,
inventarnummer: geraet.inventarnummer,
status: geraet.status,
indienststellung: geraet.indienststellung,
letztePruefungZurVorlage: geraet.pruefungen[0]?.durchgefuehrtAm ?? null,
letztePruefung: geraet.letztePruefung,
hatOffenenTermin: geraet.wartungstermine.length > 0,
}));
const geplant = zuPlanendeTermine(vorlage, planungsdaten, { horizontTage, heute });
if (geplant.length === 0) continue;
const ergebnis = await db.wartungstermin.createMany({
data: geplant.map((termin) => ({
organisationId,
geraetId: termin.geraetId,
vorlageId: termin.vorlageId,
faelligAm: termin.faelligAm,
automatischErzeugt: true,
bemerkung: termin.ohneBasisdatum
? "Ohne Angabe zu letzter Prüfung oder Indienststellung — sofort zu prüfen."
: null,
})),
// Ein zweiter Lauf am selben Tag legt nichts doppelt an.
skipDuplicates: true,
});
summe += ergebnis.count;
}
if (summe > 0) {
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Wartungstermin",
beschreibung: `Nächtlicher Lauf: ${summe} Prüftermine geplant`,
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
kontext: { vorlagen: vorlagen.length, horizontTage },
});
}
return { summe, vorlagen: vorlagen.length };
});
const statusGeaendert = await geraeteStatusNachziehen(organisationId, heute);
return {
angelegt: angelegt.summe,
vorlagen: angelegt.vorlagen,
statusGeaendert,
};
}
/**
* Setzt an jedem Gerät die nächste Frist und den daraus folgenden Status.
*
* Die Frist am Gerät ist die früheste offene Terminfälligkeit — ein
* Pressluftatmer, der jährlich sichtgeprüft und alle sechs Jahre überholt wird,
* hat zwei Termine, aber nur eine nächste Frist.
*/
async function geraeteStatusNachziehen(organisationId: string, heute: Date): Promise<number> {
return fuerOrganisation(organisationId, async (db) => {
const geraete = await db.geraet.findMany({
where: { geloeschtAm: null, status: { not: "AUSGESONDERT" } },
select: {
id: true,
inventarnummer: true,
status: true,
naechstePruefung: true,
lebensdauerBis: true,
wartungstermine: {
where: { status: "OFFEN", geloeschtAm: null },
select: { faelligAm: true },
},
},
});
let geaendert = 0;
for (const geraet of geraete) {
const ausTerminen = fruehesteFaelligkeit(geraet.wartungstermine);
const naechste = ausTerminen ?? geraet.naechstePruefung;
const neuerStatus = abgeleiteterGeraetStatus({
aktuellerStatus: geraet.status,
naechstePruefung: naechste,
lebensdauerBis: geraet.lebensdauerBis,
heute,
});
const fristGleich =
(naechste?.getTime() ?? null) === (geraet.naechstePruefung?.getTime() ?? null);
if (neuerStatus === geraet.status && fristGleich) continue;
await db.geraet.update({
where: { id: geraet.id },
data: { status: neuerStatus, naechstePruefung: naechste },
});
if (neuerStatus !== geraet.status) {
geaendert += 1;
await protokolliereIn(db, {
organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraet",
entitaetId: geraet.id,
beschreibung: `Nächtlicher Lauf: Status von ${geraet.inventarnummer} auf ${neuerStatus}`,
akteurTyp: AkteurTyp.WORKER,
akteurName: "Hintergrundprozess",
vorher: { status: geraet.status, naechstePruefung: geraet.naechstePruefung },
nachher: { status: neuerStatus, naechstePruefung: naechste },
});
}
}
return geaendert;
});
}
// -----------------------------------------------------------------------------
// Erinnerungen
// -----------------------------------------------------------------------------
const STUFE_TEXT: Record<Erinnerungsstufe, string> = {
T_30: "Vorwarnung",
T_7: "in einer Woche fällig",
T_0: "heute fällig",
UEBERFAELLIG: "überfällig",
};
interface Faellig {
terminId: string;
stufe: Erinnerungsstufe;
faelligAm: Date;
inventarnummer: string;
bezeichnung: string;
vorlage: string;
empfaenger: string[];
}
/**
* Verschickt die Vorwarnungen zu offenen Terminen.
*
* Empfänger ist, wer den Termin übernommen hat. Ist niemand zuständig, gehen
* die Erinnerungen an die Gerätewarte der Wehr — der Kommandant bekommt nicht
* jeden Schlauchprüftermin auf den Tisch.
*
* Verschickt wird eine Sammelnachricht je Empfänger, nicht eine Mail je Gerät.
* Zwanzig einzelne Mails an einem Morgen liest niemand.
*/
export async function erinnerungenSenden(
organisationId: string,
heute = new Date(),
): Promise<{ termine: number; nachrichten: number }> {
const faellige = await fuerOrganisation(organisationId, async (db) => {
const termine = await db.wartungstermin.findMany({
where: { status: "OFFEN", geloeschtAm: null, geraet: { geloeschtAm: null } },
select: {
id: true,
faelligAm: true,
geraet: { select: { inventarnummer: true, bezeichnung: true } },
vorlage: { select: { name: true, vorwarnzeitTage: true } },
zugewiesenAn: { select: { email: true, aktiv: true } },
erinnerungen: { select: { stufe: true } },
},
take: 5000,
});
// Ersatzempfänger: die Gerätewarte der Wehr.
const geraetewarte = await db.person.findMany({
where: {
aktiv: true,
geloeschtAm: null,
email: { not: null },
rollen: {
some: {
rolle: { schluessel: { in: ["GERAETEWART", "ATEMSCHUTZGERAETEWART"] } },
},
},
},
select: { email: true },
});
const ersatz = geraetewarte
.map((person) => person.email)
.filter((adresse): adresse is string => Boolean(adresse));
const offen: Faellig[] = [];
for (const termin of termine) {
const stufe = erinnerungsstufe(
termin.faelligAm,
termin.vorlage?.vorwarnzeitTage ?? 30,
heute,
);
if (!stufe) continue;
const bereits = new Set(termin.erinnerungen.map((eintrag) => eintrag.stufe));
if (bereits.has(stufe)) continue;
const zustaendig =
termin.zugewiesenAn?.aktiv && termin.zugewiesenAn.email
? [termin.zugewiesenAn.email]
: ersatz;
offen.push({
terminId: termin.id,
stufe,
faelligAm: termin.faelligAm,
inventarnummer: termin.geraet.inventarnummer,
bezeichnung: termin.geraet.bezeichnung,
vorlage: termin.vorlage?.name ?? "Prüfung",
empfaenger: zustaendig,
});
}
return offen;
});
if (faellige.length === 0) return { termine: 0, nachrichten: 0 };
// Nach Empfänger bündeln.
const nachEmpfaenger = new Map<string, Faellig[]>();
for (const eintrag of faellige) {
for (const adresse of eintrag.empfaenger) {
const liste = nachEmpfaenger.get(adresse) ?? [];
liste.push(eintrag);
nachEmpfaenger.set(adresse, liste);
}
}
const versand = mail();
let nachrichten = 0;
for (const [adresse, eintraege] of nachEmpfaenger) {
const ueberfaellig = eintraege.filter((eintrag) => eintrag.stufe === "UEBERFAELLIG").length;
const zeilen = eintraege
.sort((a, b) => a.faelligAm.getTime() - b.faelligAm.getTime())
.map(
(eintrag) =>
`- ${eintrag.inventarnummer} (${eintrag.bezeichnung}): ${eintrag.vorlage}, ` +
`fällig am ${alsDatum(eintrag.faelligAm)} — ${STUFE_TEXT[eintrag.stufe]}`,
);
const ergebnis = await versand.senden({
an: adresse,
betreff:
ueberfaellig > 0
? `Prüftermine: ${ueberfaellig} überfällig, ${eintraege.length} insgesamt`
: `Prüftermine: ${eintraege.length} anstehend`,
text: [
"Folgende Prüfungen stehen an:",
"",
...zeilen,
"",
"Diese Nachricht kommt von FLORIAN und wiederholt sich je Termin und Stufe nur einmal.",
].join("\n"),
});
if (ergebnis.erfolgreich) nachrichten += 1;
}
// Erst nach dem Versand vermerken. Andersherum wäre eine ausgefallene
// Mailanbindung eine stillschweigend verschluckte Erinnerung.
await fuerOrganisation(organisationId, async (db) => {
for (const eintrag of faellige) {
await db.erinnerung.upsert({
where: { terminId_stufe: { terminId: eintrag.terminId, stufe: eintrag.stufe } },
create: {
organisationId,
terminId: eintrag.terminId,
stufe: eintrag.stufe,
kanal: versand.verfuegbar() ? "mail" : "protokoll",
empfaenger: eintrag.empfaenger,
erfolgreich: true,
},
update: {},
});
}
});
protokoll(`${faellige.length} Erinnerungen, ${nachrichten} Nachrichten verschickt`);
return { termine: faellige.length, nachrichten };
}
/**
* Benachrichtigung zu einem Mangel.
*
* Anders als die Terminerinnerung geht sie sofort raus: ein kritischer Mangel
* bedeutet, dass ein Gerät gerade aus dem Dienst genommen wurde. Das muss die
* Zuständigen noch am selben Tag erreichen.
*/
export async function mangelBenachrichtigung(
organisationId: string,
mangelId: string,
anlass: string,
): Promise<{ versendet: boolean }> {
const daten = await fuerOrganisation(organisationId, async (db) => {
const mangel = await db.mangel.findUnique({
where: { id: mangelId },
select: {
beschreibung: true,
prioritaet: true,
status: true,
geraetGesperrt: true,
melderName: true,
geraet: { select: { inventarnummer: true, bezeichnung: true } },
zugewiesenAn: { select: { email: true, aktiv: true } },
},
});
if (!mangel) return null;
const geraetewarte = await db.person.findMany({
where: {
aktiv: true,
geloeschtAm: null,
email: { not: null },
rollen: {
some: { rolle: { schluessel: { in: ["GERAETEWART", "ATEMSCHUTZGERAETEWART"] } } },
},
},
select: { email: true },
});
const empfaenger =
mangel.zugewiesenAn?.aktiv && mangel.zugewiesenAn.email
? [mangel.zugewiesenAn.email]
: geraetewarte
.map((person) => person.email)
.filter((adresse): adresse is string => Boolean(adresse));
return { mangel, empfaenger };
});
if (!daten || daten.empfaenger.length === 0) return { versendet: false };
const { mangel, empfaenger } = daten;
const ergebnis = await mail().senden({
an: empfaenger,
betreff: `Mangel an ${mangel.geraet.inventarnummer} (${mangel.prioritaet.toLowerCase()})`,
text: [
`Gerät: ${mangel.geraet.inventarnummer} — ${mangel.geraet.bezeichnung}`,
`Gemeldet von: ${mangel.melderName ?? "unbekannt"}`,
`Dringlichkeit: ${mangel.prioritaet}`,
`Stand: ${mangel.status}`,
mangel.geraetGesperrt ? "Das Gerät ist gesperrt." : "",
"",
mangel.beschreibung,
"",
`Anlass dieser Nachricht: ${anlass}`,
]
.filter(Boolean)
.join("\n"),
});
return { versendet: ergebnis.erfolgreich };
}