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:
@@ -17,7 +17,8 @@
|
||||
"emergency_new": "Neue Notdienste",
|
||||
"missing_signatures": "Fehlende Unterschriften",
|
||||
"sync_conflicts": "Sync-Konflikte",
|
||||
"time_approvals": "Zeiten zur Freigabe"
|
||||
"time_approvals": "Zeiten zur Freigabe",
|
||||
"billing_ready": "Bereit zur Abrechnung"
|
||||
},
|
||||
"hints": {
|
||||
"open": "Noch nicht abgerechnet oder storniert",
|
||||
@@ -31,7 +32,8 @@
|
||||
"emergency_new": "Neu oder zur Prüfung",
|
||||
"missing_signatures": "Unterschrift ausstehend",
|
||||
"sync_conflicts": "Offline-Änderungen prüfen",
|
||||
"time_approvals": "Nachträge und Korrekturen prüfen"
|
||||
"time_approvals": "Nachträge und Korrekturen prüfen",
|
||||
"billing_ready": "Offene Einträge der Abrechnungsübersicht"
|
||||
},
|
||||
"open": "Anzeigen"
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@
|
||||
"admin": "Admin-Konsole",
|
||||
"openMenu": "Menü öffnen",
|
||||
"closeMenu": "Menü schließen",
|
||||
"timeApprovals": "Zeiten zur Freigabe"
|
||||
"timeApprovals": "Zeiten zur Freigabe",
|
||||
"billing": "Abrechnung"
|
||||
}
|
||||
|
||||
@@ -156,7 +156,8 @@
|
||||
"notes": "Notizen",
|
||||
"reports": "Berichte",
|
||||
"documents": "Dokumente",
|
||||
"history": "Verlauf"
|
||||
"history": "Verlauf",
|
||||
"billing": "Meilensteine & Abrechnung"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"emergency_new": "New emergencies",
|
||||
"missing_signatures": "Missing signatures",
|
||||
"sync_conflicts": "Sync conflicts",
|
||||
"time_approvals": "Time approvals"
|
||||
"time_approvals": "Time approvals",
|
||||
"billing_ready": "Ready for billing"
|
||||
},
|
||||
"hints": {
|
||||
"open": "Not yet billed or cancelled",
|
||||
@@ -31,7 +32,8 @@
|
||||
"emergency_new": "New or in review",
|
||||
"missing_signatures": "Signature pending",
|
||||
"sync_conflicts": "Review offline changes",
|
||||
"time_approvals": "Review added time and corrections"
|
||||
"time_approvals": "Review added time and corrections",
|
||||
"billing_ready": "Open entries of the billing overview"
|
||||
},
|
||||
"open": "Show"
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@
|
||||
"admin": "Admin console",
|
||||
"openMenu": "Open menu",
|
||||
"closeMenu": "Close menu",
|
||||
"timeApprovals": "Time approvals"
|
||||
"timeApprovals": "Time approvals",
|
||||
"billing": "Billing"
|
||||
}
|
||||
|
||||
@@ -156,7 +156,8 @@
|
||||
"notes": "Notes",
|
||||
"reports": "Reports",
|
||||
"documents": "Documents",
|
||||
"history": "History"
|
||||
"history": "History",
|
||||
"billing": "Milestones & billing"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { IDLE_STATE, type ActionState } from "@/lib/work-orders/action-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonCls, type ButtonVariant } from "@/components/work-orders/button-cls";
|
||||
|
||||
type Action = (prev: ActionState, fd: FormData) => Promise<ActionState>;
|
||||
|
||||
/** Form bound to a billing server action; errors come from messages billing.errors.* (text + icon). */
|
||||
export function BillingActionForm({
|
||||
action,
|
||||
children,
|
||||
submitLabel,
|
||||
pendingLabel,
|
||||
successText,
|
||||
variant = "default",
|
||||
className,
|
||||
footerClassName,
|
||||
}: {
|
||||
action: Action;
|
||||
children?: React.ReactNode;
|
||||
submitLabel: string;
|
||||
pendingLabel?: string;
|
||||
successText?: string;
|
||||
variant?: ButtonVariant;
|
||||
className?: string;
|
||||
footerClassName?: string;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
|
||||
const t = useTranslations("billing");
|
||||
|
||||
let errorText = "";
|
||||
if (state.status === "error") {
|
||||
const key = `errors.${state.message}`;
|
||||
errorText = t.has(key) ? t(key) : t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.internal");
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className={className}>
|
||||
{children}
|
||||
{state.status === "error" && (
|
||||
<p role="alert" className="mt-3 flex items-center gap-2 rounded-lg border border-[var(--risk)] bg-card px-3 py-2 text-sm font-semibold text-[var(--risk)]">
|
||||
<AlertCircle className="size-4 shrink-0" aria-hidden />
|
||||
{errorText}
|
||||
</p>
|
||||
)}
|
||||
{state.status === "ok" && successText && (
|
||||
<p role="status" className="mt-3 flex items-center gap-2 text-sm text-[var(--ok)]">
|
||||
<CheckCircle2 className="size-4" aria-hidden />
|
||||
{successText}
|
||||
</p>
|
||||
)}
|
||||
<div className={cn("mt-3 flex flex-wrap gap-2", footerClassName)}>
|
||||
<button type="submit" disabled={pending} className={buttonCls(variant)}>
|
||||
{pending ? (pendingLabel ?? submitLabel) : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Printer } from "lucide-react";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
|
||||
/** Opens the browser print dialog (buttons are hidden in print by globals.css). */
|
||||
export function PrintButton({ label }: { label: string }) {
|
||||
return (
|
||||
<button type="button" onClick={() => window.print()} className={buttonCls("primary")}>
|
||||
<Printer className="size-4" aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Ban, CheckCheck, CircleDashed, Clock, Flag, Receipt, TriangleAlert, type LucideIcon } from "lucide-react";
|
||||
import type { BillingKind, BillingStatus, MilestoneStatus } from "@/lib/billing/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Status badges of the billing overview — always text + icon, colours only via tokens. */
|
||||
|
||||
const RECORD: Record<BillingStatus, { icon: LucideIcon; color: string }> = {
|
||||
open: { icon: Receipt, color: "var(--info)" },
|
||||
billed: { icon: CheckCheck, color: "var(--ok)" },
|
||||
voided: { icon: Ban, color: "var(--risk)" },
|
||||
};
|
||||
|
||||
const MILESTONE: Record<MilestoneStatus, { icon: LucideIcon; color: string }> = {
|
||||
open: { icon: CircleDashed, color: "var(--txt-muted)" },
|
||||
reached: { icon: Flag, color: "var(--warn)" },
|
||||
confirmed: { icon: Receipt, color: "var(--info)" },
|
||||
billed: { icon: CheckCheck, color: "var(--ok)" },
|
||||
};
|
||||
|
||||
function Badge({ icon: Icon, color, label, className }: { icon: LucideIcon; color: string; label: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap", className)}
|
||||
style={{ color, borderColor: color, background: `color-mix(in oklch, ${color} 10%, transparent)` }}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecordStatusBadge({ status, label, className }: { status: BillingStatus; label: string; className?: string }) {
|
||||
return <Badge {...RECORD[status]} label={label} className={className} />;
|
||||
}
|
||||
|
||||
export function MilestoneStatusBadge({ status, label, className }: { status: MilestoneStatus; label: string; className?: string }) {
|
||||
return <Badge {...MILESTONE[status]} label={label} className={className} />;
|
||||
}
|
||||
|
||||
export function PendingBadge({ label }: { label: string }) {
|
||||
return <Badge icon={TriangleAlert} color="var(--warn)" label={label} />;
|
||||
}
|
||||
|
||||
export function KindLabel({ kind, label }: { kind: BillingKind; label: string }) {
|
||||
const Icon = kind === "milestone" ? Flag : kind === "daily_report" ? Clock : Receipt;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-semibold">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Format a YYYY-MM-DD key as a date without time zone shifts. */
|
||||
export function formatDayKey(key: string, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale, { timeZone: "UTC", dateStyle: "medium" }).format(new Date(`${key}T00:00:00Z`));
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { BillingActionForm } from "@/components/billing/action-form";
|
||||
import { KindLabel, MilestoneStatusBadge, RecordStatusBadge } from "@/components/billing/ui";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
|
||||
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
|
||||
import {
|
||||
confirmMilestoneAction,
|
||||
createMilestoneAction,
|
||||
deleteMilestoneAction,
|
||||
moveMilestoneAction,
|
||||
rejectMilestoneAction,
|
||||
} from "@/server/actions/billing/milestones";
|
||||
import { can, type ServiceCtx } from "@/server/services/context";
|
||||
import { listMilestones } from "@/server/services/billing/milestones";
|
||||
import { listWorkOrderBillingRecords } from "@/server/services/billing/queries";
|
||||
import type { WorkOrderDetail } from "@/server/services/work-orders/detail";
|
||||
|
||||
/**
|
||||
* Work order detail tab "Meilensteine & Abrechnung" (L14): define/sort/delete milestones (open only),
|
||||
* confirm/reject reported ones, list the billing records of the order with links.
|
||||
*/
|
||||
export async function WorkOrderBillingTab({ ctx, wo, locale, tz }: { ctx: ServiceCtx; wo: WorkOrderDetail; locale: string; tz: string }) {
|
||||
const t = await getTranslations("billing");
|
||||
const canDefine = can(ctx, "work_order:write") && !["billed", "cancelled"].includes(wo.status);
|
||||
const canDecide = can(ctx, "billing:write");
|
||||
const [milestones, records] = await Promise.all([
|
||||
listMilestones(ctx, wo.id),
|
||||
can(ctx, "billing:read") ? listWorkOrderBillingRecords(ctx, wo.id) : Promise.resolve(null),
|
||||
]);
|
||||
const userIds = [...new Set(milestones.flatMap((m) => [m.reachedById, m.confirmedById, m.rejectedById]).filter((x): x is string => !!x))];
|
||||
const users = userIds.length ? await ctx.db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } }) : [];
|
||||
const nameOf = new Map(users.map((u) => [u.id, u.name]));
|
||||
const titleOf = new Map(milestones.map((m) => [m.id, m.title]));
|
||||
const hidden = (name: string, value: string) => <input type="hidden" name={name} value={value} />;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[3fr_2fr]">
|
||||
<Section title={t("tab.milestones")}>
|
||||
<p className="mb-3 text-sm text-muted-foreground">{milestones.length ? t("tab.milestonesHint") : t("tab.noMilestonesHint")}</p>
|
||||
{milestones.length === 0 ? (
|
||||
<Empty>{t("tab.noMilestones")}</Empty>
|
||||
) : (
|
||||
<ol className="divide-y">
|
||||
{milestones.map((m, i) => (
|
||||
<li key={m.id} className="flex flex-wrap items-start justify-between gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-2 font-semibold">
|
||||
<span className="text-muted-foreground">{i + 1}.</span>
|
||||
{m.title}
|
||||
<MilestoneStatusBadge status={m.status} label={t(`milestoneStatus.${m.status}`)} />
|
||||
</p>
|
||||
{m.description && <p className="mt-1 text-sm whitespace-pre-wrap text-muted-foreground">{m.description}</p>}
|
||||
{m.reachedAt && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("tab.reachedBy", { name: (m.reachedById && nameOf.get(m.reachedById)) || "—", at: formatDateTime(m.reachedAt, locale, tz) })}
|
||||
{m.reachedNote && ` · ${m.reachedNote}`}
|
||||
</p>
|
||||
)}
|
||||
{m.confirmedAt && (
|
||||
<p className="text-xs text-muted-foreground">{t("tab.confirmedBy", { name: (m.confirmedById && nameOf.get(m.confirmedById)) || "—", at: formatDateTime(m.confirmedAt, locale, tz) })}</p>
|
||||
)}
|
||||
{m.status === "open" && m.rejectionReason && <p className="mt-1 text-xs text-[var(--risk)]">{t("tab.rejected", { reason: m.rejectionReason })}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
{canDefine && (
|
||||
<>
|
||||
<BillingActionForm action={moveMilestoneAction} submitLabel={t("tab.up")} variant="ghost" footerClassName="mt-0">
|
||||
{hidden("milestoneId", m.id)}
|
||||
{hidden("workOrderId", wo.id)}
|
||||
{hidden("direction", "up")}
|
||||
</BillingActionForm>
|
||||
<BillingActionForm action={moveMilestoneAction} submitLabel={t("tab.down")} variant="ghost" footerClassName="mt-0">
|
||||
{hidden("milestoneId", m.id)}
|
||||
{hidden("workOrderId", wo.id)}
|
||||
{hidden("direction", "down")}
|
||||
</BillingActionForm>
|
||||
</>
|
||||
)}
|
||||
{canDefine && m.status === "open" && (
|
||||
<BillingActionForm action={deleteMilestoneAction} submitLabel={t("tab.delete")} variant="danger" footerClassName="mt-0">
|
||||
{hidden("milestoneId", m.id)}
|
||||
{hidden("workOrderId", wo.id)}
|
||||
</BillingActionForm>
|
||||
)}
|
||||
{canDecide && (m.status === "reached" || m.status === "open") && (
|
||||
<BillingActionForm action={confirmMilestoneAction} submitLabel={t("tab.confirm")} variant={m.status === "reached" ? "primary" : "outline"} footerClassName="mt-0">
|
||||
{hidden("milestoneId", m.id)}
|
||||
{hidden("workOrderId", wo.id)}
|
||||
</BillingActionForm>
|
||||
)}
|
||||
</div>
|
||||
{canDecide && m.status === "reached" && (
|
||||
<BillingActionForm action={rejectMilestoneAction} submitLabel={t("tab.reject")} variant="outline" className="flex w-full flex-wrap items-end gap-2" footerClassName="mt-0">
|
||||
{hidden("milestoneId", m.id)}
|
||||
{hidden("workOrderId", wo.id)}
|
||||
<Field label={`${t("tab.rejectReason")} *`} htmlFor={`rej-${m.id}`} className="min-w-56 flex-1">
|
||||
<input id={`rej-${m.id}`} name="reason" required maxLength={2000} className={inputCls} />
|
||||
</Field>
|
||||
</BillingActionForm>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{canDefine && (
|
||||
<BillingActionForm action={createMilestoneAction} submitLabel={t("tab.add")} className="mt-4 grid gap-3 border-t pt-4 md:grid-cols-2">
|
||||
{hidden("workOrderId", wo.id)}
|
||||
<Field label={`${t("tab.newTitle")} *`} htmlFor="ms-title">
|
||||
<input id="ms-title" name="title" required maxLength={200} placeholder={t("tab.newTitlePlaceholder")} className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("tab.newDescription")} htmlFor="ms-desc">
|
||||
<input id="ms-desc" name="description" maxLength={2000} className={inputCls} />
|
||||
</Field>
|
||||
</BillingActionForm>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("tab.records")}>
|
||||
{records === null ? (
|
||||
<Empty>{t("list.noPermission")}</Empty>
|
||||
) : records.length === 0 ? (
|
||||
<Empty>{t("tab.noRecords")}</Empty>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{records.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<KindLabel kind={r.kind} label={[t(`kind.${r.kind}`), r.milestoneId ? titleOf.get(r.milestoneId) : null].filter(Boolean).join(" · ")} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(r.periodFrom, locale, tz)} – {formatDate(r.periodTo, locale, tz)}
|
||||
{r.invoiceNumber && ` · ${t("list.invoice", { number: r.invoiceNumber })}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RecordStatusBadge status={r.status} label={t(`status.${r.status}`)} />
|
||||
<Link href={`/billing/${r.id}`} className={buttonCls("outline")}>
|
||||
{t("tab.open")}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Flag, TriangleAlert, CheckCircle2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { errorKey, isQueued, isSuccess, submitOp } from "@/lib/field/client-ops";
|
||||
import { btnPrimary, btnSecondary, inputClass, noticeError, noticeOk } from "./ui";
|
||||
|
||||
/** Large "Erreicht melden" button with an optional note (L14). Goes through the offline outbox. */
|
||||
export function MilestoneReachButton({ workOrderId, milestoneId, title }: { workOrderId: string; milestoneId: string; title: string }) {
|
||||
const t = useTranslations("billing");
|
||||
const tf = useTranslations("field");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<"sent" | "queued" | null>(null);
|
||||
|
||||
async function send() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const result = await submitOp({ opType: "milestone.reach", payload: { workOrderId, milestoneId, note: note.trim() || null } });
|
||||
setBusy(false);
|
||||
if (!isSuccess(result)) {
|
||||
setError(tf(`errors.${errorKey(result)}`));
|
||||
return;
|
||||
}
|
||||
setDone(isQueued(result) ? "queued" : "sent");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<p className={noticeOk} role="status">
|
||||
<CheckCircle2 className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{t(done === "queued" ? "mobile.queued" : "mobile.sent")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{open && (
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
aria-label={t("mobile.note")}
|
||||
placeholder={t("mobile.notePlaceholder")}
|
||||
className={cn(inputClass, "py-3")}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<button type="button" disabled={busy} onClick={() => (open ? send() : setOpen(true))} className={cn(btnPrimary, "w-full")} aria-label={t("mobile.reachAria", { title })}>
|
||||
<Flag className="size-5" aria-hidden />
|
||||
{open ? t("mobile.confirmReach") : t("mobile.reach")}
|
||||
</button>
|
||||
{open && (
|
||||
<button type="button" disabled={busy} onClick={() => setOpen(false)} className={cn(btnSecondary, "w-full")}>
|
||||
{t("mobile.cancel")}
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<p className={noticeError} role="alert">
|
||||
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { CheckCheck, CircleDashed, Flag, Receipt, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { listMilestones } from "@/server/services/billing/milestones";
|
||||
import { MilestoneReachButton } from "./milestone-reach-button";
|
||||
import { card } from "./ui";
|
||||
|
||||
const STATUS_ICON: Record<string, LucideIcon> = { open: CircleDashed, reached: Flag, confirmed: Receipt, billed: CheckCheck };
|
||||
|
||||
/**
|
||||
* L14 mobile order detail: milestones of the order with status (text + icon) and a large
|
||||
* "Erreicht melden" button for open ones (offline-capable sync op `milestone.reach`).
|
||||
* Renders nothing when the order has no milestones or the billing module is unavailable.
|
||||
*/
|
||||
export async function MilestonesSection({ ctx, workOrderId, editable }: { ctx: ServiceCtx; workOrderId: string; editable: boolean }) {
|
||||
let milestones;
|
||||
try {
|
||||
milestones = await listMilestones(ctx, workOrderId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!milestones.length) return null;
|
||||
const t = await getTranslations("billing");
|
||||
|
||||
return (
|
||||
<section className={card} aria-labelledby="milestones-title">
|
||||
<div className="flex min-h-12 items-center gap-2.5">
|
||||
<Flag className="size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<h2 id="milestones-title" className="flex-1 text-[17px]">
|
||||
{t("mobile.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<ol className="mt-1 space-y-3">
|
||||
{milestones.map((m) => {
|
||||
const Icon = STATUS_ICON[m.status] ?? CircleDashed;
|
||||
return (
|
||||
<li key={m.id} className="rounded-xl border p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-[16px] font-semibold leading-snug">{m.title}</p>
|
||||
<span className={cn("inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-[13px] font-semibold", m.status === "reached" && "text-[var(--warn)]", m.status === "billed" && "text-[var(--ok)]")}>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
{t(`milestoneStatus.${m.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
{m.description && <p className="mt-1 text-[14px] whitespace-pre-line text-muted-foreground">{m.description}</p>}
|
||||
{m.status === "open" && m.rejectionReason && <p className="mt-1 text-[14px] text-[var(--risk)]">{t("mobile.rejected", { reason: m.rejectionReason })}</p>}
|
||||
{m.status === "reached" && <p className="mt-1 text-[14px] text-muted-foreground">{t("mobile.waiting")}</p>}
|
||||
{editable && m.status === "open" && (
|
||||
<div className="mt-3">
|
||||
<MilestoneReachButton workOrderId={workOrderId} milestoneId={m.id} title={m.title} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1042,6 +1042,152 @@ const paths: Record<string, Record<string, Schema>> = {
|
||||
},
|
||||
}),
|
||||
},
|
||||
// ---------- L14 Abrechnungsübersicht (keine Preise, Beträge, Steuern oder Rechnungen) ----------
|
||||
"/billing": {
|
||||
get: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "listBillingRecords",
|
||||
summary: "Abrechnungsübersicht: offene, abgerechnete oder stornierte Einträge",
|
||||
description:
|
||||
"Einträge entstehen bei Abrechnungsfreigabe (Auftragsabschluss), bestätigtem Meilenstein oder – ohne Meilensteine – freigegebenem Tagesbericht. Sortierung: offen nach „bereit seit“ (älteste zuerst), abgerechnet/storniert neueste zuerst. `summary` enthält Arbeitszeit, Fahrzeit (Minuten), Anfahrten, Materialpositionen und offene (nicht freigegebene) Minuten.",
|
||||
module: "billing",
|
||||
permissions: ["billing:read"],
|
||||
parameters: [
|
||||
query("status", str({ enum: ["open", "billed", "voided"], default: "open" })),
|
||||
query("kind", str({ enum: ["order_completion", "milestone", "daily_report"] })),
|
||||
query("customerId", str()),
|
||||
query("teamId", str()),
|
||||
query("from", str({ format: "date" }), "Zeitraum-Überschneidung ab YYYY-MM-DD"),
|
||||
query("to", str({ format: "date" }), "Zeitraum-Überschneidung bis YYYY-MM-DD (inkl.)"),
|
||||
query("q", str({ maxLength: 100 }), "Auftrags-/Angebotsnummer, Titel, Kunde"),
|
||||
query("page", int({ minimum: 1, default: 1 })),
|
||||
query("pageSize", int({ minimum: 1, maximum: 100, default: 25 })),
|
||||
],
|
||||
responses: {
|
||||
"200": jsonResponse(
|
||||
"Liste",
|
||||
obj(
|
||||
{
|
||||
data: arr(
|
||||
open(
|
||||
{
|
||||
id: str(),
|
||||
kind: str({ enum: ["order_completion", "milestone", "daily_report"] }),
|
||||
status: str({ enum: ["open", "billed", "voided"] }),
|
||||
periodFrom: dateTime(),
|
||||
periodTo: dateTime(),
|
||||
readySince: dateTime(),
|
||||
invoiceNumber: nstr(),
|
||||
summary: obj({ workMinutes: int(), travelMinutes: int(), trips: int(), materialCount: int(), pendingMinutes: int() }),
|
||||
},
|
||||
["id", "kind", "status", "summary"],
|
||||
),
|
||||
),
|
||||
pagination: ref("Pagination"),
|
||||
},
|
||||
["data", "pagination"],
|
||||
),
|
||||
),
|
||||
...errors(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
"/billing/{id}": {
|
||||
get: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "getBillingRecord",
|
||||
summary: "Abrechnungseintrag mit Aufstellung (Abrechnungsblatt)",
|
||||
description: "Offen: live berechnet aus noch nicht abgerechneten, freigegebenen Zeiten und Material. Abgerechnet/storniert: eingefrorener Snapshot.",
|
||||
module: "billing",
|
||||
permissions: ["billing:read"],
|
||||
parameters: [idParam("des Abrechnungseintrags")],
|
||||
responses: { "200": jsonResponse("Eintrag", open({ record: open({ id: str(), status: str() }), statement: open({}) }, ["record", "statement"])), ...errors("not_found") },
|
||||
}),
|
||||
},
|
||||
"/billing/{id}/billed": {
|
||||
post: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "markBillingRecordBilled",
|
||||
summary: "Als abgerechnet markieren (optional mit Rechnungsnummer der Buchhaltung)",
|
||||
description:
|
||||
"Friert die Aufstellung ein, ordnet Zeiten/Material dem Eintrag zu (nie doppelt abrechenbar), erzeugt das PDF-Abrechnungsblatt im Worker; Auftragsabschluss → Auftrag `billed`. Offene Zeiten → 422 `blocked` (`pending_time_entries`), außer `confirmPendingExcluded: true`. Nicht offen → 409.",
|
||||
module: "billing",
|
||||
permissions: ["billing:write"],
|
||||
parameters: [idParam("des Abrechnungseintrags")],
|
||||
requestBody: jsonBody(obj({ invoiceNumber: str({ maxLength: 100 }), confirmPendingExcluded: bool() }), false),
|
||||
responses: { "200": jsonResponse("Abgerechnet", obj({ id: str(), status: str({ const: "billed" }), invoiceNumber: nstr(), billedAt: dateTime() })), ...errors("not_found", "conflict", "unprocessable") },
|
||||
}),
|
||||
},
|
||||
"/billing/{id}/void": {
|
||||
post: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "voidBillingRecord",
|
||||
summary: "Abrechnung stornieren (Grund Pflicht)",
|
||||
description: "Nur `billed`: Positionen werden wieder frei, ein neuer offener Eintrag entsteht; Auftragsabschluss → Auftrag zurück auf `released_for_billing`.",
|
||||
module: "billing",
|
||||
permissions: ["billing:write"],
|
||||
parameters: [idParam("des Abrechnungseintrags")],
|
||||
requestBody: jsonBody(obj({ reason: str({ minLength: 1, maxLength: 2000 }) }, ["reason"])),
|
||||
responses: { "200": jsonResponse("Storniert", obj({ id: str(), status: str({ const: "voided" }), reopenedId: nstr() })), ...errors("not_found", "conflict", "unprocessable") },
|
||||
}),
|
||||
},
|
||||
"/work-orders/{id}/milestones": {
|
||||
get: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "listWorkOrderMilestones",
|
||||
summary: "Meilensteine eines Auftrags (im Sichtbarkeits-Scope)",
|
||||
module: "billing",
|
||||
permissions: [],
|
||||
parameters: [idParam("des Auftrags")],
|
||||
responses: { "200": jsonResponse("Meilensteine", obj({ data: arr(open({ id: str(), title: str(), status: str({ enum: ["open", "reached", "confirmed", "billed"] }) })) }, ["data"])), ...errors("not_found") },
|
||||
}),
|
||||
post: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "createWorkOrderMilestone",
|
||||
summary: "Meilenstein anlegen",
|
||||
module: "billing",
|
||||
permissions: ["work_order:write"],
|
||||
parameters: [idParam("des Auftrags")],
|
||||
requestBody: jsonBody(obj({ title: str({ minLength: 1, maxLength: 200 }), description: str({ maxLength: 2000 }) }, ["title"])),
|
||||
responses: { "201": jsonResponse("Angelegt", open({ id: str(), title: str(), status: str() })), ...errors("not_found", "unprocessable") },
|
||||
}),
|
||||
},
|
||||
"/milestones/{id}/reach": {
|
||||
post: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "reachMilestone",
|
||||
summary: "Meilenstein als erreicht melden (idempotent)",
|
||||
description: "Recht im Service: `field:execute` + Auftrag im Scope oder `work_order:write`. Offline: Sync-Op `milestone.reach`.",
|
||||
module: "billing",
|
||||
permissions: [],
|
||||
parameters: [idParam("des Meilensteins")],
|
||||
requestBody: jsonBody(obj({ note: str({ maxLength: 2000 }) }), false),
|
||||
responses: { "200": jsonResponse("Gemeldet", obj({ milestone: open({ id: str(), status: str() }), changed: bool() })), ...errors("not_found", "unprocessable") },
|
||||
}),
|
||||
},
|
||||
"/milestones/{id}/confirm": {
|
||||
post: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "confirmMilestone",
|
||||
summary: "Meilenstein bestätigen → offener Abrechnungseintrag",
|
||||
module: "billing",
|
||||
permissions: ["billing:write"],
|
||||
parameters: [idParam("des Meilensteins")],
|
||||
responses: { "200": jsonResponse("Bestätigt", open({ id: str(), status: str() })), ...errors("not_found", "conflict") },
|
||||
}),
|
||||
},
|
||||
"/milestones/{id}/reject": {
|
||||
post: op({
|
||||
tag: "Abrechnung",
|
||||
operationId: "rejectMilestone",
|
||||
summary: "Gemeldeten Meilenstein ablehnen (Grund Pflicht)",
|
||||
module: "billing",
|
||||
permissions: ["billing:write"],
|
||||
parameters: [idParam("des Meilensteins")],
|
||||
requestBody: jsonBody(obj({ reason: str({ minLength: 1, maxLength: 2000 }) }, ["reason"])),
|
||||
responses: { "200": jsonResponse("Abgelehnt", open({ id: str(), status: str() })), ...errors("not_found", "conflict", "unprocessable") },
|
||||
}),
|
||||
},
|
||||
"/sync": {
|
||||
post: op({
|
||||
tag: "Einsatz",
|
||||
@@ -1137,6 +1283,7 @@ export const openApiDocument = {
|
||||
{ name: "Import", description: "Dokumentenimport mit KI-Extraktion" },
|
||||
{ name: "Berichte", description: "Tages-/Abschlussberichte" },
|
||||
{ name: "Einsatz", description: "Mobile/Offline: Sync, Uploads, Bundle" },
|
||||
{ name: "Abrechnung", description: "Abrechnungsübersicht für die externe Buchhaltung (keine Preise, Beträge, Steuern oder Rechnungen)" },
|
||||
{ name: "Meta" },
|
||||
],
|
||||
paths,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Siren,
|
||||
Compass,
|
||||
Timer,
|
||||
Receipt,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ModuleKey } from "@/lib/modules";
|
||||
@@ -51,6 +52,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
section: "main",
|
||||
},
|
||||
{ href: "/work-orders/time-approvals", label: "timeApprovals", icon: Timer, module: "work_orders", permissions: ["time:approve"], section: "main" },
|
||||
{ href: "/billing", label: "billing", icon: Receipt, module: "billing", permissions: ["billing:read"], section: "main" }, // L14
|
||||
{ href: "/work-orders/emergency-review", label: "emergencyReview", icon: Siren, module: "emergency", permissions: ["emergency:review"], section: "main" },
|
||||
{ href: "/imports", label: "imports", icon: FileInput, module: "imports", permissions: ["import:write"], section: "main" },
|
||||
{ href: "/customers", label: "customers", icon: Users, module: "customers", permissions: ["customer:read"], section: "main" },
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ActionState } from "@/lib/work-orders/action-state";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { ok, str, toErrorState } from "@/server/actions/work_orders/_form";
|
||||
import { ctxFromGuard } from "@/server/services/context";
|
||||
import { markBilled, requestBillingPdf, updateInvoiceNumber, voidBilling } from "@/server/services/billing/records";
|
||||
|
||||
/**
|
||||
* L14 billing overview — thin adapters (module "billing"). Permission, validation, positions,
|
||||
* snapshot, audit and status changes live in services/billing/records.ts.
|
||||
*/
|
||||
const guard = moduleGuard("billing");
|
||||
|
||||
function revalidate(recordId?: string) {
|
||||
revalidatePath("/billing");
|
||||
if (recordId) revalidatePath(`/billing/${recordId}`);
|
||||
}
|
||||
|
||||
export async function markBilledAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "recordId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("billing:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await markBilled(ctxFromGuard(g), { recordId: id, invoiceNumber: str(fd, "invoiceNumber") ?? null, confirmPendingExcluded: fd.get("confirmPendingExcluded") === "on" });
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "billing_record", entityId: id });
|
||||
}
|
||||
revalidate(id);
|
||||
redirect(`/billing/${encodeURIComponent(id)}?done=billed`);
|
||||
}
|
||||
|
||||
export async function voidBillingAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "recordId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
let reopenedId: string | null = null;
|
||||
try {
|
||||
const g = await guard("billing:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
reopenedId = (await voidBilling(ctxFromGuard(g), { recordId: id, reason: str(fd, "reason") ?? "" })).reopened?.id ?? null;
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "billing_record", entityId: id });
|
||||
}
|
||||
revalidate(id);
|
||||
redirect(reopenedId ? `/billing/${encodeURIComponent(reopenedId)}?done=voided` : `/billing/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function updateInvoiceNumberAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "recordId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("billing:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await updateInvoiceNumber(ctxFromGuard(g), { recordId: id, invoiceNumber: str(fd, "invoiceNumber") ?? null });
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "billing_record", entityId: id });
|
||||
}
|
||||
revalidate(id);
|
||||
redirect(`/billing/${encodeURIComponent(id)}?done=invoice`);
|
||||
}
|
||||
|
||||
export async function requestBillingPdfAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "recordId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("billing:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await requestBillingPdf(ctxFromGuard(g), id);
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "billing_record", entityId: id });
|
||||
}
|
||||
revalidate(id);
|
||||
return ok();
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { ActionState } from "@/lib/work-orders/action-state";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { ok, str, toErrorState } from "@/server/actions/work_orders/_form";
|
||||
import { ctxFromGuard } from "@/server/services/context";
|
||||
import { confirmMilestone, createMilestone, deleteMilestone, markMilestoneReached, moveMilestone, rejectMilestone } from "@/server/services/billing/milestones";
|
||||
|
||||
/**
|
||||
* L14 milestones in the back office work order detail (tab "Meilensteine & Abrechnung").
|
||||
* Define/sort/delete: `work_order:write`; confirm/reject: `billing:write`; report as reached on
|
||||
* behalf of the field: `work_order:write` (the field uses the sync op `milestone.reach`).
|
||||
*/
|
||||
const guard = moduleGuard("billing");
|
||||
|
||||
function revalidate(fd: FormData) {
|
||||
const workOrderId = str(fd, "workOrderId");
|
||||
if (workOrderId) revalidatePath(`/work-orders/${workOrderId}`);
|
||||
revalidatePath("/billing");
|
||||
}
|
||||
|
||||
export async function createMilestoneAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("work_order:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await createMilestone(ctxFromGuard(g), { workOrderId: str(fd, "workOrderId") ?? "", title: str(fd, "title") ?? "", description: str(fd, "description") ?? null });
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "work_order_milestone" });
|
||||
}
|
||||
revalidate(fd);
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function moveMilestoneAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "milestoneId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("work_order:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await moveMilestone(ctxFromGuard(g), { milestoneId: id, direction: str(fd, "direction") === "up" ? "up" : "down" });
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "work_order_milestone", entityId: id });
|
||||
}
|
||||
revalidate(fd);
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function deleteMilestoneAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "milestoneId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("work_order:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await deleteMilestone(ctxFromGuard(g), id);
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "work_order_milestone", entityId: id });
|
||||
}
|
||||
revalidate(fd);
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function reachMilestoneAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "milestoneId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("work_order:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await markMilestoneReached(ctxFromGuard(g), { milestoneId: id, note: str(fd, "note") ?? null });
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "work_order_milestone", entityId: id });
|
||||
}
|
||||
revalidate(fd);
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function confirmMilestoneAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "milestoneId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("billing:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await confirmMilestone(ctxFromGuard(g), id);
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "work_order_milestone", entityId: id });
|
||||
}
|
||||
revalidate(fd);
|
||||
return ok();
|
||||
}
|
||||
|
||||
export async function rejectMilestoneAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
const id = str(fd, "milestoneId") ?? "";
|
||||
let tenantId: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
try {
|
||||
const g = await guard("billing:write");
|
||||
tenantId = g.session.user.tenantId;
|
||||
actorId = g.session.user.id;
|
||||
await rejectMilestone(ctxFromGuard(g), { milestoneId: id, reason: str(fd, "reason") ?? "" });
|
||||
} catch (err) {
|
||||
return toErrorState(err, { tenantId, actorId, entity: "work_order_milestone", entityId: id });
|
||||
}
|
||||
revalidate(fd);
|
||||
return ok();
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import { can, type ServiceCtx } from "@/server/services/context";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { buildWorkOrderWhere, presetWhere } from "@/server/services/work-orders/list";
|
||||
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
|
||||
import { countOpenBillingRecords } from "@/server/services/billing/queries";
|
||||
|
||||
export type DashboardTiles = Record<Preset, number> & { reportsToReview: number; syncConflicts: number; timeApprovals: number };
|
||||
export type DashboardTiles = Record<Preset, number> & { reportsToReview: number; syncConflicts: number; timeApprovals: number; billingReady: number };
|
||||
|
||||
/**
|
||||
* Backoffice dashboard counts (spec §21). Every tile = base filter ∧ preset, within workOrderScope.
|
||||
@@ -18,14 +19,15 @@ export async function getDashboardTiles(ctx: ServiceCtx, filter: WorkOrderFilter
|
||||
const counts = await Promise.all(
|
||||
PRESETS.map((preset) => ctx.db.workOrder.count({ where: { AND: [base, presetWhere(preset, pc)] } })),
|
||||
);
|
||||
const [reportsToReview, syncConflicts, timeApprovals] = await Promise.all([
|
||||
const [reportsToReview, syncConflicts, timeApprovals, billingReady] = await Promise.all([
|
||||
ctx.db.report.count({ where: { status: { in: ["submitted", "team_approved"] }, workOrder: base } }),
|
||||
can(ctx, "work_order:write")
|
||||
? ctx.db.syncOperation.count({ where: { status: "conflict", resolvedAt: null } })
|
||||
: Promise.resolve(0),
|
||||
countPendingTimeEntries(ctx), // L12 „Zeiten zur Freigabe" (0 without time:approve)
|
||||
countOpenBillingRecords(ctx), // L14 „Bereit zur Abrechnung" (0 without billing:read)
|
||||
]);
|
||||
|
||||
const tiles = Object.fromEntries(PRESETS.map((p, i) => [p, counts[i]])) as Record<Preset, number>;
|
||||
return { ...tiles, reportsToReview, syncConflicts, timeApprovals };
|
||||
return { ...tiles, reportsToReview, syncConflicts, timeApprovals, billingReady };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user