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