L14 Abrechnungsübersicht: Services für Einträge, Aufstellung, Abrechnen/Stornieren, Meilensteine und PDF

syncBillingCandidates (idempotent, Event-Hook für work_order.released_for_billing und report.approved), Aufstellung ohne Preise (nur freigegebene Zeiten, Fahrzeit getrennt, Pausen informativ, Anfahrten-Zählung, Material), markBilled mit eingefrorenem Snapshot und Positionszuordnung, voidBilling mit Grund und neuem offenen Eintrag (billed → released_for_billing nur über applyTransition-Option billingVoid), Meilenstein-Flow mit Events, Job billing-pdf (Dokument other/backoffice_only), Sync-Op milestone.reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:35:38 +02:00
co-authored by Claude Opus 5
parent 6358d5762c
commit ac2d857e26
27 changed files with 2179 additions and 5 deletions
+46
View File
@@ -0,0 +1,46 @@
/* eslint-disable @next/next/no-head-element -- standalone print document for Chromium, not a Next.js page */
import { renderToStaticMarkup } from "react-dom/server";
import type { BillingStatement } from "@/lib/billing/statement";
import { DOCUMENT_THEME, documentFooterLine } from "@/lib/document-brand";
import { StatementDocument, statementCss, type Translate } from "@/components/billing/statement-document";
/**
* L14 billing sheet PDF (React SSR → static HTML → src/server/pdf/render.ts). Same markup as the
* detail/print pages (`StatementDocument`), themed with the Craftvia document CD.
*/
const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
export function renderBillingHtml(input: { statement: BillingStatement; t: Translate; locale: string; timeZone: string; fontDataUri?: string | null }) {
const { statement: s, t } = input;
const th = DOCUMENT_THEME;
const font = input.fontDataUri ? `"CraftviaInter",${th.bodyFont}` : th.bodyFont;
const css =
`${input.fontDataUri ? `@font-face{font-family:"CraftviaInter";src:url(${input.fontDataUri}) format("truetype");font-weight:100 900;}` : ""}` +
`@page{size:A4;}*{box-sizing:border-box;}html,body{margin:0;padding:0;background:${th.pageBackground};}` +
statementCss({ text: th.text, muted: th.textMuted, accent: th.accent, rule: th.rule, headBg: th.tableHeaderBackground, warn: th.accentStrong, font }).replace(/font-size:13px/, "font-size:9.5pt");
const html =
"<!doctype html>" +
renderToStaticMarkup(
<html lang={input.locale}>
<head>
<meta charSet="utf-8" />
<title>{`${t("sheet.title")} ${s.workOrder.number}`}</title>
<style dangerouslySetInnerHTML={{ __html: css }} />
</head>
<body>
<StatementDocument s={s} t={t} locale={input.locale} timeZone={input.timeZone} />
</body>
</html>,
);
const small = `font-family:${th.bodyFont};font-size:7pt;color:${th.textMuted};width:100%;padding:0 16mm;display:flex;justify-content:space-between;gap:6mm;`;
const created = new Intl.DateTimeFormat(input.locale, { timeZone: input.timeZone, dateStyle: "medium", timeStyle: "short" }).format(new Date(s.record.billedAt ?? s.generatedAt));
const headerHtml = `<div style="${small}"><span>${esc(s.tenant.name)}</span><span>${esc(t("sheet.title"))} ${esc(s.workOrder.number)} · ${esc(t(`kind.${s.record.kind}`))}</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.recordId"))}: ${esc(s.record.id)} · ${esc(t("pdf.created"))}: ${esc(created)}<br/>${esc(documentFooterLine())}</span>` +
`<span style="white-space:nowrap">${page}</span></div>`;
return { html, headerHtml, footerHtml };
}