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