Files
craftvia/src/app/(app)/billing/page.tsx
T
msolarczekandClaude Opus 5 b76c1bdbfd 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>
2026-09-15 10:35:56 +02:00

229 lines
11 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 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>
);
}