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>
308 lines
13 KiB
TypeScript
308 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|