Merge lane/abrechnung in feature/craftvia-mvp

Konflikte gelöst (additiv): Benachrichtigungstexte (Planung + Meilensteine), Event-Typen,
Navigation, Job-Queues/Prozessoren (geocode-site, planning-watch, billing-pdf), OpenAPI-Tags,
Smoke-Prüfungen (zweiter Mandant: Planung + Abrechnung).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:37:52 +02:00
co-authored by Claude Opus 5
79 changed files with 4481 additions and 40 deletions
+2
View File
@@ -71,6 +71,8 @@ const ENTITY_LABEL: Record<string, string> = {
import_job: "Auftragsimport",
material_usage: "Material",
time_entry: "Arbeitszeit",
billing_record: "Abrechnungseintrag",
work_order_milestone: "Meilenstein",
work_session: "Einsatzzeit",
photo: "Foto",
voice_note: "Sprachnotiz",
+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>
);
}
@@ -0,0 +1,307 @@
import type { Address, BillingStatement, StatementMaterialLine, StatementTextBlock } from "@/lib/billing/statement";
import { formatHm } from "@/lib/billing/statement";
/**
* Billing statement document (L14 §3.3) — pure markup without hooks, shared by the detail page,
* the print view (`/billing/print`) and the PDF template (react-dom/server). Styling lives in
* `statementCss` (scoped `.bs-*` classes), so screen, print and PDF show the same layout.
* No prices, amounts or taxes.
*/
export type Translate = (key: string, values?: Record<string, string | number>) => string;
export type StatementTheme = {
text: string;
muted: string;
accent: string;
rule: string;
headBg: string;
warn: string;
font?: string;
};
/** Token theme for screen/print pages (CSS variables, no hex values). */
export const SCREEN_THEME: StatementTheme = {
text: "var(--foreground)",
muted: "var(--txt-muted)",
accent: "var(--ui-primary)",
rule: "var(--line)",
headBg: "var(--muted)",
warn: "var(--warn)",
};
export function statementCss(th: StatementTheme): string {
return `
.bs{color:${th.text};${th.font ? `font-family:${th.font};` : ""}font-size:13px;line-height:1.45;}
.bs+.bs{margin-top:32px;}
.bs h1{font-size:20px;margin:0 0 4px;color:${th.accent};}
.bs h2{font-size:14px;margin:18px 0 6px;padding-bottom:3px;border-bottom:1px solid ${th.rule};color:${th.accent};break-after:avoid;}
.bs h3{font-size:13px;margin:10px 0 4px;break-after:avoid;}
.bs-muted{color:${th.muted};}
.bs-head{display:flex;flex-wrap:wrap;justify-content:space-between;gap:12px;border-bottom:2px solid ${th.accent};padding-bottom:8px;}
.bs-org{font-weight:700;font-size:15px;color:${th.accent};}
.bs-meta{text-align:right;}
.bs-note{margin:8px 0 0;font-size:12px;color:${th.muted};}
.bs-kv{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:4px 20px;margin:0;}
.bs-kv dt{font-size:11px;text-transform:uppercase;letter-spacing:.03em;color:${th.muted};margin:0;}
.bs-kv dd{margin:0 0 6px;overflow-wrap:anywhere;}
.bs-callout{border:1px solid ${th.warn};border-left-width:4px;padding:8px 10px;margin:10px 0;border-radius:6px;break-inside:avoid;}
.bs-scroll{overflow-x:auto;}
.bs table{width:100%;border-collapse:collapse;margin:4px 0 8px;}
.bs th{background:${th.headBg};text-align:left;font-size:12px;padding:5px 6px;border-bottom:1px solid ${th.rule};}
.bs td{padding:5px 6px;border-bottom:1px solid ${th.rule};vertical-align:top;}
.bs tr{break-inside:avoid;}
.bs .num{text-align:right;white-space:nowrap;}
.bs .bs-sum td{font-weight:700;}
.bs-badge{display:inline-block;border:1px solid ${th.accent};color:${th.accent};border-radius:999px;padding:0 8px;font-size:12px;font-weight:600;}
.bs-flag{font-weight:700;color:${th.warn};}
.bs-hint{border-left:4px solid ${th.warn};padding:4px 8px;margin:6px 0;}
.bs-text{white-space:pre-wrap;}
.bs-add td{font-weight:600;}
@media print{.bs-break{break-before:page;}.bs+.bs{margin-top:0;}}
`;
}
const addr = (a: Address) => [a.line1, a.line2].filter(Boolean).join(", ");
function Kv({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
);
}
function MaterialTable({ t, lines, highlight }: { t: Translate; lines: StatementMaterialLine[]; highlight?: boolean }) {
return (
<div className="bs-scroll">
<table>
<thead>
<tr>
<th>{t("material.name")}</th>
<th>{t("material.articleNumber")}</th>
<th className="num">{t("material.quantity")}</th>
<th className="num">{t("material.planned")}</th>
<th>{t("material.status")}</th>
<th>{t("material.reason")}</th>
</tr>
</thead>
<tbody>
{lines.map((m, i) => (
<tr key={m.usageId ?? m.planId ?? i} className={highlight ? "bs-add" : undefined}>
<td>{m.name}</td>
<td>{m.articleNumber ?? "—"}</td>
<td className="num">{m.quantity ? `${m.quantity} ${m.unit}` : "—"}</td>
<td className="num">{m.plannedQuantity ? `${m.plannedQuantity} ${m.unit}` : "—"}</td>
<td>{m.status ? t(`materialStatus.${m.status}`) : t("material.undocumented")}</td>
<td>{m.deviationReason ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function TextBlocks({ t, title, blocks }: { t: Translate; title: string; blocks: StatementTextBlock[] }) {
if (!blocks.length) return null;
return (
<>
<h3>{title}</h3>
{blocks.map((b, i) => (
<div key={i} className="bs-text">
{b.reportNumber ? <span className="bs-muted">{t("texts.fromReport", { number: b.reportNumber })} </span> : null}
{b.text}
</div>
))}
</>
);
}
export function StatementDocument({ s, t, locale, timeZone, pageBreak }: { s: BillingStatement; t: Translate; locale: string; timeZone: string; pageBreak?: boolean }) {
const date = (iso: string) => new Intl.DateTimeFormat(locale, { timeZone, dateStyle: "medium" }).format(new Date(iso));
const dateTime = (iso: string) => new Intl.DateTimeFormat(locale, { timeZone, dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
const day = (key: string) => new Intl.DateTimeFormat(locale, { timeZone: "UTC", dateStyle: "medium" }).format(new Date(`${key}T00:00:00Z`));
const hm = (m: number) => t("time.hm", { value: formatHm(m) });
const r = s.record;
// the period end of a daily report is the start of the next day → show the report day
const period = r.kind === "daily_report" && r.reportDate ? day(r.reportDate) : `${date(r.periodFrom)} – ${date(r.periodTo)}`;
const section = r.kind === "milestone" ? r.milestoneTitle : r.kind === "daily_report" && r.reportDate ? t("sheet.dailyReportOf", { date: day(r.reportDate) }) : null;
const tt = s.time.totals;
const hasMaterial = s.materials.used.length + s.materials.additional.length + s.materials.notUsed.length > 0;
const hasTexts = s.texts.additionalWork.length + s.texts.deviations.length + s.texts.openItems.length > 0;
return (
<article className={`bs${pageBreak ? " bs-break" : ""}`} aria-label={t("sheet.title")}>
<header className="bs-head">
<div>
<div className="bs-org">{s.tenant.name}</div>
<div className="bs-muted">{[s.tenant.address, s.tenant.phone, s.tenant.email].filter(Boolean).join(" · ")}</div>
</div>
<div className="bs-meta">
<h1>{t("sheet.title")}</h1>
<div>
{t(`kind.${r.kind}`)}
{section ? ` · ${section}` : ""}
</div>
<div>
{t("sheet.period")}: {period}
</div>
<div>
{t("sheet.status")}: <span className="bs-badge">{t(`status.${r.status}`)}</span>
</div>
{r.invoiceNumber ? (
<div>
{t("sheet.invoiceNumber")}: <strong>{r.invoiceNumber}</strong>
</div>
) : null}
</div>
</header>
<p className="bs-note">{t("sheet.noAccounting")}</p>
<h2>{t("section.order")}</h2>
<dl className="bs-kv">
<Kv label={t("field.customer")} value={s.customer.name} />
<Kv label={t("field.customerNumber")} value={s.customer.number} />
<Kv label={t("field.billingAddress")} value={addr(s.customer.billingAddress)} />
<Kv label={t("field.site")} value={s.site ? [s.site.name, addr(s.site.address)].filter(Boolean).join(", ") : null} />
<Kv label={t("field.orderNumber")} value={s.workOrder.number} />
<Kv label={t("field.externalOrderNumber")} value={s.workOrder.externalOrderNumber} />
<Kv label={t("field.offerNumber")} value={s.workOrder.offerNumber} />
<Kv label={t("field.title")} value={s.workOrder.title} />
<Kv label={t("field.orderType")} value={s.workOrder.orderType} />
<Kv label={t("field.billingType")} value={s.workOrder.billingType ? t(`billingType.${s.workOrder.billingType}`) : null} />
<Kv label={t("field.team")} value={s.workOrder.team} />
<Kv label={t("field.section")} value={[t(`kind.${r.kind}`), section].filter(Boolean).join(" · ")} />
<Kv label={t("field.workDates")} value={s.workDates.map(day).join(", ")} />
<Kv label={t("field.billedAt")} value={r.billedAt ? `${dateTime(r.billedAt)}${r.billedByName ? ` · ${r.billedByName}` : ""}` : null} />
</dl>
{r.milestoneDescription ? <p className="bs-text">{r.milestoneDescription}</p> : null}
{s.customer.billingNotes ? (
<div className="bs-callout">
<strong>{t("field.billingNotes")}</strong>
<div className="bs-text">{s.customer.billingNotes}</div>
</div>
) : null}
<h2>{t("section.time")}</h2>
{s.time.persons.length === 0 ? (
<p className="bs-muted">{t("time.empty")}</p>
) : (
<div className="bs-scroll">
<table>
<thead>
<tr>
<th>{t("time.person")}</th>
<th className="num">{t("time.work")}</th>
<th className="num">{t("time.travel")}</th>
<th className="num">{t("time.materialProcurement")}</th>
<th className="num">{t("time.total")}</th>
<th className="num">{t("time.breaks")}</th>
</tr>
</thead>
<tbody>
{s.time.persons.map((p) => (
<tr key={p.userId}>
<td>
{p.name}
{p.manualMinutes > 0 ? <div className="bs-flag">{t("time.manual", { value: formatHm(p.manualMinutes) })}</div> : null}
</td>
<td className="num">{hm(p.work)}</td>
<td className="num">{hm(p.travel)}</td>
<td className="num">{hm(p.materialProcurement)}</td>
<td className="num">{hm(p.total)}</td>
<td className="num bs-muted">{hm(p.breaks)}</td>
</tr>
))}
<tr className="bs-sum">
<td>{t("time.sum")}</td>
<td className="num">{hm(tt.work)}</td>
<td className="num">{hm(tt.travel)}</td>
<td className="num">{hm(tt.materialProcurement)}</td>
<td className="num">{hm(tt.total)}</td>
<td className="num bs-muted">{hm(tt.breaks)}</td>
</tr>
</tbody>
</table>
</div>
)}
<p className="bs-muted">{t("time.breaksInfo")}</p>
{s.time.pendingMinutes > 0 ? <p className="bs-hint">{t("time.pendingHint", { value: formatHm(s.time.pendingMinutes), count: s.time.pendingCount })}</p> : null}
{s.time.hasRunningEntries ? <p className="bs-hint">{t("time.runningHint")}</p> : null}
<h2>{t("section.trips")}</h2>
<p>
<strong>{t("trips.count", { count: s.trips.count })}</strong>
{s.trips.dates.length ? <span className="bs-muted"> · {s.trips.dates.map(day).join(", ")}</span> : null}
</p>
<p className="bs-muted">{t("trips.rule")}</p>
<h2>{t("section.material")}</h2>
{!hasMaterial ? <p className="bs-muted">{t("material.empty")}</p> : null}
{s.materials.used.length ? (
<>
<h3>{t("material.used")}</h3>
<MaterialTable t={t} lines={s.materials.used} />
</>
) : null}
{s.materials.additional.length ? (
<>
<h3 className="bs-flag">{t("material.additional")}</h3>
<MaterialTable t={t} lines={s.materials.additional} highlight />
</>
) : null}
{s.materials.notUsed.length ? (
<>
<h3>{t("material.notUsed")}</h3>
<p className="bs-muted">{t("material.notUsedInfo")}</p>
<MaterialTable t={t} lines={s.materials.notUsed} />
</>
) : null}
<h2>{t("section.texts")}</h2>
{!hasTexts ? <p className="bs-muted">{t("texts.empty")}</p> : null}
<TextBlocks t={t} title={t("texts.additionalWork")} blocks={s.texts.additionalWork} />
<TextBlocks t={t} title={t("texts.deviations")} blocks={s.texts.deviations} />
<TextBlocks t={t} title={t("texts.openItems")} blocks={s.texts.openItems} />
<h2>{t("section.reports")}</h2>
{s.reports.length === 0 ? (
<p className="bs-muted">{t("reports.empty")}</p>
) : (
<div className="bs-scroll">
<table>
<thead>
<tr>
<th>{t("reports.number")}</th>
<th>{t("reports.type")}</th>
<th>{t("reports.date")}</th>
<th>{t("reports.approvedAt")}</th>
<th>{t("reports.signature")}</th>
</tr>
</thead>
<tbody>
{s.reports.map((rep) => (
<tr key={rep.id}>
<td>
{rep.number ?? "—"} · v{rep.version}
</td>
<td>{t(`reportType.${rep.type}`)}</td>
<td>{day(rep.reportDate)}</td>
<td>{rep.approvedAt ? date(rep.approvedAt) : "—"}</td>
<td>
{rep.signature
? `${t(`outcome.${rep.signature.outcome}`)}${rep.signature.signerName ? ` · ${rep.signature.signerName}` : ""} · ${dateTime(rep.signature.signedAt)}`
: t("reports.noSignature")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</article>
);
}
+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>
);
}