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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:35:56 +02:00
co-authored by Claude Opus 5
parent ac2d857e26
commit b76c1bdbfd
32 changed files with 1402 additions and 16 deletions
+63
View File
@@ -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>
);
}
+14
View File
@@ -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>
);
}
+57
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}