Files
FireAdministration/src/lib/wartung/protokoll.ts
T
Florian WildeandClaude Opus 5 e503aafc18 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>
2026-09-03 12:07:11 +02:00

480 lines
14 KiB
TypeScript

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),
});
}