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
@@ -0,0 +1,307 @@
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>
);
}