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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FileDown, PencilLine } from "lucide-react";
|
||||
import type { PruefungStatus } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
|
||||
/**
|
||||
* Die beiden Dinge, die man mit einem fertigen Protokoll tut: herunterladen
|
||||
* oder korrigieren.
|
||||
*
|
||||
* Korrigieren heißt hier nicht ändern. Es entsteht ein neuer Entwurf mit den
|
||||
* bisherigen Antworten und einem Verweis auf dieses Protokoll; erst mit seinem
|
||||
* Abschluss gilt das alte als ersetzt. So bleibt nachvollziehbar, was zuerst
|
||||
* dastand — genau darum geht es bei einem Nachweis.
|
||||
*/
|
||||
export function ProtokollAktionen({
|
||||
pruefungId,
|
||||
status,
|
||||
hatProtokoll,
|
||||
darfKorrigieren,
|
||||
}: {
|
||||
pruefungId: string;
|
||||
status: PruefungStatus;
|
||||
hatProtokoll: boolean;
|
||||
darfKorrigieren: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const link = trpc.pruefung.protokollLink.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
if (ergebnis.bereit && ergebnis.adresse) {
|
||||
window.open(ergebnis.adresse, "_blank", "noopener");
|
||||
setMeldung(null);
|
||||
} else {
|
||||
setMeldung(
|
||||
"Das PDF wird gerade erzeugt. In ein paar Sekunden noch einmal versuchen — Gotenberg braucht einen Moment.",
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const korrigieren = trpc.pruefung.korrigieren.useMutation({
|
||||
// Der Wizard nimmt den frischen Entwurf auf: er sucht zu Gerät, Vorlage
|
||||
// und Prüfer nach einer angefangenen Prüfung und setzt sie fort — samt der
|
||||
// übernommenen Antworten aus dem alten Protokoll.
|
||||
onSuccess: (neu) =>
|
||||
router.push(
|
||||
`/pruefungen/neu?geraetId=${neu.geraetId}${neu.vorlageId ? `&vorlageId=${neu.vorlageId}` : ""}`,
|
||||
),
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => link.mutate({ id: pruefungId })}
|
||||
disabled={link.isPending || status === "ENTWURF"}
|
||||
>
|
||||
<FileDown aria-hidden />
|
||||
{hatProtokoll ? "Protokoll als PDF" : "PDF erzeugen"}
|
||||
</Button>
|
||||
|
||||
{darfKorrigieren && status !== "ENTWURF" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const grund = window.prompt(
|
||||
"Warum wird korrigiert? Der Grund steht später im neuen Protokoll.",
|
||||
);
|
||||
if (grund?.trim()) korrigieren.mutate({ id: pruefungId, grund: grund.trim() });
|
||||
}}
|
||||
disabled={korrigieren.isPending}
|
||||
>
|
||||
<PencilLine aria-hidden />
|
||||
Korrektur anlegen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="protokollmeldung" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { notFound, 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 { fuerOrganisation } from "@/lib/db";
|
||||
import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ProtokollAktionen } from "@/app/(app)/pruefungen/[id]/aktionen";
|
||||
import { ErgebnisAbzeichen, PruefungStatusAbzeichen } from "@/components/wartung/anzeige";
|
||||
import { PRIORITAET_TEXT, PUNKTTYP_TEXT } from "@/lib/wartung/texte";
|
||||
|
||||
export const metadata = { title: "Prüfprotokoll" };
|
||||
|
||||
/**
|
||||
* Ein Prüfprotokoll auf dem Bildschirm.
|
||||
*
|
||||
* Zeigt dasselbe wie das PDF, nur ohne Papierlayout. Das PDF bleibt der
|
||||
* Nachweis; diese Seite ist der schnelle Blick, ohne erst eine Datei öffnen zu
|
||||
* müssen.
|
||||
*/
|
||||
export default async function PruefungSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const pruefung = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.pruefung.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
geraet: {
|
||||
select: { id: true, inventarnummer: true, bezeichnung: true, seriennummer: true },
|
||||
},
|
||||
vorlage: { select: { id: true, name: true, pruefvorschrift: true, intervallMonate: true } },
|
||||
pruefer: { select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true } },
|
||||
ergebnisse: { include: { punkt: true } },
|
||||
signaturen: true,
|
||||
maengel: { where: { geloeschtAm: null } },
|
||||
ersetzt: { select: { id: true, protokollNummer: true } },
|
||||
ersetztDurch: { select: { id: true, protokollNummer: true, status: true } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!pruefung) notFound();
|
||||
|
||||
const ergebnisseSortiert = [...pruefung.ergebnisse].sort(
|
||||
(a, b) => a.punkt.sortierung - b.punkt.sortierung,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
|
||||
<Link href="/pruefungen/verlauf">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Protokollen
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Prüfprotokoll {pruefung.protokollNummer ?? ""}
|
||||
</h1>
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
<Link href={`/geraete/${pruefung.geraet.id}`} className="underline underline-offset-4">
|
||||
{pruefung.geraet.inventarnummer} — {pruefung.geraet.bezeichnung}
|
||||
</Link>
|
||||
<ErgebnisAbzeichen ergebnis={pruefung.ergebnis} />
|
||||
<PruefungStatusAbzeichen status={pruefung.status} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProtokollAktionen
|
||||
pruefungId={pruefung.id}
|
||||
status={pruefung.status}
|
||||
hatProtokoll={Boolean(pruefung.protokollDateiId)}
|
||||
darfKorrigieren={hatRecht(sitzung, "pruefung.durchfuehren")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pruefung.ersetzt && (
|
||||
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
|
||||
Dieses Protokoll ist eine Korrektur zu{" "}
|
||||
<Link href={`/pruefungen/${pruefung.ersetzt.id}`} className="underline underline-offset-4">
|
||||
{pruefung.ersetzt.protokollNummer ?? "einem früheren Protokoll"}
|
||||
</Link>
|
||||
{pruefung.korrekturGrund && `. Grund: ${pruefung.korrekturGrund}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pruefung.ersetztDurch.length > 0 && (
|
||||
<div className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
|
||||
Dieses Protokoll wurde ersetzt durch{" "}
|
||||
{pruefung.ersetztDurch.map((nachfolger) => (
|
||||
<Link
|
||||
key={nachfolger.id}
|
||||
href={`/pruefungen/${nachfolger.id}`}
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
{nachfolger.protokollNummer ?? "eine Korrektur"}
|
||||
</Link>
|
||||
))}
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Angaben</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
["Prüfdatum", alsDatum(pruefung.durchgefuehrtAm)],
|
||||
[
|
||||
"Prüfer",
|
||||
pruefung.pruefer
|
||||
? `${pruefung.pruefer.vorname} ${pruefung.pruefer.nachname}`
|
||||
: (pruefung.prueferName ?? "—"),
|
||||
],
|
||||
["Prüfer-Kennung", pruefung.pruefer?.mitgliedsnummer ?? "—"],
|
||||
["Art", pruefung.art === "EXTERN" ? "extern durch Fachfirma" : "intern"],
|
||||
["Fachfirma", pruefung.externeFirma ?? "—"],
|
||||
["Kosten", pruefung.kosten ? alsEuro(Number(pruefung.kosten)) : "—"],
|
||||
["Vorlage", pruefung.vorlage?.name ?? "—"],
|
||||
["Prüfvorschrift", pruefung.vorlage?.pruefvorschrift ?? "—"],
|
||||
["Dauer", pruefung.dauerMinuten ? `${pruefung.dauerMinuten} Minuten` : "—"],
|
||||
["Nächste Prüfung", alsDatum(pruefung.naechstePruefungAm)],
|
||||
["Abgeschlossen", alsDatumZeit(pruefung.abgeschlossenAm)],
|
||||
] as Array<[string, string]>
|
||||
).map(([bezeichnung, wert]) => (
|
||||
<div key={bezeichnung}>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
{bezeichnung}
|
||||
</dt>
|
||||
<dd className="text-sm">{wert}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{pruefung.bemerkung && (
|
||||
<div className="mt-4 border-t border-[var(--border)] pt-4">
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Bemerkung
|
||||
</p>
|
||||
<p className="whitespace-pre-line text-sm">{pruefung.bemerkung}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Unterschriften</CardTitle>
|
||||
<CardDescription>
|
||||
Die Bilder liegen in MinIO und sind im PDF eingebettet.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{pruefung.signaturen.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">Keine Unterschrift erfasst.</p>
|
||||
) : (
|
||||
pruefung.signaturen.map((signatur) => (
|
||||
<div key={signatur.id} className="text-sm">
|
||||
<p className="font-medium">{signatur.name}</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{signatur.rolle} · {alsDatumZeit(signatur.unterzeichnetAm)}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Checkliste</CardTitle>
|
||||
<CardDescription>
|
||||
{ergebnisseSortiert.filter((eintrag) => !eintrag.inOrdnung).length} von{" "}
|
||||
{ergebnisseSortiert.length} Punkten beanstandet.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Prüfpunkt</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Soll</TableHead>
|
||||
<TableHead>Ist</TableHead>
|
||||
<TableHead>Befund</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ergebnisseSortiert.map((eintrag) => (
|
||||
<TableRow key={eintrag.id}>
|
||||
<TableCell>
|
||||
{eintrag.punkt.text}
|
||||
{eintrag.punkt.pflicht && (
|
||||
<span className="ml-1 text-[var(--gesperrt)]">*</span>
|
||||
)}
|
||||
{eintrag.bemerkung && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{eintrag.bemerkung}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--muted-foreground)]">
|
||||
{PUNKTTYP_TEXT[eintrag.punkt.typ]}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{eintrag.punkt.sollwert ?? "—"}
|
||||
{eintrag.punkt.einheit ? ` ${eintrag.punkt.einheit}` : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm tabellenzahlen">
|
||||
{eintrag.punkt.typ === "JA_NEIN"
|
||||
? eintrag.jaNein === true
|
||||
? "ja"
|
||||
: eintrag.jaNein === false
|
||||
? "nein"
|
||||
: "—"
|
||||
: eintrag.punkt.typ === "MESSWERT"
|
||||
? eintrag.zahlwert
|
||||
? `${Number(eintrag.zahlwert).toLocaleString("de-DE")}${
|
||||
eintrag.punkt.einheit ? ` ${eintrag.punkt.einheit}` : ""
|
||||
}`
|
||||
: "—"
|
||||
: eintrag.punkt.typ === "FOTO"
|
||||
? eintrag.fotoDateiId
|
||||
? "Foto liegt bei"
|
||||
: "—"
|
||||
: (eintrag.text ?? "—")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={
|
||||
eintrag.inOrdnung ? "text-sm" : "text-sm font-medium text-[var(--gesperrt)]"
|
||||
}
|
||||
>
|
||||
{eintrag.inOrdnung ? "i. O." : "nicht i. O."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{pruefung.maengel.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Festgestellte Mängel</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{pruefung.maengel.map((mangel) => (
|
||||
<li key={mangel.id} className="text-sm">
|
||||
<Link href="/maengel" className="underline underline-offset-4">
|
||||
{PRIORITAET_TEXT[mangel.prioritaet]}
|
||||
</Link>
|
||||
: {mangel.beschreibung}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { CalendarClock, ClipboardCheck, Hourglass, Users } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Fehlermeldung,
|
||||
FristAnzeige,
|
||||
LadeZeilen,
|
||||
NichtsGefunden,
|
||||
StatusAbzeichen,
|
||||
} from "@/components/stammdaten/anzeige";
|
||||
import { FensterAbzeichen, Kennzahl } from "@/components/wartung/anzeige";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
import type { Fristfenster } from "@/lib/wartung/fristen";
|
||||
|
||||
/**
|
||||
* Fristen-Dashboard.
|
||||
*
|
||||
* Aufbau von oben nach unten in der Reihenfolge, in der ein Gerätewart denkt:
|
||||
*
|
||||
* 1. Wie viel ist überfällig? (die vier Zahlen, gleichzeitig Filter)
|
||||
* 2. Wo steckt es? (gebündelt nach Fahrzeug, Standort, Typ oder Zuständigem)
|
||||
* 3. Was genau ist zu tun? (die Terminliste, aus der die Prüfung startet)
|
||||
*
|
||||
* Die Bündelung ist kein Zierrat: wer einen Prüftag ansetzt, arbeitet ein
|
||||
* Fahrzeug ab. Eine alphabetische Gesamtliste hilft dabei nicht.
|
||||
*/
|
||||
|
||||
const GRUPPIERUNGEN = [
|
||||
{ wert: "fahrzeug", text: "nach Fahrzeug" },
|
||||
{ wert: "standort", text: "nach Standort" },
|
||||
{ wert: "geraetetyp", text: "nach Gerätetyp" },
|
||||
{ wert: "zustaendig", text: "nach Zuständigem" },
|
||||
] as const;
|
||||
|
||||
type Gruppierung = (typeof GRUPPIERUNGEN)[number]["wert"];
|
||||
type Zeitfenster = "ueberfaellig" | "diese_woche" | "dieser_monat" | "naechste_90_tage";
|
||||
|
||||
export function FristenDashboard({
|
||||
darfPlanen,
|
||||
darfPruefen,
|
||||
}: {
|
||||
darfPlanen: boolean;
|
||||
darfPruefen: boolean;
|
||||
}) {
|
||||
const [gruppierung, setGruppierung] = React.useState<Gruppierung>("fahrzeug");
|
||||
const [fenster, setFenster] = React.useState<Zeitfenster | null>(null);
|
||||
const [ausgewaehlt, setAusgewaehlt] = React.useState<Set<string>>(new Set());
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const dashboard = trpc.wartungstermin.dashboard.useQuery({ gruppierung });
|
||||
|
||||
const termine = trpc.wartungstermin.liste.useQuery({
|
||||
seite: 1,
|
||||
seitengroesse: 100,
|
||||
richtung: "asc",
|
||||
fenster: fenster ?? undefined,
|
||||
});
|
||||
|
||||
const mitglieder = trpc.person.liste.useQuery(
|
||||
{ seite: 1, seitengroesse: 200, richtung: "asc" },
|
||||
{ enabled: darfPlanen },
|
||||
);
|
||||
|
||||
const zuweisen = trpc.wartungstermin.zuweisen.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
setMeldung(`${ergebnis.zugewiesen} Termine zugewiesen.`);
|
||||
setAusgewaehlt(new Set());
|
||||
void werkzeuge.wartungstermin.invalidate();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const zahlen = dashboard.data?.fenster;
|
||||
const eintraege = termine.data?.eintraege ?? [];
|
||||
|
||||
function auswahlUmschalten(id: string) {
|
||||
setAusgewaehlt((alt) => {
|
||||
const neu = new Set(alt);
|
||||
if (neu.has(id)) neu.delete(id);
|
||||
else neu.add(id);
|
||||
return neu;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Die vier Zahlen. Ein Klick filtert die Liste darunter. */}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Kennzahl
|
||||
wert={zahlen?.ueberfaellig ?? 0}
|
||||
bezeichnung="überfällig"
|
||||
hinweis="hätte längst geprüft sein müssen"
|
||||
dringend
|
||||
aktiv={fenster === "ueberfaellig"}
|
||||
onClick={() => setFenster(fenster === "ueberfaellig" ? null : "ueberfaellig")}
|
||||
/>
|
||||
<Kennzahl
|
||||
wert={zahlen?.diese_woche ?? 0}
|
||||
bezeichnung="diese Woche"
|
||||
hinweis="in den nächsten sieben Tagen"
|
||||
aktiv={fenster === "diese_woche"}
|
||||
onClick={() => setFenster(fenster === "diese_woche" ? null : "diese_woche")}
|
||||
/>
|
||||
<Kennzahl
|
||||
wert={zahlen?.dieser_monat ?? 0}
|
||||
bezeichnung="diesen Monat"
|
||||
hinweis="in den nächsten 30 Tagen"
|
||||
aktiv={fenster === "dieser_monat"}
|
||||
onClick={() => setFenster(fenster === "dieser_monat" ? null : "dieser_monat")}
|
||||
/>
|
||||
<Kennzahl
|
||||
wert={zahlen?.naechste_90_tage ?? 0}
|
||||
bezeichnung="nächste 90 Tage"
|
||||
hinweis="mittelfristig zu planen"
|
||||
aktiv={fenster === "naechste_90_tage"}
|
||||
onClick={() => setFenster(fenster === "naechste_90_tage" ? null : "naechste_90_tage")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Was sonst noch Aufmerksamkeit braucht. */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Link
|
||||
href="/pruefungen/lebensdauer"
|
||||
className="flex items-center gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-colors hover:border-[var(--primary)]"
|
||||
>
|
||||
<Hourglass className="size-5 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
Lebensdauer: {dashboard.data?.lebensdauer.abgelaufen ?? 0} abgelaufen
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{dashboard.data?.lebensdauer.bald ?? 0} laufen in den nächsten 90 Tagen ab
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/maengel"
|
||||
className="flex items-center gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-colors hover:border-[var(--primary)]"
|
||||
>
|
||||
<ClipboardCheck className="size-5 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{dashboard.data?.maengelOffen ?? 0} offene Mängel</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">gemeldet, zugewiesen, in Arbeit</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/rueckrufe"
|
||||
className="flex items-center gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 transition-colors hover:border-[var(--primary)]"
|
||||
>
|
||||
<CalendarClock className="size-5 text-[var(--muted-foreground)]" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{dashboard.data?.rueckrufeOffen ?? 0} offene Rückrufe
|
||||
</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">Herstellerwarnungen in Arbeit</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="dashboardmeldung" />}
|
||||
{dashboard.error && <Fehlermeldung meldung={dashboard.error.message} />}
|
||||
|
||||
{/* Bündelung */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Verteilung</CardTitle>
|
||||
<CardDescription>Wo die offenen Termine hängen.</CardDescription>
|
||||
</div>
|
||||
<Select
|
||||
value={gruppierung}
|
||||
onValueChange={(wert) => setGruppierung(wert as Gruppierung)}
|
||||
>
|
||||
<SelectTrigger className="w-52" aria-label="Bündelung wählen">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GRUPPIERUNGEN.map((eintrag) => (
|
||||
<SelectItem key={eintrag.wert} value={eintrag.wert}>
|
||||
{eintrag.text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Bezeichnung</TableHead>
|
||||
<TableHead className="text-right">überfällig</TableHead>
|
||||
<TableHead className="text-right">diese Woche</TableHead>
|
||||
<TableHead className="text-right">diesen Monat</TableHead>
|
||||
<TableHead className="text-right">gesamt offen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboard.isLoading && <LadeZeilen spalten={5} zeilen={4} />}
|
||||
|
||||
{dashboard.data?.gruppen.map((gruppe) => (
|
||||
<TableRow key={gruppe.schluessel}>
|
||||
<TableCell className="font-medium">{gruppe.bezeichnung}</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{gruppe.fenster.ueberfaellig > 0 ? (
|
||||
<span className="font-medium text-[var(--gesperrt)]">
|
||||
{gruppe.fenster.ueberfaellig}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{gruppe.fenster.diese_woche || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{gruppe.fenster.dieser_monat || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen font-medium">
|
||||
{gruppe.gesamt}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!dashboard.isLoading && (dashboard.data?.gruppen.length ?? 0) === 0 && (
|
||||
<div className="p-4">
|
||||
<NichtsGefunden
|
||||
titel="Keine offenen Prüftermine"
|
||||
hinweis={
|
||||
darfPlanen
|
||||
? "Termine entstehen aus den Wartungsvorlagen — der nächtliche Lauf legt sie an. Ohne Vorlage gibt es keine Termine."
|
||||
: "Sobald Termine anstehen, erscheinen sie hier."
|
||||
}
|
||||
aktion={
|
||||
darfPlanen ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/pruefungen/vorlagen">Vorlagen einrichten</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terminliste */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>
|
||||
Offene Termine
|
||||
{fenster && (
|
||||
<span className="ml-2 text-sm font-normal text-[var(--muted-foreground)]">
|
||||
gefiltert auf „{fenster.replace(/_/g, " ")}"
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{termine.data?.gesamt ?? 0} Termine · nach Fälligkeit sortiert
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{fenster && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setFenster(null)}>
|
||||
Filter aufheben
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{darfPlanen && ausgewaehlt.size > 0 && (
|
||||
<Select
|
||||
onValueChange={(wert) =>
|
||||
zuweisen.mutate({
|
||||
ids: [...ausgewaehlt],
|
||||
zugewiesenAnId: wert === "niemand" ? null : wert,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-64" aria-label="Ausgewählte Termine zuweisen">
|
||||
<SelectValue placeholder={`${ausgewaehlt.size} Termine zuweisen an …`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="niemand">niemandem</SelectItem>
|
||||
{mitglieder.data?.eintraege.map((person) => (
|
||||
<SelectItem key={person.id} value={person.id}>
|
||||
{person.nachname}, {person.vorname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{darfPlanen && <TableHead className="w-10" aria-label="Auswahl" />}
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Prüfung</TableHead>
|
||||
<TableHead>Lagerung</TableHead>
|
||||
<TableHead>Zuständig</TableHead>
|
||||
<TableHead className="text-right">fällig</TableHead>
|
||||
<TableHead className="w-32" aria-label="Aktion" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{termine.isLoading && <LadeZeilen spalten={darfPlanen ? 7 : 6} />}
|
||||
|
||||
{eintraege.map((termin) => (
|
||||
<TableRow key={termin.id}>
|
||||
{darfPlanen && (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={ausgewaehlt.has(termin.id)}
|
||||
onCheckedChange={() => auswahlUmschalten(termin.id)}
|
||||
aria-label={`Termin für ${termin.geraet.inventarnummer} auswählen`}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${termin.geraet.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{termin.geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{termin.geraet.bezeichnung}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<StatusAbzeichen status={termin.geraet.status} />
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{termin.vorlage?.name ?? "ohne Vorlage"}
|
||||
{termin.vorlage?.pruefvorschrift && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{termin.vorlage.pruefvorschrift}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{termin.geraet.fahrzeug
|
||||
? `${termin.geraet.fahrzeug.funkrufname}${
|
||||
termin.geraet.geraeteraum
|
||||
? ` · ${termin.geraet.geraeteraum.bezeichnung}`
|
||||
: ""
|
||||
}`
|
||||
: (termin.geraet.standort?.name ?? "—")}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{termin.zugewiesenAn ? (
|
||||
`${termin.zugewiesenAn.nachname}, ${termin.zugewiesenAn.vorname}`
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--muted-foreground)]">
|
||||
<Users className="size-3.5" aria-hidden />
|
||||
offen
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
<FristAnzeige datum={termin.faelligAm} vorwarnTage={30} />
|
||||
<div className="mt-1">
|
||||
<FensterAbzeichen fenster={termin.fenster as Fristfenster} />
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{darfPruefen && (
|
||||
<Button size="sm" asChild>
|
||||
<Link
|
||||
href={`/pruefungen/neu?geraetId=${termin.geraet.id}&terminId=${termin.id}`}
|
||||
>
|
||||
Prüfen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!termine.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-4">
|
||||
<NichtsGefunden
|
||||
titel={fenster ? "In diesem Zeitraum steht nichts an" : "Keine offenen Termine"}
|
||||
hinweis={
|
||||
fenster
|
||||
? "Ein anderer Zeitraum zeigt vielleicht mehr."
|
||||
: "Alles geprüft — oder es sind noch keine Vorlagen eingerichtet."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-xs text-[var(--muted-foreground)]">
|
||||
Stand: {alsDatum(dashboard.data?.stand ?? new Date())}. Termine entstehen nachts aus den
|
||||
Wartungsvorlagen; überfällig wird jeden Tag neu gerechnet, nicht gespeichert.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Archive } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Fehlermeldung,
|
||||
FristAnzeige,
|
||||
LadeZeilen,
|
||||
NichtsGefunden,
|
||||
StatusAbzeichen,
|
||||
} from "@/components/stammdaten/anzeige";
|
||||
|
||||
/**
|
||||
* Geräte am Ende der Nutzungsdauer.
|
||||
*
|
||||
* Der Vorschlag zur Aussonderung ist bewusst ein Knopf und kein Automatismus:
|
||||
* ein abgelaufener Helm wird gesperrt (das macht der nächtliche Lauf), aber
|
||||
* ausgesondert wird er von einem Menschen, der weiß, ob Ersatz da ist.
|
||||
*/
|
||||
export function LebensdauerListe({ darfAussondern }: { darfAussondern: boolean }) {
|
||||
const [vorwarnTage, setVorwarnTage] = React.useState(180);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const geraete = trpc.wartungstermin.lebensdauer.useQuery({ vorwarnTage });
|
||||
|
||||
const aussondern = trpc.geraet.loeschen.useMutation({
|
||||
onSuccess: () => {
|
||||
setMeldung("Gerät ausgesondert.");
|
||||
void werkzeuge.wartungstermin.lebensdauer.invalidate();
|
||||
void werkzeuge.geraet.invalidate();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const eintraege = geraete.data ?? [];
|
||||
const abgelaufen = eintraege.filter((geraet) => geraet.abgelaufen).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="vorwarn">Zeitraum</Label>
|
||||
<Select
|
||||
value={String(vorwarnTage)}
|
||||
onValueChange={(wert) => setVorwarnTage(Number(wert))}
|
||||
>
|
||||
<SelectTrigger id="vorwarn" className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">nur bereits abgelaufen</SelectItem>
|
||||
<SelectItem value="90">nächste 90 Tage</SelectItem>
|
||||
<SelectItem value="180">nächste 6 Monate</SelectItem>
|
||||
<SelectItem value="365">nächstes Jahr</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{abgelaufen} abgelaufen · {eintraege.length - abgelaufen} laufen demnächst ab
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="lebensdauermeldung" />}
|
||||
{geraete.error && <Fehlermeldung meldung={geraete.error.message} />}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Kategorie</TableHead>
|
||||
<TableHead>Lagerung</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">nutzbar bis</TableHead>
|
||||
<TableHead className="w-40" aria-label="Aktion" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{geraete.isLoading && <LadeZeilen spalten={6} />}
|
||||
|
||||
{eintraege.map((geraet) => (
|
||||
<TableRow key={geraet.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${geraet.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">{geraet.bezeichnung}</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{geraet.lebensdauerKategorie ??
|
||||
geraet.geraetetyp?.lebensdauerKategorie ??
|
||||
"—"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{geraet.fahrzeug?.funkrufname ?? geraet.standort?.name ?? "—"}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<StatusAbzeichen status={geraet.status} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
<FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={vorwarnTage || 90} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{darfAussondern && geraet.abgelaufen && geraet.status !== "AUSGESONDERT" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`${geraet.inventarnummer} aussondern? Das Gerät bleibt mit allen Prüfungen erhalten, verschwindet aber aus dem Bestand.`,
|
||||
)
|
||||
) {
|
||||
aussondern.mutate({
|
||||
id: geraet.id,
|
||||
grund: "Nutzungsdauer abgelaufen",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Archive aria-hidden />
|
||||
Aussondern
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!geraete.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-6">
|
||||
<NichtsGefunden
|
||||
titel="Nichts läuft ab"
|
||||
hinweis="In diesem Zeitraum erreicht kein Gerät das Ende seiner Nutzungsdauer. Voraussetzung dafür ist, dass am Gerätetyp eine Lebensdauer hinterlegt ist."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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 { LebensdauerListe } from "@/app/(app)/pruefungen/lebensdauer/lebensdauer-liste";
|
||||
|
||||
export const metadata = { title: "Lebensdauer" };
|
||||
|
||||
/**
|
||||
* Lebensdauerüberwachung.
|
||||
*
|
||||
* Helme, Seile, Schutzkleidung und Pressluftatmer sind ab einem Stichtag nicht
|
||||
* mehr zugelassen — unabhängig davon, wie gut sie aussehen. Diese Seite zeigt,
|
||||
* was abgelaufen ist und was demnächst abläuft, und schlägt die Aussonderung
|
||||
* vor.
|
||||
*/
|
||||
export default async function LebensdauerSeite() {
|
||||
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">Lebensdauer</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Geräte am Ende ihrer zulässigen Nutzungsdauer — unabhängig vom Zustand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LebensdauerListe darfAussondern={hatRecht(sitzung, "geraet.aussondern")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { PruefWizard } from "@/app/(app)/pruefungen/neu/pruef-wizard";
|
||||
|
||||
export const metadata = { title: "Prüfung durchführen" };
|
||||
|
||||
/**
|
||||
* Der Prüfwizard.
|
||||
*
|
||||
* Aufgerufen wird er aus dem Fristen-Dashboard (mit Termin) oder von der
|
||||
* Gerätedetailseite (nur mit Gerät). Beides kommt als Suchparameter herein —
|
||||
* so lässt sich der Vorgang auch über einen QR-Scan am Regal starten.
|
||||
*/
|
||||
export default async function NeuePruefungSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ geraetId?: string; terminId?: string; vorlageId?: string }>;
|
||||
}) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
if (!hatRecht(sitzung, "pruefung.durchfuehren")) {
|
||||
redirect("/pruefungen");
|
||||
}
|
||||
|
||||
const { geraetId, terminId, vorlageId } = await searchParams;
|
||||
|
||||
if (!geraetId) redirect("/pruefungen");
|
||||
|
||||
return (
|
||||
<PruefWizard
|
||||
geraetId={geraetId}
|
||||
terminId={terminId ?? null}
|
||||
vorlageId={vorlageId ?? null}
|
||||
prueferName={sitzung.name}
|
||||
prueferId={sitzung.personId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { ChecklistenpunktTyp, MangelPrioritaet, PruefErgebnis, PruefungArt } 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 { 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 } from "@/components/stammdaten/anzeige";
|
||||
import { DateiFeld } from "@/components/wartung/datei-feld";
|
||||
import { UnterschriftFeld } from "@/components/wartung/unterschrift";
|
||||
import { ERGEBNIS_TEXT } from "@/lib/wartung/texte";
|
||||
|
||||
/**
|
||||
* Prüfwizard — Schritt für Schritt durch die Checkliste.
|
||||
*
|
||||
* Der Ablauf ist auf das Tablet im Gerätehaus zugeschnitten:
|
||||
*
|
||||
* * Ein Punkt je Bildschirm, große Schaltflächen. Wer Handschuhe trägt,
|
||||
* trifft keine 24-Pixel-Kästchen.
|
||||
* * Jede Antwort geht sofort zum Server. Bricht das WLAN weg, ist höchstens
|
||||
* die letzte Antwort verloren, nicht die ganze Prüfung.
|
||||
* * Die Zeit läuft im Hintergrund mit und landet im Protokoll — sie ist
|
||||
* Bestandteil des Nachweises, wie gründlich geprüft wurde.
|
||||
*
|
||||
* Am Ende steht der Abschluss: Ergebnis, Mängel, Unterschrift. Danach ist das
|
||||
* Protokoll festgeschrieben; Korrekturen erzeugen ein neues.
|
||||
*/
|
||||
|
||||
interface Antwortstand {
|
||||
jaNein?: boolean | null;
|
||||
zahlwert?: string;
|
||||
text?: string;
|
||||
fotoDateiId?: string | null;
|
||||
bemerkung?: string;
|
||||
inOrdnung?: boolean | null;
|
||||
}
|
||||
|
||||
interface MangelEntwurf {
|
||||
beschreibung: string;
|
||||
prioritaet: MangelPrioritaet;
|
||||
fotoDateiId?: string | null;
|
||||
}
|
||||
|
||||
function verstricheneZeit(seit: Date | null): string {
|
||||
if (!seit) return "0:00";
|
||||
const sekunden = Math.max(0, Math.floor((Date.now() - seit.getTime()) / 1000));
|
||||
const minuten = Math.floor(sekunden / 60);
|
||||
return `${minuten}:${String(sekunden % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function PruefWizard({
|
||||
geraetId,
|
||||
terminId,
|
||||
vorlageId,
|
||||
prueferName,
|
||||
prueferId,
|
||||
}: {
|
||||
geraetId: string;
|
||||
terminId: string | null;
|
||||
vorlageId: string | null;
|
||||
prueferName: string;
|
||||
prueferId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const [pruefungId, setPruefungId] = React.useState<string | null>(null);
|
||||
const [schritt, setSchritt] = React.useState(0);
|
||||
const [antworten, setAntworten] = React.useState<Record<string, Antwortstand>>({});
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
|
||||
// Abschlussangaben
|
||||
const [ergebnis, setErgebnis] = React.useState<PruefErgebnis | null>(null);
|
||||
const [bemerkung, setBemerkung] = React.useState("");
|
||||
const [maengel, setMaengel] = React.useState<MangelEntwurf[]>([]);
|
||||
const [signaturDateiId, setSignaturDateiId] = React.useState<string | null>(null);
|
||||
const [art, setArt] = React.useState<PruefungArt>(PruefungArt.INTERN);
|
||||
const [externeFirma, setExterneFirma] = React.useState("");
|
||||
const [kosten, setKosten] = React.useState("");
|
||||
const [fremdprotokollId, setFremdprotokollId] = React.useState<string | null>(null);
|
||||
|
||||
const [uhr, setUhr] = React.useState(0);
|
||||
const gestartet = React.useRef(false);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const starten = trpc.pruefung.starten.useMutation({
|
||||
onSuccess: (ergebnisDaten) => {
|
||||
const pruefung = ergebnisDaten.pruefung;
|
||||
setPruefungId(pruefung.id);
|
||||
setArt(pruefung.art);
|
||||
|
||||
// Fortgesetzter Entwurf: bereits gegebene Antworten übernehmen.
|
||||
const vorhandene: Record<string, Antwortstand> = {};
|
||||
for (const eintrag of pruefung.ergebnisse) {
|
||||
vorhandene[eintrag.punktId] = {
|
||||
jaNein: eintrag.jaNein,
|
||||
zahlwert: eintrag.zahlwert ? String(eintrag.zahlwert) : "",
|
||||
text: eintrag.text ?? "",
|
||||
fotoDateiId: eintrag.fotoDateiId,
|
||||
bemerkung: eintrag.bemerkung ?? "",
|
||||
};
|
||||
}
|
||||
setAntworten(vorhandene);
|
||||
},
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
// Genau einmal starten — React ruft Effekte in der Entwicklung doppelt auf.
|
||||
React.useEffect(() => {
|
||||
if (gestartet.current) return;
|
||||
gestartet.current = true;
|
||||
starten.mutate({
|
||||
geraetId,
|
||||
terminId,
|
||||
vorlageId,
|
||||
art: PruefungArt.INTERN,
|
||||
});
|
||||
}, [geraetId, terminId, vorlageId, starten]);
|
||||
|
||||
const pruefung = starten.data?.pruefung;
|
||||
const punkte = React.useMemo(() => pruefung?.vorlage?.punkte ?? [], [pruefung]);
|
||||
const begonnenUm = pruefung?.begonnenUm ? new Date(pruefung.begonnenUm) : null;
|
||||
|
||||
React.useEffect(() => {
|
||||
const zeitgeber = setInterval(() => setUhr((wert) => wert + 1), 1000);
|
||||
return () => clearInterval(zeitgeber);
|
||||
}, []);
|
||||
|
||||
const speichern = trpc.pruefung.ergebnisSpeichern.useMutation({
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const abschliessen = trpc.pruefung.abschliessen.useMutation({
|
||||
onSuccess: (fertig) => {
|
||||
void werkzeuge.wartungstermin.invalidate();
|
||||
void werkzeuge.geraet.invalidate();
|
||||
router.push(`/pruefungen/${fertig.id}`);
|
||||
},
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
function antwortSetzen(punktId: string, teil: Partial<Antwortstand>) {
|
||||
setAntworten((alt) => ({ ...alt, [punktId]: { ...alt[punktId], ...teil } }));
|
||||
}
|
||||
|
||||
/** Antwort zum aktuellen Punkt zum Server schicken. */
|
||||
async function punktSpeichern(punktId: string) {
|
||||
if (!pruefungId) return;
|
||||
const antwort = antworten[punktId];
|
||||
if (!antwort) return;
|
||||
|
||||
await speichern.mutateAsync({
|
||||
pruefungId,
|
||||
punktId,
|
||||
jaNein: antwort.jaNein ?? null,
|
||||
zahlwert:
|
||||
antwort.zahlwert !== undefined && antwort.zahlwert !== ""
|
||||
? Number(antwort.zahlwert)
|
||||
: null,
|
||||
text: antwort.text || null,
|
||||
fotoDateiId: antwort.fotoDateiId ?? null,
|
||||
inOrdnung: antwort.inOrdnung ?? null,
|
||||
bemerkung: antwort.bemerkung || null,
|
||||
});
|
||||
}
|
||||
|
||||
async function weiter() {
|
||||
setFehler(null);
|
||||
const punkt = punkte[schritt];
|
||||
if (punkt) await punktSpeichern(punkt.id);
|
||||
setSchritt((wert) => Math.min(wert + 1, punkte.length));
|
||||
}
|
||||
|
||||
function zurueck() {
|
||||
setFehler(null);
|
||||
setSchritt((wert) => Math.max(0, wert - 1));
|
||||
}
|
||||
|
||||
/** Mängel aus den beanstandeten Punkten vorschlagen. */
|
||||
function maengelVorschlagen() {
|
||||
const vorschlaege: MangelEntwurf[] = [];
|
||||
|
||||
for (const punkt of punkte) {
|
||||
const antwort = antworten[punkt.id];
|
||||
if (!antwort) continue;
|
||||
|
||||
const nichtInOrdnung =
|
||||
antwort.inOrdnung === false ||
|
||||
(punkt.typ === ChecklistenpunktTyp.JA_NEIN && antwort.jaNein === false);
|
||||
|
||||
if (!nichtInOrdnung) continue;
|
||||
|
||||
vorschlaege.push({
|
||||
beschreibung: `${punkt.text}${antwort.bemerkung ? `: ${antwort.bemerkung}` : ""}`,
|
||||
prioritaet: punkt.pflicht ? MangelPrioritaet.KRITISCH : MangelPrioritaet.MITTEL,
|
||||
fotoDateiId: antwort.fotoDateiId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
setMaengel((alt) => [...alt, ...vorschlaege]);
|
||||
}
|
||||
|
||||
const amAbschluss = schritt >= punkte.length;
|
||||
|
||||
// Vorschlag für das Gesamtergebnis, sobald der Abschluss erreicht ist.
|
||||
const stand = trpc.pruefung.stand.useQuery(
|
||||
{ id: pruefungId ?? "" },
|
||||
{ enabled: Boolean(pruefungId) && amAbschluss },
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (amAbschluss && stand.data && ergebnis === null) {
|
||||
setErgebnis(stand.data.vorschlag as PruefErgebnis);
|
||||
if (stand.data.vorschlag !== "BESTANDEN" && maengel.length === 0) maengelVorschlagen();
|
||||
}
|
||||
// maengelVorschlagen hängt nur an bereits gesetztem Zustand.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [amAbschluss, stand.data]);
|
||||
|
||||
if (starten.isPending || !pruefung) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Prüfung wird vorbereitet …</h1>
|
||||
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const punkt = punkte[schritt];
|
||||
const antwort = punkt ? (antworten[punkt.id] ?? {}) : {};
|
||||
const fortschritt = punkte.length === 0 ? 100 : Math.round((schritt / punkte.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* Kopf: Gerät, Uhr, Fortschritt */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{pruefung.geraet.bezeichnung}
|
||||
</h1>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-[var(--muted-foreground)]">
|
||||
<Clock className="size-4" aria-hidden />
|
||||
<span className="tabellenzahlen" aria-live="off">
|
||||
{verstricheneZeit(begonnenUm)}
|
||||
</span>
|
||||
<span className="sr-only">seit Beginn der Prüfung ({uhr} Sekunden aktualisiert)</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
<span className="tabellenzahlen">{pruefung.geraet.inventarnummer}</span>
|
||||
{pruefung.vorlage && ` · ${pruefung.vorlage.name}`}
|
||||
{starten.data?.fortgesetzt && " · angefangene Prüfung wird fortgesetzt"}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="mt-3 h-2 overflow-hidden rounded-full bg-[var(--muted)]"
|
||||
role="progressbar"
|
||||
aria-valuenow={fortschritt}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Fortschritt der Prüfung"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[var(--primary)] transition-all"
|
||||
style={{ width: `${fortschritt}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} kennung="wizardfehler" />}
|
||||
|
||||
{punkte.length === 0 && (
|
||||
<Fehlermeldung
|
||||
meldung="Zu dieser Prüfung gibt es keine Checkliste. Ohne Prüfpunkte lässt sich nichts dokumentieren — bitte erst eine Wartungsvorlage mit Punkten anlegen."
|
||||
kennung="ohnecheckliste"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ein Prüfpunkt */}
|
||||
{!amAbschluss && punkt && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>
|
||||
Schritt {schritt + 1} von {punkte.length}
|
||||
{punkt.pflicht && " · Pflichtpunkt"}
|
||||
</CardDescription>
|
||||
<CardTitle className="text-xl">{punkt.text}</CardTitle>
|
||||
{punkt.hinweis && (
|
||||
<CardDescription>{punkt.hinweis}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
{punkt.sollwert && (
|
||||
<p className="rounded-md bg-[var(--muted)] px-3 py-2 text-sm">
|
||||
Soll: <strong>{punkt.sollwert}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{punkt.typ === ChecklistenpunktTyp.JA_NEIN && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={antwort.jaNein === true ? "default" : "outline"}
|
||||
className="h-16 text-base"
|
||||
onClick={() => antwortSetzen(punkt.id, { jaNein: true, inOrdnung: null })}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
in Ordnung
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={antwort.jaNein === false ? "destructive" : "outline"}
|
||||
className="h-16 text-base"
|
||||
onClick={() => antwortSetzen(punkt.id, { jaNein: false, inOrdnung: null })}
|
||||
>
|
||||
<X aria-hidden />
|
||||
nicht in Ordnung
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{punkt.typ === ChecklistenpunktTyp.MESSWERT && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="messwert">
|
||||
Messwert{punkt.einheit ? ` in ${punkt.einheit}` : ""}
|
||||
</Label>
|
||||
<Input
|
||||
id="messwert"
|
||||
type="number"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
className="h-14 text-lg"
|
||||
value={antwort.zahlwert ?? ""}
|
||||
onChange={(ereignis) =>
|
||||
antwortSetzen(punkt.id, { zahlwert: ereignis.target.value })
|
||||
}
|
||||
/>
|
||||
{(punkt.minWert !== null || punkt.maxWert !== null) && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
zulässig {punkt.minWert !== null ? String(punkt.minWert) : "…"} bis{" "}
|
||||
{punkt.maxWert !== null ? String(punkt.maxWert) : "…"}
|
||||
{punkt.einheit ? ` ${punkt.einheit}` : ""} — außerhalb gilt der Punkt als
|
||||
beanstandet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{punkt.typ === ChecklistenpunktTyp.TEXT && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="freitext">Befund</Label>
|
||||
<Textarea
|
||||
id="freitext"
|
||||
rows={4}
|
||||
value={antwort.text ?? ""}
|
||||
onChange={(ereignis) => antwortSetzen(punkt.id, { text: ereignis.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{punkt.typ === ChecklistenpunktTyp.FOTO && (
|
||||
<div className="space-y-2">
|
||||
<Label>Foto</Label>
|
||||
<DateiFeld
|
||||
entitaet="Pruefung"
|
||||
entitaetId={pruefungId ?? undefined}
|
||||
kategorie="foto"
|
||||
dateiId={antwort.fotoDateiId ?? null}
|
||||
onHochgeladen={(dateiId) => antwortSetzen(punkt.id, { fotoDateiId: dateiId })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Foto und Bemerkung sind zu jedem Punkt möglich, nicht nur beim Fototyp. */}
|
||||
{punkt.typ !== ChecklistenpunktTyp.FOTO && (
|
||||
<div className="space-y-2">
|
||||
<Label>Foto (freiwillig)</Label>
|
||||
<DateiFeld
|
||||
entitaet="Pruefung"
|
||||
entitaetId={pruefungId ?? undefined}
|
||||
kategorie="foto"
|
||||
dateiId={antwort.fotoDateiId ?? null}
|
||||
onHochgeladen={(dateiId) => antwortSetzen(punkt.id, { fotoDateiId: dateiId })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="punktbemerkung">Bemerkung</Label>
|
||||
<Input
|
||||
id="punktbemerkung"
|
||||
value={antwort.bemerkung ?? ""}
|
||||
onChange={(ereignis) =>
|
||||
antwortSetzen(punkt.id, { bemerkung: ereignis.target.value })
|
||||
}
|
||||
placeholder="Was aufgefallen ist"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={antwort.inOrdnung === false}
|
||||
onCheckedChange={(an) =>
|
||||
antwortSetzen(punkt.id, { inOrdnung: an === true ? false : null })
|
||||
}
|
||||
/>
|
||||
Trotz Wert beanstanden — der Prüfer sieht mehr als die Zahl
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Abschluss */}
|
||||
{amAbschluss && (
|
||||
<div className="space-y-6">
|
||||
{stand.data && stand.data.offenePflichtpunkte.length > 0 && (
|
||||
<Fehlermeldung
|
||||
kennung="offenepflichtpunkte"
|
||||
meldung={`Es fehlen noch Pflichtangaben: ${stand.data.offenePflichtpunkte
|
||||
.map((offen) => offen.text)
|
||||
.join(", ")}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ergebnis</CardTitle>
|
||||
<CardDescription>
|
||||
Vorgeschlagen ist {stand.data ? ERGEBNIS_TEXT[stand.data.vorschlag] : "…"} —
|
||||
{" "}
|
||||
{stand.data?.beantwortet ?? 0} von {stand.data?.gesamt ?? punkte.length} Punkten
|
||||
beantwortet. Die Entscheidung trifft der Prüfer.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{(Object.keys(ERGEBNIS_TEXT) as PruefErgebnis[]).map((wert) => (
|
||||
<Button
|
||||
key={wert}
|
||||
type="button"
|
||||
variant={ergebnis === wert ? "default" : "outline"}
|
||||
className="h-14"
|
||||
onClick={() => setErgebnis(wert)}
|
||||
>
|
||||
{ERGEBNIS_TEXT[wert]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="abschlussbemerkung">Bemerkung zum Protokoll</Label>
|
||||
<Textarea
|
||||
id="abschlussbemerkung"
|
||||
rows={3}
|
||||
value={bemerkung}
|
||||
onChange={(ereignis) => setBemerkung(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="art">Durchgeführt</Label>
|
||||
<Select value={art} onValueChange={(wert) => setArt(wert as PruefungArt)}>
|
||||
<SelectTrigger id="art">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PruefungArt.INTERN}>intern durch die Wehr</SelectItem>
|
||||
<SelectItem value={PruefungArt.EXTERN}>extern durch eine Fachfirma</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{art === PruefungArt.EXTERN && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firma">Fachfirma</Label>
|
||||
<Input
|
||||
id="firma"
|
||||
value={externeFirma}
|
||||
onChange={(ereignis) => setExterneFirma(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kosten">Kosten in Euro</Label>
|
||||
<Input
|
||||
id="kosten"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
value={kosten}
|
||||
onChange={(ereignis) => setKosten(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Fremdprotokoll als PDF</Label>
|
||||
<DateiFeld
|
||||
entitaet="Pruefung"
|
||||
entitaetId={pruefungId ?? undefined}
|
||||
kategorie="fremdprotokoll"
|
||||
nurBilder={false}
|
||||
beschriftung="Protokoll der Fachfirma anhängen"
|
||||
dateiId={fremdprotokollId}
|
||||
onHochgeladen={setFremdprotokollId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>Mängel</CardTitle>
|
||||
<CardDescription>
|
||||
Was aufgefallen ist und noch behoben werden muss. Kritische Mängel sperren das
|
||||
Gerät.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setMaengel((alt) => [
|
||||
...alt,
|
||||
{ beschreibung: "", prioritaet: MangelPrioritaet.MITTEL },
|
||||
])
|
||||
}
|
||||
>
|
||||
<TriangleAlert aria-hidden />
|
||||
Mangel ergänzen
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{maengel.length === 0 && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Kein Mangel erfasst.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{maengel.map((mangel, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="space-y-2 rounded-md border border-[var(--border)] p-3"
|
||||
>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={mangel.beschreibung}
|
||||
onChange={(ereignis) =>
|
||||
setMaengel((alt) =>
|
||||
alt.map((eintrag, position) =>
|
||||
position === index
|
||||
? { ...eintrag, beschreibung: ereignis.target.value }
|
||||
: eintrag,
|
||||
),
|
||||
)
|
||||
}
|
||||
placeholder="Was ist beschädigt, fehlt oder funktioniert nicht?"
|
||||
aria-label={`Beschreibung des Mangels ${index + 1}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={mangel.prioritaet}
|
||||
onValueChange={(wert) =>
|
||||
setMaengel((alt) =>
|
||||
alt.map((eintrag, position) =>
|
||||
position === index
|
||||
? { ...eintrag, prioritaet: wert as MangelPrioritaet }
|
||||
: eintrag,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-44"
|
||||
aria-label={`Dringlichkeit des Mangels ${index + 1}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={MangelPrioritaet.NIEDRIG}>niedrig</SelectItem>
|
||||
<SelectItem value={MangelPrioritaet.MITTEL}>mittel</SelectItem>
|
||||
<SelectItem value={MangelPrioritaet.HOCH}>hoch</SelectItem>
|
||||
<SelectItem value={MangelPrioritaet.KRITISCH}>
|
||||
kritisch — sperrt das Gerät
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setMaengel((alt) => alt.filter((_, position) => position !== index))
|
||||
}
|
||||
>
|
||||
<X aria-hidden />
|
||||
Entfernen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Unterschrift</CardTitle>
|
||||
<CardDescription>
|
||||
Ohne Unterschrift ist ein Protokoll kein Nachweis. Mit dem Abschluss wird es
|
||||
festgeschrieben — Korrekturen erzeugen danach ein neues Protokoll.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UnterschriftFeld
|
||||
name={prueferName}
|
||||
rolle="Prüfer"
|
||||
pruefungId={pruefungId ?? undefined}
|
||||
onFertig={setSignaturDateiId}
|
||||
uebernommen={Boolean(signaturDateiId)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steuerung */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-[var(--border)] pt-4">
|
||||
<Button type="button" variant="outline" onClick={zurueck} disabled={schritt === 0}>
|
||||
<ChevronLeft aria-hidden />
|
||||
Zurück
|
||||
</Button>
|
||||
|
||||
{!amAbschluss ? (
|
||||
<Button type="button" onClick={weiter} disabled={speichern.isPending}>
|
||||
{speichern.isPending ? "wird gespeichert …" : "Weiter"}
|
||||
<ChevronRight aria-hidden />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
abschliessen.isPending ||
|
||||
!ergebnis ||
|
||||
!signaturDateiId ||
|
||||
(stand.data?.offenePflichtpunkte.length ?? 0) > 0
|
||||
}
|
||||
onClick={() => {
|
||||
if (!pruefungId || !ergebnis || !signaturDateiId) return;
|
||||
setFehler(null);
|
||||
|
||||
abschliessen.mutate({
|
||||
id: pruefungId,
|
||||
ergebnis,
|
||||
bemerkung: bemerkung || null,
|
||||
externeFirma: art === PruefungArt.EXTERN ? externeFirma || null : null,
|
||||
externeProtokollDateiId: art === PruefungArt.EXTERN ? fremdprotokollId : null,
|
||||
kosten: art === PruefungArt.EXTERN && kosten ? Number(kosten) : null,
|
||||
signaturen: [
|
||||
{
|
||||
rolle: "PRUEFER",
|
||||
name: prueferName,
|
||||
personId: prueferId,
|
||||
bildDateiId: signaturDateiId,
|
||||
},
|
||||
],
|
||||
maengel: maengel
|
||||
.filter((mangel) => mangel.beschreibung.trim().length > 0)
|
||||
.map((mangel) => ({
|
||||
beschreibung: mangel.beschreibung,
|
||||
prioritaet: mangel.prioritaet,
|
||||
fotoDateiId: mangel.fotoDateiId ?? null,
|
||||
})),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
{abschliessen.isPending ? "wird abgeschlossen …" : "Prüfung abschließen"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ClipboardList, ListChecks, ShieldAlert, TriangleAlert } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FristenDashboard } from "@/app/(app)/pruefungen/fristen-dashboard";
|
||||
|
||||
export const metadata = { title: "Prüfungen" };
|
||||
|
||||
/**
|
||||
* Das Fristen-Dashboard — die Startseite des Gerätewarts.
|
||||
*
|
||||
* Die Rechteprüfung passiert hier auf dem Server; die Client-Komponente bekommt
|
||||
* nur das Ergebnis. So bleibt die Regel aus CLAUDE.md gewahrt: keine
|
||||
* Rollenprüfung in Komponenten.
|
||||
*/
|
||||
export default async function PruefungenSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const darfPlanen = hatRecht(sitzung, "pruefung.planen");
|
||||
const darfPruefen = hatRecht(sitzung, "pruefung.durchfuehren");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Prüfungen und Fristen</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Was ansteht, was überfällig ist und was demnächst aus der Nutzungsdauer läuft.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/maengel">
|
||||
<TriangleAlert aria-hidden />
|
||||
Mängel
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/rueckrufe">
|
||||
<ShieldAlert aria-hidden />
|
||||
Rückrufe
|
||||
</Link>
|
||||
</Button>
|
||||
{darfPlanen && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/pruefungen/vorlagen">
|
||||
<ListChecks aria-hidden />
|
||||
Vorlagen
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{darfPruefen && (
|
||||
<Button asChild>
|
||||
<Link href="/pruefungen/verlauf">
|
||||
<ClipboardList aria-hidden />
|
||||
Protokolle
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FristenDashboard darfPlanen={darfPlanen} darfPruefen={darfPruefen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ProtokollListe } from "@/app/(app)/pruefungen/verlauf/protokoll-liste";
|
||||
|
||||
export const metadata = { title: "Prüfprotokolle" };
|
||||
|
||||
/**
|
||||
* Alle Prüfprotokolle der Wehr.
|
||||
*
|
||||
* Der Nachweis nach außen: wenn die Unfallkasse fragt, wird hier gesucht —
|
||||
* nach Gerät, nach Zeitraum, nach Ergebnis.
|
||||
*/
|
||||
export default async function VerlaufSeite() {
|
||||
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">Prüfprotokolle</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Abgeschlossene Prüfungen mit Ergebnis, Prüfer und PDF.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProtokollListe />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { PruefErgebnis, PruefungStatus } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Fehlermeldung, LadeZeilen, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { ErgebnisAbzeichen, PruefungStatusAbzeichen } from "@/components/wartung/anzeige";
|
||||
import { ERGEBNIS_TEXT } from "@/lib/wartung/texte";
|
||||
import { alsDatum, alsEuro } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Liste der Protokolle.
|
||||
*
|
||||
* Entwürfe stehen mit drin, aber gekennzeichnet: eine angefangene Prüfung ist
|
||||
* kein Nachweis, und wer eine sucht, um sie zu Ende zu bringen, findet sie hier.
|
||||
*/
|
||||
export function ProtokollListe() {
|
||||
const [ergebnis, setErgebnis] = React.useState<PruefErgebnis | "alle">("alle");
|
||||
const [vonAm, setVonAm] = React.useState("");
|
||||
const [bisAm, setBisAm] = React.useState("");
|
||||
|
||||
const pruefungen = trpc.pruefung.liste.useQuery({
|
||||
anzahl: 200,
|
||||
ergebnis: ergebnis === "alle" ? undefined : [ergebnis],
|
||||
vonAm: vonAm ? new Date(vonAm) : undefined,
|
||||
bisAm: bisAm ? new Date(bisAm) : undefined,
|
||||
});
|
||||
|
||||
const eintraege = pruefungen.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="ergebnisfilter">Ergebnis</Label>
|
||||
<Select
|
||||
value={ergebnis}
|
||||
onValueChange={(wert) => setErgebnis(wert as PruefErgebnis | "alle")}
|
||||
>
|
||||
<SelectTrigger id="ergebnisfilter" className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="alle">alle Ergebnisse</SelectItem>
|
||||
{(Object.keys(ERGEBNIS_TEXT) as PruefErgebnis[]).map((wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{ERGEBNIS_TEXT[wert]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="von">geprüft ab</Label>
|
||||
<Input
|
||||
id="von"
|
||||
type="date"
|
||||
value={vonAm}
|
||||
onChange={(ereignis) => setVonAm(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bis">geprüft bis</Label>
|
||||
<Input
|
||||
id="bis"
|
||||
type="date"
|
||||
value={bisAm}
|
||||
onChange={(ereignis) => setBisAm(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pruefungen.error && <Fehlermeldung meldung={pruefungen.error.message} />}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Protokoll</TableHead>
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Prüfung</TableHead>
|
||||
<TableHead>Prüfer</TableHead>
|
||||
<TableHead>Ergebnis</TableHead>
|
||||
<TableHead className="text-right">geprüft am</TableHead>
|
||||
<TableHead className="text-right">Kosten</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pruefungen.isLoading && <LadeZeilen spalten={7} />}
|
||||
|
||||
{eintraege.map((pruefung) => (
|
||||
<TableRow key={pruefung.id}>
|
||||
<TableCell className="tabellenzahlen font-medium">
|
||||
<Link
|
||||
href={`/pruefungen/${pruefung.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{pruefung.protokollNummer ?? "Entwurf"}
|
||||
</Link>
|
||||
{pruefung.status !== PruefungStatus.ABGESCHLOSSEN && (
|
||||
<div className="mt-1">
|
||||
<PruefungStatusAbzeichen status={pruefung.status} />
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${pruefung.geraet.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{pruefung.geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{pruefung.geraet.bezeichnung}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{pruefung.vorlage?.name ?? "—"}
|
||||
{pruefung.art === "EXTERN" && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
extern: {pruefung.externeFirma ?? "Fachfirma"}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-sm">
|
||||
{pruefung.pruefer
|
||||
? `${pruefung.pruefer.nachname}, ${pruefung.pruefer.vorname}`
|
||||
: (pruefung.prueferName ?? "—")}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<ErgebnisAbzeichen ergebnis={pruefung.ergebnis} />
|
||||
{pruefung._count.maengel > 0 && (
|
||||
<div className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
{pruefung._count.maengel} Mängel
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{alsDatum(pruefung.durchgefuehrtAm)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{pruefung.kosten ? alsEuro(Number(pruefung.kosten)) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!pruefungen.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-6">
|
||||
<NichtsGefunden
|
||||
titel="Noch kein Protokoll"
|
||||
hinweis="Sobald eine Prüfung abgeschlossen ist, steht sie hier — mit PDF, Unterschrift und Ergebnis."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { BetroffenheitStatus } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Fehlermeldung, NichtsGefunden, StatusAbzeichen } from "@/components/stammdaten/anzeige";
|
||||
import { BetroffenheitAbzeichen, RueckrufStatusAbzeichen } from "@/components/wartung/anzeige";
|
||||
import { BETROFFENHEIT_TEXT } from "@/lib/wartung/texte";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Rückruf bearbeiten: suchen, zuordnen, abarbeiten.
|
||||
*
|
||||
* Der Ablauf entspricht dem, was eine Wehr wirklich tut, wenn ein
|
||||
* Sicherheitshinweis eintrifft: nachsehen, was man davon hat, es aus dem Dienst
|
||||
* nehmen, die Maßnahme durchführen, wieder freigeben.
|
||||
*/
|
||||
export function RueckrufBearbeitung({
|
||||
rueckrufId,
|
||||
darfBearbeiten,
|
||||
}: {
|
||||
rueckrufId: string;
|
||||
darfBearbeiten: boolean;
|
||||
}) {
|
||||
const [ausgewaehlt, setAusgewaehlt] = React.useState<Set<string>>(new Set());
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
const rueckruf = trpc.rueckruf.einzeln.useQuery({ id: rueckrufId });
|
||||
const treffer = trpc.rueckruf.geraeteSuchen.useQuery({ id: rueckrufId });
|
||||
|
||||
const aktualisieren = () => {
|
||||
void werkzeuge.rueckruf.invalidate();
|
||||
void werkzeuge.geraet.invalidate();
|
||||
};
|
||||
|
||||
const zuordnen = trpc.rueckruf.zuordnen.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
setMeldung(
|
||||
`${ergebnis.zugeordnet} Geräte zugeordnet, ${ergebnis.gesperrt} davon gesperrt.`,
|
||||
);
|
||||
setAusgewaehlt(new Set());
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const betroffenheitSetzen = trpc.rueckruf.betroffenheitSetzen.useMutation({
|
||||
onSuccess: (ergebnis) => {
|
||||
if (ergebnis.geraetFreigegeben) setMeldung("Gerät wieder einsatzbereit.");
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
const abschliessen = trpc.rueckruf.abschliessen.useMutation({
|
||||
onSuccess: () => {
|
||||
setMeldung("Rückruf abgeschlossen.");
|
||||
aktualisieren();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
if (rueckruf.isLoading) {
|
||||
return <p className="text-sm text-[var(--muted-foreground)]">Wird geladen …</p>;
|
||||
}
|
||||
if (rueckruf.error) return <Fehlermeldung meldung={rueckruf.error.message} />;
|
||||
if (!rueckruf.data) return null;
|
||||
|
||||
const daten = rueckruf.data;
|
||||
const offeneTreffer = (treffer.data?.treffer ?? []).filter(
|
||||
(geraet) => !geraet.bereitsZugeordnet,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{daten.titel}</h1>
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
||||
{daten.hersteller}
|
||||
{daten.referenz && ` · ${daten.referenz}`}
|
||||
{daten.veroeffentlichtAm && ` · ${alsDatum(daten.veroeffentlichtAm)}`}
|
||||
<RueckrufStatusAbzeichen status={daten.status} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{darfBearbeiten && daten.status !== "ERLEDIGT" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => abschliessen.mutate({ id: rueckrufId })}
|
||||
disabled={abschliessen.isPending}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
Rückruf abschließen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="rueckrufmeldung" />}
|
||||
|
||||
{(daten.beschreibung || daten.massnahme) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Warnung des Herstellers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{daten.beschreibung && <p className="whitespace-pre-line">{daten.beschreibung}</p>}
|
||||
{daten.massnahme && (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Maßnahme
|
||||
</p>
|
||||
<p className="whitespace-pre-line">{daten.massnahme}</p>
|
||||
</div>
|
||||
)}
|
||||
{daten.typen.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
Betroffene Bauart
|
||||
</p>
|
||||
<ul className="list-inside list-disc">
|
||||
{daten.typen.map((typ) => (
|
||||
<li key={typ.id}>
|
||||
{typ.geraetetyp?.name ?? typ.modell ?? "ohne nähere Eingrenzung"}
|
||||
{(typ.seriennummerVon || typ.seriennummerBis) &&
|
||||
` · Seriennummern ${typ.seriennummerVon ?? "…"} bis ${typ.seriennummerBis ?? "…"}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vorschlag aus dem Bestand */}
|
||||
{darfBearbeiten && (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Mögliche Treffer im Bestand</CardTitle>
|
||||
<CardDescription>
|
||||
Vorschlag anhand von Bauart, Modell und Seriennummer. Zugeordnet wird nur, was hier
|
||||
bestätigt wird.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{ausgewaehlt.size > 0 && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
zuordnen.mutate({
|
||||
rueckrufId,
|
||||
geraetIds: [...ausgewaehlt],
|
||||
})
|
||||
}
|
||||
disabled={zuordnen.isPending}
|
||||
>
|
||||
{ausgewaehlt.size} Geräte zuordnen
|
||||
{daten.sperrtGeraete && " und sperren"}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{treffer.data?.hinweis && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{treffer.data.hinweis}</p>
|
||||
)}
|
||||
|
||||
{offeneTreffer.length === 0 && !treffer.data?.hinweis && (
|
||||
<NichtsGefunden
|
||||
titel="Kein weiteres Gerät passt auf die Beschreibung"
|
||||
hinweis="Entweder ist der Bestand nicht betroffen, oder alle Treffer sind bereits zugeordnet."
|
||||
/>
|
||||
)}
|
||||
|
||||
{offeneTreffer.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" aria-label="Auswahl" />
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Modell</TableHead>
|
||||
<TableHead>Seriennummer</TableHead>
|
||||
<TableHead>Lagerung</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{offeneTreffer.map((geraet) => (
|
||||
<TableRow key={geraet.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={ausgewaehlt.has(geraet.id)}
|
||||
onCheckedChange={() =>
|
||||
setAusgewaehlt((alt) => {
|
||||
const neu = new Set(alt);
|
||||
if (neu.has(geraet.id)) neu.delete(geraet.id);
|
||||
else neu.add(geraet.id);
|
||||
return neu;
|
||||
})
|
||||
}
|
||||
aria-label={`${geraet.inventarnummer} auswählen`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${geraet.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{geraet.bezeichnung}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{geraet.modell ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm tabellenzahlen">
|
||||
{geraet.seriennummer ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{geraet.fahrzeug?.funkrufname ?? geraet.standort?.name ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusAbzeichen status={geraet.status} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bereits zugeordnete Geräte */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Betroffene Geräte</CardTitle>
|
||||
<CardDescription>
|
||||
{daten.betroffenheiten.length} zugeordnet ·{" "}
|
||||
{
|
||||
daten.betroffenheiten.filter(
|
||||
(eintrag) =>
|
||||
eintrag.status === "OFFEN" || eintrag.status === "MASSNAHME_LAEUFT",
|
||||
).length
|
||||
}{" "}
|
||||
noch offen
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{daten.betroffenheiten.length === 0 ? (
|
||||
<NichtsGefunden
|
||||
titel="Noch kein Gerät zugeordnet"
|
||||
hinweis="Solange kein Gerät zugeordnet ist, hat der Rückruf keine Wirkung auf den Bestand."
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Gerät</TableHead>
|
||||
<TableHead>Seriennummer</TableHead>
|
||||
<TableHead>Lagerung</TableHead>
|
||||
<TableHead>Gerätestatus</TableHead>
|
||||
<TableHead>Stand</TableHead>
|
||||
<TableHead className="w-56" aria-label="Aktion" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{daten.betroffenheiten.map((eintrag) => (
|
||||
<TableRow key={eintrag.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/geraete/${eintrag.geraet.id}`}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
<span className="tabellenzahlen">{eintrag.geraet.inventarnummer}</span>
|
||||
</Link>
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{eintrag.geraet.bezeichnung}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm tabellenzahlen">
|
||||
{eintrag.geraet.seriennummer ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{eintrag.geraet.fahrzeug?.funkrufname ??
|
||||
eintrag.geraet.standort?.name ??
|
||||
"—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusAbzeichen status={eintrag.geraet.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<BetroffenheitAbzeichen status={eintrag.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{darfBearbeiten && (
|
||||
<Select
|
||||
value={eintrag.status}
|
||||
onValueChange={(wert) =>
|
||||
betroffenheitSetzen.mutate({
|
||||
id: eintrag.id,
|
||||
status: wert as BetroffenheitStatus,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-52"
|
||||
aria-label={`Stand für ${eintrag.geraet.inventarnummer}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(BETROFFENHEIT_TEXT) as BetroffenheitStatus[]).map(
|
||||
(wert) => (
|
||||
<SelectItem key={wert} value={wert}>
|
||||
{BETROFFENHEIT_TEXT[wert]}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
|
||||
<Search className="size-3.5" aria-hidden />
|
||||
Die Suche vergleicht Seriennummern als Text. Wo Hersteller reine Zahlen vergeben, stimmt
|
||||
das nur bei gleicher Stellenzahl — deshalb ist das Ergebnis ein Vorschlag und keine
|
||||
automatische Sperre.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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 { RueckrufBearbeitung } from "@/app/(app)/rueckrufe/[id]/bearbeitung";
|
||||
|
||||
export const metadata = { title: "Rückruf" };
|
||||
|
||||
/**
|
||||
* Ein Rückruf mit Gerätesuche und Bearbeitungsstand.
|
||||
*
|
||||
* Die Suche schlägt vor, was auf die Beschreibung passt; zugeordnet wird nur,
|
||||
* was der Gerätewart bestätigt. Eine Chargenangabe des Herstellers passt selten
|
||||
* eins zu eins auf den eigenen Bestand.
|
||||
*/
|
||||
export default async function RueckrufSeite({ 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="/rueckrufe">
|
||||
<ArrowLeft aria-hidden />
|
||||
Zurück zu den Rückrufen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RueckrufBearbeitung
|
||||
rueckrufId={id}
|
||||
darfBearbeiten={hatRecht(sitzung, "pruefung.planen")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { RueckrufListe } from "@/app/(app)/rueckrufe/rueckruf-liste";
|
||||
|
||||
export const metadata = { title: "Rückrufe" };
|
||||
|
||||
/**
|
||||
* Herstellerrückrufe.
|
||||
*
|
||||
* Kommt eine Warnung des Herstellers, muss die Wehr wissen, ob sie betroffene
|
||||
* Stücke im Bestand hat. Genau das ist der Zweck dieser Seite.
|
||||
*/
|
||||
export default async function RueckrufeSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Rückrufe</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Herstellerwarnungen, betroffene Geräte und die Maßnahmen dazu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RueckrufListe darfBearbeiten={hatRecht(sitzung, "pruefung.planen")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus } 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";
|
||||
import { RueckrufStatusAbzeichen } from "@/components/wartung/anzeige";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Liste der Rückrufe.
|
||||
*
|
||||
* Beim Anlegen wird gleich die betroffene Bauart mit erfasst — ohne sie kann
|
||||
* die Suche nach betroffenen Geräten nichts finden, und der Rückruf bliebe ein
|
||||
* Zettel ohne Wirkung.
|
||||
*/
|
||||
export function RueckrufListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
|
||||
const [offen, setOffen] = React.useState(false);
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
|
||||
const [hersteller, setHersteller] = React.useState("");
|
||||
const [titel, setTitel] = React.useState("");
|
||||
const [referenz, setReferenz] = React.useState("");
|
||||
const [beschreibung, setBeschreibung] = React.useState("");
|
||||
const [massnahme, setMassnahme] = React.useState("");
|
||||
const [veroeffentlichtAm, setVeroeffentlichtAm] = React.useState("");
|
||||
const [sperrtGeraete, setSperrtGeraete] = React.useState(true);
|
||||
const [geraetetypId, setGeraetetypId] = React.useState("");
|
||||
const [modell, setModell] = React.useState("");
|
||||
const [seriennummerVon, setSeriennummerVon] = React.useState("");
|
||||
const [seriennummerBis, setSeriennummerBis] = React.useState("");
|
||||
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const rueckrufe = trpc.rueckruf.liste.useQuery({
|
||||
seite: 1,
|
||||
seitengroesse: 100,
|
||||
richtung: "desc",
|
||||
});
|
||||
|
||||
const geraetetypen = trpc.geraetetyp.liste.useQuery(
|
||||
{ seite: 1, seitengroesse: 200, richtung: "asc" },
|
||||
{ enabled: offen },
|
||||
);
|
||||
|
||||
const anlegen = trpc.rueckruf.anlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setOffen(false);
|
||||
setHersteller("");
|
||||
setTitel("");
|
||||
setReferenz("");
|
||||
setBeschreibung("");
|
||||
setMassnahme("");
|
||||
setGeraetetypId("");
|
||||
setModell("");
|
||||
setSeriennummerVon("");
|
||||
setSeriennummerBis("");
|
||||
void werkzeuge.rueckruf.liste.invalidate();
|
||||
},
|
||||
onError: (problem) => setFehler(problem.message),
|
||||
});
|
||||
|
||||
const eintraege = rueckrufe.data?.eintraege ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{darfBearbeiten && (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Rückruf erfassen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} />}
|
||||
{rueckrufe.error && <Fehlermeldung meldung={rueckrufe.error.message} />}
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Titel</TableHead>
|
||||
<TableHead>Hersteller</TableHead>
|
||||
<TableHead>Referenz</TableHead>
|
||||
<TableHead>Stand</TableHead>
|
||||
<TableHead className="text-right">betroffene Geräte</TableHead>
|
||||
<TableHead className="text-right">veröffentlicht</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rueckrufe.isLoading && <LadeZeilen spalten={6} />}
|
||||
|
||||
{eintraege.map((rueckruf) => (
|
||||
<TableRow key={rueckruf.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/rueckrufe/${rueckruf.id}`}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
{rueckruf.titel}
|
||||
</Link>
|
||||
{rueckruf.sperrtGeraete && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
sperrt betroffene Geräte
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{rueckruf.hersteller}</TableCell>
|
||||
<TableCell className="text-sm tabellenzahlen">
|
||||
{rueckruf.referenz ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RueckrufStatusAbzeichen status={rueckruf.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{rueckruf._count.betroffenheiten}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabellenzahlen">
|
||||
{alsDatum(rueckruf.veroeffentlichtAm)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!rueckrufe.isLoading && eintraege.length === 0 && (
|
||||
<div className="p-6">
|
||||
<NichtsGefunden
|
||||
titel="Kein Rückruf erfasst"
|
||||
hinweis="Kommt eine Herstellerwarnung, wird sie hier eingetragen. FLORIAN sucht dann die betroffenen Geräte im Bestand."
|
||||
aktion={
|
||||
darfBearbeiten ? (
|
||||
<Button onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Rückruf erfassen
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={offen} onOpenChange={setOffen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<form
|
||||
onSubmit={(ereignis) => {
|
||||
ereignis.preventDefault();
|
||||
setFehler(null);
|
||||
|
||||
anlegen.mutate({
|
||||
hersteller,
|
||||
titel,
|
||||
referenz: referenz || null,
|
||||
beschreibung: beschreibung || null,
|
||||
massnahme: massnahme || null,
|
||||
veroeffentlichtAm: veroeffentlichtAm ? new Date(veroeffentlichtAm) : null,
|
||||
quelle: null,
|
||||
sperrtGeraete,
|
||||
typen:
|
||||
geraetetypId || modell || seriennummerVon || seriennummerBis
|
||||
? [
|
||||
{
|
||||
geraetetypId: geraetetypId || null,
|
||||
modell: modell || null,
|
||||
seriennummerVon: seriennummerVon || null,
|
||||
seriennummerBis: seriennummerBis || null,
|
||||
herstellungVon: null,
|
||||
herstellungBis: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rückruf erfassen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die betroffene Bauart gleich mit angeben — danach sucht FLORIAN im Bestand.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hersteller">Hersteller</Label>
|
||||
<Input
|
||||
id="hersteller"
|
||||
required
|
||||
value={hersteller}
|
||||
onChange={(ereignis) => setHersteller(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="referenz">Referenz des Herstellers</Label>
|
||||
<Input
|
||||
id="referenz"
|
||||
value={referenz}
|
||||
onChange={(ereignis) => setReferenz(ereignis.target.value)}
|
||||
placeholder="SI-2026-04"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="titel">Titel</Label>
|
||||
<Input
|
||||
id="titel"
|
||||
required
|
||||
value={titel}
|
||||
onChange={(ereignis) => setTitel(ereignis.target.value)}
|
||||
placeholder="Sicherheitshinweis Lungenautomat Baureihe X"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="beschreibung">Beschreibung</Label>
|
||||
<Textarea
|
||||
id="beschreibung"
|
||||
rows={3}
|
||||
value={beschreibung}
|
||||
onChange={(ereignis) => setBeschreibung(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="massnahme">Maßnahme</Label>
|
||||
<Textarea
|
||||
id="massnahme"
|
||||
rows={2}
|
||||
value={massnahme}
|
||||
onChange={(ereignis) => setMassnahme(ereignis.target.value)}
|
||||
placeholder="Zur Überprüfung an den Hersteller einsenden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="veroeffentlicht">Veröffentlicht am</Label>
|
||||
<Input
|
||||
id="veroeffentlicht"
|
||||
type="date"
|
||||
value={veroeffentlichtAm}
|
||||
onChange={(ereignis) => setVeroeffentlichtAm(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="typ">Betroffener Gerätetyp</Label>
|
||||
<Select
|
||||
value={geraetetypId || "keiner"}
|
||||
onValueChange={(wert) => setGeraetetypId(wert === "keiner" ? "" : wert)}
|
||||
>
|
||||
<SelectTrigger id="typ">
|
||||
<SelectValue placeholder="Gerätetyp" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="keiner">nicht über den Typ eingrenzen</SelectItem>
|
||||
{geraetetypen.data?.eintraege.map((typ) => (
|
||||
<SelectItem key={typ.id} value={typ.id}>
|
||||
{typ.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modell">Modell enthält</Label>
|
||||
<Input
|
||||
id="modell"
|
||||
value={modell}
|
||||
onChange={(ereignis) => setModell(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serievon">Seriennummer von</Label>
|
||||
<Input
|
||||
id="serievon"
|
||||
value={seriennummerVon}
|
||||
onChange={(ereignis) => setSeriennummerVon(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="seriebis">Seriennummer bis</Label>
|
||||
<Input
|
||||
id="seriebis"
|
||||
value={seriennummerBis}
|
||||
onChange={(ereignis) => setSeriennummerBis(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={sperrtGeraete}
|
||||
onCheckedChange={(an) => setSperrtGeraete(an === true)}
|
||||
/>
|
||||
Betroffene Geräte beim Zuordnen sperren
|
||||
</label>
|
||||
|
||||
{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 erfasst …" : "Erfassen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { hatRecht } from "@/lib/authz";
|
||||
import { hochladen, objektSchluessel } from "@/lib/storage";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
|
||||
/**
|
||||
* Datei-Upload.
|
||||
*
|
||||
* Eigener Endpunkt statt einer tRPC-Prozedur, aus demselben Grund wie beim
|
||||
* Etikettenbogen: Dateien sind keine JSON-Daten. Über tRPC müssten sie
|
||||
* Base64-kodiert durch die Leitung — ein Drittel mehr Bytes, alles im
|
||||
* Arbeitsspeicher, und die Fortschrittsanzeige des Browsers wäre weg.
|
||||
*
|
||||
* Verwendet für die Fotos im Prüfwizard, die Unterschrift aus dem Zeichenfeld
|
||||
* und das Fremdprotokoll einer Fachfirma.
|
||||
*
|
||||
* Die Datei geht ausschließlich über lib/storage.ts nach MinIO — Projektregel.
|
||||
* Was hier entsteht, ist der Datei-Datensatz mit der Kennung, die der Aufrufer
|
||||
* anschließend an Prüfergebnis, Signatur oder Mangel hängt.
|
||||
*/
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Was hochgeladen werden darf. Alles andere hat in einem Protokoll nichts verloren. */
|
||||
const ERLAUBTE_TYPEN = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"application/pdf",
|
||||
]);
|
||||
|
||||
/** 20 MB. Ein Handyfoto liegt darunter, ein Fremdprotokoll erst recht. */
|
||||
const MAX_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
const felder = z.object({
|
||||
/** Woran die Datei hängt: "Pruefung", "Mangel", "Signatur". */
|
||||
entitaet: z.string().trim().min(1).max(80),
|
||||
entitaetId: z.string().uuid().optional(),
|
||||
/** Einordnung: "foto", "signatur", "fremdprotokoll". */
|
||||
kategorie: z.string().trim().max(60).default("foto"),
|
||||
});
|
||||
|
||||
export async function POST(anfrage: Request) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) {
|
||||
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
|
||||
}
|
||||
if (!hatRecht(sitzung, "datei.hochladen")) {
|
||||
return NextResponse.json({ fehler: "Fehlende Berechtigung zum Hochladen." }, { status: 403 });
|
||||
}
|
||||
|
||||
const formular = await anfrage.formData().catch(() => null);
|
||||
if (!formular) {
|
||||
return NextResponse.json(
|
||||
{ fehler: "Die Anfrage enthält kein Formular mit einer Datei." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const datei = formular.get("datei");
|
||||
if (!(datei instanceof File)) {
|
||||
return NextResponse.json({ fehler: "Es wurde keine Datei übergeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const geprueft = felder.safeParse({
|
||||
entitaet: formular.get("entitaet") ?? "",
|
||||
entitaetId: formular.get("entitaetId") || undefined,
|
||||
kategorie: formular.get("kategorie") || undefined,
|
||||
});
|
||||
|
||||
if (!geprueft.success) {
|
||||
return NextResponse.json(
|
||||
{ fehler: "Es fehlt die Angabe, wozu die Datei gehört." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const mimeTyp = datei.type || "application/octet-stream";
|
||||
|
||||
if (!ERLAUBTE_TYPEN.has(mimeTyp)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler: `Dateien vom Typ ${mimeTyp} werden nicht angenommen. Erlaubt sind Bilder und PDF.`,
|
||||
},
|
||||
{ status: 415 },
|
||||
);
|
||||
}
|
||||
|
||||
if (datei.size > MAX_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ fehler: `Die Datei ist zu groß (${Math.round(datei.size / 1024 / 1024)} MB, erlaubt sind 20 MB).` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const inhalt = Buffer.from(await datei.arrayBuffer());
|
||||
|
||||
try {
|
||||
const organisation = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.organisation.findUnique({
|
||||
where: { id: sitzung.organisationId },
|
||||
select: { slug: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const schluessel = objektSchluessel({
|
||||
organisationSlug: organisation?.slug ?? sitzung.organisationId,
|
||||
entitaet: geprueft.data.entitaet,
|
||||
entitaetId: geprueft.data.entitaetId,
|
||||
dateiname: datei.name || "datei",
|
||||
});
|
||||
|
||||
const abgelegt = await hochladen(schluessel, inhalt, mimeTyp, {
|
||||
kategorie: geprueft.data.kategorie,
|
||||
hochgeladenvon: sitzung.personId,
|
||||
});
|
||||
|
||||
const datensatz = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const angelegt = await db.datei.create({
|
||||
data: {
|
||||
organisationId: sitzung.organisationId,
|
||||
bucket: abgelegt.bucket,
|
||||
objektSchluessel: abgelegt.objektSchluessel,
|
||||
dateiname: datei.name || "datei",
|
||||
mimeTyp,
|
||||
groesseBytes: BigInt(abgelegt.groesseBytes),
|
||||
pruefsumme: abgelegt.pruefsumme,
|
||||
entitaet: geprueft.data.entitaet,
|
||||
entitaetId: geprueft.data.entitaetId ?? null,
|
||||
kategorie: geprueft.data.kategorie,
|
||||
hochgeladenVonId: sitzung.personId,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: sitzung.organisationId,
|
||||
aktion: AuditAktion.DATEI_HOCHGELADEN,
|
||||
entitaet: geprueft.data.entitaet,
|
||||
entitaetId: geprueft.data.entitaetId ?? null,
|
||||
beschreibung: `Datei "${angelegt.dateiname}" hochgeladen (${geprueft.data.kategorie})`,
|
||||
sitzung,
|
||||
nachher: { dateiId: angelegt.id, mimeTyp, groesseBytes: abgelegt.groesseBytes },
|
||||
});
|
||||
|
||||
return angelegt;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: datensatz.id,
|
||||
dateiname: datensatz.dateiname,
|
||||
mimeTyp: datensatz.mimeTyp,
|
||||
groesseBytes: abgelegt.groesseBytes,
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[dateien] Upload fehlgeschlagen:", fehler);
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler:
|
||||
"Die Datei ließ sich nicht ablegen. Läuft der MinIO-Container? (docker compose ps)",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
Map,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
ShieldAlert,
|
||||
Siren,
|
||||
TriangleAlert,
|
||||
Truck,
|
||||
Wallet,
|
||||
Users,
|
||||
@@ -64,6 +66,20 @@ const EINTRAEGE: Eintrag[] = [
|
||||
rechte: ["pruefung.lesen"],
|
||||
abPhase: 2,
|
||||
},
|
||||
{
|
||||
titel: "Mängel",
|
||||
pfad: "/maengel",
|
||||
symbol: TriangleAlert,
|
||||
rechte: ["pruefung.lesen"],
|
||||
abPhase: 2,
|
||||
},
|
||||
{
|
||||
titel: "Rückrufe",
|
||||
pfad: "/rueckrufe",
|
||||
symbol: ShieldAlert,
|
||||
rechte: ["pruefung.lesen"],
|
||||
abPhase: 2,
|
||||
},
|
||||
{ titel: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 },
|
||||
{
|
||||
titel: "Terminal",
|
||||
@@ -99,7 +115,7 @@ const EINTRAEGE: Eintrag[] = [
|
||||
|
||||
/** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
|
||||
* sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
|
||||
const AKTUELLE_PHASE = 1;
|
||||
const AKTUELLE_PHASE = 2;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
BetroffenheitStatus,
|
||||
MangelPrioritaet,
|
||||
MangelStatus,
|
||||
PruefErgebnis,
|
||||
PruefungStatus,
|
||||
RueckrufStatus,
|
||||
WartungsterminStatus,
|
||||
} from "@prisma/client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Fristfenster } from "@/lib/wartung/fristen";
|
||||
import {
|
||||
BETROFFENHEIT_TEXT,
|
||||
ERGEBNIS_TEXT,
|
||||
FENSTER_TEXT,
|
||||
MANGEL_STATUS_TEXT,
|
||||
PRIORITAET_TEXT,
|
||||
PRUEFUNG_STATUS_TEXT,
|
||||
RUECKRUF_STATUS_TEXT,
|
||||
TERMIN_STATUS_TEXT,
|
||||
} from "@/lib/wartung/texte";
|
||||
|
||||
/**
|
||||
* Anzeigebausteine für Wartung und Prüfung.
|
||||
*
|
||||
* Dieselbe Farblehre wie bei den Stammdaten: grün in Ordnung, gelb es eilt,
|
||||
* rot es steht still, grau erledigt. Und wie dort trägt jede Farbe zusätzlich
|
||||
* Klartext — im Gerätehaus steht kein kalibrierter Bildschirm, und
|
||||
* Rot-Grün-Sehschwäche ist häufig.
|
||||
*
|
||||
* Die Beschriftungen selbst stehen in lib/wartung/texte.ts, damit auch
|
||||
* Server-Komponenten sie verwenden können.
|
||||
*/
|
||||
|
||||
export function ErgebnisAbzeichen({ ergebnis }: { ergebnis: PruefErgebnis | null }) {
|
||||
if (!ergebnis) return <Badge variant="secondary">offen</Badge>;
|
||||
|
||||
const variante =
|
||||
ergebnis === "BESTANDEN"
|
||||
? "einsatzbereit"
|
||||
: ergebnis === "BESTANDEN_MIT_MAENGELN"
|
||||
? "pruefungFaellig"
|
||||
: "gesperrt";
|
||||
|
||||
return <Badge variant={variante}>{ERGEBNIS_TEXT[ergebnis]}</Badge>;
|
||||
}
|
||||
|
||||
export function PruefungStatusAbzeichen({ status }: { status: PruefungStatus }) {
|
||||
const variante =
|
||||
status === "ABGESCHLOSSEN" ? "outline" : status === "ENTWURF" ? "pruefungFaellig" : "ausgesondert";
|
||||
|
||||
return <Badge variant={variante}>{PRUEFUNG_STATUS_TEXT[status]}</Badge>;
|
||||
}
|
||||
|
||||
export function TerminStatusAbzeichen({ status }: { status: WartungsterminStatus }) {
|
||||
const variante =
|
||||
status === "OFFEN" ? "outline" : status === "ERLEDIGT" ? "einsatzbereit" : "ausgesondert";
|
||||
|
||||
return <Badge variant={variante}>{TERMIN_STATUS_TEXT[status]}</Badge>;
|
||||
}
|
||||
|
||||
export function FensterAbzeichen({ fenster }: { fenster: Fristfenster }) {
|
||||
const variante =
|
||||
fenster === "ueberfaellig"
|
||||
? "gesperrt"
|
||||
: fenster === "diese_woche"
|
||||
? "pruefungFaellig"
|
||||
: "outline";
|
||||
|
||||
return <Badge variant={variante}>{FENSTER_TEXT[fenster]}</Badge>;
|
||||
}
|
||||
|
||||
export function MangelStatusAbzeichen({ status }: { status: MangelStatus }) {
|
||||
const variante =
|
||||
status === "BEHOBEN"
|
||||
? "einsatzbereit"
|
||||
: status === "VERWORFEN"
|
||||
? "ausgesondert"
|
||||
: status === "IN_ARBEIT"
|
||||
? "pruefungFaellig"
|
||||
: "outline";
|
||||
|
||||
return <Badge variant={variante}>{MANGEL_STATUS_TEXT[status]}</Badge>;
|
||||
}
|
||||
|
||||
export function PrioritaetAbzeichen({ prioritaet }: { prioritaet: MangelPrioritaet }) {
|
||||
const variante =
|
||||
prioritaet === "KRITISCH"
|
||||
? "gesperrt"
|
||||
: prioritaet === "HOCH"
|
||||
? "pruefungFaellig"
|
||||
: prioritaet === "NIEDRIG"
|
||||
? "ausgesondert"
|
||||
: "secondary";
|
||||
|
||||
return <Badge variant={variante}>{PRIORITAET_TEXT[prioritaet]}</Badge>;
|
||||
}
|
||||
|
||||
export function RueckrufStatusAbzeichen({ status }: { status: RueckrufStatus }) {
|
||||
const variante =
|
||||
status === "OFFEN" ? "gesperrt" : status === "IN_BEARBEITUNG" ? "pruefungFaellig" : "einsatzbereit";
|
||||
|
||||
return <Badge variant={variante}>{RUECKRUF_STATUS_TEXT[status]}</Badge>;
|
||||
}
|
||||
|
||||
export function BetroffenheitAbzeichen({ status }: { status: BetroffenheitStatus }) {
|
||||
const variante =
|
||||
status === "ERLEDIGT"
|
||||
? "einsatzbereit"
|
||||
: status === "NICHT_BETROFFEN"
|
||||
? "ausgesondert"
|
||||
: status === "MASSNAHME_LAEUFT"
|
||||
? "pruefungFaellig"
|
||||
: "gesperrt";
|
||||
|
||||
return <Badge variant={variante}>{BETROFFENHEIT_TEXT[status]}</Badge>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kennzahl mit Klartext darunter. Die Zahl ist groß, weil sie aus zwei Metern
|
||||
* Abstand lesbar sein soll — das Tablet liegt beim Prüfen auf der Werkbank.
|
||||
*/
|
||||
export function Kennzahl({
|
||||
wert,
|
||||
bezeichnung,
|
||||
hinweis,
|
||||
dringend,
|
||||
aktiv,
|
||||
onClick,
|
||||
}: {
|
||||
wert: number;
|
||||
bezeichnung: string;
|
||||
hinweis?: string;
|
||||
dringend?: boolean;
|
||||
aktiv?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const inhalt = (
|
||||
<>
|
||||
<span
|
||||
className={`tabellenzahlen text-3xl font-semibold ${
|
||||
dringend && wert > 0 ? "text-[var(--gesperrt)]" : ""
|
||||
}`}
|
||||
>
|
||||
{wert}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{bezeichnung}</span>
|
||||
{hinweis && <span className="text-xs text-[var(--muted-foreground)]">{hinweis}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
const klassen = `flex flex-col gap-0.5 rounded-lg border p-4 text-left transition-colors ${
|
||||
aktiv
|
||||
? "border-[var(--primary)] bg-[var(--accent)]"
|
||||
: "border-[var(--border)] bg-[var(--card)]"
|
||||
} ${onClick ? "hover:border-[var(--primary)]" : ""}`;
|
||||
|
||||
if (!onClick) return <div className={klassen}>{inhalt}</div>;
|
||||
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={klassen} aria-pressed={aktiv}>
|
||||
{inhalt}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Camera, FileUp, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { dateiHochladen } from "@/lib/hochladen";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
|
||||
/**
|
||||
* Foto- und Dateifeld.
|
||||
*
|
||||
* Auf dem Tablet öffnet es unmittelbar die Kamera (capture="environment"),
|
||||
* denn genau dafür ist es da: der Prüfer fotografiert den Riss im Schlauch,
|
||||
* während er davorsteht. Am Rechner ist es ein gewöhnliches Dateifeld.
|
||||
*
|
||||
* Hochgeladen wird sofort — nicht erst beim Speichern des Formulars. Wer eine
|
||||
* halbe Stunde an einer Prüfung sitzt, soll seine Fotos nicht verlieren, weil
|
||||
* am Ende etwas schiefgeht.
|
||||
*/
|
||||
|
||||
export interface DateiFeldProps {
|
||||
entitaet: string;
|
||||
entitaetId?: string | null;
|
||||
kategorie?: string;
|
||||
/** Nur Bilder annehmen und die Kamera anbieten. */
|
||||
nurBilder?: boolean;
|
||||
beschriftung?: string;
|
||||
dateiId?: string | null;
|
||||
onHochgeladen: (dateiId: string | null, dateiname?: string) => void;
|
||||
}
|
||||
|
||||
export function DateiFeld({
|
||||
entitaet,
|
||||
entitaetId,
|
||||
kategorie = "foto",
|
||||
nurBilder = true,
|
||||
beschriftung,
|
||||
dateiId,
|
||||
onHochgeladen,
|
||||
}: DateiFeldProps) {
|
||||
const eingabeRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [laeuft, setLaeuft] = React.useState(false);
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
const [name, setName] = React.useState<string | null>(null);
|
||||
const [vorschau, setVorschau] = React.useState<string | null>(null);
|
||||
|
||||
// Die Vorschau ist eine Objekt-URL auf die lokale Datei — sie wird beim
|
||||
// Austausch wieder freigegeben, sonst hält der Browser sie bis zum Neuladen.
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (vorschau) URL.revokeObjectURL(vorschau);
|
||||
};
|
||||
}, [vorschau]);
|
||||
|
||||
async function auswaehlen(ereignis: React.ChangeEvent<HTMLInputElement>) {
|
||||
const datei = ereignis.target.files?.[0];
|
||||
if (!datei) return;
|
||||
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
|
||||
try {
|
||||
const hochgeladen = await dateiHochladen(datei, entitaet, entitaetId, kategorie);
|
||||
setName(hochgeladen.dateiname);
|
||||
if (datei.type.startsWith("image/")) {
|
||||
setVorschau(URL.createObjectURL(datei));
|
||||
}
|
||||
onHochgeladen(hochgeladen.id, hochgeladen.dateiname);
|
||||
} catch (problem) {
|
||||
setFehler(problem instanceof Error ? problem.message : String(problem));
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
// Damit dieselbe Datei erneut gewählt werden kann.
|
||||
if (eingabeRef.current) eingabeRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function entfernen() {
|
||||
setName(null);
|
||||
if (vorschau) URL.revokeObjectURL(vorschau);
|
||||
setVorschau(null);
|
||||
onHochgeladen(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={eingabeRef}
|
||||
type="file"
|
||||
accept={nurBilder ? "image/*" : "image/*,application/pdf"}
|
||||
{...(nurBilder ? { capture: "environment" as const } : {})}
|
||||
onChange={auswaehlen}
|
||||
className="hidden"
|
||||
aria-label={beschriftung ?? "Datei auswählen"}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => eingabeRef.current?.click()}
|
||||
disabled={laeuft}
|
||||
>
|
||||
{nurBilder ? <Camera aria-hidden /> : <FileUp aria-hidden />}
|
||||
{laeuft
|
||||
? "wird hochgeladen …"
|
||||
: dateiId
|
||||
? "Ersetzen"
|
||||
: (beschriftung ?? (nurBilder ? "Foto aufnehmen" : "Datei wählen"))}
|
||||
</Button>
|
||||
|
||||
{dateiId && (
|
||||
<>
|
||||
<span className="text-sm text-[var(--muted-foreground)]">{name ?? "Datei liegt bei"}</span>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={entfernen}>
|
||||
<X aria-hidden />
|
||||
Entfernen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{vorschau && (
|
||||
// Bewusst kein next/image: die Quelle ist eine lokale Objekt-URL, die
|
||||
// der Bildoptimierer weder kennt noch abrufen kann.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={vorschau}
|
||||
alt="Vorschau der ausgewählten Datei"
|
||||
className="max-h-40 rounded border border-[var(--border)]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} kennung="dateifehler" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, Eraser } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { canvasAlsPng, dateiHochladen } from "@/lib/hochladen";
|
||||
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
|
||||
|
||||
/**
|
||||
* Unterschriftenfeld.
|
||||
*
|
||||
* Ein Zeichenfeld, das mit Finger, Stift und Maus funktioniert — deshalb
|
||||
* Pointer-Events und nicht getrennt Maus und Touch: die Browser fassen beides
|
||||
* darunter zusammen, und ein Tablet mit Stift liefert je nach Gerät mal das
|
||||
* eine, mal das andere.
|
||||
*
|
||||
* Zwei Feinheiten, die im Gerätehaus den Unterschied machen:
|
||||
*
|
||||
* * Die Zeichenfläche wird auf die tatsächliche Pixeldichte skaliert. Ohne
|
||||
* das ist die Unterschrift auf einem Tablet ein verwaschener Klotz.
|
||||
* * touch-action: none verhindert, dass das Wischen beim Unterschreiben die
|
||||
* Seite scrollt.
|
||||
*
|
||||
* Das Ergebnis ist ein PNG in MinIO; zurück kommt dessen Kennung, die an die
|
||||
* Signatur der Prüfung gehängt wird.
|
||||
*/
|
||||
|
||||
export interface UnterschriftFeldProps {
|
||||
/** Wer unterschreibt — steht unter dem Feld und später im Protokoll. */
|
||||
name: string;
|
||||
/** Rolle als Klartext, nur zur Beschriftung. */
|
||||
rolle?: string;
|
||||
/** Prüfung, an der die Datei hängt. */
|
||||
pruefungId?: string;
|
||||
onFertig: (dateiId: string) => void;
|
||||
/** Bereits übernommen — dann ist das Feld gesperrt. */
|
||||
uebernommen?: boolean;
|
||||
}
|
||||
|
||||
export function UnterschriftFeld({
|
||||
name,
|
||||
rolle,
|
||||
pruefungId,
|
||||
onFertig,
|
||||
uebernommen = false,
|
||||
}: UnterschriftFeldProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
const zeichnet = React.useRef(false);
|
||||
const [leer, setLeer] = React.useState(true);
|
||||
const [laeuft, setLaeuft] = React.useState(false);
|
||||
const [fehler, setFehler] = React.useState<string | null>(null);
|
||||
|
||||
// Zeichenfläche an die Anzeigegröße und die Pixeldichte anpassen.
|
||||
React.useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const anpassen = () => {
|
||||
const dichte = window.devicePixelRatio || 1;
|
||||
const breite = canvas.clientWidth;
|
||||
const hoehe = canvas.clientHeight;
|
||||
|
||||
canvas.width = Math.round(breite * dichte);
|
||||
canvas.height = Math.round(hoehe * dichte);
|
||||
|
||||
const stift = canvas.getContext("2d");
|
||||
if (!stift) return;
|
||||
stift.scale(dichte, dichte);
|
||||
stift.lineWidth = 2;
|
||||
stift.lineCap = "round";
|
||||
stift.lineJoin = "round";
|
||||
stift.strokeStyle = "#111111";
|
||||
// Weißer Grund: das PNG landet später auf einem weißen Blatt Papier.
|
||||
stift.fillStyle = "#ffffff";
|
||||
stift.fillRect(0, 0, breite, hoehe);
|
||||
};
|
||||
|
||||
anpassen();
|
||||
window.addEventListener("resize", anpassen);
|
||||
return () => window.removeEventListener("resize", anpassen);
|
||||
}, []);
|
||||
|
||||
function position(ereignis: React.PointerEvent<HTMLCanvasElement>) {
|
||||
const canvas = canvasRef.current!;
|
||||
const rahmen = canvas.getBoundingClientRect();
|
||||
return { x: ereignis.clientX - rahmen.left, y: ereignis.clientY - rahmen.top };
|
||||
}
|
||||
|
||||
function beginnen(ereignis: React.PointerEvent<HTMLCanvasElement>) {
|
||||
if (uebernommen) return;
|
||||
const stift = canvasRef.current?.getContext("2d");
|
||||
if (!stift) return;
|
||||
|
||||
canvasRef.current?.setPointerCapture(ereignis.pointerId);
|
||||
zeichnet.current = true;
|
||||
const { x, y } = position(ereignis);
|
||||
stift.beginPath();
|
||||
stift.moveTo(x, y);
|
||||
setLeer(false);
|
||||
}
|
||||
|
||||
function ziehen(ereignis: React.PointerEvent<HTMLCanvasElement>) {
|
||||
if (!zeichnet.current || uebernommen) return;
|
||||
const stift = canvasRef.current?.getContext("2d");
|
||||
if (!stift) return;
|
||||
|
||||
const { x, y } = position(ereignis);
|
||||
stift.lineTo(x, y);
|
||||
stift.stroke();
|
||||
}
|
||||
|
||||
function beenden(ereignis: React.PointerEvent<HTMLCanvasElement>) {
|
||||
if (!zeichnet.current) return;
|
||||
zeichnet.current = false;
|
||||
canvasRef.current?.releasePointerCapture(ereignis.pointerId);
|
||||
}
|
||||
|
||||
function leeren() {
|
||||
const canvas = canvasRef.current;
|
||||
const stift = canvas?.getContext("2d");
|
||||
if (!canvas || !stift) return;
|
||||
|
||||
stift.fillStyle = "#ffffff";
|
||||
stift.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
|
||||
setLeer(true);
|
||||
}
|
||||
|
||||
async function uebernehmen() {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || leer) return;
|
||||
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
|
||||
try {
|
||||
const png = await canvasAlsPng(canvas);
|
||||
const datei = await dateiHochladen(
|
||||
png,
|
||||
"Pruefung",
|
||||
pruefungId,
|
||||
"signatur",
|
||||
`unterschrift-${Date.now()}.png`,
|
||||
);
|
||||
onFertig(datei.id);
|
||||
} catch (problem) {
|
||||
setFehler(problem instanceof Error ? problem.message : String(problem));
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={beginnen}
|
||||
onPointerMove={ziehen}
|
||||
onPointerUp={beenden}
|
||||
onPointerLeave={beenden}
|
||||
className="h-40 w-full touch-none rounded-md border border-[var(--border)] bg-white"
|
||||
aria-label={`Unterschriftenfeld für ${name}`}
|
||||
style={{ cursor: uebernommen ? "not-allowed" : "crosshair" }}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="mr-auto text-sm">
|
||||
<span className="font-medium">{name}</span>
|
||||
{rolle && <span className="text-[var(--muted-foreground)]"> · {rolle}</span>}
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="ghost" size="sm" onClick={leeren} disabled={uebernommen || leer}>
|
||||
<Eraser aria-hidden />
|
||||
Löschen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={uebernehmen}
|
||||
disabled={uebernommen || leer || laeuft}
|
||||
>
|
||||
<Check aria-hidden />
|
||||
{uebernommen ? "übernommen" : laeuft ? "wird gespeichert …" : "Übernehmen"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fehler && <Fehlermeldung meldung={fehler} kennung="unterschriftfehler" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -106,6 +106,14 @@ const schema = z.object({
|
||||
LLM_API_KEY: z.string().optional(),
|
||||
LLM_MODELL: z.string().optional(),
|
||||
|
||||
// Wartung und Prüfung (Phase 2)
|
||||
/** Wann der nächtliche Lauf Termine plant. Cron-Syntax, Europe/Berlin. */
|
||||
WARTUNG_NACHTLAUF: z.string().default("15 3 * * *"),
|
||||
/** Wann Erinnerungen verschickt werden — nach der Planung, nicht davor. */
|
||||
WARTUNG_ERINNERUNG_LAUF: z.string().default("45 3 * * *"),
|
||||
/** Wie weit im Voraus Termine angelegt werden. */
|
||||
WARTUNG_PLANUNGSHORIZONT_TAGE: zahl(180),
|
||||
|
||||
// Aufbewahrung
|
||||
AUFBEWAHRUNG_JAHRE: zahl(10),
|
||||
AUDIT_AUFBEWAHRUNG_TAGE: zahl(3650),
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Datei-Upload aus dem Browser.
|
||||
*
|
||||
* Gegenstück zu src/app/api/dateien/route.ts. Bewusst kein tRPC: Dateien
|
||||
* gehören als Multipart durch die Leitung, nicht Base64-kodiert durch eine
|
||||
* JSON-Antwort.
|
||||
*
|
||||
* Diese Datei läuft im Browser — hier darf nichts aus lib/storage oder lib/db
|
||||
* hineingeraten.
|
||||
*/
|
||||
|
||||
export interface HochgeladeneDatei {
|
||||
id: string;
|
||||
dateiname: string;
|
||||
mimeTyp: string;
|
||||
groesseBytes: number;
|
||||
}
|
||||
|
||||
export class UploadFehler extends Error {}
|
||||
|
||||
/**
|
||||
* Lädt eine Datei hoch und gibt den angelegten Datensatz zurück.
|
||||
*
|
||||
* @param entitaet woran die Datei hängt, z. B. "Pruefung"
|
||||
* @param entitaetId der zugehörige Datensatz, sofern schon vorhanden
|
||||
* @param kategorie "foto", "signatur" oder "fremdprotokoll"
|
||||
*/
|
||||
export async function dateiHochladen(
|
||||
datei: File | Blob,
|
||||
entitaet: string,
|
||||
entitaetId?: string | null,
|
||||
kategorie = "foto",
|
||||
dateiname?: string,
|
||||
): Promise<HochgeladeneDatei> {
|
||||
const formular = new FormData();
|
||||
formular.append(
|
||||
"datei",
|
||||
datei,
|
||||
dateiname ?? (datei instanceof File ? datei.name : "datei.png"),
|
||||
);
|
||||
formular.append("entitaet", entitaet);
|
||||
if (entitaetId) formular.append("entitaetId", entitaetId);
|
||||
formular.append("kategorie", kategorie);
|
||||
|
||||
const antwort = await fetch("/api/dateien", { method: "POST", body: formular });
|
||||
|
||||
if (!antwort.ok) {
|
||||
const inhalt = (await antwort.json().catch(() => null)) as { fehler?: string } | null;
|
||||
throw new UploadFehler(inhalt?.fehler ?? `Der Upload schlug fehl (${antwort.status}).`);
|
||||
}
|
||||
|
||||
return (await antwort.json()) as HochgeladeneDatei;
|
||||
}
|
||||
|
||||
/** Wandelt den Inhalt eines Zeichenfelds in eine PNG-Datei um. */
|
||||
export async function canvasAlsPng(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
return new Promise((auflösen, ablehnen) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) auflösen(blob);
|
||||
else ablehnen(new UploadFehler("Die Unterschrift ließ sich nicht in ein Bild umwandeln."));
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
@@ -23,10 +23,14 @@ import { formatInTimeZone } from "date-fns-tz";
|
||||
*
|
||||
* Deshalb hier eigene Arithmetik auf den UTC-Bestandteilen. Sie ist
|
||||
* unabhängig davon, wo der Server steht.
|
||||
*
|
||||
* Die vier Grundfunktionen sind ab Phase 2 exportiert: die Wartungsplanung
|
||||
* rechnet mit denselben Kalendertagen, und zwei Umsetzungen derselben
|
||||
* Datumsarithmetik würden früher oder später um einen Tag auseinanderlaufen.
|
||||
*/
|
||||
|
||||
/** Setzt ein Datum auf UTC-Mitternacht seines eigenen Kalendertages. */
|
||||
function utcTag(wert: Date): Date {
|
||||
export function utcTag(wert: Date): Date {
|
||||
return new Date(Date.UTC(wert.getUTCFullYear(), wert.getUTCMonth(), wert.getUTCDate()));
|
||||
}
|
||||
|
||||
@@ -35,7 +39,7 @@ function utcTag(wert: Date): Date {
|
||||
* geklemmt: der 31. Januar plus einen Monat ist der 28. Februar, nicht der
|
||||
* 3. März.
|
||||
*/
|
||||
function monateAddieren(basis: Date, monate: number): Date {
|
||||
export function monateAddieren(basis: Date, monate: number): Date {
|
||||
const jahr = basis.getUTCFullYear();
|
||||
const monat = basis.getUTCMonth() + monate;
|
||||
const tag = basis.getUTCDate();
|
||||
@@ -56,7 +60,7 @@ function jahreAddieren(basis: Date, jahre: number): Date {
|
||||
* nach UTC noch der Vortag. Eine Frist, die "heute" abläuft, darf davon nicht
|
||||
* abhängen.
|
||||
*/
|
||||
function heutigerTag(jetzt: Date): Date {
|
||||
export function heutigerTag(jetzt: Date): Date {
|
||||
const [jahr, monat, tag] = formatInTimeZone(jetzt, "Europe/Berlin", "yyyy-MM-dd")
|
||||
.split("-")
|
||||
.map(Number);
|
||||
@@ -64,7 +68,7 @@ function heutigerTag(jetzt: Date): Date {
|
||||
}
|
||||
|
||||
/** Kalendertage zwischen zwei UTC-Tagen. */
|
||||
function tagesabstand(von: Date, bis: Date): number {
|
||||
export function tagesabstand(von: Date, bis: Date): number {
|
||||
return Math.round((utcTag(bis).getTime() - utcTag(von).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Bewertung einer Prüfung.
|
||||
*
|
||||
* Die Frage, ob ein Gerät bestanden hat, wird hier beantwortet und nirgends
|
||||
* sonst. Weder der Browser noch die Datenbank entscheiden das mit: der Browser
|
||||
* nicht, weil man ihm nicht trauen kann, und die Datenbank nicht, weil die
|
||||
* Regel fachlich ist und sich ändern kann.
|
||||
*
|
||||
* Die Regel in einem Satz: ein nicht erfüllter Pflichtpunkt bedeutet "nicht
|
||||
* bestanden", ein nicht erfüllter Kann-Punkt "bestanden mit Mängeln".
|
||||
*
|
||||
* Dahinter steckt die Festlegung, dass Pflichtpunkte genau die sind, an denen
|
||||
* die Einsatzbereitschaft hängt — deshalb heißen sie so. Wer einen Punkt als
|
||||
* Pflicht anlegt, sagt damit: fällt der durch, geht das Gerät aus dem Dienst.
|
||||
*/
|
||||
|
||||
export type PunktTyp = "JA_NEIN" | "MESSWERT" | "TEXT" | "FOTO";
|
||||
export type Ergebnis = "BESTANDEN" | "BESTANDEN_MIT_MAENGELN" | "NICHT_BESTANDEN";
|
||||
|
||||
export const ERGEBNIS_BEZEICHNUNG: Record<Ergebnis, string> = {
|
||||
BESTANDEN: "bestanden",
|
||||
BESTANDEN_MIT_MAENGELN: "bestanden mit Mängeln",
|
||||
NICHT_BESTANDEN: "nicht bestanden",
|
||||
};
|
||||
|
||||
/** Ein Checklistenpunkt, so weit die Bewertung ihn braucht. */
|
||||
export interface Punkt {
|
||||
id: string;
|
||||
text: string;
|
||||
typ: PunktTyp;
|
||||
pflicht: boolean;
|
||||
minWert?: number | null;
|
||||
maxWert?: number | null;
|
||||
}
|
||||
|
||||
/** Die Antwort darauf. */
|
||||
export interface Antwort {
|
||||
punktId: string;
|
||||
jaNein?: boolean | null;
|
||||
zahlwert?: number | null;
|
||||
text?: string | null;
|
||||
/** Kennung der hochgeladenen Datei, wenn ein Foto verlangt war. */
|
||||
fotoDateiId?: string | null;
|
||||
/** Vom Prüfer übersteuert. Fehlt die Angabe, rechnet der Server sie aus. */
|
||||
inOrdnung?: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liegt ein Messwert im zulässigen Bereich?
|
||||
*
|
||||
* Fehlt eine Grenze, schränkt sie nicht ein — "mindestens 270 bar" ist ein
|
||||
* Bereich mit Untergrenze und ohne Obergrenze. Fehlt der Wert selbst, ist die
|
||||
* Frage unbeantwortet; das fängt vollstaendig() ab, nicht diese Funktion.
|
||||
*/
|
||||
export function messwertInOrdnung(
|
||||
wert: number | null | undefined,
|
||||
minWert?: number | null,
|
||||
maxWert?: number | null,
|
||||
): boolean {
|
||||
if (wert === null || wert === undefined || Number.isNaN(wert)) return false;
|
||||
if (minWert !== null && minWert !== undefined && wert < minWert) return false;
|
||||
if (maxWert !== null && maxWert !== undefined && wert > maxWert) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Ist der Punkt überhaupt beantwortet? */
|
||||
export function beantwortet(punkt: Punkt, antwort: Antwort | undefined): boolean {
|
||||
if (!antwort) return false;
|
||||
|
||||
switch (punkt.typ) {
|
||||
case "JA_NEIN":
|
||||
return antwort.jaNein === true || antwort.jaNein === false;
|
||||
case "MESSWERT":
|
||||
return (
|
||||
antwort.zahlwert !== null &&
|
||||
antwort.zahlwert !== undefined &&
|
||||
!Number.isNaN(antwort.zahlwert)
|
||||
);
|
||||
case "TEXT":
|
||||
return Boolean(antwort.text?.trim());
|
||||
case "FOTO":
|
||||
return Boolean(antwort.fotoDateiId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob ein einzelner Punkt in Ordnung ist.
|
||||
*
|
||||
* Die Angabe des Prüfers hat Vorrang: bei einem Messwert innerhalb der Toleranz
|
||||
* kann trotzdem etwas nicht stimmen, und wer davorsteht, sieht mehr als die
|
||||
* Zahl. Ohne ausdrückliche Angabe entscheidet die Art des Punktes:
|
||||
*
|
||||
* ja_nein die Antwort selbst
|
||||
* messwert der zulässige Bereich
|
||||
* text/foto beantwortet heißt in Ordnung — hier gibt es nichts zu rechnen
|
||||
*/
|
||||
export function punktInOrdnung(punkt: Punkt, antwort: Antwort | undefined): boolean {
|
||||
if (!antwort) return false;
|
||||
if (antwort.inOrdnung === true || antwort.inOrdnung === false) return antwort.inOrdnung;
|
||||
|
||||
switch (punkt.typ) {
|
||||
case "JA_NEIN":
|
||||
return antwort.jaNein === true;
|
||||
case "MESSWERT":
|
||||
return messwertInOrdnung(antwort.zahlwert, punkt.minWert, punkt.maxWert);
|
||||
case "TEXT":
|
||||
case "FOTO":
|
||||
return beantwortet(punkt, antwort);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pflichtpunkte, die noch offen sind. Solange welche übrig sind, ist die
|
||||
* Prüfung nicht abschließbar. */
|
||||
export function offenePflichtpunkte(punkte: Punkt[], antworten: Antwort[]): Punkt[] {
|
||||
const nachId = new Map(antworten.map((antwort) => [antwort.punktId, antwort]));
|
||||
return punkte.filter((punkt) => punkt.pflicht && !beantwortet(punkt, nachId.get(punkt.id)));
|
||||
}
|
||||
|
||||
/** Punkte, die nicht in Ordnung sind — die Vorlage für die Mängelmeldung. */
|
||||
export function beanstandetePunkte(punkte: Punkt[], antworten: Antwort[]): Punkt[] {
|
||||
const nachId = new Map(antworten.map((antwort) => [antwort.punktId, antwort]));
|
||||
return punkte.filter((punkt) => {
|
||||
const antwort = nachId.get(punkt.id);
|
||||
if (!beantwortet(punkt, antwort)) return false;
|
||||
return !punktInOrdnung(punkt, antwort);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Gesamtergebnis.
|
||||
*
|
||||
* Es ist ein Vorschlag, kein Urteil: der Prüfer darf es übersteuern, denn er
|
||||
* trägt die Verantwortung. Was er wählt, steht im Protokoll — was hier
|
||||
* herauskommt, ist die Vorbelegung und der Abgleich dazu.
|
||||
*/
|
||||
export function ergebnisVorschlag(punkte: Punkt[], antworten: Antwort[]): Ergebnis {
|
||||
const beanstandet = beanstandetePunkte(punkte, antworten);
|
||||
|
||||
if (beanstandet.length === 0) return "BESTANDEN";
|
||||
if (beanstandet.some((punkt) => punkt.pflicht)) return "NICHT_BESTANDEN";
|
||||
return "BESTANDEN_MIT_MAENGELN";
|
||||
}
|
||||
|
||||
/**
|
||||
* Welchen Status das Gerät nach dieser Prüfung haben soll.
|
||||
*
|
||||
* Nicht bestanden heißt gesperrt — ein Gerät, das die Prüfung nicht besteht,
|
||||
* gehört nicht auf das Fahrzeug. Mit Mängeln bestanden bleibt einsatzbereit;
|
||||
* der Mangel wird verfolgt, aber er hindert den Einsatz nicht.
|
||||
*/
|
||||
export function geraetStatusNachPruefung(
|
||||
ergebnis: Ergebnis,
|
||||
): "EINSATZBEREIT" | "GESPERRT" {
|
||||
return ergebnis === "NICHT_BESTANDEN" ? "GESPERRT" : "EINSATZBEREIT";
|
||||
}
|
||||
|
||||
/** Wie lange die Prüfung gedauert hat, in vollen Minuten. */
|
||||
export function dauerInMinuten(begonnenUm: Date | null | undefined, beendetUm: Date): number | null {
|
||||
if (!begonnenUm) return null;
|
||||
const minuten = Math.round((beendetUm.getTime() - begonnenUm.getTime()) / 60_000);
|
||||
return minuten >= 0 ? minuten : null;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
heutigerTag,
|
||||
monateAddieren,
|
||||
tagesabstand,
|
||||
utcTag,
|
||||
} from "@/lib/stammdaten/fristen";
|
||||
|
||||
/**
|
||||
* Fristenlogik der Wartung.
|
||||
*
|
||||
* Wie in Phase 1: reine Funktionen ohne Datenbankzugriff. Was hier entschieden
|
||||
* wird — wann ein Termin fällig ist, in welchen Topf er im Dashboard fällt, ob
|
||||
* heute erinnert wird — muss ohne laufenden Stack durchtestbar sein.
|
||||
*
|
||||
* Alle Angaben sind Kalendertage in Europe/Berlin, gerechnet auf UTC-Basis.
|
||||
* Die Uhrzeit spielt bei Prüffristen keine Rolle.
|
||||
*/
|
||||
|
||||
/** Die Töpfe des Fristen-Dashboards. */
|
||||
export type Fristfenster =
|
||||
| "ueberfaellig"
|
||||
| "diese_woche"
|
||||
| "dieser_monat"
|
||||
| "naechste_90_tage"
|
||||
| "spaeter";
|
||||
|
||||
export const FRISTFENSTER_BEZEICHNUNG: Record<Fristfenster, string> = {
|
||||
ueberfaellig: "überfällig",
|
||||
diese_woche: "diese Woche",
|
||||
dieser_monat: "diesen Monat",
|
||||
naechste_90_tage: "nächste 90 Tage",
|
||||
spaeter: "später",
|
||||
};
|
||||
|
||||
/** Reihenfolge der Anzeige — dringend zuerst. */
|
||||
export const FRISTFENSTER_REIHENFOLGE: Fristfenster[] = [
|
||||
"ueberfaellig",
|
||||
"diese_woche",
|
||||
"dieser_monat",
|
||||
"naechste_90_tage",
|
||||
"spaeter",
|
||||
];
|
||||
|
||||
/**
|
||||
* Die Grenzen der Töpfe, als UTC-Kalendertage.
|
||||
*
|
||||
* "Diese Woche" heißt bewusst "innerhalb von sieben Tagen" und nicht "bis
|
||||
* Sonntag": am Freitag interessiert den Gerätewart, was bis nächsten Freitag
|
||||
* ansteht, nicht was das Kalenderblatt sagt. Dasselbe gilt für den Monat.
|
||||
*/
|
||||
export function fensterGrenzen(heute: Date = new Date()) {
|
||||
const start = heutigerTag(heute);
|
||||
const plusTage = (tage: number) => new Date(start.getTime() + tage * 86_400_000);
|
||||
|
||||
return {
|
||||
heute: start,
|
||||
inSiebenTagen: plusTage(7),
|
||||
inDreissigTagen: plusTage(30),
|
||||
inNeunzigTagen: plusTage(90),
|
||||
};
|
||||
}
|
||||
|
||||
/** In welchen Topf ein Fälligkeitsdatum fällt. */
|
||||
export function terminFenster(faelligAm: Date, heute: Date = new Date()): Fristfenster {
|
||||
const tage = tagesabstand(heutigerTag(heute), faelligAm);
|
||||
|
||||
if (tage < 0) return "ueberfaellig";
|
||||
if (tage <= 7) return "diese_woche";
|
||||
if (tage <= 30) return "dieser_monat";
|
||||
if (tage <= 90) return "naechste_90_tage";
|
||||
return "spaeter";
|
||||
}
|
||||
|
||||
/**
|
||||
* Nächste Fälligkeit nach einer Prüfung.
|
||||
*
|
||||
* Basis ist das Datum der letzten durchgeführten Prüfung. Gibt es keine, zählt
|
||||
* die Indienststellung des Geräts — eine Wehr, die ein Gerät vor drei Jahren in
|
||||
* Dienst gestellt und nie geprüft hat, soll das im Dashboard sehen und nicht
|
||||
* durch einen fehlenden Termin verdeckt bekommen.
|
||||
*
|
||||
* Ohne beides gibt es keinen Termin. Das ist kein Fehler: bei einem Gerät ohne
|
||||
* jede Datumsangabe wäre jedes errechnete Datum geraten.
|
||||
*/
|
||||
export function naechsteFaelligkeit(angaben: {
|
||||
letztePruefung?: Date | null;
|
||||
indienststellung?: Date | null;
|
||||
intervallMonate: number;
|
||||
}): Date | null {
|
||||
const { letztePruefung, indienststellung, intervallMonate } = angaben;
|
||||
|
||||
if (!intervallMonate || intervallMonate <= 0) return null;
|
||||
|
||||
const basis = letztePruefung ?? indienststellung;
|
||||
if (!basis) return null;
|
||||
|
||||
return monateAddieren(utcTag(basis), intervallMonate);
|
||||
}
|
||||
|
||||
/** Vorwarnstufen der Erinnerung, wie sie im Schema stehen. */
|
||||
export type Erinnerungsstufe = "T_30" | "T_7" | "T_0" | "UEBERFAELLIG";
|
||||
|
||||
/**
|
||||
* Welche Erinnerung heute ansteht — oder keine.
|
||||
*
|
||||
* Der nächtliche Lauf ruft das für jeden offenen Termin auf. Dass eine Stufe
|
||||
* nicht zweimal verschickt wird, entscheidet nicht diese Funktion, sondern der
|
||||
* Erinnerungs-Eintrag in der Datenbank: je Termin und Stufe gibt es ihn nur
|
||||
* einmal.
|
||||
*
|
||||
* Deshalb steht hier "kleiner oder gleich" und nicht "genau": ein Termin, der
|
||||
* erst 20 Tage vor Fälligkeit angelegt wird, soll seine erste Erinnerung sofort
|
||||
* bekommen und nicht durchrutschen, weil der Tag T-30 nie eingetreten ist.
|
||||
*
|
||||
* @param vorwarnzeitTage Vorwarnzeit der Vorlage. Sie ersetzt die 30 Tage der
|
||||
* ersten Stufe; die Stufen T-7 und T-0 bleiben fest.
|
||||
*/
|
||||
export function erinnerungsstufe(
|
||||
faelligAm: Date,
|
||||
vorwarnzeitTage = 30,
|
||||
heute: Date = new Date(),
|
||||
): Erinnerungsstufe | null {
|
||||
const tage = tagesabstand(heutigerTag(heute), faelligAm);
|
||||
|
||||
if (tage < 0) return "UEBERFAELLIG";
|
||||
if (tage === 0) return "T_0";
|
||||
if (tage <= 7) return "T_7";
|
||||
if (tage <= Math.max(vorwarnzeitTage, 7)) return "T_30";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bis wann im Voraus geplant wird.
|
||||
*
|
||||
* Der nächtliche Lauf legt Termine nicht bis in alle Ewigkeit an, sondern nur
|
||||
* innerhalb dieses Horizonts. Sonst stünden im Dashboard Termine für 2031, und
|
||||
* jede Änderung an einer Vorlage müsste hunderte Zukunftstermine nachziehen.
|
||||
*/
|
||||
export function innerhalbPlanungshorizont(
|
||||
faelligAm: Date,
|
||||
horizontTage: number,
|
||||
heute: Date = new Date(),
|
||||
): boolean {
|
||||
return tagesabstand(heutigerTag(heute), faelligAm) <= horizontTage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Laufende Protokollnummer im Format "JJJJ-NNNN", z. B. "2026-0042".
|
||||
*
|
||||
* Die Nummer ist je Wehr und Jahr fortlaufend. Sie steht auf dem PDF und ist
|
||||
* das, was in einer Rückfrage der Unfallkasse genannt wird — deshalb kurz,
|
||||
* sprechbar und ohne Bindestrichwüste.
|
||||
*/
|
||||
export function protokollNummer(jahr: number, laufend: number): string {
|
||||
return `${jahr}-${String(laufend).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
/** Zerlegt eine Protokollnummer wieder. Gibt null zurück, wenn sie nicht passt. */
|
||||
export function protokollNummerLesen(
|
||||
nummer: string | null | undefined,
|
||||
): { jahr: number; laufend: number } | null {
|
||||
if (!nummer) return null;
|
||||
const treffer = /^(\d{4})-(\d{1,6})$/.exec(nummer.trim());
|
||||
if (!treffer) return null;
|
||||
return { jahr: Number(treffer[1]), laufend: Number(treffer[2]) };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { heutigerTag } from "@/lib/stammdaten/fristen";
|
||||
import { innerhalbPlanungshorizont, naechsteFaelligkeit } from "@/lib/wartung/fristen";
|
||||
|
||||
/**
|
||||
* Terminplanung.
|
||||
*
|
||||
* Der nächtliche Lauf beantwortet für jedes Gerät und jede Vorlage dieselbe
|
||||
* Frage: steht ein Termin an, den es noch nicht gibt? Die Antwort steht hier
|
||||
* als reine Funktion — der Job in src/worker holt die Daten und schreibt das
|
||||
* Ergebnis, entscheiden tut er nichts.
|
||||
*
|
||||
* Das ist Absicht: die Planung ist die Stelle, an der eine Wehr merkt, dass ein
|
||||
* Gerät seit vier Jahren nicht geprüft wurde. Solche Logik gehört testbar, ohne
|
||||
* dass eine Datenbank, ein Redis und eine Uhr richtig stehen müssen.
|
||||
*/
|
||||
|
||||
/** Ein Gerät, so weit die Planung es braucht. */
|
||||
export interface PlanungsGeraet {
|
||||
id: string;
|
||||
inventarnummer: string;
|
||||
status: string;
|
||||
indienststellung?: Date | null;
|
||||
/** Datum der letzten abgeschlossenen Prüfung nach dieser Vorlage. */
|
||||
letztePruefungZurVorlage?: Date | null;
|
||||
/** Ersatzweise das Prüfdatum am Gerät selbst (Phase 1, aus dem Import). */
|
||||
letztePruefung?: Date | null;
|
||||
/** Es gibt bereits einen offenen Termin für diese Vorlage. */
|
||||
hatOffenenTermin: boolean;
|
||||
}
|
||||
|
||||
export interface Planungsvorlage {
|
||||
id: string;
|
||||
intervallMonate: number;
|
||||
}
|
||||
|
||||
export interface GeplanterTermin {
|
||||
geraetId: string;
|
||||
vorlageId: string;
|
||||
faelligAm: Date;
|
||||
/** Es gab keinen Anhaltspunkt für ein Datum — der Termin ist sofort fällig. */
|
||||
ohneBasisdatum: boolean;
|
||||
}
|
||||
|
||||
/** Geräte, für die grundsätzlich nicht geplant wird. */
|
||||
const NICHT_PLANEN = new Set(["AUSGESONDERT"]);
|
||||
|
||||
/**
|
||||
* Welche Termine anzulegen sind.
|
||||
*
|
||||
* Vier Fälle, in dieser Reihenfolge:
|
||||
*
|
||||
* 1. Ausgesonderte Geräte fallen raus. Ein ausgesondertes Gerät wird nicht
|
||||
* mehr geprüft, und ein Termin darauf wäre nur Rauschen im Dashboard.
|
||||
* 2. Gibt es schon einen offenen Termin, kommt kein zweiter dazu. Erst wenn
|
||||
* der erledigt ist, wird der nächste geplant.
|
||||
* 3. Fehlt jeder Anhaltspunkt — keine frühere Prüfung, keine Indienststellung
|
||||
* —, wird der Termin auf heute gesetzt. Ein Gerät ohne jedes Datum ist
|
||||
* nicht "in Ordnung", sondern ungeklärt, und genau so soll es auffallen.
|
||||
* 4. Sonst: letzte Prüfung plus Intervall. Liegt das Ergebnis jenseits des
|
||||
* Planungshorizonts, wird noch nicht geplant.
|
||||
*/
|
||||
export function zuPlanendeTermine(
|
||||
vorlage: Planungsvorlage,
|
||||
geraete: PlanungsGeraet[],
|
||||
optionen: { horizontTage: number; heute?: Date },
|
||||
): GeplanterTermin[] {
|
||||
const heute = optionen.heute ?? new Date();
|
||||
const heutigerStichtag = heutigerTag(heute);
|
||||
const geplant: GeplanterTermin[] = [];
|
||||
|
||||
for (const geraet of geraete) {
|
||||
if (NICHT_PLANEN.has(geraet.status)) continue;
|
||||
if (geraet.hatOffenenTermin) continue;
|
||||
|
||||
const basis = geraet.letztePruefungZurVorlage ?? geraet.letztePruefung ?? null;
|
||||
|
||||
const faellig = naechsteFaelligkeit({
|
||||
letztePruefung: basis,
|
||||
indienststellung: geraet.indienststellung,
|
||||
intervallMonate: vorlage.intervallMonate,
|
||||
});
|
||||
|
||||
if (!faellig) {
|
||||
geplant.push({
|
||||
geraetId: geraet.id,
|
||||
vorlageId: vorlage.id,
|
||||
faelligAm: heutigerStichtag,
|
||||
ohneBasisdatum: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!innerhalbPlanungshorizont(faellig, optionen.horizontTage, heute)) continue;
|
||||
|
||||
geplant.push({
|
||||
geraetId: geraet.id,
|
||||
vorlageId: vorlage.id,
|
||||
faelligAm: faellig,
|
||||
ohneBasisdatum: false,
|
||||
});
|
||||
}
|
||||
|
||||
return geplant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Prüftermin, der am Gerät steht.
|
||||
*
|
||||
* Ein Gerät kann mehreren Vorlagen unterliegen — ein Pressluftatmer wird
|
||||
* jährlich sichtgeprüft und alle sechs Jahre grundüberholt. Am Gerät selbst
|
||||
* steht die nächste Frist, und das ist immer die früheste offene.
|
||||
*/
|
||||
export function fruehesteFaelligkeit(termine: Array<{ faelligAm: Date }>): Date | null {
|
||||
if (termine.length === 0) return null;
|
||||
return termine.reduce(
|
||||
(frueheste, termin) => (termin.faelligAm < frueheste ? termin.faelligAm : frueheste),
|
||||
termine[0]!.faelligAm,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import "server-only";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { herunterladen, hochladen, objektSchluessel } from "@/lib/storage";
|
||||
import {
|
||||
alsDataUri,
|
||||
protokollPdf,
|
||||
type ProtokollDaten,
|
||||
type ProtokollPunkt,
|
||||
} from "@/lib/wartung/protokoll";
|
||||
import type { Ergebnis, PunktTyp } from "@/lib/wartung/bewertung";
|
||||
|
||||
/**
|
||||
* Vom Datensatz zum abgelegten PDF.
|
||||
*
|
||||
* Läuft im Hintergrundprozess, nicht in der Anfrage: Gotenberg braucht ein paar
|
||||
* Sekunden, und die Fotos wollen aus MinIO geholt und eingebettet werden. Wer
|
||||
* gerade eine Prüfung abgeschlossen hat, soll dabei nicht warten.
|
||||
*
|
||||
* Der Ablauf ist bewusst dreigeteilt — lesen, rendern, schreiben. Zwischen
|
||||
* Lesen und Schreiben liegen Netzaufrufe zu MinIO und Gotenberg; die dürfen
|
||||
* dauern. Eine offene Datenbanktransaktion dürfte das nicht, sie liefe in ihr
|
||||
* Zeitlimit und gäbe eine Verbindung nicht frei.
|
||||
*
|
||||
* Der Vorgang ist wiederholbar: gibt es das Protokoll schon, passiert nichts.
|
||||
* So darf der Auftrag zweimal laufen, ohne dass zwei PDFs entstehen.
|
||||
*/
|
||||
|
||||
/** Mehr Fotos passen nicht sinnvoll in ein Protokoll — und nicht in den Speicher. */
|
||||
const MAX_FOTOS = 24;
|
||||
|
||||
/** Ein einzelnes Bild größer als das wird nicht eingebettet. */
|
||||
const MAX_BILD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const ROLLE_TEXT: Record<string, string> = {
|
||||
PRUEFER: "Prüfer",
|
||||
GERAETEWART: "Gerätewart",
|
||||
FREMDFIRMA: "Fachfirma",
|
||||
};
|
||||
|
||||
const PRIORITAET_TEXT: Record<string, string> = {
|
||||
NIEDRIG: "niedrig",
|
||||
MITTEL: "mittel",
|
||||
HOCH: "hoch",
|
||||
KRITISCH: "kritisch",
|
||||
};
|
||||
|
||||
/** Antwort in Klartext für die Protokollspalte "Ist". */
|
||||
function istwert(
|
||||
typ: PunktTyp,
|
||||
ergebnis: {
|
||||
jaNein: boolean | null;
|
||||
zahlwert: { toNumber(): number } | null;
|
||||
text: string | null;
|
||||
fotoDateiId: string | null;
|
||||
} | null,
|
||||
einheit?: string | null,
|
||||
): string {
|
||||
if (!ergebnis) return "nicht geprüft";
|
||||
|
||||
switch (typ) {
|
||||
case "JA_NEIN":
|
||||
return ergebnis.jaNein === true ? "ja" : ergebnis.jaNein === false ? "nein" : "—";
|
||||
case "MESSWERT": {
|
||||
if (!ergebnis.zahlwert) return "—";
|
||||
const zahl = ergebnis.zahlwert.toNumber();
|
||||
return `${zahl.toLocaleString("de-DE", { maximumFractionDigits: 3 })}${
|
||||
einheit ? ` ${einheit}` : ""
|
||||
}`;
|
||||
}
|
||||
case "TEXT":
|
||||
return ergebnis.text ?? "—";
|
||||
case "FOTO":
|
||||
return ergebnis.fotoDateiId ? "Foto liegt bei" : "—";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt das Protokoll-PDF und legt es in MinIO ab.
|
||||
*
|
||||
* @returns die Kennung der Datei, oder null wenn es zu dieser Prüfung kein
|
||||
* Protokoll geben kann (unbekannt oder noch Entwurf)
|
||||
*/
|
||||
export async function protokollErzeugen(
|
||||
organisationId: string,
|
||||
pruefungId: string,
|
||||
): Promise<{ dateiId: string; neu: boolean } | null> {
|
||||
// ---------------------------------------------------------------- lesen
|
||||
const geladen = await fuerOrganisation(organisationId, async (db) => {
|
||||
const pruefung = await db.pruefung.findUnique({
|
||||
where: { id: pruefungId },
|
||||
include: {
|
||||
organisation: { select: { name: true, slug: true, strasse: true, plz: true, ort: true } },
|
||||
geraet: {
|
||||
include: {
|
||||
geraetetyp: { select: { name: true } },
|
||||
fahrzeug: { select: { funkrufname: true } },
|
||||
standort: { select: { name: true } },
|
||||
geraeteraum: { select: { bezeichnung: true } },
|
||||
},
|
||||
},
|
||||
vorlage: {
|
||||
select: {
|
||||
name: true,
|
||||
pruefvorschrift: true,
|
||||
intervallMonate: true,
|
||||
punkte: { orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }] },
|
||||
},
|
||||
},
|
||||
pruefer: { select: { mitgliedsnummer: true } },
|
||||
ergebnisse: true,
|
||||
signaturen: { orderBy: { unterzeichnetAm: "asc" } },
|
||||
maengel: { where: { geloeschtAm: null } },
|
||||
ersetzt: { select: { protokollNummer: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!pruefung) return null;
|
||||
if (pruefung.status === "ENTWURF") return null;
|
||||
|
||||
const bildIds = [
|
||||
...pruefung.ergebnisse.map((ergebnis) => ergebnis.fotoDateiId),
|
||||
...pruefung.signaturen.map((signatur) => signatur.bildDateiId),
|
||||
].filter((id): id is string => Boolean(id));
|
||||
|
||||
const dateien = bildIds.length
|
||||
? await db.datei.findMany({
|
||||
where: { id: { in: bildIds } },
|
||||
select: { id: true, objektSchluessel: true, mimeTyp: true, groesseBytes: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
return { pruefung, dateien };
|
||||
});
|
||||
|
||||
if (!geladen) return null;
|
||||
|
||||
const { pruefung, dateien } = geladen;
|
||||
|
||||
if (pruefung.protokollDateiId) {
|
||||
return { dateiId: pruefung.protokollDateiId, neu: false };
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- rendern
|
||||
const bilder = new Map<string, string>();
|
||||
let eingebettet = 0;
|
||||
|
||||
for (const datei of dateien) {
|
||||
if (eingebettet >= MAX_FOTOS) break;
|
||||
if (Number(datei.groesseBytes) > MAX_BILD_BYTES) continue;
|
||||
|
||||
try {
|
||||
const inhalt = await herunterladen(datei.objektSchluessel);
|
||||
bilder.set(datei.id, alsDataUri(inhalt, datei.mimeTyp));
|
||||
eingebettet += 1;
|
||||
} catch (fehler) {
|
||||
// Ein fehlendes Bild darf das Protokoll nicht verhindern — der Nachweis
|
||||
// steckt in den Werten, nicht im Foto.
|
||||
console.error(`[protokoll] Bild ${datei.id} nicht ladbar:`, fehler);
|
||||
}
|
||||
}
|
||||
|
||||
const ergebnisNachPunkt = new Map(
|
||||
pruefung.ergebnisse.map((ergebnis) => [ergebnis.punktId, ergebnis]),
|
||||
);
|
||||
|
||||
const punkte: ProtokollPunkt[] = (pruefung.vorlage?.punkte ?? []).map((punkt) => {
|
||||
const ergebnis = ergebnisNachPunkt.get(punkt.id) ?? null;
|
||||
|
||||
return {
|
||||
text: punkt.text,
|
||||
typ: punkt.typ,
|
||||
pflicht: punkt.pflicht,
|
||||
sollwert: punkt.sollwert,
|
||||
einheit: punkt.einheit,
|
||||
istwert: istwert(punkt.typ, ergebnis, punkt.einheit),
|
||||
inOrdnung: ergebnis?.inOrdnung ?? false,
|
||||
bemerkung: ergebnis?.bemerkung ?? null,
|
||||
bild: ergebnis?.fotoDateiId ? (bilder.get(ergebnis.fotoDateiId) ?? null) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const lagerung = pruefung.geraet.fahrzeug
|
||||
? [pruefung.geraet.fahrzeug.funkrufname, pruefung.geraet.geraeteraum?.bezeichnung]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: (pruefung.geraet.standort?.name ?? null);
|
||||
|
||||
const daten: ProtokollDaten = {
|
||||
organisation: {
|
||||
name: pruefung.organisation.name,
|
||||
strasse: pruefung.organisation.strasse,
|
||||
plz: pruefung.organisation.plz,
|
||||
ort: pruefung.organisation.ort,
|
||||
},
|
||||
geraet: {
|
||||
inventarnummer: pruefung.geraet.inventarnummer,
|
||||
bezeichnung: pruefung.geraet.bezeichnung,
|
||||
hersteller: pruefung.geraet.hersteller,
|
||||
modell: pruefung.geraet.modell,
|
||||
seriennummer: pruefung.geraet.seriennummer,
|
||||
typName: pruefung.geraet.geraetetyp?.name ?? null,
|
||||
lagerung,
|
||||
baujahr: pruefung.geraet.herstellungsdatum,
|
||||
indienststellung: pruefung.geraet.indienststellung,
|
||||
},
|
||||
pruefung: {
|
||||
protokollNummer: pruefung.protokollNummer,
|
||||
durchgefuehrtAm: pruefung.durchgefuehrtAm,
|
||||
art: pruefung.art,
|
||||
ergebnis: (pruefung.ergebnis ?? "BESTANDEN") as Ergebnis,
|
||||
dauerMinuten: pruefung.dauerMinuten,
|
||||
prueferName: pruefung.prueferName,
|
||||
prueferKennung: pruefung.pruefer?.mitgliedsnummer ?? null,
|
||||
externeFirma: pruefung.externeFirma,
|
||||
kosten: pruefung.kosten ? pruefung.kosten.toNumber() : null,
|
||||
naechstePruefungAm: pruefung.naechstePruefungAm,
|
||||
bemerkung: pruefung.bemerkung,
|
||||
ersetztProtokollNummer: pruefung.ersetzt?.protokollNummer ?? null,
|
||||
korrekturGrund: pruefung.korrekturGrund,
|
||||
},
|
||||
vorlage: pruefung.vorlage
|
||||
? {
|
||||
name: pruefung.vorlage.name,
|
||||
pruefvorschrift: pruefung.vorlage.pruefvorschrift,
|
||||
intervallMonate: pruefung.vorlage.intervallMonate,
|
||||
}
|
||||
: null,
|
||||
punkte,
|
||||
maengel: pruefung.maengel.map((mangel) => ({
|
||||
beschreibung: mangel.beschreibung,
|
||||
prioritaet: PRIORITAET_TEXT[mangel.prioritaet] ?? mangel.prioritaet,
|
||||
})),
|
||||
signaturen: pruefung.signaturen.map((signatur) => ({
|
||||
name: signatur.name,
|
||||
rolle: ROLLE_TEXT[signatur.rolle] ?? signatur.rolle,
|
||||
unterzeichnetAm: signatur.unterzeichnetAm,
|
||||
bild: signatur.bildDateiId ? (bilder.get(signatur.bildDateiId) ?? null) : null,
|
||||
})),
|
||||
erzeugtAm: new Date(),
|
||||
};
|
||||
|
||||
const pdf = await protokollPdf(daten);
|
||||
|
||||
const dateiname = `Pruefprotokoll-${pruefung.protokollNummer ?? pruefung.id}.pdf`;
|
||||
const schluessel = objektSchluessel({
|
||||
organisationSlug: pruefung.organisation.slug,
|
||||
entitaet: "Pruefung",
|
||||
entitaetId: pruefung.id,
|
||||
dateiname,
|
||||
});
|
||||
|
||||
const abgelegt = await hochladen(schluessel, pdf, "application/pdf", {
|
||||
pruefung: pruefung.id,
|
||||
protokoll: pruefung.protokollNummer ?? "",
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- schreiben
|
||||
return fuerOrganisation(organisationId, async (db) => {
|
||||
// Zwischenzeitlich könnte ein zweiter Lauf schneller gewesen sein.
|
||||
const stand = await db.pruefung.findUnique({
|
||||
where: { id: pruefung.id },
|
||||
select: { protokollDateiId: true },
|
||||
});
|
||||
|
||||
if (stand?.protokollDateiId) {
|
||||
return { dateiId: stand.protokollDateiId, neu: false };
|
||||
}
|
||||
|
||||
const datei = await db.datei.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bucket: abgelegt.bucket,
|
||||
objektSchluessel: abgelegt.objektSchluessel,
|
||||
dateiname,
|
||||
mimeTyp: "application/pdf",
|
||||
groesseBytes: BigInt(abgelegt.groesseBytes),
|
||||
pruefsumme: abgelegt.pruefsumme,
|
||||
entitaet: "Pruefung",
|
||||
entitaetId: pruefung.id,
|
||||
kategorie: "pruefprotokoll",
|
||||
hochgeladenVonId: pruefung.prueferId,
|
||||
},
|
||||
});
|
||||
|
||||
// Erlaubt trotz Änderungssperre: das Protokollfeld war leer (siehe Trigger
|
||||
// pruefung_unveraenderlich in Migration 20260903000300).
|
||||
await db.pruefung.update({
|
||||
where: { id: pruefung.id },
|
||||
data: { protokollDateiId: datei.id },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId,
|
||||
aktion: AuditAktion.DATEI_HOCHGELADEN,
|
||||
entitaet: "Pruefung",
|
||||
entitaetId: pruefung.id,
|
||||
beschreibung: `Prüfprotokoll ${pruefung.protokollNummer ?? ""} erzeugt (${Math.round(
|
||||
abgelegt.groesseBytes / 1024,
|
||||
)} kB)`,
|
||||
akteurTyp: AkteurTyp.WORKER,
|
||||
akteurName: "Hintergrundprozess",
|
||||
nachher: { dateiId: datei.id, pruefsumme: abgelegt.pruefsumme },
|
||||
});
|
||||
|
||||
return { dateiId: datei.id, neu: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import "server-only";
|
||||
import { htmlZuPdf, html } from "@/lib/pdf";
|
||||
import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
|
||||
import { ERGEBNIS_BEZEICHNUNG, type Ergebnis, type PunktTyp } from "@/lib/wartung/bewertung";
|
||||
|
||||
/**
|
||||
* Das Prüfprotokoll als PDF.
|
||||
*
|
||||
* Es ist der Nachweis, den die Wehr bei einer Prüfung durch die Unfallkasse
|
||||
* vorlegt. Danach richtet sich der Aufbau:
|
||||
*
|
||||
* Kopf wer geprüft hat, welche Wehr, welches Gerät, welche Vorschrift
|
||||
* Mitte jeder Checklistenpunkt mit Soll, Ist und Befund
|
||||
* Fotos eingebettet, nicht verlinkt — ein Link ins Nichts ist kein Beleg
|
||||
* Mängel was aufgefallen ist
|
||||
* Fuß Unterschriften, Prüfvorschrift, Prüfdatum, Seitenzahl
|
||||
*
|
||||
* Alles ist eingebettet: keine externen Schriften, keine nachgeladenen Bilder.
|
||||
* Ein Protokoll muss in zehn Jahren noch genauso aussehen wie heute, auch wenn
|
||||
* es dann keinen Server mehr gibt, der irgendetwas nachliefert.
|
||||
*/
|
||||
|
||||
/** Bild als data-URI, damit es fest im PDF steckt. */
|
||||
export function alsDataUri(inhalt: Buffer, mimeTyp: string): string {
|
||||
return `data:${mimeTyp};base64,${inhalt.toString("base64")}`;
|
||||
}
|
||||
|
||||
export interface ProtokollPunkt {
|
||||
text: string;
|
||||
typ: PunktTyp;
|
||||
pflicht: boolean;
|
||||
sollwert?: string | null;
|
||||
einheit?: string | null;
|
||||
/** Die Antwort, schon für die Anzeige aufbereitet. */
|
||||
istwert: string;
|
||||
inOrdnung: boolean;
|
||||
bemerkung?: string | null;
|
||||
/** Foto zum Punkt, bereits als data-URI. */
|
||||
bild?: string | null;
|
||||
}
|
||||
|
||||
export interface ProtokollSignatur {
|
||||
name: string;
|
||||
rolle: string;
|
||||
unterzeichnetAm: Date;
|
||||
/** PNG des Zeichenfelds als data-URI. */
|
||||
bild?: string | null;
|
||||
}
|
||||
|
||||
export interface ProtokollDaten {
|
||||
organisation: {
|
||||
name: string;
|
||||
strasse?: string | null;
|
||||
plz?: string | null;
|
||||
ort?: string | null;
|
||||
};
|
||||
geraet: {
|
||||
inventarnummer: string;
|
||||
bezeichnung: string;
|
||||
hersteller?: string | null;
|
||||
modell?: string | null;
|
||||
seriennummer?: string | null;
|
||||
typName?: string | null;
|
||||
lagerung?: string | null;
|
||||
baujahr?: Date | null;
|
||||
indienststellung?: Date | null;
|
||||
};
|
||||
pruefung: {
|
||||
protokollNummer?: string | null;
|
||||
durchgefuehrtAm: Date;
|
||||
art: "INTERN" | "EXTERN";
|
||||
ergebnis: Ergebnis;
|
||||
dauerMinuten?: number | null;
|
||||
prueferName?: string | null;
|
||||
/** Mitgliedsnummer oder Kennung des Prüfers — die "Prüfer-ID" im Kopf. */
|
||||
prueferKennung?: string | null;
|
||||
externeFirma?: string | null;
|
||||
kosten?: number | null;
|
||||
naechstePruefungAm?: Date | null;
|
||||
bemerkung?: string | null;
|
||||
/** Bei einer Korrektur: Nummer des ersetzten Protokolls. */
|
||||
ersetztProtokollNummer?: string | null;
|
||||
korrekturGrund?: string | null;
|
||||
};
|
||||
vorlage?: {
|
||||
name: string;
|
||||
pruefvorschrift?: string | null;
|
||||
intervallMonate: number;
|
||||
} | null;
|
||||
punkte: ProtokollPunkt[];
|
||||
maengel: Array<{ beschreibung: string; prioritaet: string }>;
|
||||
signaturen: ProtokollSignatur[];
|
||||
erzeugtAm: Date;
|
||||
}
|
||||
|
||||
const ERGEBNIS_FARBE: Record<Ergebnis, string> = {
|
||||
BESTANDEN: "#15803d",
|
||||
BESTANDEN_MIT_MAENGELN: "#b45309",
|
||||
NICHT_BESTANDEN: "#b91c1c",
|
||||
};
|
||||
|
||||
function zeile(bezeichnung: string, wert: string | null | undefined): string {
|
||||
return `
|
||||
<div class="feld">
|
||||
<div class="beschriftung">${html(bezeichnung)}</div>
|
||||
<div class="wert">${html(wert && wert !== "" ? wert : "—")}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function punktZeile(punkt: ProtokollPunkt, nummer: number): string {
|
||||
const soll = [punkt.sollwert, punkt.einheit].filter(Boolean).join(" ");
|
||||
|
||||
return `
|
||||
<tr class="${punkt.inOrdnung ? "" : "beanstandet"}">
|
||||
<td class="nummer">${nummer}</td>
|
||||
<td>
|
||||
${html(punkt.text)}
|
||||
${punkt.pflicht ? '<span class="pflicht" title="Pflichtpunkt">*</span>' : ""}
|
||||
${punkt.bemerkung ? `<div class="bemerkung">${html(punkt.bemerkung)}</div>` : ""}
|
||||
</td>
|
||||
<td class="soll">${html(soll || "—")}</td>
|
||||
<td class="ist">${html(punkt.istwert || "—")}</td>
|
||||
<td class="befund">${punkt.inOrdnung ? "i. O." : "nicht i. O."}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function fotoBlock(punkte: ProtokollPunkt[]): string {
|
||||
const mitBild = punkte.filter((punkt) => punkt.bild);
|
||||
if (mitBild.length === 0) return "";
|
||||
|
||||
return `
|
||||
<section>
|
||||
<h2>Fotos</h2>
|
||||
<div class="fotos">
|
||||
${mitBild
|
||||
.map(
|
||||
(punkt) => `
|
||||
<figure>
|
||||
<img src="${punkt.bild}" alt="">
|
||||
<figcaption>${html(punkt.text)}</figcaption>
|
||||
</figure>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/** Baut das eigenständige HTML des Protokolls. */
|
||||
export function protokollHtml(daten: ProtokollDaten): string {
|
||||
const { organisation, geraet, pruefung, vorlage, punkte, maengel, signaturen } = daten;
|
||||
|
||||
const anschrift = [
|
||||
organisation.strasse,
|
||||
[organisation.plz, organisation.ort].filter(Boolean).join(" "),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
const geraetetext = [geraet.hersteller, geraet.modell].filter(Boolean).join(" ");
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Prüfprotokoll ${html(pruefung.protokollNummer ?? geraet.inventarnummer)}</title>
|
||||
<style>
|
||||
/* Ränder kommen von Gotenberg (siehe protokollPdf) — hier keine setzen,
|
||||
sonst bleibt kein Platz für Kopf- und Fußzeile. */
|
||||
@page { size: A4; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 9.5pt;
|
||||
color: #111;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
h1 { font-size: 15pt; margin: 0 0 1mm; }
|
||||
h2 {
|
||||
font-size: 10.5pt;
|
||||
margin: 6mm 0 2mm;
|
||||
padding-bottom: 1mm;
|
||||
border-bottom: 0.4mm solid #111;
|
||||
}
|
||||
|
||||
.kopf {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 8mm;
|
||||
padding-bottom: 3mm;
|
||||
border-bottom: 0.6mm solid #111;
|
||||
}
|
||||
|
||||
.kopf .wehr { font-size: 8.5pt; color: #333; }
|
||||
|
||||
.ergebnis {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ergebnis .marke {
|
||||
display: inline-block;
|
||||
padding: 1.5mm 3mm;
|
||||
border: 0.5mm solid currentColor;
|
||||
border-radius: 1.5mm;
|
||||
font-weight: 700;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
.ergebnis .nummer { font-size: 8.5pt; color: #333; margin-top: 1.5mm; }
|
||||
|
||||
.felder {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2mm 6mm;
|
||||
margin-top: 3mm;
|
||||
}
|
||||
|
||||
.feld .beschriftung {
|
||||
font-size: 7.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.feld .wert { font-size: 9.5pt; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 7.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #555;
|
||||
border-bottom: 0.3mm solid #999;
|
||||
padding: 1.5mm 2mm;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1.8mm 2mm;
|
||||
border-bottom: 0.2mm solid #ddd;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr { break-inside: avoid; }
|
||||
|
||||
td.nummer { width: 8mm; color: #666; }
|
||||
td.soll, td.ist { width: 28mm; }
|
||||
td.befund { width: 24mm; font-weight: 600; }
|
||||
|
||||
tr.beanstandet td.befund { color: #b91c1c; }
|
||||
tr.beanstandet td { background: #fdf2f2; }
|
||||
|
||||
.pflicht { color: #b91c1c; font-weight: 700; }
|
||||
.bemerkung { font-size: 8pt; color: #444; margin-top: 0.8mm; }
|
||||
|
||||
.fotos {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4mm;
|
||||
}
|
||||
|
||||
.fotos figure { margin: 0; break-inside: avoid; }
|
||||
.fotos img {
|
||||
width: 100%;
|
||||
height: 45mm;
|
||||
object-fit: cover;
|
||||
border: 0.2mm solid #bbb;
|
||||
}
|
||||
.fotos figcaption { font-size: 7.5pt; color: #444; margin-top: 1mm; }
|
||||
|
||||
.maengel li { margin-bottom: 1.5mm; }
|
||||
|
||||
.unterschriften {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8mm;
|
||||
margin-top: 4mm;
|
||||
}
|
||||
|
||||
.unterschrift { break-inside: avoid; }
|
||||
.unterschrift .bild {
|
||||
height: 22mm;
|
||||
border-bottom: 0.3mm solid #111;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.unterschrift .bild img { max-height: 21mm; max-width: 100%; }
|
||||
.unterschrift .name { font-size: 8.5pt; margin-top: 1.5mm; }
|
||||
.unterschrift .rolle { font-size: 7.5pt; color: #555; }
|
||||
|
||||
.hinweis {
|
||||
margin-top: 6mm;
|
||||
padding: 2.5mm 3mm;
|
||||
background: #f4f4f5;
|
||||
border-left: 1mm solid #999;
|
||||
font-size: 8pt;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.korrektur {
|
||||
margin-top: 3mm;
|
||||
padding: 2.5mm 3mm;
|
||||
border: 0.4mm solid #b45309;
|
||||
color: #7c2d12;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="kopf">
|
||||
<div>
|
||||
<h1>Prüfprotokoll</h1>
|
||||
<div class="wehr">
|
||||
${html(organisation.name)}${anschrift ? ` · ${html(anschrift)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ergebnis" style="color: ${ERGEBNIS_FARBE[pruefung.ergebnis]}">
|
||||
<div class="marke">${html(ERGEBNIS_BEZEICHNUNG[pruefung.ergebnis])}</div>
|
||||
<div class="nummer">
|
||||
${pruefung.protokollNummer ? `Protokoll ${html(pruefung.protokollNummer)}` : "ohne Nummer"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
pruefung.ersetztProtokollNummer
|
||||
? `<div class="korrektur">
|
||||
<strong>Korrekturprotokoll.</strong> Dieses Protokoll ersetzt
|
||||
${html(pruefung.ersetztProtokollNummer)}.
|
||||
${pruefung.korrekturGrund ? ` Grund: ${html(pruefung.korrekturGrund)}` : ""}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<section>
|
||||
<h2>Gerät</h2>
|
||||
<div class="felder">
|
||||
${zeile("Inventarnummer", geraet.inventarnummer)}
|
||||
${zeile("Bezeichnung", geraet.bezeichnung)}
|
||||
${zeile("Gerätetyp", geraet.typName)}
|
||||
${zeile("Hersteller / Modell", geraetetext)}
|
||||
${zeile("Seriennummer", geraet.seriennummer)}
|
||||
${zeile("Lagerung", geraet.lagerung)}
|
||||
${zeile("Baujahr", geraet.baujahr ? alsDatum(geraet.baujahr) : null)}
|
||||
${zeile("Indienststellung", geraet.indienststellung ? alsDatum(geraet.indienststellung) : null)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Prüfung</h2>
|
||||
<div class="felder">
|
||||
${zeile("Prüfdatum", alsDatum(pruefung.durchgefuehrtAm))}
|
||||
${zeile("Prüfer", pruefung.prueferName)}
|
||||
${zeile("Prüfer-Kennung", pruefung.prueferKennung)}
|
||||
${zeile("Art", pruefung.art === "EXTERN" ? "extern durch Fachfirma" : "intern")}
|
||||
${zeile("Fachfirma", pruefung.externeFirma)}
|
||||
${zeile("Kosten", pruefung.kosten !== null && pruefung.kosten !== undefined ? alsEuro(pruefung.kosten) : null)}
|
||||
${zeile("Vorlage", vorlage?.name)}
|
||||
${zeile("Prüfvorschrift", vorlage?.pruefvorschrift)}
|
||||
${zeile("Intervall", vorlage ? `${vorlage.intervallMonate} Monate` : null)}
|
||||
${zeile("Dauer", pruefung.dauerMinuten ? `${pruefung.dauerMinuten} Minuten` : null)}
|
||||
${zeile("Nächste Prüfung", pruefung.naechstePruefungAm ? alsDatum(pruefung.naechstePruefungAm) : null)}
|
||||
</div>
|
||||
${pruefung.bemerkung ? `<p style="margin-top:3mm">${html(pruefung.bemerkung)}</p>` : ""}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Checkliste</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nr.</th>
|
||||
<th>Prüfpunkt</th>
|
||||
<th>Soll</th>
|
||||
<th>Ist</th>
|
||||
<th>Befund</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${punkte.map((punkt, index) => punktZeile(punkt, index + 1)).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="font-size:7.5pt;color:#555;margin-top:2mm">
|
||||
<span class="pflicht">*</span> Pflichtpunkt — davon hängt die
|
||||
Einsatzbereitschaft ab.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
${fotoBlock(punkte)}
|
||||
|
||||
${
|
||||
maengel.length > 0
|
||||
? `<section>
|
||||
<h2>Festgestellte Mängel</h2>
|
||||
<ul class="maengel">
|
||||
${maengel
|
||||
.map(
|
||||
(mangel) =>
|
||||
`<li><strong>${html(mangel.prioritaet)}:</strong> ${html(mangel.beschreibung)}</li>`,
|
||||
)
|
||||
.join("")}
|
||||
</ul>
|
||||
</section>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<section>
|
||||
<h2>Unterschriften</h2>
|
||||
<div class="unterschriften">
|
||||
${signaturen
|
||||
.map(
|
||||
(signatur) => `
|
||||
<div class="unterschrift">
|
||||
<div class="bild">${signatur.bild ? `<img src="${signatur.bild}" alt="">` : ""}</div>
|
||||
<div class="name">${html(signatur.name)}</div>
|
||||
<div class="rolle">${html(signatur.rolle)} · ${html(alsDatumZeit(signatur.unterzeichnetAm))}</div>
|
||||
</div>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="hinweis">
|
||||
Erstellt mit FLORIAN am ${html(alsDatumZeit(daten.erzeugtAm))}. Dieses
|
||||
Protokoll ist nach dem Abschluss unveränderbar gespeichert. Korrekturen
|
||||
erscheinen als eigenes Protokoll mit Verweis auf dieses.
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Kopfzeile des PDF — sie wiederholt sich auf jeder Seite. */
|
||||
export function kopfzeileHtml(daten: ProtokollDaten): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
|
||||
width: 100%; margin: 0; padding: 0 12mm; }
|
||||
.zeile { display: flex; justify-content: space-between; }
|
||||
</style></head><body>
|
||||
<div class="zeile">
|
||||
<span>${html(daten.organisation.name)}</span>
|
||||
<span>Prüfprotokoll ${html(daten.pruefung.protokollNummer ?? "")} · ${html(daten.geraet.inventarnummer)}</span>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
/** Fußzeile mit Prüfvorschrift, Prüfdatum und Seitenzahl. */
|
||||
export function fusszeileHtml(daten: ProtokollDaten): string {
|
||||
const vorschrift = daten.vorlage?.pruefvorschrift ?? "ohne Angabe der Vorschrift";
|
||||
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
body { font-family: Helvetica, Arial, sans-serif; font-size: 7pt; color: #555;
|
||||
width: 100%; margin: 0; padding: 0 12mm; }
|
||||
.zeile { display: flex; justify-content: space-between; }
|
||||
</style></head><body>
|
||||
<div class="zeile">
|
||||
<span>${html(vorschrift)} · geprüft am ${html(alsDatum(daten.pruefung.durchgefuehrtAm))}</span>
|
||||
<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
/** Erzeugt das Protokoll als PDF über Gotenberg. */
|
||||
export async function protokollPdf(daten: ProtokollDaten): Promise<Buffer> {
|
||||
return htmlZuPdf(protokollHtml(daten), {
|
||||
randOben: 0.55,
|
||||
randUnten: 0.55,
|
||||
randLinks: 0.5,
|
||||
randRechts: 0.5,
|
||||
hintergrundDrucken: true,
|
||||
kopfzeileHtml: kopfzeileHtml(daten),
|
||||
fusszeileHtml: fusszeileHtml(daten),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
BetroffenheitStatus,
|
||||
ChecklistenpunktTyp,
|
||||
MangelPrioritaet,
|
||||
MangelStatus,
|
||||
PruefErgebnis,
|
||||
PruefungStatus,
|
||||
RueckrufStatus,
|
||||
WartungsterminStatus,
|
||||
} from "@prisma/client";
|
||||
import type { Fristfenster } from "@/lib/wartung/fristen";
|
||||
|
||||
/**
|
||||
* Klartext zu den Aufzählungen der Wartung.
|
||||
*
|
||||
* Steht bewusst in einer Datei ohne "use client": Server-Komponenten und
|
||||
* Client-Komponenten brauchen dieselben Beschriftungen. Läge das in einem
|
||||
* Client-Modul, bekäme der Server statt der Texte nur Platzhalter — Next.js
|
||||
* ersetzt alle Exporte eines "use client"-Moduls durch Verweise.
|
||||
*/
|
||||
|
||||
export const ERGEBNIS_TEXT: Record<PruefErgebnis, string> = {
|
||||
BESTANDEN: "bestanden",
|
||||
BESTANDEN_MIT_MAENGELN: "mit Mängeln bestanden",
|
||||
NICHT_BESTANDEN: "nicht bestanden",
|
||||
};
|
||||
|
||||
export const PRUEFUNG_STATUS_TEXT: Record<PruefungStatus, string> = {
|
||||
ENTWURF: "Entwurf",
|
||||
ABGESCHLOSSEN: "abgeschlossen",
|
||||
ERSETZT: "ersetzt",
|
||||
};
|
||||
|
||||
export const TERMIN_STATUS_TEXT: Record<WartungsterminStatus, string> = {
|
||||
OFFEN: "offen",
|
||||
ERLEDIGT: "erledigt",
|
||||
ENTFALLEN: "entfallen",
|
||||
};
|
||||
|
||||
export const MANGEL_STATUS_TEXT: Record<MangelStatus, string> = {
|
||||
GEMELDET: "gemeldet",
|
||||
ZUGEWIESEN: "zugewiesen",
|
||||
IN_ARBEIT: "in Arbeit",
|
||||
BEHOBEN: "behoben",
|
||||
VERWORFEN: "verworfen",
|
||||
};
|
||||
|
||||
export const PRIORITAET_TEXT: Record<MangelPrioritaet, string> = {
|
||||
NIEDRIG: "niedrig",
|
||||
MITTEL: "mittel",
|
||||
HOCH: "hoch",
|
||||
KRITISCH: "kritisch",
|
||||
};
|
||||
|
||||
export const RUECKRUF_STATUS_TEXT: Record<RueckrufStatus, string> = {
|
||||
OFFEN: "offen",
|
||||
IN_BEARBEITUNG: "in Bearbeitung",
|
||||
ERLEDIGT: "erledigt",
|
||||
};
|
||||
|
||||
export const BETROFFENHEIT_TEXT: Record<BetroffenheitStatus, string> = {
|
||||
OFFEN: "offen",
|
||||
NICHT_BETROFFEN: "nicht betroffen",
|
||||
MASSNAHME_LAEUFT: "Maßnahme läuft",
|
||||
ERLEDIGT: "erledigt",
|
||||
};
|
||||
|
||||
export const PUNKTTYP_TEXT: Record<ChecklistenpunktTyp, string> = {
|
||||
JA_NEIN: "Ja / Nein",
|
||||
MESSWERT: "Messwert",
|
||||
TEXT: "Text",
|
||||
FOTO: "Foto",
|
||||
};
|
||||
|
||||
export const FENSTER_TEXT: Record<Fristfenster, string> = {
|
||||
ueberfaellig: "überfällig",
|
||||
diese_woche: "diese Woche",
|
||||
dieser_monat: "diesen Monat",
|
||||
naechste_90_tage: "nächste 90 Tage",
|
||||
spaeter: "später",
|
||||
};
|
||||
@@ -9,6 +9,11 @@ import { standortRouter, geraeteraumRouter } from "@/server/routers/stammdaten/o
|
||||
import { geraetetypRouter, qualifikationstypRouter } from "@/server/routers/stammdaten/katalog";
|
||||
import { personRouter, qualifikationRouter } from "@/server/routers/stammdaten/person";
|
||||
import { importRouter } from "@/server/routers/stammdaten/import";
|
||||
import { wartungsvorlageRouter } from "@/server/routers/wartung/vorlage";
|
||||
import { wartungsterminRouter } from "@/server/routers/wartung/termin";
|
||||
import { pruefungRouter } from "@/server/routers/wartung/pruefung";
|
||||
import { mangelRouter } from "@/server/routers/wartung/mangel";
|
||||
import { rueckrufRouter } from "@/server/routers/wartung/rueckruf";
|
||||
|
||||
/**
|
||||
* Wurzel-Router.
|
||||
@@ -139,6 +144,13 @@ export const appRouter = router({
|
||||
qualifikationstyp: qualifikationstypRouter,
|
||||
qualifikation: qualifikationRouter,
|
||||
datenImport: importRouter,
|
||||
|
||||
// Phase 2 — Wartung und Prüfung
|
||||
wartungsvorlage: wartungsvorlageRouter,
|
||||
wartungstermin: wartungsterminRouter,
|
||||
pruefung: pruefungRouter,
|
||||
mangel: mangelRouter,
|
||||
rueckruf: rueckrufRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { MangelPrioritaet, MangelStatus, Prisma } from "@prisma/client";
|
||||
import { router, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import {
|
||||
blaettern,
|
||||
idEingabe,
|
||||
listenAntwort,
|
||||
listenEingabe,
|
||||
ohneGeloeschte,
|
||||
} from "@/server/routers/stammdaten/gemeinsam";
|
||||
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
|
||||
|
||||
/**
|
||||
* Mängel.
|
||||
*
|
||||
* Der Weg ist immer derselbe: melden → zugewiesen → in Arbeit → behoben. Jeder
|
||||
* Schritt landet im Audit-Log, und wer zuständig ist, bekommt eine Nachricht.
|
||||
*
|
||||
* Melden darf jedes Mitglied. Das ist Absicht: der Mangel fällt demjenigen auf,
|
||||
* der das Gerät benutzt, nicht dem Gerätewart am Schreibtisch. Eine Hürde beim
|
||||
* Melden führt dazu, dass gar nicht gemeldet wird — und ein nicht gemeldeter
|
||||
* Riss im Schlauch ist gefährlicher als eine Fehlmeldung, die jemand wieder
|
||||
* verwirft.
|
||||
*
|
||||
* Bearbeiten, zuweisen und beheben bleibt beim Gerätewart.
|
||||
*/
|
||||
|
||||
const SUCHFELDER = ["beschreibung", "behebung"];
|
||||
|
||||
const LISTEN_AUSWAHL = {
|
||||
id: true,
|
||||
beschreibung: true,
|
||||
prioritaet: true,
|
||||
status: true,
|
||||
gemeldetAm: true,
|
||||
behobenAm: true,
|
||||
behebung: true,
|
||||
geraetGesperrt: true,
|
||||
melderName: true,
|
||||
fotoDateiId: true,
|
||||
geloeschtAm: true,
|
||||
geraet: {
|
||||
select: {
|
||||
id: true,
|
||||
inventarnummer: true,
|
||||
bezeichnung: true,
|
||||
status: true,
|
||||
fahrzeug: { select: { id: true, funkrufname: true } },
|
||||
standort: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
melder: { select: { id: true, vorname: true, nachname: true } },
|
||||
zugewiesenAn: { select: { id: true, vorname: true, nachname: true } },
|
||||
behobenVon: { select: { id: true, vorname: true, nachname: true } },
|
||||
pruefung: { select: { id: true, protokollNummer: true, durchgefuehrtAm: true } },
|
||||
} satisfies Prisma.MangelSelect;
|
||||
|
||||
/** Zustände, die als erledigt gelten. */
|
||||
const ERLEDIGT: MangelStatus[] = [MangelStatus.BEHOBEN, MangelStatus.VERWORFEN];
|
||||
|
||||
export const mangelRouter = router({
|
||||
liste: mitRecht("pruefung.lesen")
|
||||
.input(
|
||||
listenEingabe(["gemeldetAm", "prioritaet", "status"] as const).extend({
|
||||
status: z.array(z.nativeEnum(MangelStatus)).optional(),
|
||||
prioritaet: z.array(z.nativeEnum(MangelPrioritaet)).optional(),
|
||||
geraetId: z.string().uuid().optional(),
|
||||
zugewiesenAnId: z.string().uuid().optional(),
|
||||
nurOffene: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const wo: Prisma.MangelWhereInput = {
|
||||
...ohneGeloeschte(input.mitGeloeschten),
|
||||
...(input.status?.length ? { status: { in: input.status } } : {}),
|
||||
...(input.nurOffene ? { status: { notIn: ERLEDIGT } } : {}),
|
||||
...(input.prioritaet?.length ? { prioritaet: { in: input.prioritaet } } : {}),
|
||||
...(input.geraetId ? { geraetId: input.geraetId } : {}),
|
||||
...(input.zugewiesenAnId ? { zugewiesenAnId: input.zugewiesenAnId } : {}),
|
||||
...(input.suche
|
||||
? {
|
||||
OR: [
|
||||
...SUCHFELDER.map((feld) => ({
|
||||
[feld]: { contains: input.suche, mode: "insensitive" as const },
|
||||
})),
|
||||
{ geraet: { inventarnummer: { contains: input.suche, mode: "insensitive" } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [eintraege, gesamt] = await Promise.all([
|
||||
db.mangel.findMany({
|
||||
where: wo,
|
||||
select: LISTEN_AUSWAHL,
|
||||
orderBy:
|
||||
input.sortierung === "prioritaet"
|
||||
? [{ prioritaet: input.richtung }, { gemeldetAm: "desc" }]
|
||||
: input.sortierung === "status"
|
||||
? [{ status: input.richtung }, { gemeldetAm: "desc" }]
|
||||
: { gemeldetAm: input.sortierung ? input.richtung : "desc" },
|
||||
...blaettern(input),
|
||||
}),
|
||||
db.mangel.count({ where: wo }),
|
||||
]);
|
||||
|
||||
return listenAntwort(eintraege, gesamt, input);
|
||||
}),
|
||||
),
|
||||
|
||||
einzeln: mitRecht("pruefung.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const mangel = await db.mangel.findUnique({
|
||||
where: { id: input.id },
|
||||
select: LISTEN_AUSWAHL,
|
||||
});
|
||||
if (!mangel) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
|
||||
}
|
||||
return mangel;
|
||||
}),
|
||||
),
|
||||
|
||||
/** Zahlen für Übersicht und Filterleiste. */
|
||||
kennzahlen: mitRecht("pruefung.lesen").query(async ({ ctx }) =>
|
||||
ctx.db(async (db) => {
|
||||
const nachStatus = await db.mangel.groupBy({
|
||||
by: ["status"],
|
||||
where: { geloeschtAm: null },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const [offen, kritisch] = await Promise.all([
|
||||
db.mangel.count({ where: { geloeschtAm: null, status: { notIn: ERLEDIGT } } }),
|
||||
db.mangel.count({
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
status: { notIn: ERLEDIGT },
|
||||
prioritaet: MangelPrioritaet.KRITISCH,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
offen,
|
||||
kritisch,
|
||||
nachStatus: Object.fromEntries(
|
||||
nachStatus.map((eintrag) => [eintrag.status, eintrag._count._all]),
|
||||
) as Record<MangelStatus, number>,
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Mangel melden.
|
||||
*
|
||||
* Ein kritischer Mangel sperrt das Gerät sofort — ohne Rückfrage. Wer die
|
||||
* höchste Stufe wählt, sagt damit: das Ding darf nicht mehr aufs Fahrzeug.
|
||||
* Dass die Sperre zu diesem Mangel gehört, wird vermerkt; beim Beheben wird
|
||||
* sie wieder aufgehoben.
|
||||
*/
|
||||
melden: mitRecht("pruefung.lesen")
|
||||
.input(
|
||||
z.object({
|
||||
geraetId: z.string().uuid(),
|
||||
beschreibung: z.string().trim().min(1, "Ohne Beschreibung nützt die Meldung nichts.").max(2000),
|
||||
prioritaet: z.nativeEnum(MangelPrioritaet).default(MangelPrioritaet.MITTEL),
|
||||
fotoDateiId: z.string().uuid().nullish(),
|
||||
geraetSperren: z.boolean().optional(),
|
||||
zugewiesenAnId: z.string().uuid().nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const geraet = await db.geraet.findUnique({
|
||||
where: { id: input.geraetId },
|
||||
select: { id: true, inventarnummer: true, bezeichnung: true, status: true },
|
||||
});
|
||||
if (!geraet) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
|
||||
}
|
||||
|
||||
const sperren =
|
||||
input.geraetSperren ?? input.prioritaet === MangelPrioritaet.KRITISCH;
|
||||
const wirdGesperrt = sperren && geraet.status !== "GESPERRT";
|
||||
|
||||
const mangel = await db.mangel.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
geraetId: input.geraetId,
|
||||
beschreibung: input.beschreibung,
|
||||
prioritaet: input.prioritaet,
|
||||
fotoDateiId: input.fotoDateiId ?? null,
|
||||
melderId: ctx.sitzung.personId,
|
||||
melderName: ctx.sitzung.name,
|
||||
zugewiesenAnId: input.zugewiesenAnId ?? null,
|
||||
status: input.zugewiesenAnId ? MangelStatus.ZUGEWIESEN : MangelStatus.GEMELDET,
|
||||
geraetGesperrt: wirdGesperrt,
|
||||
},
|
||||
});
|
||||
|
||||
if (wirdGesperrt) {
|
||||
await db.geraet.update({
|
||||
where: { id: input.geraetId },
|
||||
data: { status: "GESPERRT" },
|
||||
});
|
||||
}
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Mangel",
|
||||
entitaetId: mangel.id,
|
||||
beschreibung: `Mangel an ${geraet.inventarnummer} gemeldet (${input.prioritaet})${
|
||||
wirdGesperrt ? ", Gerät gesperrt" : ""
|
||||
}`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: mangel,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
await beauftrage(AUFTRAG.ERINNERUNG_SENDEN, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
anlass: "mangel_gemeldet",
|
||||
mangelId: mangel.id,
|
||||
}).catch((fehler) => {
|
||||
console.error("[mangel] Benachrichtigung ließ sich nicht einstellen:", fehler);
|
||||
});
|
||||
|
||||
return mangel;
|
||||
}),
|
||||
),
|
||||
|
||||
/** Zuständigkeit setzen. Der Status springt dabei auf "zugewiesen". */
|
||||
zuweisen: mitRecht("pruefung.planen")
|
||||
.input(idEingabe.extend({ zugewiesenAnId: z.string().uuid().nullish() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorher = await db.mangel.findUnique({
|
||||
where: { id: input.id },
|
||||
include: { geraet: { select: { inventarnummer: true } } },
|
||||
});
|
||||
if (!vorher || vorher.geloeschtAm) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
|
||||
}
|
||||
|
||||
const nachher = await db.mangel.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
zugewiesenAnId: input.zugewiesenAnId ?? null,
|
||||
status: input.zugewiesenAnId
|
||||
? vorher.status === MangelStatus.GEMELDET
|
||||
? MangelStatus.ZUGEWIESEN
|
||||
: vorher.status
|
||||
: MangelStatus.GEMELDET,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Mangel",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Mangel an ${vorher.geraet.inventarnummer} zugewiesen`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
await beauftrage(AUFTRAG.ERINNERUNG_SENDEN, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
anlass: "mangel_zugewiesen",
|
||||
mangelId: input.id,
|
||||
}).catch(() => undefined);
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
/** In Arbeit nehmen. */
|
||||
inArbeit: mitRecht("pruefung.durchfuehren")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorher = await db.mangel.findUnique({
|
||||
where: { id: input.id },
|
||||
include: { geraet: { select: { inventarnummer: true } } },
|
||||
});
|
||||
if (!vorher || vorher.geloeschtAm) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
|
||||
}
|
||||
if (ERLEDIGT.includes(vorher.status)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Dieser Mangel ist bereits abgeschlossen.",
|
||||
});
|
||||
}
|
||||
|
||||
const nachher = await db.mangel.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: MangelStatus.IN_ARBEIT,
|
||||
zugewiesenAnId: vorher.zugewiesenAnId ?? ctx.sitzung.personId,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Mangel",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Mangel an ${vorher.geraet.inventarnummer} in Arbeit`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher: { status: vorher.status },
|
||||
nachher: { status: nachher.status },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Mangel beheben.
|
||||
*
|
||||
* Hat dieser Mangel das Gerät gesperrt, wird die Sperre hier aufgehoben —
|
||||
* aber nur, wenn kein anderer offener Mangel das Gerät ebenfalls sperrt.
|
||||
* Sonst gäbe ein behobener Kratzer ein Gerät frei, das wegen eines Risses
|
||||
* gesperrt ist.
|
||||
*/
|
||||
beheben: mitRecht("pruefung.durchfuehren")
|
||||
.input(
|
||||
idEingabe.extend({
|
||||
behebung: z.string().trim().max(2000).nullish(),
|
||||
geraetFreigeben: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorher = await db.mangel.findUnique({
|
||||
where: { id: input.id },
|
||||
include: { geraet: { select: { id: true, inventarnummer: true, status: true } } },
|
||||
});
|
||||
if (!vorher || vorher.geloeschtAm) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
|
||||
}
|
||||
|
||||
const jetzt = new Date();
|
||||
|
||||
const nachher = await db.mangel.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: MangelStatus.BEHOBEN,
|
||||
behobenAm: jetzt,
|
||||
behobenVonId: ctx.sitzung.personId,
|
||||
behebung: input.behebung ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
let freigegeben = false;
|
||||
|
||||
if (vorher.geraetGesperrt && vorher.geraet.status === "GESPERRT") {
|
||||
const weitereSperren = await db.mangel.count({
|
||||
where: {
|
||||
geraetId: vorher.geraetId,
|
||||
id: { not: input.id },
|
||||
geraetGesperrt: true,
|
||||
status: { notIn: ERLEDIGT },
|
||||
geloeschtAm: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (weitereSperren === 0 && (input.geraetFreigeben ?? true)) {
|
||||
await db.geraet.update({
|
||||
where: { id: vorher.geraetId },
|
||||
data: { status: "EINSATZBEREIT" },
|
||||
});
|
||||
freigegeben = true;
|
||||
}
|
||||
}
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Mangel",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Mangel an ${vorher.geraet.inventarnummer} behoben${
|
||||
freigegeben ? ", Gerät wieder einsatzbereit" : ""
|
||||
}`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
kontext: { geraetFreigegeben: freigegeben },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
await beauftrage(AUFTRAG.ERINNERUNG_SENDEN, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
anlass: "mangel_behoben",
|
||||
mangelId: input.id,
|
||||
}).catch(() => undefined);
|
||||
|
||||
return { ...nachher, geraetFreigegeben: freigegeben };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Fehlmeldung. Bleibt stehen, damit nachvollziehbar ist, was gemeldet war. */
|
||||
verwerfen: mitRecht("pruefung.durchfuehren")
|
||||
.input(idEingabe.extend({ grund: z.string().trim().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorher = await db.mangel.findUnique({
|
||||
where: { id: input.id },
|
||||
include: { geraet: { select: { id: true, inventarnummer: true, status: true } } },
|
||||
});
|
||||
if (!vorher || vorher.geloeschtAm) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Mangel gibt es nicht." });
|
||||
}
|
||||
|
||||
const nachher = await db.mangel.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: MangelStatus.VERWORFEN,
|
||||
behebung: input.grund ?? null,
|
||||
behobenAm: new Date(),
|
||||
behobenVonId: ctx.sitzung.personId,
|
||||
},
|
||||
});
|
||||
|
||||
if (vorher.geraetGesperrt && vorher.geraet.status === "GESPERRT") {
|
||||
const weitereSperren = await db.mangel.count({
|
||||
where: {
|
||||
geraetId: vorher.geraetId,
|
||||
id: { not: input.id },
|
||||
geraetGesperrt: true,
|
||||
status: { notIn: ERLEDIGT },
|
||||
geloeschtAm: null,
|
||||
},
|
||||
});
|
||||
if (weitereSperren === 0) {
|
||||
await db.geraet.update({
|
||||
where: { id: vorher.geraetId },
|
||||
data: { status: "EINSATZBEREIT" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Mangel",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Mangelmeldung zu ${vorher.geraet.inventarnummer} verworfen${
|
||||
input.grund ? `: ${input.grund}` : ""
|
||||
}`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,883 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
ChecklistenpunktTyp,
|
||||
MangelPrioritaet,
|
||||
Prisma,
|
||||
PruefErgebnis,
|
||||
PruefungArt,
|
||||
PruefungStatus,
|
||||
SignaturRolle,
|
||||
WartungsterminStatus,
|
||||
} from "@prisma/client";
|
||||
import { router, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { idEingabe } from "@/server/routers/stammdaten/gemeinsam";
|
||||
import { AUFTRAG, beauftrage } from "@/lib/warteschlange";
|
||||
import { downloadLink } from "@/lib/storage";
|
||||
import {
|
||||
dauerInMinuten,
|
||||
ergebnisVorschlag,
|
||||
offenePflichtpunkte,
|
||||
punktInOrdnung,
|
||||
type Antwort,
|
||||
type Punkt,
|
||||
} from "@/lib/wartung/bewertung";
|
||||
import {
|
||||
naechsteFaelligkeit,
|
||||
protokollNummer,
|
||||
protokollNummerLesen,
|
||||
} from "@/lib/wartung/fristen";
|
||||
import { utcTag } from "@/lib/stammdaten/fristen";
|
||||
|
||||
/**
|
||||
* Prüfungen — der Wizard von der ersten Frage bis zum unterschriebenen PDF.
|
||||
*
|
||||
* Der Ablauf in vier Schritten:
|
||||
*
|
||||
* starten legt einen Entwurf an und liefert die Checkliste
|
||||
* ergebnisSpeichern je Punkt, so oft wie nötig — der Wizard speichert laufend
|
||||
* abschliessen prüft auf Vollständigkeit, unterschreibt, schreibt fest
|
||||
* (Hintergrund) das PDF entsteht im Worker und wird nachgetragen
|
||||
*
|
||||
* Warum laufend gespeichert wird: eine Atemschutzprüfung dauert eine
|
||||
* Dreiviertelstunde, und ein Tablet im Gerätehaus verliert schon mal das WLAN.
|
||||
* Wer dann von vorn anfangen muss, macht die Prüfung beim nächsten Mal wieder
|
||||
* auf Papier.
|
||||
*
|
||||
* Nach dem Abschluss ist Schluss: die Datenbank lässt keine Änderung mehr zu
|
||||
* (siehe Migration 20260903000300). Korrekturen laufen über korrigieren() und
|
||||
* erzeugen ein neues Protokoll mit Verweis auf das alte.
|
||||
*/
|
||||
|
||||
/** Was die Detailansicht und der Wizard brauchen. */
|
||||
const PRUEFUNG_EINSCHLUSS = {
|
||||
geraet: {
|
||||
select: {
|
||||
id: true,
|
||||
inventarnummer: true,
|
||||
bezeichnung: true,
|
||||
status: true,
|
||||
seriennummer: true,
|
||||
hersteller: true,
|
||||
modell: true,
|
||||
herstellungsdatum: true,
|
||||
indienststellung: true,
|
||||
geraetetyp: { select: { id: true, name: true } },
|
||||
fahrzeug: { select: { id: true, funkrufname: true } },
|
||||
standort: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
vorlage: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
intervallMonate: true,
|
||||
pruefvorschrift: true,
|
||||
standardDauerMinuten: true,
|
||||
punkte: {
|
||||
where: { geloeschtAm: null },
|
||||
orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
pruefer: { select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true } },
|
||||
ergebnisse: { include: { punkt: true } },
|
||||
signaturen: true,
|
||||
maengel: { where: { geloeschtAm: null } },
|
||||
termin: { select: { id: true, faelligAm: true, status: true } },
|
||||
ersetzt: { select: { id: true, protokollNummer: true, durchgefuehrtAm: true } },
|
||||
} satisfies Prisma.PruefungInclude;
|
||||
|
||||
/** Prisma-Punkt in die Form, mit der lib/wartung/bewertung rechnet. */
|
||||
function alsPunkt(punkt: {
|
||||
id: string;
|
||||
text: string;
|
||||
typ: ChecklistenpunktTyp;
|
||||
pflicht: boolean;
|
||||
minWert: Prisma.Decimal | null;
|
||||
maxWert: Prisma.Decimal | null;
|
||||
}): Punkt {
|
||||
return {
|
||||
id: punkt.id,
|
||||
text: punkt.text,
|
||||
typ: punkt.typ,
|
||||
pflicht: punkt.pflicht,
|
||||
minWert: punkt.minWert ? punkt.minWert.toNumber() : null,
|
||||
maxWert: punkt.maxWert ? punkt.maxWert.toNumber() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function alsAntwort(ergebnis: {
|
||||
punktId: string;
|
||||
jaNein: boolean | null;
|
||||
zahlwert: Prisma.Decimal | null;
|
||||
text: string | null;
|
||||
fotoDateiId: string | null;
|
||||
inOrdnung: boolean;
|
||||
}): Antwort {
|
||||
return {
|
||||
punktId: ergebnis.punktId,
|
||||
jaNein: ergebnis.jaNein,
|
||||
zahlwert: ergebnis.zahlwert ? ergebnis.zahlwert.toNumber() : null,
|
||||
text: ergebnis.text,
|
||||
fotoDateiId: ergebnis.fotoDateiId,
|
||||
inOrdnung: ergebnis.inOrdnung,
|
||||
};
|
||||
}
|
||||
|
||||
export const pruefungRouter = router({
|
||||
/** Liste der Prüfungen, für die Historie am Gerät und die Gesamtübersicht. */
|
||||
liste: mitRecht("pruefung.lesen")
|
||||
.input(
|
||||
z.object({
|
||||
geraetId: z.string().uuid().optional(),
|
||||
prueferId: z.string().uuid().optional(),
|
||||
status: z.array(z.nativeEnum(PruefungStatus)).optional(),
|
||||
ergebnis: z.array(z.nativeEnum(PruefErgebnis)).optional(),
|
||||
vonAm: z.coerce.date().optional(),
|
||||
bisAm: z.coerce.date().optional(),
|
||||
anzahl: z.number().int().min(1).max(200).default(50),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.pruefung.findMany({
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
...(input.geraetId ? { geraetId: input.geraetId } : {}),
|
||||
...(input.prueferId ? { prueferId: input.prueferId } : {}),
|
||||
...(input.status?.length ? { status: { in: input.status } } : {}),
|
||||
...(input.ergebnis?.length ? { ergebnis: { in: input.ergebnis } } : {}),
|
||||
...(input.vonAm || input.bisAm
|
||||
? {
|
||||
durchgefuehrtAm: {
|
||||
...(input.vonAm ? { gte: input.vonAm } : {}),
|
||||
...(input.bisAm ? { lte: input.bisAm } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { durchgefuehrtAm: "desc" },
|
||||
take: input.anzahl,
|
||||
select: {
|
||||
id: true,
|
||||
protokollNummer: true,
|
||||
durchgefuehrtAm: true,
|
||||
art: true,
|
||||
status: true,
|
||||
ergebnis: true,
|
||||
dauerMinuten: true,
|
||||
externeFirma: true,
|
||||
kosten: true,
|
||||
naechstePruefungAm: true,
|
||||
protokollDateiId: true,
|
||||
prueferName: true,
|
||||
pruefer: { select: { id: true, vorname: true, nachname: true } },
|
||||
vorlage: { select: { id: true, name: true } },
|
||||
geraet: { select: { id: true, inventarnummer: true, bezeichnung: true } },
|
||||
_count: { select: { maengel: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
einzeln: mitRecht("pruefung.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const pruefung = await db.pruefung.findUnique({
|
||||
where: { id: input.id },
|
||||
include: PRUEFUNG_EINSCHLUSS,
|
||||
});
|
||||
|
||||
if (!pruefung) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
|
||||
}
|
||||
return pruefung;
|
||||
}),
|
||||
),
|
||||
|
||||
/** Eigene Entwürfe — angefangene Prüfungen, die noch offen liegen. */
|
||||
entwuerfe: mitRecht("pruefung.durchfuehren").query(async ({ ctx }) =>
|
||||
ctx.db((db) =>
|
||||
db.pruefung.findMany({
|
||||
where: {
|
||||
status: PruefungStatus.ENTWURF,
|
||||
geloeschtAm: null,
|
||||
prueferId: ctx.sitzung.personId,
|
||||
},
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
begonnenUm: true,
|
||||
durchgefuehrtAm: true,
|
||||
geraet: { select: { id: true, inventarnummer: true, bezeichnung: true } },
|
||||
vorlage: { select: { id: true, name: true } },
|
||||
_count: { select: { ergebnisse: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
/**
|
||||
* Prüfung beginnen.
|
||||
*
|
||||
* Gibt es zu diesem Gerät und dieser Vorlage bereits einen eigenen Entwurf,
|
||||
* wird der fortgesetzt statt ein zweiter angelegt. Sonst sammeln sich nach
|
||||
* jedem verlorenen WLAN halbe Prüfungen an.
|
||||
*/
|
||||
starten: mitRecht("pruefung.durchfuehren")
|
||||
.input(
|
||||
z.object({
|
||||
geraetId: z.string().uuid(),
|
||||
terminId: z.string().uuid().nullish(),
|
||||
vorlageId: z.string().uuid().nullish(),
|
||||
art: z.nativeEnum(PruefungArt).default(PruefungArt.INTERN),
|
||||
externeFirma: z.string().trim().max(200).nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const geraet = await db.geraet.findUnique({
|
||||
where: { id: input.geraetId },
|
||||
select: { id: true, inventarnummer: true, bezeichnung: true, geraetetypId: true },
|
||||
});
|
||||
if (!geraet) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
|
||||
}
|
||||
|
||||
// Vorlage: ausdrücklich gewählt, sonst über den Termin, sonst die des
|
||||
// Gerätetyps — in dieser Reihenfolge.
|
||||
let vorlageId = input.vorlageId ?? null;
|
||||
|
||||
if (!vorlageId && input.terminId) {
|
||||
const termin = await db.wartungstermin.findUnique({
|
||||
where: { id: input.terminId },
|
||||
select: { vorlageId: true },
|
||||
});
|
||||
vorlageId = termin?.vorlageId ?? null;
|
||||
}
|
||||
|
||||
if (!vorlageId && geraet.geraetetypId) {
|
||||
const passende = await db.wartungsvorlage.findFirst({
|
||||
where: { geraetetypId: geraet.geraetetypId, aktiv: true, geloeschtAm: null },
|
||||
orderBy: { intervallMonate: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
vorlageId = passende?.id ?? null;
|
||||
}
|
||||
|
||||
const laufender = await db.pruefung.findFirst({
|
||||
where: {
|
||||
geraetId: input.geraetId,
|
||||
vorlageId,
|
||||
status: PruefungStatus.ENTWURF,
|
||||
prueferId: ctx.sitzung.personId,
|
||||
geloeschtAm: null,
|
||||
},
|
||||
include: PRUEFUNG_EINSCHLUSS,
|
||||
});
|
||||
|
||||
if (laufender) return { pruefung: laufender, fortgesetzt: true };
|
||||
|
||||
const person = await db.person.findUnique({
|
||||
where: { id: ctx.sitzung.personId },
|
||||
select: { vorname: true, nachname: true },
|
||||
});
|
||||
|
||||
const pruefung = await db.pruefung.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
geraetId: input.geraetId,
|
||||
terminId: input.terminId ?? null,
|
||||
vorlageId,
|
||||
art: input.art,
|
||||
externeFirma: input.externeFirma ?? null,
|
||||
prueferId: ctx.sitzung.personId,
|
||||
prueferName: person ? `${person.vorname} ${person.nachname}` : ctx.sitzung.name,
|
||||
durchgefuehrtAm: utcTag(new Date()),
|
||||
begonnenUm: new Date(),
|
||||
},
|
||||
include: PRUEFUNG_EINSCHLUSS,
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Pruefung",
|
||||
entitaetId: pruefung.id,
|
||||
beschreibung: `Prüfung an ${geraet.inventarnummer} begonnen`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: { geraetId: geraet.id, vorlageId, art: input.art },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { pruefung, fortgesetzt: false };
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Eine Antwort speichern.
|
||||
*
|
||||
* Ob der Punkt in Ordnung ist, rechnet der Server — der Browser schickt nur,
|
||||
* was gemessen oder angekreuzt wurde. Eine ausdrückliche Angabe des Prüfers
|
||||
* hat Vorrang: er steht davor und sieht mehr als die Zahl.
|
||||
*/
|
||||
ergebnisSpeichern: mitRecht("pruefung.durchfuehren")
|
||||
.input(
|
||||
z.object({
|
||||
pruefungId: z.string().uuid(),
|
||||
punktId: z.string().uuid(),
|
||||
jaNein: z.boolean().nullish(),
|
||||
zahlwert: z.number().nullish(),
|
||||
text: z.string().trim().max(1000).nullish(),
|
||||
fotoDateiId: z.string().uuid().nullish(),
|
||||
inOrdnung: z.boolean().nullish(),
|
||||
bemerkung: z.string().trim().max(500).nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const pruefung = await db.pruefung.findUnique({
|
||||
where: { id: input.pruefungId },
|
||||
select: { id: true, status: true, vorlageId: true },
|
||||
});
|
||||
if (!pruefung) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
|
||||
}
|
||||
if (pruefung.status !== PruefungStatus.ENTWURF) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Diese Prüfung ist abgeschlossen und lässt sich nicht mehr ändern. Für eine Korrektur eine neue Prüfung anlegen.",
|
||||
});
|
||||
}
|
||||
|
||||
const punkt = await db.checklistenpunkt.findUnique({ where: { id: input.punktId } });
|
||||
if (!punkt) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
|
||||
}
|
||||
|
||||
const bewertet = punktInOrdnung(alsPunkt(punkt), {
|
||||
punktId: input.punktId,
|
||||
jaNein: input.jaNein,
|
||||
zahlwert: input.zahlwert,
|
||||
text: input.text,
|
||||
fotoDateiId: input.fotoDateiId,
|
||||
inOrdnung: input.inOrdnung,
|
||||
});
|
||||
|
||||
const daten = {
|
||||
jaNein: input.jaNein ?? null,
|
||||
zahlwert: input.zahlwert ?? null,
|
||||
text: input.text ?? null,
|
||||
fotoDateiId: input.fotoDateiId ?? null,
|
||||
bemerkung: input.bemerkung ?? null,
|
||||
inOrdnung: bewertet,
|
||||
};
|
||||
|
||||
return db.pruefergebnis.upsert({
|
||||
where: { pruefungId_punktId: { pruefungId: input.pruefungId, punktId: input.punktId } },
|
||||
create: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
pruefungId: input.pruefungId,
|
||||
punktId: input.punktId,
|
||||
...daten,
|
||||
},
|
||||
update: daten,
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
||||
/** Zwischenstand: was fehlt noch, was ist beanstandet, was käme heraus. */
|
||||
stand: mitRecht("pruefung.durchfuehren")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const pruefung = await db.pruefung.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
vorlage: {
|
||||
select: { punkte: { where: { geloeschtAm: null }, orderBy: { sortierung: "asc" } } },
|
||||
},
|
||||
ergebnisse: true,
|
||||
},
|
||||
});
|
||||
if (!pruefung) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
|
||||
}
|
||||
|
||||
const punkte = (pruefung.vorlage?.punkte ?? []).map(alsPunkt);
|
||||
const antworten = pruefung.ergebnisse.map(alsAntwort);
|
||||
|
||||
return {
|
||||
offenePflichtpunkte: offenePflichtpunkte(punkte, antworten).map((punkt) => ({
|
||||
id: punkt.id,
|
||||
text: punkt.text,
|
||||
})),
|
||||
beantwortet: antworten.length,
|
||||
gesamt: punkte.length,
|
||||
vorschlag: ergebnisVorschlag(punkte, antworten),
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Prüfung abschließen.
|
||||
*
|
||||
* Hier passiert alles, was zusammengehört, in einer Transaktion:
|
||||
* Unterschriften anlegen, Mängel melden, Protokollnummer ziehen, Prüfung
|
||||
* festschreiben, Termin erledigen, nächsten Termin planen und den Status des
|
||||
* Geräts nachziehen. Ginge davon etwas schief, darf nichts davon gelten.
|
||||
*
|
||||
* Die Reihenfolge ist nicht beliebig: Unterschriften und Mängel müssen vor
|
||||
* dem Statuswechsel entstehen, denn danach lässt die Datenbank am Protokoll
|
||||
* nichts mehr zu.
|
||||
*/
|
||||
abschliessen: mitRecht("pruefung.durchfuehren")
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
ergebnis: z.nativeEnum(PruefErgebnis).optional(),
|
||||
durchgefuehrtAm: z.coerce.date().optional(),
|
||||
bemerkung: z.string().trim().max(2000).nullish(),
|
||||
naechstePruefungAm: z.coerce.date().nullish(),
|
||||
externeFirma: z.string().trim().max(200).nullish(),
|
||||
externeProtokollDateiId: z.string().uuid().nullish(),
|
||||
kosten: z.number().nonnegative().max(9_999_999).nullish(),
|
||||
signaturen: z
|
||||
.array(
|
||||
z.object({
|
||||
rolle: z.nativeEnum(SignaturRolle).default(SignaturRolle.PRUEFER),
|
||||
name: z.string().trim().min(1).max(200),
|
||||
personId: z.string().uuid().nullish(),
|
||||
bildDateiId: z.string().uuid().nullish(),
|
||||
}),
|
||||
)
|
||||
.min(1, "Ohne Unterschrift ist ein Protokoll kein Nachweis.")
|
||||
.max(4),
|
||||
maengel: z
|
||||
.array(
|
||||
z.object({
|
||||
beschreibung: z.string().trim().min(1).max(2000),
|
||||
prioritaet: z.nativeEnum(MangelPrioritaet).default(MangelPrioritaet.MITTEL),
|
||||
fotoDateiId: z.string().uuid().nullish(),
|
||||
}),
|
||||
)
|
||||
.max(50)
|
||||
.default([]),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const pruefung = await db.pruefung.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
geraet: {
|
||||
select: {
|
||||
id: true,
|
||||
inventarnummer: true,
|
||||
status: true,
|
||||
indienststellung: true,
|
||||
pruefintervallMonate: true,
|
||||
},
|
||||
},
|
||||
vorlage: {
|
||||
select: {
|
||||
id: true,
|
||||
intervallMonate: true,
|
||||
punkte: { where: { geloeschtAm: null } },
|
||||
},
|
||||
},
|
||||
ergebnisse: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!pruefung) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
|
||||
}
|
||||
if (pruefung.status !== PruefungStatus.ENTWURF) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Diese Prüfung ist bereits abgeschlossen.",
|
||||
});
|
||||
}
|
||||
|
||||
const punkte = (pruefung.vorlage?.punkte ?? []).map(alsPunkt);
|
||||
const antworten = pruefung.ergebnisse.map(alsAntwort);
|
||||
|
||||
const offene = offenePflichtpunkte(punkte, antworten);
|
||||
if (offene.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Es fehlen noch ${offene.length} Pflichtangaben: ${offene
|
||||
.slice(0, 3)
|
||||
.map((punkt) => punkt.text)
|
||||
.join(", ")}${offene.length > 3 ? " …" : ""}`,
|
||||
});
|
||||
}
|
||||
|
||||
const ergebnis = input.ergebnis ?? ergebnisVorschlag(punkte, antworten);
|
||||
const jetzt = new Date();
|
||||
const durchgefuehrtAm = utcTag(input.durchgefuehrtAm ?? pruefung.durchgefuehrtAm);
|
||||
|
||||
// Nächster Termin: was der Prüfer angibt, sonst das Intervall der
|
||||
// Vorlage, sonst das am Gerät hinterlegte.
|
||||
const intervall = pruefung.vorlage?.intervallMonate ?? pruefung.geraet.pruefintervallMonate;
|
||||
const naechste =
|
||||
input.naechstePruefungAm ??
|
||||
(intervall
|
||||
? naechsteFaelligkeit({ letztePruefung: durchgefuehrtAm, intervallMonate: intervall })
|
||||
: null);
|
||||
|
||||
// Unterschriften und Mängel zuerst — danach ist das Protokoll dicht.
|
||||
for (const signatur of input.signaturen) {
|
||||
await db.signatur.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
pruefungId: pruefung.id,
|
||||
rolle: signatur.rolle,
|
||||
name: signatur.name,
|
||||
personId: signatur.personId ?? null,
|
||||
bildDateiId: signatur.bildDateiId ?? null,
|
||||
unterzeichnetAm: jetzt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const mangel of input.maengel) {
|
||||
await db.mangel.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
geraetId: pruefung.geraetId,
|
||||
pruefungId: pruefung.id,
|
||||
melderId: ctx.sitzung.personId,
|
||||
melderName: ctx.sitzung.name,
|
||||
beschreibung: mangel.beschreibung,
|
||||
prioritaet: mangel.prioritaet,
|
||||
fotoDateiId: mangel.fotoDateiId ?? null,
|
||||
geraetGesperrt: ergebnis === PruefErgebnis.NICHT_BESTANDEN,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fortlaufende Protokollnummer je Wehr und Jahr.
|
||||
//
|
||||
// Zwei Prüfer, die gleichzeitig abschließen, würden ohne Sperre
|
||||
// dieselbe Nummer ziehen — die eindeutige Bedingung in der Datenbank
|
||||
// fängt das ab, aber der zweite bekäme einen Datenbankfehler statt
|
||||
// eines Protokolls. Die Sperre gilt nur bis zum Ende dieser
|
||||
// Transaktion und nur für diese Wehr und dieses Jahr.
|
||||
const jahr = durchgefuehrtAm.getUTCFullYear();
|
||||
const sperrschluessel = `pruefung:${ctx.sitzung.organisationId}:${jahr}`;
|
||||
await db.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${sperrschluessel}))`;
|
||||
|
||||
// Höchste vergebene Nummer plus eins — nicht die Anzahl der Zeilen.
|
||||
// Nummern werden nie wiederverwendet, auch wenn eine Prüfung im
|
||||
// Aufbewahrungslauf verschwindet.
|
||||
const letzte = await db.pruefung.findFirst({
|
||||
where: { protokollNummer: { startsWith: `${jahr}-` } },
|
||||
orderBy: { protokollNummer: "desc" },
|
||||
select: { protokollNummer: true },
|
||||
});
|
||||
const nummer = protokollNummer(
|
||||
jahr,
|
||||
(protokollNummerLesen(letzte?.protokollNummer)?.laufend ?? 0) + 1,
|
||||
);
|
||||
|
||||
const abgeschlossen = await db.pruefung.update({
|
||||
where: { id: pruefung.id },
|
||||
data: {
|
||||
status: PruefungStatus.ABGESCHLOSSEN,
|
||||
ergebnis,
|
||||
durchgefuehrtAm,
|
||||
bemerkung: input.bemerkung ?? pruefung.bemerkung,
|
||||
naechstePruefungAm: naechste,
|
||||
externeFirma: input.externeFirma ?? pruefung.externeFirma,
|
||||
externeProtokollDateiId:
|
||||
input.externeProtokollDateiId ?? pruefung.externeProtokollDateiId,
|
||||
kosten: input.kosten ?? pruefung.kosten,
|
||||
dauerMinuten: dauerInMinuten(pruefung.begonnenUm, jetzt),
|
||||
protokollNummer: nummer,
|
||||
abgeschlossenAm: jetzt,
|
||||
},
|
||||
});
|
||||
|
||||
// Eine Korrektur löst das alte Protokoll ab. Es bleibt lesbar.
|
||||
if (pruefung.ersetztPruefungId) {
|
||||
await db.pruefung.update({
|
||||
where: { id: pruefung.ersetztPruefungId },
|
||||
data: { status: PruefungStatus.ERSETZT },
|
||||
});
|
||||
}
|
||||
|
||||
// Termin erledigen und den nächsten planen.
|
||||
if (pruefung.terminId) {
|
||||
await db.wartungstermin.update({
|
||||
where: { id: pruefung.terminId },
|
||||
data: { status: WartungsterminStatus.ERLEDIGT, erledigtAm: jetzt },
|
||||
});
|
||||
}
|
||||
|
||||
if (naechste && pruefung.vorlageId) {
|
||||
await db.wartungstermin.upsert({
|
||||
where: {
|
||||
geraetId_vorlageId_faelligAm: {
|
||||
geraetId: pruefung.geraetId,
|
||||
vorlageId: pruefung.vorlageId,
|
||||
faelligAm: naechste,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
geraetId: pruefung.geraetId,
|
||||
vorlageId: pruefung.vorlageId,
|
||||
faelligAm: naechste,
|
||||
automatischErzeugt: true,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
// Der Status des Geräts folgt dem Ergebnis: nicht bestanden heißt
|
||||
// gesperrt. Bestanden hebt eine frühere Sperre bewusst NICHT auf —
|
||||
// wer gesperrt hat, hatte einen Grund, und der kann außerhalb dieser
|
||||
// Prüfung liegen. Nur "Prüfung fällig" löst sich von selbst.
|
||||
const neuerStatus =
|
||||
ergebnis === PruefErgebnis.NICHT_BESTANDEN
|
||||
? "GESPERRT"
|
||||
: pruefung.geraet.status === "PRUEFUNG_FAELLIG"
|
||||
? "EINSATZBEREIT"
|
||||
: pruefung.geraet.status;
|
||||
|
||||
await db.geraet.update({
|
||||
where: { id: pruefung.geraetId },
|
||||
data: {
|
||||
letztePruefung: durchgefuehrtAm,
|
||||
naechstePruefung: naechste,
|
||||
status: neuerStatus,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Pruefung",
|
||||
entitaetId: pruefung.id,
|
||||
beschreibung: `Prüfung ${nummer} an ${pruefung.geraet.inventarnummer} abgeschlossen: ${ergebnis}`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher: { status: pruefung.status },
|
||||
nachher: {
|
||||
status: PruefungStatus.ABGESCHLOSSEN,
|
||||
ergebnis,
|
||||
protokollNummer: nummer,
|
||||
naechstePruefungAm: naechste,
|
||||
},
|
||||
kontext: {
|
||||
maengel: input.maengel.length,
|
||||
unterschriften: input.signaturen.length,
|
||||
geraetStatus: neuerStatus,
|
||||
},
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
// Das PDF entsteht im Hintergrund: Gotenberg braucht ein paar Sekunden,
|
||||
// und der Prüfer soll währenddessen nicht auf einen Ladebalken sehen.
|
||||
await beauftrage(AUFTRAG.PDF_ERZEUGEN, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
pruefungId: pruefung.id,
|
||||
}).catch((fehler) => {
|
||||
console.error("[pruefung] PDF-Auftrag ließ sich nicht einstellen:", fehler);
|
||||
});
|
||||
|
||||
return abgeschlossen;
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Korrektur eines abgeschlossenen Protokolls.
|
||||
*
|
||||
* Es wird nichts überschrieben. Stattdessen entsteht ein neuer Entwurf mit
|
||||
* denselben Antworten und einem Verweis auf das alte Protokoll. Erst mit
|
||||
* seinem Abschluss gilt das alte als ersetzt.
|
||||
*/
|
||||
korrigieren: mitRecht("pruefung.durchfuehren")
|
||||
.input(idEingabe.extend({ grund: z.string().trim().min(1, "Ein Grund gehört dazu.").max(500) }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const alt = await db.pruefung.findUnique({
|
||||
where: { id: input.id },
|
||||
include: { ergebnisse: true, geraet: { select: { inventarnummer: true } } },
|
||||
});
|
||||
|
||||
if (!alt) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
|
||||
}
|
||||
if (alt.status === PruefungStatus.ENTWURF) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Ein Entwurf lässt sich unmittelbar ändern — dafür braucht es keine Korrektur.",
|
||||
});
|
||||
}
|
||||
|
||||
const person = await db.person.findUnique({
|
||||
where: { id: ctx.sitzung.personId },
|
||||
select: { vorname: true, nachname: true },
|
||||
});
|
||||
|
||||
const neu = await db.pruefung.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
geraetId: alt.geraetId,
|
||||
terminId: alt.terminId,
|
||||
vorlageId: alt.vorlageId,
|
||||
art: alt.art,
|
||||
externeFirma: alt.externeFirma,
|
||||
kosten: alt.kosten,
|
||||
prueferId: ctx.sitzung.personId,
|
||||
prueferName: person ? `${person.vorname} ${person.nachname}` : ctx.sitzung.name,
|
||||
durchgefuehrtAm: alt.durchgefuehrtAm,
|
||||
begonnenUm: new Date(),
|
||||
bemerkung: alt.bemerkung,
|
||||
ersetztPruefungId: alt.id,
|
||||
korrekturGrund: input.grund,
|
||||
},
|
||||
});
|
||||
|
||||
// Die bisherigen Antworten übernehmen, damit nur das Falsche geändert
|
||||
// werden muss und nicht alles noch einmal einzutippen ist.
|
||||
for (const ergebnis of alt.ergebnisse) {
|
||||
await db.pruefergebnis.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
pruefungId: neu.id,
|
||||
punktId: ergebnis.punktId,
|
||||
jaNein: ergebnis.jaNein,
|
||||
zahlwert: ergebnis.zahlwert,
|
||||
text: ergebnis.text,
|
||||
fotoDateiId: ergebnis.fotoDateiId,
|
||||
inOrdnung: ergebnis.inOrdnung,
|
||||
bemerkung: ergebnis.bemerkung,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Pruefung",
|
||||
entitaetId: neu.id,
|
||||
beschreibung: `Korrektur zu Protokoll ${alt.protokollNummer ?? alt.id} an ${alt.geraet.inventarnummer} begonnen: ${input.grund}`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: { ersetztPruefungId: alt.id, korrekturGrund: input.grund },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return neu;
|
||||
}),
|
||||
),
|
||||
|
||||
/** Entwurf verwerfen. Abgeschlossene Prüfungen sind davon ausgenommen. */
|
||||
entwurfVerwerfen: mitRecht("pruefung.durchfuehren")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const pruefung = await db.pruefung.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { id: true, status: true, prueferId: true, geraetId: true },
|
||||
});
|
||||
|
||||
if (!pruefung) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
|
||||
}
|
||||
if (pruefung.status !== PruefungStatus.ENTWURF) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Abgeschlossene Protokolle lassen sich nicht verwerfen.",
|
||||
});
|
||||
}
|
||||
|
||||
await db.pruefung.delete({ where: { id: input.id } });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GELOESCHT,
|
||||
entitaet: "Pruefung",
|
||||
entitaetId: input.id,
|
||||
beschreibung: "Angefangene Prüfung verworfen",
|
||||
sitzung: ctx.sitzung,
|
||||
vorher: pruefung,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { erfolgreich: true };
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Zeitlich begrenzter Link auf das Protokoll-PDF.
|
||||
*
|
||||
* Fehlt das PDF noch, wird die Erzeugung angestoßen und der Aufrufer
|
||||
* gebeten, es gleich noch einmal zu versuchen — statt ihn mit einem Fehler
|
||||
* stehen zu lassen.
|
||||
*/
|
||||
protokollLink: mitRecht("pruefung.lesen")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const pruefung = await db.pruefung.findUnique({
|
||||
where: { id: input.id },
|
||||
select: {
|
||||
id: true,
|
||||
protokollNummer: true,
|
||||
status: true,
|
||||
protokoll: { select: { objektSchluessel: true, dateiname: true } },
|
||||
geraet: { select: { inventarnummer: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!pruefung) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Prüfung gibt es nicht." });
|
||||
}
|
||||
|
||||
if (!pruefung.protokoll) {
|
||||
if (pruefung.status === PruefungStatus.ENTWURF) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Ein Protokoll entsteht erst mit dem Abschluss der Prüfung.",
|
||||
});
|
||||
}
|
||||
|
||||
await beauftrage(AUFTRAG.PDF_ERZEUGEN, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
pruefungId: pruefung.id,
|
||||
});
|
||||
|
||||
return { bereit: false as const, adresse: null };
|
||||
}
|
||||
|
||||
const adresse = await downloadLink(
|
||||
pruefung.protokoll.objektSchluessel,
|
||||
pruefung.protokoll.dateiname,
|
||||
);
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.DATEI_ABGERUFEN,
|
||||
entitaet: "Pruefung",
|
||||
entitaetId: pruefung.id,
|
||||
beschreibung: `Protokoll ${pruefung.protokollNummer ?? ""} abgerufen`,
|
||||
sitzung: ctx.sitzung,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { bereit: true as const, adresse };
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { BetroffenheitStatus, Prisma, RueckrufStatus } from "@prisma/client";
|
||||
import { router, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import {
|
||||
blaettern,
|
||||
idEingabe,
|
||||
listenAntwort,
|
||||
listenEingabe,
|
||||
ohneGeloeschte,
|
||||
sucheUeber,
|
||||
} from "@/server/routers/stammdaten/gemeinsam";
|
||||
|
||||
/**
|
||||
* Rückrufe.
|
||||
*
|
||||
* Ein Hersteller meldet einen Fehler an einer Baureihe. Die Wehr muss dann
|
||||
* genau zwei Dinge tun: herausfinden, ob sie betroffene Stücke hat, und sie aus
|
||||
* dem Verkehr ziehen, bis die Maßnahme des Herstellers durch ist.
|
||||
*
|
||||
* Genau das bildet dieser Router ab:
|
||||
* geraeteSuchen zeigt, welche Geräte auf die Beschreibung passen
|
||||
* zuordnen hält fest, welche betroffen sind, und sperrt sie auf Wunsch
|
||||
* erledigen hebt die Sperre wieder auf, wenn die Maßnahme durch ist
|
||||
*
|
||||
* Die Suche ist bewusst ein Vorschlag und keine automatische Sperre. Eine
|
||||
* Chargenangabe des Herstellers passt selten eins zu eins auf die
|
||||
* Seriennummern im eigenen Bestand — das letzte Wort hat der Gerätewart.
|
||||
*/
|
||||
|
||||
const LISTEN_AUSWAHL = {
|
||||
id: true,
|
||||
hersteller: true,
|
||||
titel: true,
|
||||
referenz: true,
|
||||
veroeffentlichtAm: true,
|
||||
status: true,
|
||||
sperrtGeraete: true,
|
||||
erledigtAm: true,
|
||||
geloeschtAm: true,
|
||||
_count: { select: { typen: true, betroffenheiten: true } },
|
||||
} satisfies Prisma.RueckrufSelect;
|
||||
|
||||
const rueckrufFelder = z.object({
|
||||
hersteller: z.string().trim().min(1, "Der Hersteller fehlt.").max(160),
|
||||
titel: z.string().trim().min(1, "Ein Titel gehört dazu.").max(200),
|
||||
referenz: z.string().trim().max(80).nullish(),
|
||||
beschreibung: z.string().trim().max(4000).nullish(),
|
||||
massnahme: z.string().trim().max(2000).nullish(),
|
||||
veroeffentlichtAm: z.coerce.date().nullish(),
|
||||
quelle: z.string().trim().max(500).nullish(),
|
||||
sperrtGeraete: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const typFelder = z.object({
|
||||
geraetetypId: z.string().uuid().nullish(),
|
||||
modell: z.string().trim().max(160).nullish(),
|
||||
seriennummerVon: z.string().trim().max(120).nullish(),
|
||||
seriennummerBis: z.string().trim().max(120).nullish(),
|
||||
herstellungVon: z.coerce.date().nullish(),
|
||||
herstellungBis: z.coerce.date().nullish(),
|
||||
});
|
||||
|
||||
type TypEingrenzung = z.infer<typeof typFelder>;
|
||||
|
||||
/**
|
||||
* Aus einer Eingrenzung eine Suchbedingung machen.
|
||||
*
|
||||
* Die Seriennummern werden als Text verglichen, nicht als Zahl. Das ist die
|
||||
* einzige Variante, die immer funktioniert: Feuerwehrgerät trägt Nummern wie
|
||||
* "PA-2019-0421" oder "A45X99". Wo Hersteller reine Zahlen vergeben, stimmt der
|
||||
* Textvergleich, solange die Nummern gleich lang sind — und das sind sie in
|
||||
* einer Charge fast immer. Deshalb ist das Ergebnis ein Vorschlag.
|
||||
*/
|
||||
function bedingungAusTyp(typ: TypEingrenzung): Prisma.GeraetWhereInput {
|
||||
return {
|
||||
geloeschtAm: null,
|
||||
...(typ.geraetetypId ? { geraetetypId: typ.geraetetypId } : {}),
|
||||
...(typ.modell ? { modell: { contains: typ.modell, mode: "insensitive" as const } } : {}),
|
||||
...(typ.seriennummerVon || typ.seriennummerBis
|
||||
? {
|
||||
seriennummer: {
|
||||
...(typ.seriennummerVon ? { gte: typ.seriennummerVon } : {}),
|
||||
...(typ.seriennummerBis ? { lte: typ.seriennummerBis } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(typ.herstellungVon || typ.herstellungBis
|
||||
? {
|
||||
herstellungsdatum: {
|
||||
...(typ.herstellungVon ? { gte: typ.herstellungVon } : {}),
|
||||
...(typ.herstellungBis ? { lte: typ.herstellungBis } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const rueckrufRouter = router({
|
||||
liste: mitRecht("pruefung.lesen")
|
||||
.input(
|
||||
listenEingabe(["veroeffentlichtAm", "hersteller", "titel"] as const).extend({
|
||||
status: z.array(z.nativeEnum(RueckrufStatus)).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const wo: Prisma.RueckrufWhereInput = {
|
||||
...ohneGeloeschte(input.mitGeloeschten),
|
||||
...(input.status?.length ? { status: { in: input.status } } : {}),
|
||||
...(sucheUeber(["titel", "hersteller", "referenz", "beschreibung"], input.suche) ?? {}),
|
||||
};
|
||||
|
||||
const [eintraege, gesamt] = await Promise.all([
|
||||
db.rueckruf.findMany({
|
||||
where: wo,
|
||||
select: LISTEN_AUSWAHL,
|
||||
orderBy: input.sortierung
|
||||
? { [input.sortierung]: input.richtung }
|
||||
: [{ status: "asc" }, { veroeffentlichtAm: "desc" }],
|
||||
...blaettern(input),
|
||||
}),
|
||||
db.rueckruf.count({ where: wo }),
|
||||
]);
|
||||
|
||||
return listenAntwort(eintraege, gesamt, input);
|
||||
}),
|
||||
),
|
||||
|
||||
einzeln: mitRecht("pruefung.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const rueckruf = await db.rueckruf.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
typen: { include: { geraetetyp: { select: { id: true, name: true } } } },
|
||||
betroffenheiten: {
|
||||
include: {
|
||||
geraet: {
|
||||
select: {
|
||||
id: true,
|
||||
inventarnummer: true,
|
||||
bezeichnung: true,
|
||||
seriennummer: true,
|
||||
status: true,
|
||||
fahrzeug: { select: { id: true, funkrufname: true } },
|
||||
standort: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { festgestelltAm: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!rueckruf) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
|
||||
}
|
||||
return rueckruf;
|
||||
}),
|
||||
),
|
||||
|
||||
anlegen: mitRecht("pruefung.planen")
|
||||
.input(rueckrufFelder.extend({ typen: z.array(typFelder).max(20).default([]) }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { typen, ...felder } = input;
|
||||
|
||||
const rueckruf = await db.rueckruf.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
...felder,
|
||||
typen: {
|
||||
create: typen.map((typ) => ({
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
...typ,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { typen: true },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Rueckruf",
|
||||
entitaetId: rueckruf.id,
|
||||
beschreibung: `Rückruf "${rueckruf.titel}" von ${rueckruf.hersteller} erfasst`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: rueckruf,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return rueckruf;
|
||||
}),
|
||||
),
|
||||
|
||||
aendern: mitRecht("pruefung.planen")
|
||||
.input(rueckrufFelder.partial().extend({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { id, ...aenderungen } = input;
|
||||
|
||||
const vorher = await db.rueckruf.findUnique({ where: { id } });
|
||||
if (!vorher) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
|
||||
}
|
||||
|
||||
const nachher = await db.rueckruf.update({ where: { id }, data: aenderungen });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Rueckruf",
|
||||
entitaetId: id,
|
||||
beschreibung: `Rückruf "${nachher.titel}" geändert`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
typErgaenzen: mitRecht("pruefung.planen")
|
||||
.input(z.object({ rueckrufId: z.string().uuid() }).and(typFelder))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { rueckrufId, ...felder } = input;
|
||||
|
||||
const typ = await db.rueckrufGeraetetyp.create({
|
||||
data: { organisationId: ctx.sitzung.organisationId, rueckrufId, ...felder },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Rueckruf",
|
||||
entitaetId: rueckrufId,
|
||||
beschreibung: "Betroffene Bauart zum Rückruf ergänzt",
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: typ,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return typ;
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Welche Geräte passen auf die Beschreibung?
|
||||
*
|
||||
* Liefert einen Vorschlag samt Hinweis, welche davon schon zugeordnet sind.
|
||||
* Zugeordnet wird erst im nächsten Schritt und nur, was der Gerätewart
|
||||
* bestätigt.
|
||||
*/
|
||||
geraeteSuchen: mitRecht("pruefung.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const rueckruf = await db.rueckruf.findUnique({
|
||||
where: { id: input.id },
|
||||
include: { typen: true, betroffenheiten: { select: { geraetId: true } } },
|
||||
});
|
||||
|
||||
if (!rueckruf) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
|
||||
}
|
||||
|
||||
if (rueckruf.typen.length === 0) {
|
||||
return { treffer: [], hinweis: "Für diesen Rückruf ist noch keine Bauart hinterlegt." };
|
||||
}
|
||||
|
||||
const bereitsZugeordnet = new Set(
|
||||
rueckruf.betroffenheiten.map((eintrag) => eintrag.geraetId),
|
||||
);
|
||||
|
||||
const treffer = await db.geraet.findMany({
|
||||
where: { OR: rueckruf.typen.map(bedingungAusTyp) },
|
||||
select: {
|
||||
id: true,
|
||||
inventarnummer: true,
|
||||
bezeichnung: true,
|
||||
hersteller: true,
|
||||
modell: true,
|
||||
seriennummer: true,
|
||||
herstellungsdatum: true,
|
||||
status: true,
|
||||
geraetetyp: { select: { id: true, name: true } },
|
||||
fahrzeug: { select: { id: true, funkrufname: true } },
|
||||
standort: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { inventarnummer: "asc" },
|
||||
take: 500,
|
||||
});
|
||||
|
||||
return {
|
||||
treffer: treffer.map((geraet) => ({
|
||||
...geraet,
|
||||
bereitsZugeordnet: bereitsZugeordnet.has(geraet.id),
|
||||
})),
|
||||
hinweis: null,
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
/** Geräte als betroffen festhalten und auf Wunsch sperren. */
|
||||
zuordnen: mitRecht("pruefung.planen")
|
||||
.input(
|
||||
z.object({
|
||||
rueckrufId: z.string().uuid(),
|
||||
geraetIds: z.array(z.string().uuid()).min(1).max(500),
|
||||
sperren: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const rueckruf = await db.rueckruf.findUnique({ where: { id: input.rueckrufId } });
|
||||
if (!rueckruf) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
|
||||
}
|
||||
|
||||
const sperren = input.sperren ?? rueckruf.sperrtGeraete;
|
||||
|
||||
const geraete = await db.geraet.findMany({
|
||||
where: { id: { in: input.geraetIds }, geloeschtAm: null },
|
||||
select: { id: true, inventarnummer: true, status: true },
|
||||
});
|
||||
|
||||
let gesperrt = 0;
|
||||
|
||||
for (const geraet of geraete) {
|
||||
const sperrenJetzt = sperren && geraet.status !== "GESPERRT";
|
||||
|
||||
await db.rueckrufBetroffenheit.upsert({
|
||||
where: {
|
||||
rueckrufId_geraetId: { rueckrufId: input.rueckrufId, geraetId: geraet.id },
|
||||
},
|
||||
create: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
rueckrufId: input.rueckrufId,
|
||||
geraetId: geraet.id,
|
||||
geraetGesperrt: sperrenJetzt,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
if (sperrenJetzt) {
|
||||
await db.geraet.update({ where: { id: geraet.id }, data: { status: "GESPERRT" } });
|
||||
gesperrt += 1;
|
||||
}
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: geraet.id,
|
||||
beschreibung: `Von Rückruf "${rueckruf.titel}" betroffen${
|
||||
sperrenJetzt ? " und gesperrt" : ""
|
||||
}`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher: { status: geraet.status },
|
||||
nachher: { status: sperrenJetzt ? "GESPERRT" : geraet.status },
|
||||
kontext: { rueckrufId: input.rueckrufId },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
if (rueckruf.status === RueckrufStatus.OFFEN && geraete.length > 0) {
|
||||
await db.rueckruf.update({
|
||||
where: { id: input.rueckrufId },
|
||||
data: { status: RueckrufStatus.IN_BEARBEITUNG },
|
||||
});
|
||||
}
|
||||
|
||||
return { zugeordnet: geraete.length, gesperrt };
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Stand je Gerät fortschreiben. Mit "erledigt" oder "nicht betroffen" wird
|
||||
* eine durch den Rückruf gesetzte Sperre wieder aufgehoben — sofern kein
|
||||
* offener Mangel dasselbe Gerät sperrt.
|
||||
*/
|
||||
betroffenheitSetzen: mitRecht("pruefung.planen")
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.nativeEnum(BetroffenheitStatus),
|
||||
bemerkung: z.string().trim().max(1000).nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorher = await db.rueckrufBetroffenheit.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
geraet: { select: { id: true, inventarnummer: true, status: true } },
|
||||
rueckruf: { select: { id: true, titel: true } },
|
||||
},
|
||||
});
|
||||
if (!vorher) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Eintrag gibt es nicht." });
|
||||
}
|
||||
|
||||
const abgeschlossen =
|
||||
input.status === BetroffenheitStatus.ERLEDIGT ||
|
||||
input.status === BetroffenheitStatus.NICHT_BETROFFEN;
|
||||
|
||||
const nachher = await db.rueckrufBetroffenheit.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: input.status,
|
||||
bemerkung: input.bemerkung ?? vorher.bemerkung,
|
||||
erledigtAm: abgeschlossen ? new Date() : null,
|
||||
geraetGesperrt: abgeschlossen ? false : vorher.geraetGesperrt,
|
||||
},
|
||||
});
|
||||
|
||||
let freigegeben = false;
|
||||
|
||||
if (abgeschlossen && vorher.geraetGesperrt && vorher.geraet.status === "GESPERRT") {
|
||||
const offeneMaengel = await db.mangel.count({
|
||||
where: {
|
||||
geraetId: vorher.geraetId,
|
||||
geraetGesperrt: true,
|
||||
status: { notIn: ["BEHOBEN", "VERWORFEN"] },
|
||||
geloeschtAm: null,
|
||||
},
|
||||
});
|
||||
|
||||
const weitereRueckrufe = await db.rueckrufBetroffenheit.count({
|
||||
where: {
|
||||
geraetId: vorher.geraetId,
|
||||
id: { not: input.id },
|
||||
geraetGesperrt: true,
|
||||
status: { in: [BetroffenheitStatus.OFFEN, BetroffenheitStatus.MASSNAHME_LAEUFT] },
|
||||
},
|
||||
});
|
||||
|
||||
if (offeneMaengel === 0 && weitereRueckrufe === 0) {
|
||||
await db.geraet.update({
|
||||
where: { id: vorher.geraetId },
|
||||
data: { status: "EINSATZBEREIT" },
|
||||
});
|
||||
freigegeben = true;
|
||||
}
|
||||
}
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "RueckrufBetroffenheit",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `${vorher.geraet.inventarnummer} im Rückruf "${vorher.rueckruf.titel}": ${input.status}${
|
||||
freigegeben ? ", Gerät wieder einsatzbereit" : ""
|
||||
}`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { ...nachher, geraetFreigegeben: freigegeben };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Rückruf abschließen, wenn jedes betroffene Gerät abgearbeitet ist. */
|
||||
abschliessen: mitRecht("pruefung.planen")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const rueckruf = await db.rueckruf.findUnique({
|
||||
where: { id: input.id },
|
||||
include: { betroffenheiten: { select: { status: true } } },
|
||||
});
|
||||
if (!rueckruf) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Rückruf gibt es nicht." });
|
||||
}
|
||||
|
||||
const offen = rueckruf.betroffenheiten.filter(
|
||||
(eintrag) =>
|
||||
eintrag.status === BetroffenheitStatus.OFFEN ||
|
||||
eintrag.status === BetroffenheitStatus.MASSNAHME_LAEUFT,
|
||||
).length;
|
||||
|
||||
if (offen > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Es sind noch ${offen} Geräte offen. Erst wenn zu jedem ein Ergebnis steht, lässt sich der Rückruf abschließen.`,
|
||||
});
|
||||
}
|
||||
|
||||
const nachher = await db.rueckruf.update({
|
||||
where: { id: input.id },
|
||||
data: { status: RueckrufStatus.ERLEDIGT, erledigtAm: new Date() },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Rueckruf",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Rückruf "${rueckruf.titel}" abgeschlossen`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher: { status: rueckruf.status },
|
||||
nachher: { status: nachher.status },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,513 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { Prisma, WartungsterminStatus } from "@prisma/client";
|
||||
import { router, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import {
|
||||
blaettern,
|
||||
idEingabe,
|
||||
idsEingabe,
|
||||
listenAntwort,
|
||||
listenEingabe,
|
||||
ohneGeloeschte,
|
||||
} from "@/server/routers/stammdaten/gemeinsam";
|
||||
import {
|
||||
FRISTFENSTER_REIHENFOLGE,
|
||||
fensterGrenzen,
|
||||
terminFenster,
|
||||
type Fristfenster,
|
||||
} from "@/lib/wartung/fristen";
|
||||
|
||||
/**
|
||||
* Prüftermine und das Fristen-Dashboard.
|
||||
*
|
||||
* Das Dashboard ist die Ansicht, die der Gerätewart morgens aufmacht. Es
|
||||
* beantwortet genau eine Frage — was ist zu tun — und ordnet die Antwort nach
|
||||
* Dringlichkeit: überfällig, diese Woche, diesen Monat, die nächsten 90 Tage.
|
||||
*
|
||||
* Gruppieren lässt sich das nach Fahrzeug, Standort, Gerätetyp oder Zuständigem.
|
||||
* Das ist kein Zierrat: wer einen Prüftag ansetzt, arbeitet ein Fahrzeug ab,
|
||||
* nicht eine alphabetische Liste.
|
||||
*/
|
||||
|
||||
/** Wonach sich die Termine bündeln lassen. */
|
||||
const GRUPPIERUNG = ["fahrzeug", "standort", "geraetetyp", "zustaendig"] as const;
|
||||
type Gruppierung = (typeof GRUPPIERUNG)[number];
|
||||
|
||||
const LISTEN_AUSWAHL = {
|
||||
id: true,
|
||||
faelligAm: true,
|
||||
status: true,
|
||||
automatischErzeugt: true,
|
||||
bemerkung: true,
|
||||
erledigtAm: true,
|
||||
geloeschtAm: true,
|
||||
geraet: {
|
||||
select: {
|
||||
id: true,
|
||||
inventarnummer: true,
|
||||
bezeichnung: true,
|
||||
status: true,
|
||||
geraetetyp: { select: { id: true, name: true } },
|
||||
standort: { select: { id: true, name: true } },
|
||||
fahrzeug: { select: { id: true, funkrufname: true } },
|
||||
geraeteraum: { select: { id: true, bezeichnung: true } },
|
||||
},
|
||||
},
|
||||
vorlage: {
|
||||
select: { id: true, name: true, intervallMonate: true, pruefvorschrift: true, standardExtern: true },
|
||||
},
|
||||
zugewiesenAn: { select: { id: true, vorname: true, nachname: true } },
|
||||
} satisfies Prisma.WartungsterminSelect;
|
||||
|
||||
type TerminZeile = Prisma.WartungsterminGetPayload<{ select: typeof LISTEN_AUSWAHL }>;
|
||||
|
||||
/** Nach welchem Merkmal eine Zeile gebündelt wird. */
|
||||
function gruppeVon(
|
||||
termin: TerminZeile,
|
||||
nach: Gruppierung,
|
||||
): { schluessel: string; bezeichnung: string } {
|
||||
switch (nach) {
|
||||
case "fahrzeug":
|
||||
return termin.geraet.fahrzeug
|
||||
? { schluessel: termin.geraet.fahrzeug.id, bezeichnung: termin.geraet.fahrzeug.funkrufname }
|
||||
: { schluessel: "ohne", bezeichnung: "nicht verlastet" };
|
||||
case "standort":
|
||||
return termin.geraet.standort
|
||||
? { schluessel: termin.geraet.standort.id, bezeichnung: termin.geraet.standort.name }
|
||||
: { schluessel: "ohne", bezeichnung: "ohne Standort" };
|
||||
case "geraetetyp":
|
||||
return termin.geraet.geraetetyp
|
||||
? { schluessel: termin.geraet.geraetetyp.id, bezeichnung: termin.geraet.geraetetyp.name }
|
||||
: { schluessel: "ohne", bezeichnung: "ohne Gerätetyp" };
|
||||
case "zustaendig":
|
||||
return termin.zugewiesenAn
|
||||
? {
|
||||
schluessel: termin.zugewiesenAn.id,
|
||||
bezeichnung: `${termin.zugewiesenAn.nachname}, ${termin.zugewiesenAn.vorname}`,
|
||||
}
|
||||
: { schluessel: "ohne", bezeichnung: "niemandem zugewiesen" };
|
||||
}
|
||||
}
|
||||
|
||||
const terminFilter = z.object({
|
||||
status: z.array(z.nativeEnum(WartungsterminStatus)).optional(),
|
||||
fenster: z.enum(["ueberfaellig", "diese_woche", "dieser_monat", "naechste_90_tage"]).optional(),
|
||||
fahrzeugId: z.string().uuid().optional(),
|
||||
standortId: z.string().uuid().optional(),
|
||||
geraetetypId: z.string().uuid().optional(),
|
||||
geraetId: z.string().uuid().optional(),
|
||||
vorlageId: z.string().uuid().optional(),
|
||||
zugewiesenAnId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
/** Übersetzt Filter und Zeitfenster in eine Prisma-Bedingung. */
|
||||
function bedingung(
|
||||
filter: z.infer<typeof terminFilter> & { suche?: string; mitGeloeschten?: boolean },
|
||||
heute = new Date(),
|
||||
): Prisma.WartungsterminWhereInput {
|
||||
const grenzen = fensterGrenzen(heute);
|
||||
|
||||
const zeitraum: Prisma.WartungsterminWhereInput =
|
||||
filter.fenster === "ueberfaellig"
|
||||
? { faelligAm: { lt: grenzen.heute } }
|
||||
: filter.fenster === "diese_woche"
|
||||
? { faelligAm: { gte: grenzen.heute, lte: grenzen.inSiebenTagen } }
|
||||
: filter.fenster === "dieser_monat"
|
||||
? { faelligAm: { gte: grenzen.heute, lte: grenzen.inDreissigTagen } }
|
||||
: filter.fenster === "naechste_90_tage"
|
||||
? { faelligAm: { gte: grenzen.heute, lte: grenzen.inNeunzigTagen } }
|
||||
: {};
|
||||
|
||||
return {
|
||||
...ohneGeloeschte(filter.mitGeloeschten ?? false),
|
||||
...(filter.status?.length ? { status: { in: filter.status } } : {}),
|
||||
...(filter.vorlageId ? { vorlageId: filter.vorlageId } : {}),
|
||||
...(filter.geraetId ? { geraetId: filter.geraetId } : {}),
|
||||
...(filter.zugewiesenAnId ? { zugewiesenAnId: filter.zugewiesenAnId } : {}),
|
||||
...zeitraum,
|
||||
geraet: {
|
||||
geloeschtAm: null,
|
||||
...(filter.fahrzeugId ? { fahrzeugId: filter.fahrzeugId } : {}),
|
||||
...(filter.standortId ? { standortId: filter.standortId } : {}),
|
||||
...(filter.geraetetypId ? { geraetetypId: filter.geraetetypId } : {}),
|
||||
...(filter.suche
|
||||
? {
|
||||
OR: [
|
||||
{ inventarnummer: { contains: filter.suche, mode: "insensitive" as const } },
|
||||
{ bezeichnung: { contains: filter.suche, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const wartungsterminRouter = router({
|
||||
/** Terminliste mit allen Filtern des Dashboards. */
|
||||
liste: mitRecht("pruefung.lesen")
|
||||
.input(listenEingabe(["faelligAm", "erstelltAm"] as const).extend(terminFilter.shape))
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const wo = bedingung({
|
||||
...input,
|
||||
status: input.status ?? [WartungsterminStatus.OFFEN],
|
||||
});
|
||||
|
||||
const [eintraege, gesamt] = await Promise.all([
|
||||
db.wartungstermin.findMany({
|
||||
where: wo,
|
||||
select: LISTEN_AUSWAHL,
|
||||
orderBy: { faelligAm: input.richtung },
|
||||
...blaettern(input),
|
||||
}),
|
||||
db.wartungstermin.count({ where: wo }),
|
||||
]);
|
||||
|
||||
return listenAntwort(
|
||||
eintraege.map((termin) => ({
|
||||
...termin,
|
||||
fenster: terminFenster(termin.faelligAm),
|
||||
})),
|
||||
gesamt,
|
||||
input,
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Das Fristen-Dashboard.
|
||||
*
|
||||
* Gezählt wird über die Datenbank, gebündelt in der Anwendung: die
|
||||
* Gruppierung geht über zwei Beziehungen (Termin → Gerät → Fahrzeug), und
|
||||
* das kann Prismas groupBy nicht. Die Menge ist überschaubar — eine Wehr hat
|
||||
* offene Termine in der Größenordnung von hunderten, nicht von Millionen.
|
||||
*/
|
||||
dashboard: mitRecht("pruefung.lesen")
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
gruppierung: z.enum(GRUPPIERUNG).default("fahrzeug"),
|
||||
fahrzeugId: z.string().uuid().optional(),
|
||||
standortId: z.string().uuid().optional(),
|
||||
zugewiesenAnId: z.string().uuid().optional(),
|
||||
})
|
||||
.default({ gruppierung: "fahrzeug" }),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const heute = new Date();
|
||||
const grenzen = fensterGrenzen(heute);
|
||||
|
||||
const grundfilter = {
|
||||
status: [WartungsterminStatus.OFFEN],
|
||||
fahrzeugId: input.fahrzeugId,
|
||||
standortId: input.standortId,
|
||||
zugewiesenAnId: input.zugewiesenAnId,
|
||||
};
|
||||
|
||||
const offene = await db.wartungstermin.findMany({
|
||||
where: bedingung({ ...grundfilter }, heute),
|
||||
select: LISTEN_AUSWAHL,
|
||||
orderBy: { faelligAm: "asc" },
|
||||
// Genug für jede Wehr; wird es je mehr, ist die Liste ohnehin
|
||||
// unbrauchbar und es braucht einen Filter, keine längere Liste.
|
||||
take: 2000,
|
||||
});
|
||||
|
||||
const leer = (): Record<Fristfenster, number> => ({
|
||||
ueberfaellig: 0,
|
||||
diese_woche: 0,
|
||||
dieser_monat: 0,
|
||||
naechste_90_tage: 0,
|
||||
spaeter: 0,
|
||||
});
|
||||
|
||||
const gesamtFenster = leer();
|
||||
const gruppen = new Map<
|
||||
string,
|
||||
{ schluessel: string; bezeichnung: string; fenster: Record<Fristfenster, number>; gesamt: number }
|
||||
>();
|
||||
|
||||
for (const termin of offene) {
|
||||
const fenster = terminFenster(termin.faelligAm, heute);
|
||||
gesamtFenster[fenster] += 1;
|
||||
|
||||
const { schluessel, bezeichnung } = gruppeVon(termin, input.gruppierung);
|
||||
const eintrag = gruppen.get(schluessel) ?? {
|
||||
schluessel,
|
||||
bezeichnung,
|
||||
fenster: leer(),
|
||||
gesamt: 0,
|
||||
};
|
||||
eintrag.fenster[fenster] += 1;
|
||||
eintrag.gesamt += 1;
|
||||
gruppen.set(schluessel, eintrag);
|
||||
}
|
||||
|
||||
// Lebensdauerüberwachung: was läuft ab, was ist abgelaufen. Beides
|
||||
// gehört neben die Prüffristen — für die Wehr ist es dieselbe Frage:
|
||||
// welches Gerät darf nächsten Monat nicht mehr aufs Fahrzeug.
|
||||
const [lebensdauerAbgelaufen, lebensdauerBald, maengelOffen, rueckrufeOffen] =
|
||||
await Promise.all([
|
||||
db.geraet.count({
|
||||
where: { geloeschtAm: null, lebensdauerBis: { lt: grenzen.heute } },
|
||||
}),
|
||||
db.geraet.count({
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
lebensdauerBis: { gte: grenzen.heute, lte: grenzen.inNeunzigTagen },
|
||||
},
|
||||
}),
|
||||
db.mangel.count({
|
||||
where: { geloeschtAm: null, status: { notIn: ["BEHOBEN", "VERWORFEN"] } },
|
||||
}),
|
||||
db.rueckruf.count({ where: { geloeschtAm: null, status: { not: "ERLEDIGT" } } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
fenster: gesamtFenster,
|
||||
reihenfolge: FRISTFENSTER_REIHENFOLGE,
|
||||
gruppierung: input.gruppierung,
|
||||
gruppen: [...gruppen.values()].sort((a, b) => {
|
||||
// Wo es brennt, steht oben.
|
||||
if (b.fenster.ueberfaellig !== a.fenster.ueberfaellig) {
|
||||
return b.fenster.ueberfaellig - a.fenster.ueberfaellig;
|
||||
}
|
||||
return b.gesamt - a.gesamt;
|
||||
}),
|
||||
naechste: offene.slice(0, 25).map((termin) => ({
|
||||
...termin,
|
||||
fenster: terminFenster(termin.faelligAm, heute),
|
||||
})),
|
||||
lebensdauer: { abgelaufen: lebensdauerAbgelaufen, bald: lebensdauerBald },
|
||||
maengelOffen,
|
||||
rueckrufeOffen,
|
||||
stand: heute,
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Geräte, deren Nutzungsdauer abläuft — mit Vorschlag zur Aussonderung.
|
||||
* Helme, Seile und Schutzkleidung sind irgendwann unabhängig vom Zustand
|
||||
* nicht mehr zugelassen; wer das übersieht, hat im Ernstfall ein Problem.
|
||||
*/
|
||||
lebensdauer: mitRecht("pruefung.lesen")
|
||||
.input(
|
||||
z.object({ vorwarnTage: z.number().int().min(0).max(1095).default(180) }).default({
|
||||
vorwarnTage: 180,
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const heute = fensterGrenzen().heute;
|
||||
const grenze = new Date(heute.getTime() + input.vorwarnTage * 86_400_000);
|
||||
|
||||
const geraete = await db.geraet.findMany({
|
||||
where: {
|
||||
geloeschtAm: null,
|
||||
status: { not: "AUSGESONDERT" },
|
||||
lebensdauerBis: { not: null, lte: grenze },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
inventarnummer: true,
|
||||
bezeichnung: true,
|
||||
status: true,
|
||||
lebensdauerBis: true,
|
||||
lebensdauerKategorie: true,
|
||||
herstellungsdatum: true,
|
||||
indienststellung: true,
|
||||
geraetetyp: { select: { id: true, name: true, lebensdauerKategorie: true } },
|
||||
fahrzeug: { select: { id: true, funkrufname: true } },
|
||||
standort: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { lebensdauerBis: "asc" },
|
||||
take: 500,
|
||||
});
|
||||
|
||||
return geraete.map((geraet) => ({
|
||||
...geraet,
|
||||
abgelaufen: Boolean(geraet.lebensdauerBis && geraet.lebensdauerBis < heute),
|
||||
}));
|
||||
}),
|
||||
),
|
||||
|
||||
/** Termin von Hand anlegen — für alles, was keine Vorlage abdeckt. */
|
||||
anlegen: mitRecht("pruefung.planen")
|
||||
.input(
|
||||
z.object({
|
||||
geraetId: z.string().uuid(),
|
||||
vorlageId: z.string().uuid().nullish(),
|
||||
faelligAm: z.coerce.date(),
|
||||
zugewiesenAnId: z.string().uuid().nullish(),
|
||||
bemerkung: z.string().trim().max(1000).nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const geraet = await db.geraet.findUnique({
|
||||
where: { id: input.geraetId },
|
||||
select: { id: true, inventarnummer: true, bezeichnung: true },
|
||||
});
|
||||
if (!geraet) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
|
||||
}
|
||||
|
||||
const termin = await db.wartungstermin.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
geraetId: input.geraetId,
|
||||
vorlageId: input.vorlageId ?? null,
|
||||
faelligAm: input.faelligAm,
|
||||
zugewiesenAnId: input.zugewiesenAnId ?? null,
|
||||
bemerkung: input.bemerkung ?? null,
|
||||
automatischErzeugt: false,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Wartungstermin",
|
||||
entitaetId: termin.id,
|
||||
beschreibung: `Prüftermin für ${geraet.inventarnummer} angelegt`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: termin,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return termin;
|
||||
}),
|
||||
),
|
||||
|
||||
aendern: mitRecht("pruefung.planen")
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
faelligAm: z.coerce.date().optional(),
|
||||
zugewiesenAnId: z.string().uuid().nullish(),
|
||||
bemerkung: z.string().trim().max(1000).nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { id, ...aenderungen } = input;
|
||||
|
||||
const vorher = await db.wartungstermin.findUnique({
|
||||
where: { id },
|
||||
include: { geraet: { select: { inventarnummer: true } } },
|
||||
});
|
||||
if (!vorher) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
|
||||
}
|
||||
if (vorher.status !== WartungsterminStatus.OFFEN) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Nur offene Termine lassen sich ändern.",
|
||||
});
|
||||
}
|
||||
|
||||
const nachher = await db.wartungstermin.update({ where: { id }, data: aenderungen });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Wartungstermin",
|
||||
entitaetId: id,
|
||||
beschreibung: `Prüftermin für ${vorher.geraet.inventarnummer} geändert`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
/** Mehrere Termine auf einmal jemandem zuweisen — der Prüftag am Stück. */
|
||||
zuweisen: mitRecht("pruefung.planen")
|
||||
.input(idsEingabe.extend({ zugewiesenAnId: z.string().uuid().nullish() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const termine = await db.wartungstermin.findMany({
|
||||
where: { id: { in: input.ids }, status: WartungsterminStatus.OFFEN, geloeschtAm: null },
|
||||
select: { id: true, zugewiesenAnId: true, geraet: { select: { inventarnummer: true } } },
|
||||
});
|
||||
|
||||
if (termine.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Keinen dieser Termine gibt es." });
|
||||
}
|
||||
|
||||
await db.wartungstermin.updateMany({
|
||||
where: { id: { in: termine.map((termin) => termin.id) } },
|
||||
data: { zugewiesenAnId: input.zugewiesenAnId ?? null },
|
||||
});
|
||||
|
||||
for (const termin of termine) {
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Wartungstermin",
|
||||
entitaetId: termin.id,
|
||||
beschreibung: `Prüftermin für ${termin.geraet.inventarnummer} zugewiesen`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher: { zugewiesenAnId: termin.zugewiesenAnId },
|
||||
nachher: { zugewiesenAnId: input.zugewiesenAnId ?? null },
|
||||
kontext: { sammelvorgang: true, anzahl: termine.length },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
return { zugewiesen: termine.length };
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Termin für gegenstandslos erklären. Kein Löschen: dass ein Termin bestand
|
||||
* und warum er entfiel, gehört zur Nachweiskette.
|
||||
*/
|
||||
entfallen: mitRecht("pruefung.planen")
|
||||
.input(idEingabe.extend({ grund: z.string().trim().max(500).optional() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorher = await db.wartungstermin.findUnique({
|
||||
where: { id: input.id },
|
||||
include: { geraet: { select: { inventarnummer: true } } },
|
||||
});
|
||||
if (!vorher) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Termin gibt es nicht." });
|
||||
}
|
||||
|
||||
const nachher = await db.wartungstermin.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: WartungsterminStatus.ENTFALLEN,
|
||||
bemerkung: input.grund ?? vorher.bemerkung,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Wartungstermin",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Prüftermin für ${vorher.geraet.inventarnummer} entfällt${
|
||||
input.grund ? `: ${input.grund}` : ""
|
||||
}`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { ChecklistenpunktTyp, Prisma } from "@prisma/client";
|
||||
import { router, mitRecht } from "@/server/trpc";
|
||||
import { AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import {
|
||||
blaettern,
|
||||
idEingabe,
|
||||
listenAntwort,
|
||||
listenEingabe,
|
||||
ohneGeloeschte,
|
||||
sortierenNach,
|
||||
sucheUeber,
|
||||
} from "@/server/routers/stammdaten/gemeinsam";
|
||||
|
||||
/**
|
||||
* Wartungsvorlagen und ihre Checklisten.
|
||||
*
|
||||
* Die Vorlage ist die Regel — "Pressluftatmer, alle 12 Monate, DGUV 49" — und
|
||||
* die Checklistenpunkte sind die Fragen, die dabei zu beantworten sind. Beides
|
||||
* pflegt der Gerätewart, beides ist Voraussetzung für alles Weitere: ohne
|
||||
* Vorlage gibt es keine Termine, ohne Punkte keine Prüfung.
|
||||
*
|
||||
* Gelöscht wird nur weich. Eine Vorlage hängt an abgeschlossenen Protokollen,
|
||||
* und die müssen in zehn Jahren noch erklärbar sein.
|
||||
*/
|
||||
|
||||
const SORTIERFELDER = ["name", "intervallMonate", "erstelltAm"] as const;
|
||||
const SUCHFELDER = ["name", "pruefvorschrift", "beschreibung"];
|
||||
|
||||
const LISTEN_AUSWAHL = {
|
||||
id: true,
|
||||
name: true,
|
||||
intervallMonate: true,
|
||||
vorwarnzeitTage: true,
|
||||
pruefvorschrift: true,
|
||||
standardDauerMinuten: true,
|
||||
standardExtern: true,
|
||||
automatisch: true,
|
||||
aktiv: true,
|
||||
geloeschtAm: true,
|
||||
geraetetyp: { select: { id: true, name: true, kategorie: true } },
|
||||
_count: { select: { punkte: true, termine: true, pruefungen: true } },
|
||||
} satisfies Prisma.WartungsvorlageSelect;
|
||||
|
||||
const vorlageFelder = z.object({
|
||||
name: z.string().trim().min(1, "Die Bezeichnung fehlt.").max(160),
|
||||
geraetetypId: z.string().uuid().nullish(),
|
||||
intervallMonate: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, "Ein Intervall von null Monaten ergibt keinen Termin.")
|
||||
.max(600),
|
||||
vorwarnzeitTage: z.number().int().min(0).max(365).default(30),
|
||||
pruefvorschrift: z.string().trim().max(200).nullish(),
|
||||
standardDauerMinuten: z.number().int().min(1).max(10_000).nullish(),
|
||||
standardExtern: z.boolean().default(false),
|
||||
automatisch: z.boolean().default(true),
|
||||
beschreibung: z.string().trim().max(1000).nullish(),
|
||||
aktiv: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const punktBasis = z.object({
|
||||
text: z.string().trim().min(1, "Der Prüfpunkt braucht einen Text.").max(300),
|
||||
typ: z.nativeEnum(ChecklistenpunktTyp).default(ChecklistenpunktTyp.JA_NEIN),
|
||||
pflicht: z.boolean().default(true),
|
||||
sollwert: z.string().trim().max(120).nullish(),
|
||||
einheit: z.string().trim().max(20).nullish(),
|
||||
minWert: z.number().nullish(),
|
||||
maxWert: z.number().nullish(),
|
||||
hinweis: z.string().trim().max(500).nullish(),
|
||||
sortierung: z.number().int().min(0).max(9999).default(0),
|
||||
});
|
||||
|
||||
/** Ein Bereich, dessen Untergrenze über der Obergrenze liegt, ist kein Bereich. */
|
||||
function bereichSinnvoll(wert: { minWert?: number | null; maxWert?: number | null }): boolean {
|
||||
if (wert.minWert === null || wert.minWert === undefined) return true;
|
||||
if (wert.maxWert === null || wert.maxWert === undefined) return true;
|
||||
return wert.minWert <= wert.maxWert;
|
||||
}
|
||||
|
||||
const BEREICH_FEHLER = {
|
||||
message: "Der kleinste zulässige Wert darf nicht über dem größten liegen.",
|
||||
path: ["minWert"],
|
||||
};
|
||||
|
||||
const punktFelder = punktBasis.refine(bereichSinnvoll, BEREICH_FEHLER);
|
||||
const punktAenderung = punktBasis.partial().refine(bereichSinnvoll, BEREICH_FEHLER);
|
||||
|
||||
export const wartungsvorlageRouter = router({
|
||||
liste: mitRecht("pruefung.lesen")
|
||||
.input(
|
||||
listenEingabe(SORTIERFELDER).extend({
|
||||
geraetetypId: z.string().uuid().optional(),
|
||||
nurAktive: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const wo: Prisma.WartungsvorlageWhereInput = {
|
||||
...ohneGeloeschte(input.mitGeloeschten),
|
||||
...(input.geraetetypId ? { geraetetypId: input.geraetetypId } : {}),
|
||||
...(input.nurAktive ? { aktiv: true } : {}),
|
||||
...(sucheUeber(SUCHFELDER, input.suche) ?? {}),
|
||||
};
|
||||
|
||||
const [eintraege, gesamt] = await Promise.all([
|
||||
db.wartungsvorlage.findMany({
|
||||
where: wo,
|
||||
select: LISTEN_AUSWAHL,
|
||||
orderBy: sortierenNach(input.sortierung, input.richtung, {
|
||||
name: "asc",
|
||||
}) as Prisma.WartungsvorlageOrderByWithRelationInput,
|
||||
...blaettern(input),
|
||||
}),
|
||||
db.wartungsvorlage.count({ where: wo }),
|
||||
]);
|
||||
|
||||
return listenAntwort(eintraege, gesamt, input);
|
||||
}),
|
||||
),
|
||||
|
||||
/** Eine Vorlage mit ihrer vollständigen Checkliste. */
|
||||
einzeln: mitRecht("pruefung.lesen")
|
||||
.input(idEingabe)
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorlage = await db.wartungsvorlage.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
geraetetyp: { select: { id: true, name: true, kategorie: true } },
|
||||
punkte: {
|
||||
where: { geloeschtAm: null },
|
||||
orderBy: [{ sortierung: "asc" }, { erstelltAm: "asc" }],
|
||||
},
|
||||
_count: { select: { termine: true, pruefungen: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!vorlage) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
|
||||
}
|
||||
return vorlage;
|
||||
}),
|
||||
),
|
||||
|
||||
anlegen: mitRecht("pruefung.planen")
|
||||
.input(vorlageFelder)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorlage = await db.wartungsvorlage.create({
|
||||
data: { organisationId: ctx.sitzung.organisationId, ...input },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Wartungsvorlage",
|
||||
entitaetId: vorlage.id,
|
||||
beschreibung: `Wartungsvorlage "${vorlage.name}" angelegt (alle ${vorlage.intervallMonate} Monate)`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: vorlage,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return vorlage;
|
||||
}),
|
||||
),
|
||||
|
||||
aendern: mitRecht("pruefung.planen")
|
||||
.input(vorlageFelder.partial().extend({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { id, ...aenderungen } = input;
|
||||
|
||||
const vorher = await db.wartungsvorlage.findUnique({ where: { id } });
|
||||
if (!vorher) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
|
||||
}
|
||||
|
||||
const nachher = await db.wartungsvorlage.update({ where: { id }, data: aenderungen });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Wartungsvorlage",
|
||||
entitaetId: id,
|
||||
beschreibung: `Wartungsvorlage "${nachher.name}" geändert`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Weiches Löschen. Offene Termine dieser Vorlage werden gleich mit auf
|
||||
* "entfallen" gesetzt — sonst bliebe im Dashboard eine Fälligkeit stehen,
|
||||
* zu der es keine Regel mehr gibt.
|
||||
*/
|
||||
loeschen: mitRecht("pruefung.planen")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorher = await db.wartungsvorlage.findUnique({ where: { id: input.id } });
|
||||
if (!vorher || vorher.geloeschtAm) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
|
||||
}
|
||||
|
||||
const nachher = await db.wartungsvorlage.update({
|
||||
where: { id: input.id },
|
||||
data: { geloeschtAm: new Date(), aktiv: false },
|
||||
});
|
||||
|
||||
const entfallen = await db.wartungstermin.updateMany({
|
||||
where: { vorlageId: input.id, status: "OFFEN", geloeschtAm: null },
|
||||
data: { status: "ENTFALLEN" },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GELOESCHT,
|
||||
entitaet: "Wartungsvorlage",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Wartungsvorlage "${vorher.name}" gelöscht, ${entfallen.count} offene Termine entfallen`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
kontext: { entfalleneTermine: entfallen.count },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { erfolgreich: true, entfalleneTermine: entfallen.count };
|
||||
}),
|
||||
),
|
||||
|
||||
wiederherstellen: mitRecht("pruefung.planen")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorher = await db.wartungsvorlage.findUnique({ where: { id: input.id } });
|
||||
if (!vorher?.geloeschtAm) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Diese Vorlage ist gar nicht gelöscht.",
|
||||
});
|
||||
}
|
||||
|
||||
const nachher = await db.wartungsvorlage.update({
|
||||
where: { id: input.id },
|
||||
data: { geloeschtAm: null, aktiv: true },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.WIEDERHERGESTELLT,
|
||||
entitaet: "Wartungsvorlage",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Wartungsvorlage "${nachher.name}" wiederhergestellt`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checklistenpunkte
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
punktAnlegen: mitRecht("pruefung.planen")
|
||||
.input(z.object({ vorlageId: z.string().uuid() }).and(punktFelder))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { vorlageId, ...felder } = input;
|
||||
|
||||
const vorlage = await db.wartungsvorlage.findUnique({
|
||||
where: { id: vorlageId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!vorlage) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Vorlage gibt es nicht." });
|
||||
}
|
||||
|
||||
// Neue Punkte hängen sich hinten an, wenn keine Sortierung angegeben ist.
|
||||
const sortierung =
|
||||
felder.sortierung > 0
|
||||
? felder.sortierung
|
||||
: ((
|
||||
await db.checklistenpunkt.aggregate({
|
||||
where: { vorlageId, geloeschtAm: null },
|
||||
_max: { sortierung: true },
|
||||
})
|
||||
)._max.sortierung ?? 0) + 10;
|
||||
|
||||
const punkt = await db.checklistenpunkt.create({
|
||||
data: {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
vorlageId,
|
||||
...felder,
|
||||
sortierung,
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Checklistenpunkt",
|
||||
entitaetId: punkt.id,
|
||||
beschreibung: `Prüfpunkt "${punkt.text}" zu "${vorlage.name}" ergänzt`,
|
||||
sitzung: ctx.sitzung,
|
||||
nachher: punkt,
|
||||
kontext: { vorlageId },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return punkt;
|
||||
}),
|
||||
),
|
||||
|
||||
punktAendern: mitRecht("pruefung.planen")
|
||||
.input(z.object({ id: z.string().uuid() }).and(punktAenderung))
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const { id, ...aenderungen } = input;
|
||||
|
||||
const vorher = await db.checklistenpunkt.findUnique({ where: { id } });
|
||||
if (!vorher) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
|
||||
}
|
||||
|
||||
const nachher = await db.checklistenpunkt.update({ where: { id }, data: aenderungen });
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Checklistenpunkt",
|
||||
entitaetId: id,
|
||||
beschreibung: `Prüfpunkt "${nachher.text}" geändert`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
nachher,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return nachher;
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Weiches Löschen — ein Punkt kann in abgeschlossenen Protokollen stehen.
|
||||
* Die Datenbank verhindert das harte Löschen ohnehin (Restrict auf
|
||||
* pruefergebnis), aber die verständliche Fassung steht hier.
|
||||
*/
|
||||
punktLoeschen: mitRecht("pruefung.planen")
|
||||
.input(idEingabe)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
const vorher = await db.checklistenpunkt.findUnique({ where: { id: input.id } });
|
||||
if (!vorher || vorher.geloeschtAm) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Prüfpunkt gibt es nicht." });
|
||||
}
|
||||
|
||||
await db.checklistenpunkt.update({
|
||||
where: { id: input.id },
|
||||
data: { geloeschtAm: new Date() },
|
||||
});
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GELOESCHT,
|
||||
entitaet: "Checklistenpunkt",
|
||||
entitaetId: input.id,
|
||||
beschreibung: `Prüfpunkt "${vorher.text}" entfernt`,
|
||||
sitzung: ctx.sitzung,
|
||||
vorher,
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { erfolgreich: true };
|
||||
}),
|
||||
),
|
||||
|
||||
/** Reihenfolge der Checkliste festlegen — die Prüfung läuft sie so ab. */
|
||||
punkteSortieren: mitRecht("pruefung.planen")
|
||||
.input(
|
||||
z.object({
|
||||
vorlageId: z.string().uuid(),
|
||||
reihenfolge: z.array(z.string().uuid()).min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
ctx.db(async (db) => {
|
||||
for (const [index, punktId] of input.reihenfolge.entries()) {
|
||||
await db.checklistenpunkt.updateMany({
|
||||
where: { id: punktId, vorlageId: input.vorlageId },
|
||||
data: { sortierung: (index + 1) * 10 },
|
||||
});
|
||||
}
|
||||
|
||||
await protokolliereIn(db, {
|
||||
organisationId: ctx.sitzung.organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Wartungsvorlage",
|
||||
entitaetId: input.vorlageId,
|
||||
beschreibung: `Reihenfolge der Checkliste geändert (${input.reihenfolge.length} Punkte)`,
|
||||
sitzung: ctx.sitzung,
|
||||
kontext: { reihenfolge: input.reihenfolge },
|
||||
ipAdresse: ctx.ipAdresse,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { sortiert: input.reihenfolge.length };
|
||||
}),
|
||||
),
|
||||
});
|
||||
+107
-18
@@ -1,38 +1,84 @@
|
||||
// Im Container liefert Compose die Umgebung, lokal die .env. dotenv
|
||||
// überschreibt nichts, was schon gesetzt ist — im Betrieb ändert die Zeile
|
||||
// also nichts, macht aber "npm run worker" auf dem Entwicklungsrechner möglich.
|
||||
import "dotenv/config";
|
||||
import { Worker, type Job } from "bullmq";
|
||||
import { env } from "@/lib/env";
|
||||
import { AUFTRAG, WARTESCHLANGE, redis, type AuftragsDaten } from "@/lib/warteschlange";
|
||||
import { verbindungenTrennen } from "@/lib/db";
|
||||
import {
|
||||
AUFTRAG,
|
||||
WARTESCHLANGE,
|
||||
beauftrageWiederkehrend,
|
||||
redis,
|
||||
type AuftragsDaten,
|
||||
} from "@/lib/warteschlange";
|
||||
import { ohneMandantenfilter, verbindungenTrennen } from "@/lib/db";
|
||||
import { protokollErzeugen } from "@/lib/wartung/protokoll-erzeugen";
|
||||
import { erinnerungenSenden, mangelBenachrichtigung, terminePlanen } from "@/worker/wartung";
|
||||
|
||||
/**
|
||||
* Hintergrundprozess.
|
||||
*
|
||||
* Läuft als eigener Container neben der Anwendung und arbeitet die
|
||||
* Warteschlange ab. In Phase 0 sind die Auftragsarten angelegt, aber fachlich
|
||||
* noch leer — die Fristenberechnung kommt in Phase 2, die Erinnerungen in
|
||||
* Phase 7. Was hier schon steht, ist das Gerüst: Aufnahme, Protokollierung,
|
||||
* Fehlerbehandlung und ein sauberes Herunterfahren.
|
||||
* Warteschlange ab. Seit Phase 2 steckt die eigentliche Arbeit drin:
|
||||
* Terminplanung, Erinnerungen und die Erzeugung der Prüfprotokolle. Die
|
||||
* Aufbewahrung folgt in Phase 8.
|
||||
*
|
||||
* Beim Start trägt sich der Prozess die wiederkehrenden Läufe je Wehr ein.
|
||||
* Das ist bewusst hier und nicht in der Anwendung: der Worker ist der Einzige,
|
||||
* der sie auch ausführt, und ein Zeitplan ohne Ausführenden ist nutzlos.
|
||||
*/
|
||||
|
||||
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
|
||||
console.info(`[worker] ${nachricht}`, ...weiteres);
|
||||
}
|
||||
|
||||
async function fristenPruefen(auftrag: Job<AuftragsDaten>): Promise<{ geprueft: number }> {
|
||||
protokoll(`Fristen prüfen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 2: Geräte mit überschrittener Frist auf PRUEFUNG_FAELLIG setzen.
|
||||
return { geprueft: 0 };
|
||||
async function fristenPruefen(auftrag: Job<AuftragsDaten>) {
|
||||
const { organisationId } = auftrag.data;
|
||||
const ergebnis = await terminePlanen(organisationId);
|
||||
|
||||
protokoll(
|
||||
`Fristen geprüft für ${organisationId}: ${ergebnis.angelegt} Termine geplant, ` +
|
||||
`${ergebnis.statusGeaendert} Geräte im Status geändert`,
|
||||
);
|
||||
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
async function erinnerungSenden(auftrag: Job<AuftragsDaten>): Promise<{ versendet: number }> {
|
||||
protokoll(`Erinnerungen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 7: Mail und Telegram über lib/integrations.
|
||||
return { versendet: 0 };
|
||||
async function erinnerungSenden(auftrag: Job<AuftragsDaten>) {
|
||||
const { organisationId, anlass, mangelId } = auftrag.data as AuftragsDaten & {
|
||||
anlass?: string;
|
||||
mangelId?: string;
|
||||
};
|
||||
|
||||
// Mängel melden sich sofort, Termine im nächtlichen Sammellauf.
|
||||
if (anlass?.startsWith("mangel") && mangelId) {
|
||||
return mangelBenachrichtigung(organisationId, mangelId, anlass);
|
||||
}
|
||||
|
||||
return erinnerungenSenden(organisationId);
|
||||
}
|
||||
|
||||
async function pdfErzeugen(auftrag: Job<AuftragsDaten>): Promise<{ erzeugt: boolean }> {
|
||||
protokoll(`PDF erzeugen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 2: HTML an Gotenberg, Ergebnis nach MinIO über lib/storage.
|
||||
return { erzeugt: false };
|
||||
async function pdfErzeugen(auftrag: Job<AuftragsDaten>) {
|
||||
const { organisationId, pruefungId } = auftrag.data as AuftragsDaten & { pruefungId?: string };
|
||||
|
||||
if (!pruefungId) {
|
||||
throw new Error("PDF-Auftrag ohne Angabe, wozu — pruefungId fehlt.");
|
||||
}
|
||||
|
||||
const ergebnis = await protokollErzeugen(organisationId, pruefungId);
|
||||
|
||||
if (!ergebnis) {
|
||||
protokoll(`Kein Protokoll für Prüfung ${pruefungId} — unbekannt oder noch Entwurf.`);
|
||||
return { erzeugt: false };
|
||||
}
|
||||
|
||||
protokoll(
|
||||
ergebnis.neu
|
||||
? `Protokoll für Prüfung ${pruefungId} erzeugt.`
|
||||
: `Protokoll für Prüfung ${pruefungId} lag bereits vor.`,
|
||||
);
|
||||
|
||||
return { erzeugt: ergebnis.neu, dateiId: ergebnis.dateiId };
|
||||
}
|
||||
|
||||
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
|
||||
@@ -49,6 +95,36 @@ const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknow
|
||||
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
|
||||
};
|
||||
|
||||
/**
|
||||
* Trägt die nächtlichen Läufe für jede aktive Wehr ein.
|
||||
*
|
||||
* BullMQ hält wiederkehrende Aufträge in Redis; ein zweiter Eintrag mit
|
||||
* derselben Auftragskennung ersetzt den ersten. Der Aufruf ist deshalb bei
|
||||
* jedem Start wiederholbar, und eine neu angelegte Wehr kommt beim nächsten
|
||||
* Neustart des Workers dazu.
|
||||
*/
|
||||
async function zeitplaeneEinrichten(): Promise<number> {
|
||||
const organisationen = await ohneMandantenfilter(
|
||||
"Wartungsjobs für alle Wehren einplanen",
|
||||
(db) => db.organisation.findMany({ where: { aktiv: true, geloeschtAm: null }, select: { id: true, kurzname: true } }),
|
||||
);
|
||||
|
||||
for (const organisation of organisationen) {
|
||||
await beauftrageWiederkehrend(
|
||||
AUFTRAG.FRISTEN_PRUEFEN,
|
||||
{ organisationId: organisation.id },
|
||||
env.WARTUNG_NACHTLAUF,
|
||||
);
|
||||
await beauftrageWiederkehrend(
|
||||
AUFTRAG.ERINNERUNG_SENDEN,
|
||||
{ organisationId: organisation.id },
|
||||
env.WARTUNG_ERINNERUNG_LAUF,
|
||||
);
|
||||
}
|
||||
|
||||
return organisationen.length;
|
||||
}
|
||||
|
||||
const worker = new Worker<AuftragsDaten>(
|
||||
WARTESCHLANGE,
|
||||
async (auftrag) => {
|
||||
@@ -78,6 +154,19 @@ worker.on("ready", () => {
|
||||
protokoll(
|
||||
`bereit — Warteschlange "${WARTESCHLANGE}", ${env.WORKER_CONCURRENCY} parallele Aufträge`,
|
||||
);
|
||||
|
||||
void zeitplaeneEinrichten()
|
||||
.then((anzahl) =>
|
||||
protokoll(
|
||||
`Zeitpläne eingerichtet für ${anzahl} Wehren ` +
|
||||
`(Planung ${env.WARTUNG_NACHTLAUF}, Erinnerungen ${env.WARTUNG_ERINNERUNG_LAUF})`,
|
||||
),
|
||||
)
|
||||
.catch((fehler) => {
|
||||
// Ein fehlgeschlagener Zeitplan darf den Worker nicht aufhalten: die
|
||||
// Aufträge aus der Anwendung sollen weiter abgearbeitet werden.
|
||||
console.error("[worker] Zeitpläne ließen sich nicht einrichten:", fehler);
|
||||
});
|
||||
});
|
||||
|
||||
/** Laufende Aufträge zu Ende bringen, bevor der Prozess endet. */
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { AkteurTyp, AuditAktion, protokolliereIn } from "@/lib/audit";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { env } from "@/lib/env";
|
||||
import { mail } from "@/lib/integrations/mail";
|
||||
import { abgeleiteterGeraetStatus } from "@/lib/stammdaten/fristen";
|
||||
import { erinnerungsstufe, type Erinnerungsstufe } from "@/lib/wartung/fristen";
|
||||
import { fruehesteFaelligkeit, zuPlanendeTermine, type PlanungsGeraet } from "@/lib/wartung/planung";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Die nächtliche Arbeit hinter dem Prüfmanagement.
|
||||
*
|
||||
* Zwei Läufe, die getrennt bleiben, weil sie Verschiedenes tun und
|
||||
* verschieden schiefgehen können:
|
||||
*
|
||||
* terminePlanen legt fällige Termine an und zieht den Gerätestatus nach
|
||||
* erinnerungenSenden verschickt Vorwarnungen zu offenen Terminen
|
||||
*
|
||||
* Beide sind wiederholbar. Wer sie zweimal laufen lässt, bekommt keine
|
||||
* doppelten Termine (Eindeutigkeit über Gerät, Vorlage und Tag) und keine
|
||||
* doppelten Mails (ein Erinnerungseintrag je Termin und Stufe).
|
||||
*/
|
||||
|
||||
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
|
||||
console.info(`[wartung] ${nachricht}`, ...weiteres);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Termine planen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface PlanungsErgebnis {
|
||||
angelegt: number;
|
||||
statusGeaendert: number;
|
||||
vorlagen: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plant Termine für eine Wehr und zieht danach den Status der Geräte nach.
|
||||
*
|
||||
* Der Status ist der eigentliche Zweck des Laufs: ein Gerät, dessen Prüfung
|
||||
* heute fällig geworden ist, soll morgen früh nicht mehr als einsatzbereit in
|
||||
* der Liste stehen. Von Hand gesetzte Zustände — gesperrt, ausgesondert, in
|
||||
* Reparatur — bleiben unangetastet; darüber hat jemand bewusst entschieden.
|
||||
*/
|
||||
export async function terminePlanen(
|
||||
organisationId: string,
|
||||
heute = new Date(),
|
||||
): Promise<PlanungsErgebnis> {
|
||||
const horizontTage = env.WARTUNG_PLANUNGSHORIZONT_TAGE;
|
||||
|
||||
const angelegt = await fuerOrganisation(organisationId, async (db) => {
|
||||
const vorlagen = await db.wartungsvorlage.findMany({
|
||||
where: { aktiv: true, automatisch: true, geloeschtAm: null, geraetetypId: { not: null } },
|
||||
select: { id: true, name: true, intervallMonate: true, geraetetypId: true },
|
||||
});
|
||||
|
||||
let summe = 0;
|
||||
|
||||
for (const vorlage of vorlagen) {
|
||||
const geraete = await db.geraet.findMany({
|
||||
where: {
|
||||
geraetetypId: vorlage.geraetetypId,
|
||||
geloeschtAm: null,
|
||||
status: { not: "AUSGESONDERT" },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
inventarnummer: true,
|
||||
status: true,
|
||||
indienststellung: true,
|
||||
letztePruefung: true,
|
||||
wartungstermine: {
|
||||
where: { vorlageId: vorlage.id, status: "OFFEN", geloeschtAm: null },
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
},
|
||||
pruefungen: {
|
||||
where: { vorlageId: vorlage.id, status: "ABGESCHLOSSEN", geloeschtAm: null },
|
||||
orderBy: { durchgefuehrtAm: "desc" },
|
||||
select: { durchgefuehrtAm: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const planungsdaten: PlanungsGeraet[] = geraete.map((geraet) => ({
|
||||
id: geraet.id,
|
||||
inventarnummer: geraet.inventarnummer,
|
||||
status: geraet.status,
|
||||
indienststellung: geraet.indienststellung,
|
||||
letztePruefungZurVorlage: geraet.pruefungen[0]?.durchgefuehrtAm ?? null,
|
||||
letztePruefung: geraet.letztePruefung,
|
||||
hatOffenenTermin: geraet.wartungstermine.length > 0,
|
||||
}));
|
||||
|
||||
const geplant = zuPlanendeTermine(vorlage, planungsdaten, { horizontTage, heute });
|
||||
if (geplant.length === 0) continue;
|
||||
|
||||
const ergebnis = await db.wartungstermin.createMany({
|
||||
data: geplant.map((termin) => ({
|
||||
organisationId,
|
||||
geraetId: termin.geraetId,
|
||||
vorlageId: termin.vorlageId,
|
||||
faelligAm: termin.faelligAm,
|
||||
automatischErzeugt: true,
|
||||
bemerkung: termin.ohneBasisdatum
|
||||
? "Ohne Angabe zu letzter Prüfung oder Indienststellung — sofort zu prüfen."
|
||||
: null,
|
||||
})),
|
||||
// Ein zweiter Lauf am selben Tag legt nichts doppelt an.
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
summe += ergebnis.count;
|
||||
}
|
||||
|
||||
if (summe > 0) {
|
||||
await protokolliereIn(db, {
|
||||
organisationId,
|
||||
aktion: AuditAktion.ERSTELLT,
|
||||
entitaet: "Wartungstermin",
|
||||
beschreibung: `Nächtlicher Lauf: ${summe} Prüftermine geplant`,
|
||||
akteurTyp: AkteurTyp.WORKER,
|
||||
akteurName: "Hintergrundprozess",
|
||||
kontext: { vorlagen: vorlagen.length, horizontTage },
|
||||
});
|
||||
}
|
||||
|
||||
return { summe, vorlagen: vorlagen.length };
|
||||
});
|
||||
|
||||
const statusGeaendert = await geraeteStatusNachziehen(organisationId, heute);
|
||||
|
||||
return {
|
||||
angelegt: angelegt.summe,
|
||||
vorlagen: angelegt.vorlagen,
|
||||
statusGeaendert,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt an jedem Gerät die nächste Frist und den daraus folgenden Status.
|
||||
*
|
||||
* Die Frist am Gerät ist die früheste offene Terminfälligkeit — ein
|
||||
* Pressluftatmer, der jährlich sichtgeprüft und alle sechs Jahre überholt wird,
|
||||
* hat zwei Termine, aber nur eine nächste Frist.
|
||||
*/
|
||||
async function geraeteStatusNachziehen(organisationId: string, heute: Date): Promise<number> {
|
||||
return fuerOrganisation(organisationId, async (db) => {
|
||||
const geraete = await db.geraet.findMany({
|
||||
where: { geloeschtAm: null, status: { not: "AUSGESONDERT" } },
|
||||
select: {
|
||||
id: true,
|
||||
inventarnummer: true,
|
||||
status: true,
|
||||
naechstePruefung: true,
|
||||
lebensdauerBis: true,
|
||||
wartungstermine: {
|
||||
where: { status: "OFFEN", geloeschtAm: null },
|
||||
select: { faelligAm: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let geaendert = 0;
|
||||
|
||||
for (const geraet of geraete) {
|
||||
const ausTerminen = fruehesteFaelligkeit(geraet.wartungstermine);
|
||||
const naechste = ausTerminen ?? geraet.naechstePruefung;
|
||||
|
||||
const neuerStatus = abgeleiteterGeraetStatus({
|
||||
aktuellerStatus: geraet.status,
|
||||
naechstePruefung: naechste,
|
||||
lebensdauerBis: geraet.lebensdauerBis,
|
||||
heute,
|
||||
});
|
||||
|
||||
const fristGleich =
|
||||
(naechste?.getTime() ?? null) === (geraet.naechstePruefung?.getTime() ?? null);
|
||||
|
||||
if (neuerStatus === geraet.status && fristGleich) continue;
|
||||
|
||||
await db.geraet.update({
|
||||
where: { id: geraet.id },
|
||||
data: { status: neuerStatus, naechstePruefung: naechste },
|
||||
});
|
||||
|
||||
if (neuerStatus !== geraet.status) {
|
||||
geaendert += 1;
|
||||
await protokolliereIn(db, {
|
||||
organisationId,
|
||||
aktion: AuditAktion.GEAENDERT,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: geraet.id,
|
||||
beschreibung: `Nächtlicher Lauf: Status von ${geraet.inventarnummer} auf ${neuerStatus}`,
|
||||
akteurTyp: AkteurTyp.WORKER,
|
||||
akteurName: "Hintergrundprozess",
|
||||
vorher: { status: geraet.status, naechstePruefung: geraet.naechstePruefung },
|
||||
nachher: { status: neuerStatus, naechstePruefung: naechste },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return geaendert;
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Erinnerungen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const STUFE_TEXT: Record<Erinnerungsstufe, string> = {
|
||||
T_30: "Vorwarnung",
|
||||
T_7: "in einer Woche fällig",
|
||||
T_0: "heute fällig",
|
||||
UEBERFAELLIG: "überfällig",
|
||||
};
|
||||
|
||||
interface Faellig {
|
||||
terminId: string;
|
||||
stufe: Erinnerungsstufe;
|
||||
faelligAm: Date;
|
||||
inventarnummer: string;
|
||||
bezeichnung: string;
|
||||
vorlage: string;
|
||||
empfaenger: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschickt die Vorwarnungen zu offenen Terminen.
|
||||
*
|
||||
* Empfänger ist, wer den Termin übernommen hat. Ist niemand zuständig, gehen
|
||||
* die Erinnerungen an die Gerätewarte der Wehr — der Kommandant bekommt nicht
|
||||
* jeden Schlauchprüftermin auf den Tisch.
|
||||
*
|
||||
* Verschickt wird eine Sammelnachricht je Empfänger, nicht eine Mail je Gerät.
|
||||
* Zwanzig einzelne Mails an einem Morgen liest niemand.
|
||||
*/
|
||||
export async function erinnerungenSenden(
|
||||
organisationId: string,
|
||||
heute = new Date(),
|
||||
): Promise<{ termine: number; nachrichten: number }> {
|
||||
const faellige = await fuerOrganisation(organisationId, async (db) => {
|
||||
const termine = await db.wartungstermin.findMany({
|
||||
where: { status: "OFFEN", geloeschtAm: null, geraet: { geloeschtAm: null } },
|
||||
select: {
|
||||
id: true,
|
||||
faelligAm: true,
|
||||
geraet: { select: { inventarnummer: true, bezeichnung: true } },
|
||||
vorlage: { select: { name: true, vorwarnzeitTage: true } },
|
||||
zugewiesenAn: { select: { email: true, aktiv: true } },
|
||||
erinnerungen: { select: { stufe: true } },
|
||||
},
|
||||
take: 5000,
|
||||
});
|
||||
|
||||
// Ersatzempfänger: die Gerätewarte der Wehr.
|
||||
const geraetewarte = await db.person.findMany({
|
||||
where: {
|
||||
aktiv: true,
|
||||
geloeschtAm: null,
|
||||
email: { not: null },
|
||||
rollen: {
|
||||
some: {
|
||||
rolle: { schluessel: { in: ["GERAETEWART", "ATEMSCHUTZGERAETEWART"] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { email: true },
|
||||
});
|
||||
|
||||
const ersatz = geraetewarte
|
||||
.map((person) => person.email)
|
||||
.filter((adresse): adresse is string => Boolean(adresse));
|
||||
|
||||
const offen: Faellig[] = [];
|
||||
|
||||
for (const termin of termine) {
|
||||
const stufe = erinnerungsstufe(
|
||||
termin.faelligAm,
|
||||
termin.vorlage?.vorwarnzeitTage ?? 30,
|
||||
heute,
|
||||
);
|
||||
if (!stufe) continue;
|
||||
|
||||
const bereits = new Set(termin.erinnerungen.map((eintrag) => eintrag.stufe));
|
||||
if (bereits.has(stufe)) continue;
|
||||
|
||||
const zustaendig =
|
||||
termin.zugewiesenAn?.aktiv && termin.zugewiesenAn.email
|
||||
? [termin.zugewiesenAn.email]
|
||||
: ersatz;
|
||||
|
||||
offen.push({
|
||||
terminId: termin.id,
|
||||
stufe,
|
||||
faelligAm: termin.faelligAm,
|
||||
inventarnummer: termin.geraet.inventarnummer,
|
||||
bezeichnung: termin.geraet.bezeichnung,
|
||||
vorlage: termin.vorlage?.name ?? "Prüfung",
|
||||
empfaenger: zustaendig,
|
||||
});
|
||||
}
|
||||
|
||||
return offen;
|
||||
});
|
||||
|
||||
if (faellige.length === 0) return { termine: 0, nachrichten: 0 };
|
||||
|
||||
// Nach Empfänger bündeln.
|
||||
const nachEmpfaenger = new Map<string, Faellig[]>();
|
||||
for (const eintrag of faellige) {
|
||||
for (const adresse of eintrag.empfaenger) {
|
||||
const liste = nachEmpfaenger.get(adresse) ?? [];
|
||||
liste.push(eintrag);
|
||||
nachEmpfaenger.set(adresse, liste);
|
||||
}
|
||||
}
|
||||
|
||||
const versand = mail();
|
||||
let nachrichten = 0;
|
||||
|
||||
for (const [adresse, eintraege] of nachEmpfaenger) {
|
||||
const ueberfaellig = eintraege.filter((eintrag) => eintrag.stufe === "UEBERFAELLIG").length;
|
||||
|
||||
const zeilen = eintraege
|
||||
.sort((a, b) => a.faelligAm.getTime() - b.faelligAm.getTime())
|
||||
.map(
|
||||
(eintrag) =>
|
||||
`- ${eintrag.inventarnummer} (${eintrag.bezeichnung}): ${eintrag.vorlage}, ` +
|
||||
`fällig am ${alsDatum(eintrag.faelligAm)} — ${STUFE_TEXT[eintrag.stufe]}`,
|
||||
);
|
||||
|
||||
const ergebnis = await versand.senden({
|
||||
an: adresse,
|
||||
betreff:
|
||||
ueberfaellig > 0
|
||||
? `Prüftermine: ${ueberfaellig} überfällig, ${eintraege.length} insgesamt`
|
||||
: `Prüftermine: ${eintraege.length} anstehend`,
|
||||
text: [
|
||||
"Folgende Prüfungen stehen an:",
|
||||
"",
|
||||
...zeilen,
|
||||
"",
|
||||
"Diese Nachricht kommt von FLORIAN und wiederholt sich je Termin und Stufe nur einmal.",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
if (ergebnis.erfolgreich) nachrichten += 1;
|
||||
}
|
||||
|
||||
// Erst nach dem Versand vermerken. Andersherum wäre eine ausgefallene
|
||||
// Mailanbindung eine stillschweigend verschluckte Erinnerung.
|
||||
await fuerOrganisation(organisationId, async (db) => {
|
||||
for (const eintrag of faellige) {
|
||||
await db.erinnerung.upsert({
|
||||
where: { terminId_stufe: { terminId: eintrag.terminId, stufe: eintrag.stufe } },
|
||||
create: {
|
||||
organisationId,
|
||||
terminId: eintrag.terminId,
|
||||
stufe: eintrag.stufe,
|
||||
kanal: versand.verfuegbar() ? "mail" : "protokoll",
|
||||
empfaenger: eintrag.empfaenger,
|
||||
erfolgreich: true,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
protokoll(`${faellige.length} Erinnerungen, ${nachrichten} Nachrichten verschickt`);
|
||||
|
||||
return { termine: faellige.length, nachrichten };
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigung zu einem Mangel.
|
||||
*
|
||||
* Anders als die Terminerinnerung geht sie sofort raus: ein kritischer Mangel
|
||||
* bedeutet, dass ein Gerät gerade aus dem Dienst genommen wurde. Das muss die
|
||||
* Zuständigen noch am selben Tag erreichen.
|
||||
*/
|
||||
export async function mangelBenachrichtigung(
|
||||
organisationId: string,
|
||||
mangelId: string,
|
||||
anlass: string,
|
||||
): Promise<{ versendet: boolean }> {
|
||||
const daten = await fuerOrganisation(organisationId, async (db) => {
|
||||
const mangel = await db.mangel.findUnique({
|
||||
where: { id: mangelId },
|
||||
select: {
|
||||
beschreibung: true,
|
||||
prioritaet: true,
|
||||
status: true,
|
||||
geraetGesperrt: true,
|
||||
melderName: true,
|
||||
geraet: { select: { inventarnummer: true, bezeichnung: true } },
|
||||
zugewiesenAn: { select: { email: true, aktiv: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!mangel) return null;
|
||||
|
||||
const geraetewarte = await db.person.findMany({
|
||||
where: {
|
||||
aktiv: true,
|
||||
geloeschtAm: null,
|
||||
email: { not: null },
|
||||
rollen: {
|
||||
some: { rolle: { schluessel: { in: ["GERAETEWART", "ATEMSCHUTZGERAETEWART"] } } },
|
||||
},
|
||||
},
|
||||
select: { email: true },
|
||||
});
|
||||
|
||||
const empfaenger =
|
||||
mangel.zugewiesenAn?.aktiv && mangel.zugewiesenAn.email
|
||||
? [mangel.zugewiesenAn.email]
|
||||
: geraetewarte
|
||||
.map((person) => person.email)
|
||||
.filter((adresse): adresse is string => Boolean(adresse));
|
||||
|
||||
return { mangel, empfaenger };
|
||||
});
|
||||
|
||||
if (!daten || daten.empfaenger.length === 0) return { versendet: false };
|
||||
|
||||
const { mangel, empfaenger } = daten;
|
||||
|
||||
const ergebnis = await mail().senden({
|
||||
an: empfaenger,
|
||||
betreff: `Mangel an ${mangel.geraet.inventarnummer} (${mangel.prioritaet.toLowerCase()})`,
|
||||
text: [
|
||||
`Gerät: ${mangel.geraet.inventarnummer} — ${mangel.geraet.bezeichnung}`,
|
||||
`Gemeldet von: ${mangel.melderName ?? "unbekannt"}`,
|
||||
`Dringlichkeit: ${mangel.prioritaet}`,
|
||||
`Stand: ${mangel.status}`,
|
||||
mangel.geraetGesperrt ? "Das Gerät ist gesperrt." : "",
|
||||
"",
|
||||
mangel.beschreibung,
|
||||
"",
|
||||
`Anlass dieser Nachricht: ${anlass}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
});
|
||||
|
||||
return { versendet: ergebnis.erfolgreich };
|
||||
}
|
||||
Reference in New Issue
Block a user