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