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:
co-authored by
Claude Opus 5
parent
7f866ac35c
commit
e503aafc18
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user