L14 Abrechnungsübersicht: Backoffice-Übersicht, Detail, Druckansicht, Meilensteine im Auftrag und mobil, API

/billing mit Tabs Offen/Abgerechnet/Storniert, Filtern und Mehrfachdruck; /billing/[id] mit Abrechnungsblatt und Aktionen (abgerechnet mit Rechnungsnummer, PDF, Rechnungsnummer ändern, stornieren); /billing/print; Auftrags-Tab „Meilensteine & Abrechnung“; mobiler Abschnitt „Erreicht melden“ (offline); Dashboard-Kachel „Bereit zur Abrechnung“; /api/v1/billing*, Meilenstein-Endpunkte und OpenAPI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:35:56 +02:00
co-authored by Claude Opus 5
parent ac2d857e26
commit b76c1bdbfd
32 changed files with 1402 additions and 16 deletions
+186
View File
@@ -0,0 +1,186 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { ArrowLeft, CheckCircle2, ClipboardList, FileDown, Images, Printer } from "lucide-react";
import { Modal } from "@/components/modal";
import { BillingActionForm } from "@/components/billing/action-form";
import { SCREEN_THEME, StatementDocument, statementCss, type Translate } from "@/components/billing/statement-document";
import { KindLabel, RecordStatusBadge } from "@/components/billing/ui";
import { buttonCls } from "@/components/work-orders/button-cls";
import { Field, inputCls, Section } from "@/components/work-orders/ui";
import { formatHm } from "@/lib/billing/statement";
import { formatDateTime } from "@/lib/work-orders/time";
import { requirePageContext } from "@/server/api/context";
import { markBilledAction, requestBillingPdfAction, updateInvoiceNumberAction, voidBillingAction } from "@/server/actions/billing/billing";
import { ServiceError } from "@/server/services/context";
import { getBillingRecordDetail } from "@/server/services/billing/queries";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
type SP = Record<string, string | string[] | undefined>;
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
/** `/billing/[id]` — statement (§3.3) with actions: mark billed, PDF, invoice number, void. */
export default async function BillingDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<SP> }) {
const { id } = await params;
const sp = await searchParams;
const ctx = await requirePageContext("billing");
if (!ctx.permissions.has("billing:read")) notFound();
let detail;
try {
detail = await getBillingRecordDetail(ctx, id);
} catch (err) {
if (err instanceof ServiceError && err.code === "not_found") notFound();
throw err;
}
const [t, tc, locale, tz] = await Promise.all([getTranslations("billing"), getTranslations("common"), getLocale(), tenantTimezone(ctx)]);
const tr: Translate = (key, values) => t(key as never, values as never);
const { record, statement: s } = detail;
const canWrite = ctx.permissions.has("billing:write");
const self = `/billing/${record.id}`;
const showBill = canWrite && record.status === "open" && one(sp.bill) === "1";
const showInvoice = canWrite && record.status === "billed" && one(sp.invoice) === "1";
const showVoid = canWrite && record.status === "billed" && one(sp.void) === "1";
const done = one(sp.done);
return (
<main className="flex-1 p-4 md:p-6">
<div className="no-print">
<Link href="/billing" className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" aria-hidden />
{t("detail.back")}
</Link>
<header className="shadow-card mt-1 rounded-xl border border-l-4 bg-card p-4 md:p-5" style={{ borderLeftColor: "var(--ui-primary)" }}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<p className="font-mono text-xs text-muted-foreground">{s.workOrder.number}</p>
<h1 className="mt-1 text-[22px] break-words">{t("detail.title", { number: s.workOrder.number })}</h1>
<p className="mt-1 text-sm">
{s.customer.name}
{s.site && <span className="text-muted-foreground"> · {s.site.name}</span>}
</p>
<div className="mt-2 flex flex-wrap items-center gap-3">
<RecordStatusBadge status={record.status} label={t(`status.${record.status}`)} className="text-sm" />
<KindLabel kind={record.kind} label={[t(`kind.${record.kind}`), s.record.milestoneTitle].filter(Boolean).join(" · ")} />
</div>
</div>
<div className="flex flex-wrap gap-2 sm:justify-end">
{canWrite && record.status === "open" && (
<Link href={`${self}?bill=1`} scroll={false} className={buttonCls("primary")}>
<CheckCircle2 className="size-4" aria-hidden />
{t("detail.markBilled")}
</Link>
)}
{record.pdfDocumentId ? (
<a href={`/files/${record.pdfDocumentId}`} target="_blank" rel="noopener noreferrer" className={buttonCls("outline")}>
<FileDown className="size-4" aria-hidden />
{t("detail.pdf")}
</a>
) : null}
<Link href={`/billing/print?ids=${record.id}`} className={buttonCls("outline")}>
<Printer className="size-4" aria-hidden />
{t("detail.print")}
</Link>
{canWrite && record.status === "billed" && (
<>
<Link href={`${self}?invoice=1`} scroll={false} className={buttonCls("outline")}>
{t("detail.changeInvoice")}
</Link>
<Link href={`${self}?void=1`} scroll={false} className={buttonCls("danger")}>
{t("detail.void")}
</Link>
</>
)}
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t pt-3 text-sm">
<Link href={`/work-orders/${s.workOrder.id}?tab=billing`} className={buttonCls("ghost")}>
<ClipboardList className="size-4" aria-hidden />
{t("detail.openOrder")}
</Link>
<Link href={`/work-orders/${s.workOrder.id}?tab=photos`} className={buttonCls("ghost")}>
<Images className="size-4" aria-hidden />
{t("detail.photos")}
</Link>
{s.reports.map((r) => (
<Link key={r.id} href={`/reports/${r.id}`} className={buttonCls("ghost")}>
{t("detail.report", { number: r.number ?? "—", version: r.version })}
</Link>
))}
</div>
</header>
{done && (
<p role="status" className="mt-4 flex items-center gap-2 rounded-lg border border-[var(--ok)] bg-card px-4 py-3 text-sm text-[var(--ok)]">
<CheckCircle2 className="size-4" aria-hidden />
{t(`detail.done.${done === "voided" ? "voided" : done === "invoice" ? "invoice" : "billed"}`)}
</p>
)}
{record.status === "billed" && !record.pdfDocumentId && (
<Section className="mt-4">
<p className="text-sm text-muted-foreground">{t("detail.pdfPending")}</p>
{canWrite && <BillingActionForm action={requestBillingPdfAction} submitLabel={t("detail.pdfRequest")} successText={t("detail.pdfRequested")} variant="outline">
<input type="hidden" name="recordId" value={record.id} />
</BillingActionForm>}
</Section>
)}
{record.status === "voided" && (
<Section className="mt-4">
<p className="text-sm">
<strong>{t("detail.voidedInfo", { at: formatDateTime(record.voidedAt, locale, tz), by: record.voidedByName ?? "—" })}</strong>
</p>
{record.voidReason && <p className="mt-1 text-sm whitespace-pre-wrap">{record.voidReason}</p>}
</Section>
)}
</div>
<section className="shadow-card mt-4 rounded-xl border bg-card p-4 md:p-6">
<style dangerouslySetInnerHTML={{ __html: statementCss(SCREEN_THEME) }} />
<StatementDocument s={s} t={tr} locale={locale} timeZone={tz} />
</section>
{showBill && (
<Modal title={t("bill.title")} sub={t("bill.sub")} closeHref={self} closeLabel={tc("close")}>
<BillingActionForm action={markBilledAction} submitLabel={t("bill.submit")} pendingLabel={t("bill.pending")} variant="primary" className="grid gap-4 p-5">
<input type="hidden" name="recordId" value={record.id} />
<Field label={t("bill.invoiceNumber")} htmlFor="bill-invoice" hint={t("bill.invoiceHint")}>
<input id="bill-invoice" name="invoiceNumber" maxLength={100} className={inputCls} autoFocus />
</Field>
{s.time.pendingMinutes > 0 && (
<div className="rounded-lg border border-[var(--warn)] px-3 py-2 text-sm">
<p className="font-semibold">{t("bill.pendingWarning", { value: formatHm(s.time.pendingMinutes), count: s.time.pendingCount })}</p>
<label className="mt-2 flex min-h-11 items-center gap-2.5">
<input type="checkbox" name="confirmPendingExcluded" className="size-5 accent-[var(--ui-primary)]" />
{t("bill.confirmPending")}
</label>
</div>
)}
<p className="text-xs text-muted-foreground">{t("bill.freezeHint")}</p>
</BillingActionForm>
</Modal>
)}
{showInvoice && (
<Modal title={t("invoice.title")} closeHref={self} closeLabel={tc("close")}>
<BillingActionForm action={updateInvoiceNumberAction} submitLabel={t("invoice.submit")} className="p-5">
<input type="hidden" name="recordId" value={record.id} />
<Field label={t("bill.invoiceNumber")} htmlFor="inv-number">
<input id="inv-number" name="invoiceNumber" maxLength={100} defaultValue={record.invoiceNumber ?? ""} className={inputCls} autoFocus />
</Field>
</BillingActionForm>
</Modal>
)}
{showVoid && (
<Modal title={t("void.title")} sub={t("void.sub")} closeHref={self} closeLabel={tc("close")}>
<BillingActionForm action={voidBillingAction} submitLabel={t("void.submit")} pendingLabel={t("void.pending")} variant="danger" className="p-5">
<input type="hidden" name="recordId" value={record.id} />
<Field label={`${t("void.reason")} *`} htmlFor="void-reason">
<textarea id="void-reason" name="reason" required rows={4} maxLength={2000} className={`${inputCls} py-2`} autoFocus />
</Field>
</BillingActionForm>
</Modal>
)}
</main>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „billing" (L14 Abrechnungsübersicht): deckt alle Unterrouten ab (deaktiviert ⇒ Redirect aufs Dashboard). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("billing");
return <>{children}</>;
}
+228
View File
@@ -0,0 +1,228 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { Printer } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { KindLabel, PendingBadge, formatDayKey } from "@/components/billing/ui";
import { buttonCls } from "@/components/work-orders/button-cls";
import { Empty, Field, inputCls, LinkTabs } from "@/components/work-orders/ui";
import { BILLING_KINDS, BILLING_STATUSES, type BillingKind, type BillingStatus } from "@/lib/billing/schemas";
import { formatHm } from "@/lib/billing/statement";
import { formatDate } from "@/lib/work-orders/time";
import { requirePageContext } from "@/server/api/context";
import { listBillingRecords, type BillingListItem } from "@/server/services/billing/queries";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
import { customerDisplayName, customerFilterOptions, teamOptions } from "@/server/services/work-orders/options";
type SP = Record<string, string | string[] | undefined>;
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) || undefined;
/**
* Backoffice `/billing` (L14): completed orders, confirmed milestones and approved daily reports that
* the external accounting can invoice. Tabs Offen · Abgerechnet · Storniert, filters, multi-select →
* print view. No prices, no export.
*/
export default async function BillingOverviewPage({ searchParams }: { searchParams: Promise<SP> }) {
const ctx = await requirePageContext("billing");
const t = await getTranslations("billing");
if (!ctx.permissions.has("billing:read")) {
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} />
<Empty>{t("list.noPermission")}</Empty>
</main>
);
}
const sp = await searchParams;
const statusParam = one(sp.status);
const status: BillingStatus = (BILLING_STATUSES as readonly string[]).includes(statusParam ?? "") ? (statusParam as BillingStatus) : "open";
const kindParam = one(sp.kind);
const kind = (BILLING_KINDS as readonly string[]).includes(kindParam ?? "") ? (kindParam as BillingKind) : undefined;
const base = { kind, customerId: one(sp.customerId), teamId: one(sp.teamId), from: one(sp.from), to: one(sp.to), q: one(sp.q) };
const page = Math.max(1, Math.floor(Number(one(sp.page)) || 1));
const [list, totals, customers, teams, tz, locale] = await Promise.all([
listBillingRecords(ctx, { ...base, status, page }),
Promise.all(BILLING_STATUSES.map((s) => listBillingRecords(ctx, { ...base, status: s, pageSize: 1 }).then((r) => r.total))),
customerFilterOptions(ctx),
teamOptions(ctx),
tenantTimezone(ctx),
getLocale(),
]);
const qs = (extra: Record<string, string | number | undefined>) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries({ ...base, status, ...extra })) if (v !== undefined && v !== "") p.set(k, String(v));
return `/billing?${p.toString()}`;
};
const period = (i: BillingListItem) =>
i.kind === "daily_report" && i.reportDate ? formatDayKey(i.reportDate, locale) : `${formatDate(i.periodFrom, locale, tz)} – ${formatDate(i.periodTo, locale, tz)}`;
const section = (i: BillingListItem) => (i.kind === "milestone" ? i.milestoneTitle : null);
const pages = Math.max(1, Math.ceil(list.total / list.pageSize));
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("subtitle")} />
<LinkTabs
label={t("title")}
items={BILLING_STATUSES.map((s, i) => ({ href: qs({ status: s, page: undefined }), label: t(`tabs.${s}`), active: s === status, count: totals[i] }))}
/>
<details className="shadow-card my-4 rounded-xl border bg-card" open={Object.values(base).some(Boolean)}>
<summary className="flex min-h-11 cursor-pointer items-center px-4 font-heading text-sm font-semibold">{t("filter.title")}</summary>
<form method="get" action="/billing" className="grid gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-3">
<input type="hidden" name="status" value={status} />
<Field label={t("filter.q")} htmlFor="b-q">
<input id="b-q" type="search" name="q" defaultValue={base.q ?? ""} maxLength={100} placeholder={t("filter.qPlaceholder")} className={inputCls} />
</Field>
<Field label={t("filter.from")} htmlFor="b-from">
<input id="b-from" type="date" name="from" defaultValue={base.from ?? ""} className={inputCls} />
</Field>
<Field label={t("filter.to")} htmlFor="b-to">
<input id="b-to" type="date" name="to" defaultValue={base.to ?? ""} className={inputCls} />
</Field>
<Field label={t("filter.customer")} htmlFor="b-customer">
<select id="b-customer" name="customerId" defaultValue={base.customerId ?? ""} className={inputCls}>
<option value="">{t("filter.any")}</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{customerDisplayName(c)}
</option>
))}
</select>
</Field>
<Field label={t("filter.team")} htmlFor="b-team">
<select id="b-team" name="teamId" defaultValue={base.teamId ?? ""} className={inputCls}>
<option value="">{t("filter.any")}</option>
{teams.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</Field>
<Field label={t("filter.kind")} htmlFor="b-kind">
<select id="b-kind" name="kind" defaultValue={kind ?? ""} className={inputCls}>
<option value="">{t("filter.any")}</option>
{BILLING_KINDS.map((k) => (
<option key={k} value={k}>
{t(`kind.${k}`)}
</option>
))}
</select>
</Field>
<div className="flex items-end gap-2">
<button type="submit" className={buttonCls("default")}>
{t("filter.apply")}
</button>
<Link href={`/billing?status=${status}`} className={buttonCls("ghost")}>
{t("filter.reset")}
</Link>
</div>
</form>
</details>
{list.items.length === 0 ? (
<Empty>{t(`list.empty.${status}`)}</Empty>
) : (
<form method="get" action="/billing/print" className="shadow-card rounded-xl border bg-card">
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-3">
<p className="text-sm text-muted-foreground">{t("list.selectHint")}</p>
<button type="submit" className={buttonCls("outline")}>
<Printer className="size-4" aria-hidden />
{t("list.print")}
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[960px] text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="p-3">
<span className="sr-only">{t("list.select")}</span>
</th>
<th className="p-3">{t("list.order")}</th>
<th className="p-3">{t("list.customerSite")}</th>
<th className="p-3">{t("list.section")}</th>
<th className="p-3">{t("list.period")}</th>
<th className="p-3 text-right">{t("list.work")}</th>
<th className="p-3 text-right">{t("list.travel")}</th>
<th className="p-3 text-right">{t("list.trips")}</th>
<th className="p-3 text-right">{t("list.material")}</th>
<th className="p-3">{status === "open" ? t("list.readySince") : status === "billed" ? t("list.billedAt") : t("list.voidedAt")}</th>
</tr>
</thead>
<tbody>
{list.items.map((i) => (
<tr key={i.id} className="border-b align-top last:border-0">
<td className="p-3">
<input type="checkbox" name="ids" value={i.id} aria-label={t("list.selectRow", { number: i.workOrder.number })} className="size-5 accent-[var(--ui-primary)]" />
</td>
<td className="p-3">
<Link href={`/billing/${i.id}`} className="font-mono font-semibold text-[var(--primary)] hover:underline">
{i.workOrder.number}
</Link>
<div className="text-xs text-muted-foreground">{i.workOrder.title}</div>
</td>
<td className="p-3">
<div>{i.customer.name}</div>
<div className="text-xs text-muted-foreground">{i.site?.name ?? "—"}</div>
</td>
<td className="p-3">
<KindLabel kind={i.kind} label={t(`kind.${i.kind}`)} />
{section(i) && <div className="text-xs text-muted-foreground">{section(i)}</div>}
{i.summary.pendingMinutes > 0 && (
<div className="mt-1">
<PendingBadge label={t("list.pending", { value: formatHm(i.summary.pendingMinutes) })} />
</div>
)}
</td>
<td className="p-3 whitespace-nowrap">{period(i)}</td>
<td className="p-3 text-right whitespace-nowrap">{t("hm", { value: formatHm(i.summary.workMinutes) })}</td>
<td className="p-3 text-right whitespace-nowrap">{t("hm", { value: formatHm(i.summary.travelMinutes) })}</td>
<td className="p-3 text-right">{i.summary.trips}</td>
<td className="p-3 text-right">{i.summary.materialCount}</td>
<td className="p-3 whitespace-nowrap">
{status === "open" && formatDate(i.readySince, locale, tz)}
{status === "billed" && (
<>
{formatDate(i.billedAt, locale, tz)}
{i.invoiceNumber && <div className="text-xs text-muted-foreground">{t("list.invoice", { number: i.invoiceNumber })}</div>}
</>
)}
{status === "voided" && (
<>
{formatDate(i.voidedAt, locale, tz)}
{i.voidReason && <div className="max-w-56 truncate text-xs text-muted-foreground">{i.voidReason}</div>}
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</form>
)}
{pages > 1 && (
<nav aria-label={t("list.pagination")} className="mt-4 flex items-center justify-between gap-2">
{page > 1 ? (
<Link href={qs({ page: page - 1 })} className={buttonCls("outline")}>
{t("list.prev")}
</Link>
) : (
<span />
)}
<span className="text-sm text-muted-foreground">{t("list.page", { page, pages })}</span>
{page < pages ? (
<Link href={qs({ page: page + 1 })} className={buttonCls("outline")}>
{t("list.next")}
</Link>
) : (
<span />
)}
</nav>
)}
</main>
);
}
+59
View File
@@ -0,0 +1,59 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { ArrowLeft } from "lucide-react";
import { PrintButton } from "@/components/billing/print-button";
import { SCREEN_THEME, StatementDocument, statementCss, type Translate } from "@/components/billing/statement-document";
import { buttonCls } from "@/components/work-orders/button-cls";
import { Empty } from "@/components/work-orders/ui";
import type { BillingStatement } from "@/lib/billing/statement";
import { requirePageContext } from "@/server/api/context";
import { ServiceError } from "@/server/services/context";
import { buildBillingStatement } from "@/server/services/billing/statement";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
type SP = Record<string, string | string[] | undefined>;
const MAX_SHEETS = 50;
/** `/billing/print?ids=…` — print view of one or more billing sheets (A4, page break per sheet). No export. */
export default async function BillingPrintPage({ searchParams }: { searchParams: Promise<SP> }) {
const ctx = await requirePageContext("billing");
if (!ctx.permissions.has("billing:read")) notFound();
const sp = await searchParams;
const raw = Array.isArray(sp.ids) ? sp.ids : sp.ids ? [sp.ids] : [];
const ids = [...new Set(raw.flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean))].slice(0, MAX_SHEETS);
const [t, locale, tz] = await Promise.all([getTranslations("billing"), getLocale(), tenantTimezone(ctx)]);
const tr: Translate = (key, values) => t(key as never, values as never);
const statements: BillingStatement[] = [];
for (const id of ids) {
try {
statements.push(await buildBillingStatement(ctx, id));
} catch (err) {
if (!(err instanceof ServiceError && err.code === "not_found")) throw err;
}
}
return (
<main className="flex-1 p-4 md:p-6 print:p-0">
<div className="no-print mb-4 flex flex-wrap items-center justify-between gap-2">
<Link href="/billing" className={buttonCls("ghost")}>
<ArrowLeft className="size-4" aria-hidden />
{t("print.back")}
</Link>
<p className="text-sm text-muted-foreground">{t("print.count", { count: statements.length })}</p>
{statements.length > 0 && <PrintButton label={t("print.print")} />}
</div>
{statements.length === 0 ? (
<Empty>{t("print.empty")}</Empty>
) : (
<div className="rounded-xl border bg-card p-4 md:p-6 print:border-0 print:p-0">
<style dangerouslySetInnerHTML={{ __html: statementCss(SCREEN_THEME) }} />
{statements.map((s, i) => (
<StatementDocument key={s.record.id} s={s} t={tr} locale={locale} timeZone={tz} pageBreak={i > 0} />
))}
</div>
)}
</main>
);
}
+5 -4
View File
@@ -28,7 +28,8 @@ import { listOrderTypes } from "@/server/services/work-orders/settings";
type SP = Record<string, string | string[] | undefined>;
const TILES: { key: Preset | "sync_conflicts" | "time_approvals"; icon: LucideIcon; tone: string }[] = [
const TILES: { key: Preset | "sync_conflicts" | "time_approvals" | "billing_ready"; icon: LucideIcon; tone: string }[] = [
{ key: "billing_ready", icon: Receipt, tone: "var(--ok)" }, // L14 Abrechnungsübersicht
{ key: "open", icon: ClipboardList, tone: "var(--ui-primary)" },
{ key: "today", icon: CalendarDays, tone: "var(--info)" },
{ key: "running", icon: Wrench, tone: "var(--ui-accent)" },
@@ -144,9 +145,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
</details>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve"))).map(({ key, icon: Icon, tone }) => {
const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || can("billing:read"))).map(({ key, icon: Icon, tone }) => {
const count = key === "billing_ready" ? tiles.billingReady : key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
const href = key === "billing_ready" ? "/billing" : key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
return (
<li key={key}>
<Link
+3 -1
View File
@@ -16,6 +16,7 @@ import {
ReportsTab,
TimesTab,
} from "@/components/work-orders/detail-tabs";
import { WorkOrderBillingTab } from "@/components/billing/work-order-billing-tab";
import { pageContext } from "@/components/work-orders/page-context";
import { Field, inputCls, LinkTabs, StatusBadge } from "@/components/work-orders/ui";
import { WorkOrderFields } from "@/components/work-orders/work-order-fields";
@@ -28,7 +29,7 @@ import { customerDisplayName, customerOption, teamOptions, userOptions } from "@
import { listOrderTypes } from "@/server/services/work-orders/settings";
import { reasonRequired } from "@/server/services/work-orders/transition";
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "documents", "history"] as const;
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing
type Tab = (typeof TABS)[number];
type SP = Record<string, string | string[] | undefined>;
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
@@ -198,6 +199,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
}
/>
)}
{tab === "billing" && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />}
{tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />}
{tab === "history" && <HistoryTab {...tabProps} />}
</div>
@@ -32,6 +32,7 @@ import { StatusBadge } from "@/components/field/status-badge";
import { card, toneClasses } from "@/components/field/ui";
import { loadOrder } from "./load";
import { LotseCompletenessCard } from "@/components/lotse/completeness-card";
import { MilestonesSection } from "@/components/field/milestones";
function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) {
const head = (
@@ -168,6 +169,8 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
{editable && <LotseCompletenessCard workOrderId={order.id} />}
<MilestonesSection ctx={ctx} workOrderId={order.id} editable={editable} />{/* L14 Abrechnungsübersicht */}
{order.technicianNotes && <Notice icon={Info} label={t("detail.hints")} text={order.technicianNotes} tone="info" />}
{order.site && (
@@ -0,0 +1,19 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { markBilled } from "@/server/services/billing/records";
/**
* POST /api/v1/billing/:id/billed — mark as billed (freeze statement, assign positions, PDF job).
* Body (optional): { invoiceNumber?: string, confirmPendingExcluded?: boolean }.
*/
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing", "billing:write");
const { id } = await params;
const body = await readJsonObject(req, { allowEmpty: true });
const record = await markBilled(ctx, {
recordId: id,
invoiceNumber: typeof body.invoiceNumber === "string" ? body.invoiceNumber : null,
confirmPendingExcluded: body.confirmPendingExcluded === true,
});
return json({ id: record.id, status: record.status, invoiceNumber: record.invoiceNumber, billedAt: record.billedAt });
});
+10
View File
@@ -0,0 +1,10 @@
import { requireApiContext } from "@/server/api/context";
import { json, withApi } from "@/server/api/respond";
import { getBillingRecordDetail } from "@/server/services/billing/queries";
/** GET /api/v1/billing/:id — billing record with its statement (frozen snapshot once billed). */
export const GET = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing", "billing:read");
const { id } = await params;
return json(await getBillingRecordDetail(ctx, id));
});
+12
View File
@@ -0,0 +1,12 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { voidBilling } from "@/server/services/billing/records";
/** POST /api/v1/billing/:id/void — void a billed record. Body: { reason: string } (required). */
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing", "billing:write");
const { id } = await params;
const body = await readJsonObject(req);
const res = await voidBilling(ctx, { recordId: id, reason: typeof body.reason === "string" ? body.reason : "" });
return json({ id: res.voided.id, status: res.voided.status, reopenedId: res.reopened?.id ?? null });
});
+26
View File
@@ -0,0 +1,26 @@
import { requireApiContext } from "@/server/api/context";
import { paginated, parsePagination, withApi } from "@/server/api/respond";
import { listBillingRecords } from "@/server/services/billing/queries";
/**
* GET /api/v1/billing — billing overview (L14). Query: status (open|billed|voided, default open), kind,
* customerId, teamId, from/to (YYYY-MM-DD), q, page, pageSize. No prices or amounts.
*/
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("billing", "billing:read");
const url = new URL(req.url);
const { page, pageSize } = parsePagination(url);
const sp = url.searchParams;
const res = await listBillingRecords(ctx, {
status: (sp.get("status") ?? undefined) as never,
kind: (sp.get("kind") ?? undefined) as never,
customerId: sp.get("customerId") ?? undefined,
teamId: sp.get("teamId") ?? undefined,
from: sp.get("from") ?? undefined,
to: sp.get("to") ?? undefined,
q: sp.get("q") ?? undefined,
page,
pageSize,
});
return paginated(res.items, res.total, res.page, res.pageSize);
});
@@ -0,0 +1,10 @@
import { requireApiContext } from "@/server/api/context";
import { json, withApi } from "@/server/api/respond";
import { confirmMilestone } from "@/server/services/billing/milestones";
/** POST /api/v1/milestones/:id/confirm — confirm a reported milestone → open billing record. */
export const POST = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing", "billing:write");
const { id } = await params;
return json(await confirmMilestone(ctx, id));
});
@@ -0,0 +1,15 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { markMilestoneReached } from "@/server/services/billing/milestones";
/**
* POST /api/v1/milestones/:id/reach — report a milestone as reached (field:execute + order in scope,
* or work_order:write). Idempotent. Body (optional): { note?: string }. Offline: sync op `milestone.reach`.
*/
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing");
const { id } = await params;
const body = await readJsonObject(req, { allowEmpty: true });
const res = await markMilestoneReached(ctx, { milestoneId: id, note: typeof body.note === "string" ? body.note : null });
return json({ milestone: res.milestone, changed: res.changed });
});
@@ -0,0 +1,11 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { rejectMilestone } from "@/server/services/billing/milestones";
/** POST /api/v1/milestones/:id/reject — reject a reported milestone. Body: { reason: string } (required). */
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing", "billing:write");
const { id } = await params;
const body = await readJsonObject(req);
return json(await rejectMilestone(ctx, { milestoneId: id, reason: typeof body.reason === "string" ? body.reason : "" }));
});
@@ -0,0 +1,19 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { createMilestone, listMilestones } from "@/server/services/billing/milestones";
/** GET /api/v1/work-orders/:id/milestones — milestones of a visible order (field roles: scope). */
export const GET = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing");
const { id } = await params;
return json({ data: await listMilestones(ctx, id) });
});
/** POST /api/v1/work-orders/:id/milestones — define a milestone. Body: { title, description? }. */
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
const ctx = await requireApiContext("billing", "work_order:write");
const { id } = await params;
const body = await readJsonObject(req);
const m = await createMilestone(ctx, { workOrderId: id, title: typeof body.title === "string" ? body.title : "", description: typeof body.description === "string" ? body.description : null });
return json(m, { status: 201 });
});