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:
@@ -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",
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user