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>
480 lines
14 KiB
TypeScript
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),
|
|
});
|
|
}
|