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