L5 Berichte & Unterschrift: Berichtsinhalt, Services, Unterschrift und PDF

ReportContent-Vertrag, Content-Builder mit Tagesfilter, Services für Tages-/Abschlussbericht,
Bearbeiten, Absenden, Freigabe, Zurückweisen, neue Version, Unterschrift und PDF-Erzeugung
(playwright-core, Worker-Processor, Dockerfile-Stage worker). Stubs für L2-Transition/Blocker
und Dokumenten-Store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:22:40 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 8a6fdd8f7a
26 changed files with 2129 additions and 3 deletions
+85
View File
@@ -0,0 +1,85 @@
import { existsSync } from "node:fs";
/**
* HTML → PDF via playwright-core + Chromium (ARCHITEKTUR §1: runs in the worker, never in the app container).
*
* Browser resolution (first match wins):
* 1. PDF_CHROMIUM_PATH — explicit executable (Docker worker image: /usr/bin/chromium)
* 2. Playwright-managed Chromium (`npx playwright-core install chromium`)
* 3. Locally installed Google Chrome (developer machines, channel "chrome")
* Throws PdfRendererUnavailableError if none can be launched.
*/
export class PdfRendererUnavailableError extends Error {
constructor(cause: string) {
super(`PDF renderer unavailable: ${cause}`);
this.name = "PdfRendererUnavailableError";
}
}
export type RenderPdfOptions = {
headerHtml?: string;
footerHtml?: string;
/** mm margins */
margin?: { top: string; bottom: string; left: string; right: string };
};
type Browser = import("playwright-core").Browser;
async function launch(): Promise<Browser> {
const { chromium } = await import("playwright-core");
const errors: string[] = [];
const explicit = process.env.PDF_CHROMIUM_PATH?.trim();
const attempts: Array<() => Promise<Browser>> = [];
if (explicit) attempts.push(() => chromium.launch({ executablePath: explicit, args: ["--no-sandbox", "--disable-dev-shm-usage"] }));
attempts.push(async () => {
const path = chromium.executablePath();
if (!path || !existsSync(path)) throw new Error("playwright chromium not installed");
return chromium.launch({ args: ["--disable-dev-shm-usage"] });
});
attempts.push(() => chromium.launch({ channel: "chrome" }));
for (const attempt of attempts) {
try {
return await attempt();
} catch (err) {
errors.push((err as Error).message.split("\n")[0]);
}
}
throw new PdfRendererUnavailableError(errors.join(" | "));
}
/** Render a full HTML document to an A4 PDF (print backgrounds, header/footer with page numbers). */
export async function renderHtmlToPdf(html: string, opts: RenderPdfOptions = {}): Promise<Buffer> {
const browser = await launch();
try {
const context = await browser.newContext({ javaScriptEnabled: false });
const page = await context.newPage();
// No network: all assets (photos, logo, signature, fonts) are inlined as data: URIs.
await page.route("**/*", (route) => (route.request().url().startsWith("data:") ? route.continue() : route.abort()));
await page.setContent(html, { waitUntil: "load" });
const pdf = await page.pdf({
format: "A4",
printBackground: true,
displayHeaderFooter: Boolean(opts.headerHtml || opts.footerHtml),
headerTemplate: opts.headerHtml ?? "<span></span>",
footerTemplate: opts.footerHtml ?? "<span></span>",
margin: opts.margin ?? { top: "22mm", bottom: "20mm", left: "16mm", right: "16mm" },
preferCSSPageSize: false,
});
await context.close();
return pdf;
} finally {
await browser.close();
}
}
/** true if a browser can be launched (tests skip the render smoke otherwise). */
export async function pdfRendererAvailable(): Promise<{ ok: true } | { ok: false; reason: string }> {
try {
const b = await launch();
await b.close();
return { ok: true };
} catch (err) {
return { ok: false, reason: (err as Error).message };
}
}
+322
View File
@@ -0,0 +1,322 @@
/* eslint-disable @next/next/no-head-element, @next/next/no-img-element -- standalone print document for Chromium, not a Next.js page */
import { renderToStaticMarkup } from "react-dom/server";
import { REPORT_TEXT_FIELDS, splitMinutes, type MaterialLine, type ReportContent } from "@/lib/reports/content";
import { DOCUMENT_THEME, documentFooterLine } from "@/lib/document-brand";
/**
* Report PDF template (React SSR → static HTML, rendered by src/server/pdf/render.ts).
* Craftvia document CD from src/lib/document-brand.ts; tenant logo if available, else company name.
* All labels come from messages/<locale>/reports.json (passed in as `t`).
*/
export type Translate = (key: string, values?: Record<string, string | number>) => string;
export type ReportPdfInput = {
content: ReportContent;
reportId: string;
status: string;
approvedAt: Date | null;
t: Translate;
locale: string;
timeZone: string;
/** documentId → data: URI (photos, signature image, logo) */
images: Record<string, string>;
logoDataUri?: string | null;
/** SHA-256 of the canonical content snapshot (the PDF's own checksum is stored on the report) */
contentChecksum: string;
fontDataUri?: string | null;
};
const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
function css(fontDataUri?: string | null) {
const th = DOCUMENT_THEME;
return `
${fontDataUri ? `@font-face{font-family:"CraftviaInter";src:url(${fontDataUri}) format("truetype");font-weight:100 900;}` : ""}
@page{size:A4;}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{font-family:${fontDataUri ? `"CraftviaInter",` : ""}${th.bodyFont};color:${th.text};font-size:9.5pt;line-height:1.45;background:${th.pageBackground};}
h1{font-family:${fontDataUri ? `"CraftviaInter",` : ""}${th.headingFont};color:${th.accent};font-size:17pt;margin:0 0 2mm;}
h2{font-family:${fontDataUri ? `"CraftviaInter",` : ""}${th.headingFont};color:${th.accent};font-size:11pt;margin:6mm 0 2mm;padding-bottom:1mm;border-bottom:0.6pt solid ${th.rule};break-after:avoid;}
h3{font-size:9.5pt;margin:3mm 0 1mm;color:${th.text};break-after:avoid;}
.muted{color:${th.textMuted};}
.head{display:flex;justify-content:space-between;align-items:flex-start;gap:8mm;border-bottom:2pt solid ${th.accentStrong};padding-bottom:3mm;margin-bottom:4mm;}
.logo{max-height:16mm;max-width:60mm;}
.org{font-weight:700;color:${th.accent};font-size:12pt;}
.meta{text-align:right;font-size:8.5pt;}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5mm 8mm;}
.kv dt{font-size:7.5pt;text-transform:uppercase;letter-spacing:.04em;color:${th.textMuted};margin:0;}
.kv dd{margin:0 0 1.5mm;}
table{width:100%;border-collapse:collapse;margin:1mm 0 2mm;}
th{background:${th.tableHeaderBackground};text-align:left;font-size:8pt;padding:1.2mm 1.5mm;border-bottom:0.6pt solid ${th.rule};}
td{padding:1.2mm 1.5mm;border-bottom:0.4pt solid ${th.rule};vertical-align:top;}
tr{break-inside:avoid;}
td.num,th.num{text-align:right;white-space:nowrap;}
.text{white-space:pre-wrap;break-inside:avoid-page;}
.photos{display:grid;grid-template-columns:1fr 1fr;gap:4mm;}
.photo{break-inside:avoid;border:0.4pt solid ${th.rule};padding:1.5mm;}
.photo img{width:100%;height:62mm;object-fit:contain;background:${th.tableHeaderBackground};display:block;}
.photo .cap{font-size:8pt;margin-top:1mm;}
.sig{break-inside:avoid;border:0.6pt solid ${th.rule};padding:3mm;}
.sig img{max-height:30mm;max-width:90mm;display:block;margin:2mm 0;}
.badge{display:inline-block;border:0.6pt solid ${th.accent};color:${th.accent};border-radius:2mm;padding:.3mm 2mm;font-size:8pt;font-weight:700;}
.dev{color:${th.accentStrong};font-weight:700;}
`;
}
function Kv({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
}
function fmtDuration(t: Translate, minutes: number) {
const s = splitMinutes(minutes);
return t("time.hoursMinutes", { hours: s.hours, minutes: String(s.minutes).padStart(2, "0") });
}
function MaterialTable({ t, lines }: { t: Translate; lines: MaterialLine[] }) {
return (
<table>
<thead>
<tr>
<th>{t("materials.name")}</th>
<th className="num">{t("materials.planned")}</th>
<th className="num">{t("materials.actual")}</th>
<th>{t("field.status")}</th>
<th>{t("materials.reason")}</th>
</tr>
</thead>
<tbody>
{lines.map((m, i) => (
<tr key={`${m.usageId ?? m.planId ?? i}`}>
<td>
{m.name}
{m.articleNumber ? <span className="muted"> · {m.articleNumber}</span> : null}
</td>
<td className="num">{m.plannedQuantity ? `${m.plannedQuantity} ${m.unit}` : "—"}</td>
<td className="num">{m.actualQuantity ? `${m.actualQuantity} ${m.unit}` : "—"}</td>
<td>
{m.status ? t(`materialStatus.${m.status}`) : t("materials.undocumented")}
{m.deviation ? <span className="dev"> · {t("materials.deviation")}</span> : null}
</td>
<td>{m.deviationReason ?? ""}</td>
</tr>
))}
</tbody>
</table>
);
}
function ReportDocument(input: ReportPdfInput) {
const { content: c, t } = input;
const dateFmt = new Intl.DateTimeFormat(input.locale, { timeZone: input.timeZone, dateStyle: "medium" });
const dateTimeFmt = new Intl.DateTimeFormat(input.locale, { timeZone: input.timeZone, dateStyle: "medium", timeStyle: "short" });
const dayFmt = (key: string) => new Intl.DateTimeFormat(input.locale, { timeZone: "UTC", dateStyle: "medium" }).format(new Date(`${key}T00:00:00Z`));
const addr = (a: { line1: string | null; line2: string | null }) => [a.line1, a.line2].filter(Boolean).join(", ");
const texts = REPORT_TEXT_FIELDS.filter((f) => c.texts[f].trim());
const hasMaterial = c.materials.used.length + c.materials.notUsed.length + c.materials.additional.length > 0;
return (
<html lang={input.locale}>
<head>
<meta charSet="utf-8" />
<title>{`${t(`type.${c.type}`)} ${c.reportNumber}`}</title>
<style dangerouslySetInnerHTML={{ __html: css(input.fontDataUri) }} />
</head>
<body>
<div className="head">
<div>
{input.logoDataUri ? <img className="logo" src={input.logoDataUri} alt={c.tenant.name} /> : <div className="org">{c.tenant.name}</div>}
<div className="muted">{[c.tenant.address, c.tenant.phone, c.tenant.email].filter(Boolean).join(" · ")}</div>
</div>
<div className="meta">
<h1>{t(`type.${c.type}`)}</h1>
<div>
{t("field.reportNumber")}: <strong>{c.reportNumber}</strong> · {t("field.version")} {c.version}
</div>
<div>
{t("field.reportDate")}: {dayFmt(c.reportDate)}
</div>
<div>
{t("pdf.approvalStatus")}:{" "}
<span className="badge">
{t(`status.${input.status}`)}
{input.approvedAt ? ` · ${dateFmt.format(input.approvedAt)}` : ""}
</span>
</div>
</div>
</div>
<h2>{t("section.header")}</h2>
<dl className="kv grid">
<Kv label={t("field.customer")} value={[c.customer.name, addr(c.customer.address)].filter(Boolean).join(", ")} />
<Kv label={t("field.customerNumber")} value={c.customer.number} />
<Kv label={t("field.site")} value={c.site ? [c.site.name, addr(c.site.address)].filter(Boolean).join(", ") : null} />
<Kv label={t("field.contact")} value={c.contact ? [c.contact.name, c.contact.role, c.contact.phone, c.contact.email].filter(Boolean).join(" · ") : null} />
<Kv label={t("field.orderNumber")} value={[c.workOrder.number, c.workOrder.externalOrderNumber].filter(Boolean).join(" / ")} />
<Kv label={t("field.orderType")} value={c.workOrder.orderType} />
<Kv label={t("field.workOrder")} value={c.workOrder.title} />
<Kv label={t("field.workDates")} value={c.workDates.map(dayFmt).join(", ")} />
<Kv label={t("field.staff")} value={c.staff.map((s) => s.name).join(", ")} />
<Kv label={t("field.technician")} value={c.technician?.name} />
</dl>
{c.workOrder.description ? (
<>
<h3>{t("field.description")}</h3>
<div className="text">{c.workOrder.description}</div>
</>
) : null}
<h2>{t("section.time")}</h2>
{c.time.entries.length === 0 ? (
<p className="muted">{t("time.empty")}</p>
) : (
<table>
<thead>
<tr>
<th>{t("time.person")}</th>
<th>{t("time.type")}</th>
<th className="num">{t("time.duration")}</th>
</tr>
</thead>
<tbody>
{c.time.entries.map((e) => (
<tr key={`${e.userId}-${e.type}`}>
<td>{e.name}</td>
<td>{t(`timeType.${e.type}`)}</td>
<td className="num">{fmtDuration(t, e.minutes)}</td>
</tr>
))}
{Object.entries(c.time.totalsByType).map(([type, minutes]) => (
<tr key={`sum-${type}`}>
<td className="muted">{t("time.totalByType")}</td>
<td>{t(`timeType.${type}`)}</td>
<td className="num">{fmtDuration(t, minutes)}</td>
</tr>
))}
<tr>
<td colSpan={2}>
<strong>{t("time.total")}</strong>
</td>
<td className="num">
<strong>{fmtDuration(t, c.time.totalMinutes)}</strong>
</td>
</tr>
</tbody>
</table>
)}
{c.time.hasRunningEntries ? <p className="muted">{t("time.running", { time: dateTimeFmt.format(new Date(c.generatedAt)) })}</p> : null}
{texts.length ? <h2>{t("section.texts")}</h2> : null}
{texts.map((f) => (
<div key={f}>
<h3>{t(`texts.${f}`)}</h3>
<div className="text">{c.texts[f]}</div>
</div>
))}
<h2>{t("section.materials")}</h2>
{!hasMaterial ? <p className="muted">{t("materials.empty")}</p> : null}
{c.materials.used.length ? (
<>
<h3>{t("materials.used")}</h3>
<MaterialTable t={t} lines={c.materials.used} />
</>
) : null}
{c.materials.notUsed.length ? (
<>
<h3>{t("materials.notUsed")}</h3>
<MaterialTable t={t} lines={c.materials.notUsed} />
</>
) : null}
{c.materials.additional.length ? (
<>
<h3>{t("materials.additional")}</h3>
<MaterialTable t={t} lines={c.materials.additional} />
</>
) : null}
{c.checklist.length ? (
<>
<h2>{t("section.checklist")}</h2>
<table>
<tbody>
{c.checklist.map((i, idx) => (
<tr key={idx}>
<td>
{i.label}
{i.required ? <span className="muted"> · {t("checklist.required")}</span> : null}
{i.comment ? <div className="muted">{i.comment}</div> : null}
</td>
<td className="num">{i.checked ? `✓ ${t("checklist.done")}` : `○ ${t("checklist.open")}`}</td>
</tr>
))}
</tbody>
</table>
</>
) : null}
<h2>{t("section.photos")}</h2>
{c.photos.length === 0 ? (
<p className="muted">{t("photos.empty")}</p>
) : (
<div className="photos">
{c.photos.map((p, idx) => (
<div className="photo" key={p.photoId}>
{input.images[p.documentId] ? <img src={input.images[p.documentId]} alt={t("photos.alt", { index: idx + 1 })} /> : null}
<div className="cap">
<strong>{idx + 1}.</strong> {p.phase ? t(`phase.${p.phase}`) : ""}
{p.requirement ? ` · ${t("photos.requirement", { label: p.requirement })}` : ""} · {dateTimeFmt.format(new Date(p.takenAt))}
{p.comment ? <div>{p.comment}</div> : null}
</div>
</div>
))}
</div>
)}
<h2>{t("section.signature")}</h2>
{!c.signature ? (
<p className="muted">{t("signature.none")}</p>
) : (
<div className="sig">
<div>
<strong>{t(`outcome.${c.signature.outcome}`)}</strong>
</div>
{c.signature.imageDocumentId && input.images[c.signature.imageDocumentId] ? (
<img src={input.images[c.signature.imageDocumentId]} alt={t("signature.image", { name: c.signature.signerName ?? "" })} />
) : null}
<dl className="kv grid">
<Kv label={t("signature.signer")} value={c.signature.signerName} />
<Kv label={t("signature.role")} value={c.signature.signerRole} />
<Kv label={t("signature.signedAt")} value={dateTimeFmt.format(new Date(c.signature.signedAt))} />
<Kv label={t("signature.capturedBy")} value={c.signature.capturedByName} />
<Kv label={t("signature.reason")} value={c.signature.reason} />
</dl>
{c.signature.confirmationText ? <div className="text muted">{c.signature.confirmationText}</div> : null}
</div>
)}
</body>
</html>
);
}
/** Returns the document HTML plus Chromium header/footer templates (page numbers, report id, version, checksum). */
export function renderReportHtml(input: ReportPdfInput): { html: string; headerHtml: string; footerHtml: string } {
const { content: c, t } = input;
const html = "<!doctype html>" + renderToStaticMarkup(<ReportDocument {...input} />);
const small = `font-family:${DOCUMENT_THEME.bodyFont};font-size:7pt;color:${DOCUMENT_THEME.textMuted};width:100%;padding:0 16mm;display:flex;justify-content:space-between;gap:6mm;`;
const headerHtml = `<div style="${small}"><span>${esc(c.tenant.name)}</span><span>${esc(t(`type.${c.type}`))} ${esc(c.reportNumber)} · ${esc(t("field.version"))} ${c.version}</span></div>`;
const page = esc(t("pdf.page", { page: "__P__", pages: "__N__" }))
.replace("__P__", '<span class="pageNumber"></span>')
.replace("__N__", '<span class="totalPages"></span>');
const footerHtml =
`<div style="${small}"><span>${esc(t("pdf.reportId"))}: ${esc(input.reportId)} · ${esc(t("field.version"))} ${c.version} · ` +
`${esc(t("pdf.checksum"))}: ${esc(input.contentChecksum)}<br/>${esc(documentFooterLine())}</span><span style="white-space:nowrap">${page}</span></div>`;
return { html, headerHtml, footerHtml };
}