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>
);
}