Phase 2: Wartungs- und Pruefmanagement
Wartungsvorlagen mit Checklisten, Prueftermine aus einem naechtlichen Lauf, Prueffristen-Dashboard, Pruefwizard mit Foto und Unterschrift, unveraenderliche Protokolle als PDF, Maengel-Workflow, Rueckrufe und Lebensdauerueberwachung. Elf neue Tabellen, alle mit organisationId und Row Level Security. Die Unveraenderbarkeit abgeschlossener Protokolle setzt ein Datenbanktrigger durch, nicht der Anwendungscode: eine Korrektur erzeugt eine neue Pruefung mit Verweis auf die alte, beide bleiben lesbar. 79 neue Tests (47 Unit, 10 gegen eine echte Datenbank, 22 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7f866ac35c
commit
e503aafc18
@@ -0,0 +1,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",
|
||||
};
|
||||
Reference in New Issue
Block a user