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>