import type { Address, BillingStatement, StatementMaterialLine, StatementTextBlock } from "@/lib/billing/statement"; import { formatHm } from "@/lib/billing/statement"; /** * Billing statement document (L14 §3.3) — pure markup without hooks, shared by the detail page, * the print view (`/billing/print`) and the PDF template (react-dom/server). Styling lives in * `statementCss` (scoped `.bs-*` classes), so screen, print and PDF show the same layout. * No prices, amounts or taxes. */ export type Translate = (key: string, values?: Record) => string; export type StatementTheme = { text: string; muted: string; accent: string; rule: string; headBg: string; warn: string; font?: string; }; /** Token theme for screen/print pages (CSS variables, no hex values). */ export const SCREEN_THEME: StatementTheme = { text: "var(--foreground)", muted: "var(--txt-muted)", accent: "var(--ui-primary)", rule: "var(--line)", headBg: "var(--muted)", warn: "var(--warn)", }; export function statementCss(th: StatementTheme): string { return ` .bs{color:${th.text};${th.font ? `font-family:${th.font};` : ""}font-size:13px;line-height:1.45;} .bs+.bs{margin-top:32px;} .bs h1{font-size:20px;margin:0 0 4px;color:${th.accent};} .bs h2{font-size:14px;margin:18px 0 6px;padding-bottom:3px;border-bottom:1px solid ${th.rule};color:${th.accent};break-after:avoid;} .bs h3{font-size:13px;margin:10px 0 4px;break-after:avoid;} .bs-muted{color:${th.muted};} .bs-head{display:flex;flex-wrap:wrap;justify-content:space-between;gap:12px;border-bottom:2px solid ${th.accent};padding-bottom:8px;} .bs-org{font-weight:700;font-size:15px;color:${th.accent};} .bs-meta{text-align:right;} .bs-note{margin:8px 0 0;font-size:12px;color:${th.muted};} .bs-kv{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:4px 20px;margin:0;} .bs-kv dt{font-size:11px;text-transform:uppercase;letter-spacing:.03em;color:${th.muted};margin:0;} .bs-kv dd{margin:0 0 6px;overflow-wrap:anywhere;} .bs-callout{border:1px solid ${th.warn};border-left-width:4px;padding:8px 10px;margin:10px 0;border-radius:6px;break-inside:avoid;} .bs-scroll{overflow-x:auto;} .bs table{width:100%;border-collapse:collapse;margin:4px 0 8px;} .bs th{background:${th.headBg};text-align:left;font-size:12px;padding:5px 6px;border-bottom:1px solid ${th.rule};} .bs td{padding:5px 6px;border-bottom:1px solid ${th.rule};vertical-align:top;} .bs tr{break-inside:avoid;} .bs .num{text-align:right;white-space:nowrap;} .bs .bs-sum td{font-weight:700;} .bs-badge{display:inline-block;border:1px solid ${th.accent};color:${th.accent};border-radius:999px;padding:0 8px;font-size:12px;font-weight:600;} .bs-flag{font-weight:700;color:${th.warn};} .bs-hint{border-left:4px solid ${th.warn};padding:4px 8px;margin:6px 0;} .bs-text{white-space:pre-wrap;} .bs-add td{font-weight:600;} @media print{.bs-break{break-before:page;}.bs+.bs{margin-top:0;}} `; } const addr = (a: Address) => [a.line1, a.line2].filter(Boolean).join(", "); function Kv({ label, value }: { label: string; value?: string | null }) { if (!value) return null; return (
{label}
{value}
); } function MaterialTable({ t, lines, highlight }: { t: Translate; lines: StatementMaterialLine[]; highlight?: boolean }) { return (
{lines.map((m, i) => ( ))}
{t("material.name")} {t("material.articleNumber")} {t("material.quantity")} {t("material.planned")} {t("material.status")} {t("material.reason")}
{m.name} {m.articleNumber ?? "—"} {m.quantity ? `${m.quantity} ${m.unit}` : "—"} {m.plannedQuantity ? `${m.plannedQuantity} ${m.unit}` : "—"} {m.status ? t(`materialStatus.${m.status}`) : t("material.undocumented")} {m.deviationReason ?? ""}
); } function TextBlocks({ t, title, blocks }: { t: Translate; title: string; blocks: StatementTextBlock[] }) { if (!blocks.length) return null; return ( <>

{title}

{blocks.map((b, i) => (
{b.reportNumber ? {t("texts.fromReport", { number: b.reportNumber })} : null} {b.text}
))} ); } export function StatementDocument({ s, t, locale, timeZone, pageBreak }: { s: BillingStatement; t: Translate; locale: string; timeZone: string; pageBreak?: boolean }) { const date = (iso: string) => new Intl.DateTimeFormat(locale, { timeZone, dateStyle: "medium" }).format(new Date(iso)); const dateTime = (iso: string) => new Intl.DateTimeFormat(locale, { timeZone, dateStyle: "medium", timeStyle: "short" }).format(new Date(iso)); const day = (key: string) => new Intl.DateTimeFormat(locale, { timeZone: "UTC", dateStyle: "medium" }).format(new Date(`${key}T00:00:00Z`)); const hm = (m: number) => t("time.hm", { value: formatHm(m) }); const r = s.record; // the period end of a daily report is the start of the next day → show the report day const period = r.kind === "daily_report" && r.reportDate ? day(r.reportDate) : `${date(r.periodFrom)} – ${date(r.periodTo)}`; const section = r.kind === "milestone" ? r.milestoneTitle : r.kind === "daily_report" && r.reportDate ? t("sheet.dailyReportOf", { date: day(r.reportDate) }) : null; const tt = s.time.totals; const hasMaterial = s.materials.used.length + s.materials.additional.length + s.materials.notUsed.length > 0; const hasTexts = s.texts.additionalWork.length + s.texts.deviations.length + s.texts.openItems.length > 0; return (
{s.tenant.name}
{[s.tenant.address, s.tenant.phone, s.tenant.email].filter(Boolean).join(" · ")}

{t("sheet.title")}

{t(`kind.${r.kind}`)} {section ? ` · ${section}` : ""}
{t("sheet.period")}: {period}
{t("sheet.status")}: {t(`status.${r.status}`)}
{r.invoiceNumber ? (
{t("sheet.invoiceNumber")}: {r.invoiceNumber}
) : null}

{t("sheet.noAccounting")}

{t("section.order")}

{r.milestoneDescription ?

{r.milestoneDescription}

: null} {s.customer.billingNotes ? (
{t("field.billingNotes")}
{s.customer.billingNotes}
) : null}

{t("section.time")}

{s.time.persons.length === 0 ? (

{t("time.empty")}

) : (
{s.time.persons.map((p) => ( ))}
{t("time.person")} {t("time.work")} {t("time.travel")} {t("time.materialProcurement")} {t("time.total")} {t("time.breaks")}
{p.name} {p.manualMinutes > 0 ?
{t("time.manual", { value: formatHm(p.manualMinutes) })}
: null}
{hm(p.work)} {hm(p.travel)} {hm(p.materialProcurement)} {hm(p.total)} {hm(p.breaks)}
{t("time.sum")} {hm(tt.work)} {hm(tt.travel)} {hm(tt.materialProcurement)} {hm(tt.total)} {hm(tt.breaks)}
)}

{t("time.breaksInfo")}

{s.time.pendingMinutes > 0 ?

{t("time.pendingHint", { value: formatHm(s.time.pendingMinutes), count: s.time.pendingCount })}

: null} {s.time.hasRunningEntries ?

{t("time.runningHint")}

: null}

{t("section.trips")}

{t("trips.count", { count: s.trips.count })} {s.trips.dates.length ? · {s.trips.dates.map(day).join(", ")} : null}

{t("trips.rule")}

{t("section.material")}

{!hasMaterial ?

{t("material.empty")}

: null} {s.materials.used.length ? ( <>

{t("material.used")}

) : null} {s.materials.additional.length ? ( <>

{t("material.additional")}

) : null} {s.materials.notUsed.length ? ( <>

{t("material.notUsed")}

{t("material.notUsedInfo")}

) : null}

{t("section.texts")}

{!hasTexts ?

{t("texts.empty")}

: null}

{t("section.reports")}

{s.reports.length === 0 ? (

{t("reports.empty")}

) : (
{s.reports.map((rep) => ( ))}
{t("reports.number")} {t("reports.type")} {t("reports.date")} {t("reports.approvedAt")} {t("reports.signature")}
{rep.number ?? "—"} · v{rep.version} {t(`reportType.${rep.type}`)} {day(rep.reportDate)} {rep.approvedAt ? date(rep.approvedAt) : "—"} {rep.signature ? `${t(`outcome.${rep.signature.outcome}`)}${rep.signature.signerName ? ` · ${rep.signature.signerName}` : ""} · ${dateTime(rep.signature.signedAt)}` : t("reports.noSignature")}
)}
); }