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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user