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