diff --git a/messages/de/dashboard.json b/messages/de/dashboard.json index 7388acd..0067f43 100644 --- a/messages/de/dashboard.json +++ b/messages/de/dashboard.json @@ -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" } diff --git a/messages/de/nav.json b/messages/de/nav.json index f49d785..1ba76a2 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -17,5 +17,6 @@ "admin": "Admin-Konsole", "openMenu": "Menü öffnen", "closeMenu": "Menü schließen", - "timeApprovals": "Zeiten zur Freigabe" + "timeApprovals": "Zeiten zur Freigabe", + "billing": "Abrechnung" } diff --git a/messages/de/workOrders.json b/messages/de/workOrders.json index 94eb659..6ae0021 100644 --- a/messages/de/workOrders.json +++ b/messages/de/workOrders.json @@ -156,7 +156,8 @@ "notes": "Notizen", "reports": "Berichte", "documents": "Dokumente", - "history": "Verlauf" + "history": "Verlauf", + "billing": "Meilensteine & Abrechnung" } }, "overview": { diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 3f36189..46c5514 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -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" } diff --git a/messages/en/nav.json b/messages/en/nav.json index 5e97423..8a9ddf9 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -17,5 +17,6 @@ "admin": "Admin console", "openMenu": "Open menu", "closeMenu": "Close menu", - "timeApprovals": "Time approvals" + "timeApprovals": "Time approvals", + "billing": "Billing" } diff --git a/messages/en/workOrders.json b/messages/en/workOrders.json index 9ff39e2..07ca452 100644 --- a/messages/en/workOrders.json +++ b/messages/en/workOrders.json @@ -156,7 +156,8 @@ "notes": "Notes", "reports": "Reports", "documents": "Documents", - "history": "History" + "history": "History", + "billing": "Milestones & billing" } }, "overview": { diff --git a/src/app/(app)/billing/[id]/page.tsx b/src/app/(app)/billing/[id]/page.tsx new file mode 100644 index 0000000..ec31e60 --- /dev/null +++ b/src/app/(app)/billing/[id]/page.tsx @@ -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; +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 }) { + 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 ( +
+
+ + + {t("detail.back")} + + +
+
+
+

{s.workOrder.number}

+

{t("detail.title", { number: s.workOrder.number })}

+

+ {s.customer.name} + {s.site && · {s.site.name}} +

+
+ + +
+
+
+ {canWrite && record.status === "open" && ( + + + {t("detail.markBilled")} + + )} + {record.pdfDocumentId ? ( + + + {t("detail.pdf")} + + ) : null} + + + {t("detail.print")} + + {canWrite && record.status === "billed" && ( + <> + + {t("detail.changeInvoice")} + + + {t("detail.void")} + + + )} +
+
+
+ + + {t("detail.openOrder")} + + + + {t("detail.photos")} + + {s.reports.map((r) => ( + + {t("detail.report", { number: r.number ?? "—", version: r.version })} + + ))} +
+
+ + {done && ( +

+ + {t(`detail.done.${done === "voided" ? "voided" : done === "invoice" ? "invoice" : "billed"}`)} +

+ )} + {record.status === "billed" && !record.pdfDocumentId && ( +
+

{t("detail.pdfPending")}

+ {canWrite && + + } +
+ )} + {record.status === "voided" && ( +
+

+ {t("detail.voidedInfo", { at: formatDateTime(record.voidedAt, locale, tz), by: record.voidedByName ?? "—" })} +

+ {record.voidReason &&

{record.voidReason}

} +
+ )} +
+ +
+