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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user