Files
craftvia/src/components/billing/statement-document.tsx
T
msolarczekandClaude Opus 5 ac2d857e26 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>
2026-09-15 10:35:38 +02:00

308 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, string | number>) => 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 (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
}
function MaterialTable({ t, lines, highlight }: { t: Translate; lines: StatementMaterialLine[]; highlight?: boolean }) {
return (
<div className="bs-scroll">
<table>
<thead>
<tr>
<th>{t("material.name")}</th>
<th>{t("material.articleNumber")}</th>
<th className="num">{t("material.quantity")}</th>
<th className="num">{t("material.planned")}</th>
<th>{t("material.status")}</th>
<th>{t("material.reason")}</th>
</tr>
</thead>
<tbody>
{lines.map((m, i) => (
<tr key={m.usageId ?? m.planId ?? i} className={highlight ? "bs-add" : undefined}>
<td>{m.name}</td>
<td>{m.articleNumber ?? "—"}</td>
<td className="num">{m.quantity ? `${m.quantity} ${m.unit}` : "—"}</td>
<td className="num">{m.plannedQuantity ? `${m.plannedQuantity} ${m.unit}` : "—"}</td>
<td>{m.status ? t(`materialStatus.${m.status}`) : t("material.undocumented")}</td>
<td>{m.deviationReason ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function TextBlocks({ t, title, blocks }: { t: Translate; title: string; blocks: StatementTextBlock[] }) {
if (!blocks.length) return null;
return (
<>
<h3>{title}</h3>
{blocks.map((b, i) => (
<div key={i} className="bs-text">
{b.reportNumber ? <span className="bs-muted">{t("texts.fromReport", { number: b.reportNumber })} </span> : null}
{b.text}
</div>
))}
</>
);
}
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 (
<article className={`bs${pageBreak ? " bs-break" : ""}`} aria-label={t("sheet.title")}>
<header className="bs-head">
<div>
<div className="bs-org">{s.tenant.name}</div>
<div className="bs-muted">{[s.tenant.address, s.tenant.phone, s.tenant.email].filter(Boolean).join(" · ")}</div>
</div>
<div className="bs-meta">
<h1>{t("sheet.title")}</h1>
<div>
{t(`kind.${r.kind}`)}
{section ? ` · ${section}` : ""}
</div>
<div>
{t("sheet.period")}: {period}
</div>
<div>
{t("sheet.status")}: <span className="bs-badge">{t(`status.${r.status}`)}</span>
</div>
{r.invoiceNumber ? (
<div>
{t("sheet.invoiceNumber")}: <strong>{r.invoiceNumber}</strong>
</div>
) : null}
</div>
</header>
<p className="bs-note">{t("sheet.noAccounting")}</p>
<h2>{t("section.order")}</h2>
<dl className="bs-kv">
<Kv label={t("field.customer")} value={s.customer.name} />
<Kv label={t("field.customerNumber")} value={s.customer.number} />
<Kv label={t("field.billingAddress")} value={addr(s.customer.billingAddress)} />
<Kv label={t("field.site")} value={s.site ? [s.site.name, addr(s.site.address)].filter(Boolean).join(", ") : null} />
<Kv label={t("field.orderNumber")} value={s.workOrder.number} />
<Kv label={t("field.externalOrderNumber")} value={s.workOrder.externalOrderNumber} />
<Kv label={t("field.offerNumber")} value={s.workOrder.offerNumber} />
<Kv label={t("field.title")} value={s.workOrder.title} />
<Kv label={t("field.orderType")} value={s.workOrder.orderType} />
<Kv label={t("field.billingType")} value={s.workOrder.billingType ? t(`billingType.${s.workOrder.billingType}`) : null} />
<Kv label={t("field.team")} value={s.workOrder.team} />
<Kv label={t("field.section")} value={[t(`kind.${r.kind}`), section].filter(Boolean).join(" · ")} />
<Kv label={t("field.workDates")} value={s.workDates.map(day).join(", ")} />
<Kv label={t("field.billedAt")} value={r.billedAt ? `${dateTime(r.billedAt)}${r.billedByName ? ` · ${r.billedByName}` : ""}` : null} />
</dl>
{r.milestoneDescription ? <p className="bs-text">{r.milestoneDescription}</p> : null}
{s.customer.billingNotes ? (
<div className="bs-callout">
<strong>{t("field.billingNotes")}</strong>
<div className="bs-text">{s.customer.billingNotes}</div>
</div>
) : null}
<h2>{t("section.time")}</h2>
{s.time.persons.length === 0 ? (
<p className="bs-muted">{t("time.empty")}</p>
) : (
<div className="bs-scroll">
<table>
<thead>
<tr>
<th>{t("time.person")}</th>
<th className="num">{t("time.work")}</th>
<th className="num">{t("time.travel")}</th>
<th className="num">{t("time.materialProcurement")}</th>
<th className="num">{t("time.total")}</th>
<th className="num">{t("time.breaks")}</th>
</tr>
</thead>
<tbody>
{s.time.persons.map((p) => (
<tr key={p.userId}>
<td>
{p.name}
{p.manualMinutes > 0 ? <div className="bs-flag">{t("time.manual", { value: formatHm(p.manualMinutes) })}</div> : null}
</td>
<td className="num">{hm(p.work)}</td>
<td className="num">{hm(p.travel)}</td>
<td className="num">{hm(p.materialProcurement)}</td>
<td className="num">{hm(p.total)}</td>
<td className="num bs-muted">{hm(p.breaks)}</td>
</tr>
))}
<tr className="bs-sum">
<td>{t("time.sum")}</td>
<td className="num">{hm(tt.work)}</td>
<td className="num">{hm(tt.travel)}</td>
<td className="num">{hm(tt.materialProcurement)}</td>
<td className="num">{hm(tt.total)}</td>
<td className="num bs-muted">{hm(tt.breaks)}</td>
</tr>
</tbody>
</table>
</div>
)}
<p className="bs-muted">{t("time.breaksInfo")}</p>
{s.time.pendingMinutes > 0 ? <p className="bs-hint">{t("time.pendingHint", { value: formatHm(s.time.pendingMinutes), count: s.time.pendingCount })}</p> : null}
{s.time.hasRunningEntries ? <p className="bs-hint">{t("time.runningHint")}</p> : null}
<h2>{t("section.trips")}</h2>
<p>
<strong>{t("trips.count", { count: s.trips.count })}</strong>
{s.trips.dates.length ? <span className="bs-muted"> · {s.trips.dates.map(day).join(", ")}</span> : null}
</p>
<p className="bs-muted">{t("trips.rule")}</p>
<h2>{t("section.material")}</h2>
{!hasMaterial ? <p className="bs-muted">{t("material.empty")}</p> : null}
{s.materials.used.length ? (
<>
<h3>{t("material.used")}</h3>
<MaterialTable t={t} lines={s.materials.used} />
</>
) : null}
{s.materials.additional.length ? (
<>
<h3 className="bs-flag">{t("material.additional")}</h3>
<MaterialTable t={t} lines={s.materials.additional} highlight />
</>
) : null}
{s.materials.notUsed.length ? (
<>
<h3>{t("material.notUsed")}</h3>
<p className="bs-muted">{t("material.notUsedInfo")}</p>
<MaterialTable t={t} lines={s.materials.notUsed} />
</>
) : null}
<h2>{t("section.texts")}</h2>
{!hasTexts ? <p className="bs-muted">{t("texts.empty")}</p> : null}
<TextBlocks t={t} title={t("texts.additionalWork")} blocks={s.texts.additionalWork} />
<TextBlocks t={t} title={t("texts.deviations")} blocks={s.texts.deviations} />
<TextBlocks t={t} title={t("texts.openItems")} blocks={s.texts.openItems} />
<h2>{t("section.reports")}</h2>
{s.reports.length === 0 ? (
<p className="bs-muted">{t("reports.empty")}</p>
) : (
<div className="bs-scroll">
<table>
<thead>
<tr>
<th>{t("reports.number")}</th>
<th>{t("reports.type")}</th>
<th>{t("reports.date")}</th>
<th>{t("reports.approvedAt")}</th>
<th>{t("reports.signature")}</th>
</tr>
</thead>
<tbody>
{s.reports.map((rep) => (
<tr key={rep.id}>
<td>
{rep.number ?? "—"} · v{rep.version}
</td>
<td>{t(`reportType.${rep.type}`)}</td>
<td>{day(rep.reportDate)}</td>
<td>{rep.approvedAt ? date(rep.approvedAt) : "—"}</td>
<td>
{rep.signature
? `${t(`outcome.${rep.signature.outcome}`)}${rep.signature.signerName ? ` · ${rep.signature.signerName}` : ""} · ${dateTime(rep.signature.signedAt)}`
: t("reports.noSignature")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</article>
);
}