"use client"; import { useState, Fragment } from "react"; import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { Card, CardHeader } from "@/components/ui/card"; import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types"; interface Props { detail: InvoiceDetail; /** * Phase 7: credit notes linked to this invoice (voids + refunds). * Empty array when none. Passed from the server page; client * doesn't re-fetch — router.refresh() rebuilds after actions. */ creditNotes?: CreditNote[]; } /** * Renders the invoice header (status, totals, action bar) then * line items grouped by tenant, then billing snapshot. Actions are * mark-paid (POST), void (POST), refund (POST), delete (DELETE), * PDF download (link to /pdf). * * Phase 7 adds void + refund. The action bar shows: * - status open/overdue → Mark paid, Void, Delete * - status paid → Refund, Delete * - status partially_refunded → Refund (for remainder), Delete * - status fully_refunded / void → Delete only (read-only otherwise) * * On successful action we router.refresh() — the server-side page * re-renders against the new DB state, including any new credit * notes. */ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) { const t = useTranslations("adminBilling"); const router = useRouter(); const { invoice, lines } = detail; const [busyAction, setBusyAction] = useState< null | "mark-paid" | "delete" | "void" | "refund" >(null); const [actionError, setActionError] = useState(""); const [noteInput, setNoteInput] = useState(""); const [noteOpen, setNoteOpen] = useState(false); // Phase 7 — void modal state const [voidOpen, setVoidOpen] = useState(false); const [voidReason, setVoidReason] = useState(""); // Phase 7 — refund modal state. Amount defaults to the full // remaining refundable on open. const [refundOpen, setRefundOpen] = useState(false); const [refundAmount, setRefundAmount] = useState(""); const [refundReason, setRefundReason] = useState(""); const remainingRefundable = Math.round( (invoice.totalChf - invoice.refundedTotalChf) * 100 ) / 100; const markPaid = async () => { setActionError(""); setBusyAction("mark-paid"); try { const res = await fetch( `/api/admin/billing/invoices/${invoice.id}/mark-paid`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ note: noteInput || undefined }), } ); const j = await res.json().catch(() => ({})); if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); setNoteOpen(false); setNoteInput(""); router.refresh(); } catch (e: any) { setActionError(e.message); } finally { setBusyAction(null); } }; const deleteInvoice = async () => { if (!confirm(t("confirmDeleteInvoice", { num: invoice.invoiceNumber }))) return; setActionError(""); setBusyAction("delete"); try { const res = await fetch(`/api/admin/billing/invoices/${invoice.id}`, { method: "DELETE", }); if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); } router.push("/admin/billing/invoices"); } catch (e: any) { setActionError(e.message); setBusyAction(null); } }; // Phase 7 — void: marks an unpaid invoice as cancelled and issues // a credit note. Backend rejects if the invoice is paid (use // refund) or already voided/refunded. const voidInvoice = async () => { if (!voidReason.trim()) { setActionError(t("voidReasonRequired")); return; } setActionError(""); setBusyAction("void"); try { const res = await fetch( `/api/admin/billing/invoices/${invoice.id}/void`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason: voidReason }), } ); const j = await res.json().catch(() => ({})); if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); setVoidOpen(false); setVoidReason(""); router.refresh(); } catch (e: any) { setActionError(e.message); } finally { setBusyAction(null); } }; // Phase 7 — refund: paid invoices only. Amount may be partial; // backend caps at remaining refundable. const refundInvoice = async () => { const amt = parseFloat(refundAmount); if (!isFinite(amt) || amt <= 0) { setActionError(t("refundAmountInvalid")); return; } if (amt - remainingRefundable > 0.005) { setActionError( t("refundAmountExceeds", { max: remainingRefundable.toFixed(2), }) ); return; } if (!refundReason.trim()) { setActionError(t("refundReasonRequired")); return; } setActionError(""); setBusyAction("refund"); try { const res = await fetch( `/api/admin/billing/invoices/${invoice.id}/refund`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amountChf: Math.round(amt * 100) / 100, reason: refundReason, }), } ); const j = await res.json().catch(() => ({})); if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); setRefundOpen(false); setRefundAmount(""); setRefundReason(""); router.refresh(); } catch (e: any) { setActionError(e.message); } finally { setBusyAction(null); } }; // Group lines by tenant for display (matches PDF layout). const linesByTenant = new Map(); for (const ln of lines) { const k = ln.tenantName; if (!linesByTenant.has(k)) linesByTenant.set(k, []); linesByTenant.get(k)!.push(ln); } const tenantOrder = [...linesByTenant.keys()].sort((a, b) => { if (a === null) return 1; if (b === null) return -1; return a.localeCompare(b); }); return (

{invoice.invoiceNumber}

{invoice.periodStart && invoice.periodEnd && ( <> {invoice.periodStart} → {invoice.periodEnd} · )} {t("dueOnLabel")}: {invoice.dueAt} · {invoice.locale}
{t("totalLabel")}
CHF {invoice.totalChf.toFixed(2)}
{/* Action bar */}
{invoice.hasPdf && ( {t("downloadPdfBtn")} )} {(invoice.status === "open" || invoice.status === "overdue") && ( <> {!noteOpen ? ( ) : (
setNoteInput(e.target.value)} className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm" autoFocus />
)} )} {/* Phase 7 — Void: visible only for open/overdue invoices. Same gating as Mark Paid but mutually exclusive with it via the chosen action. Opens a small inline form so the admin can enter a reason; reason is required and lands on the credit-note PDF. */} {(invoice.status === "open" || invoice.status === "overdue") && ( <> {!voidOpen ? ( ) : (
setVoidReason(e.target.value)} maxLength={500} className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm" autoFocus />
)} )} {/* Phase 7 — Refund: paid invoices, including ones already partially refunded (as long as some refundable amount remains). Opens an inline form with amount + reason. The remaining-refundable hint helps admin pick the right number. */} {(invoice.status === "paid" || invoice.status === "partially_refunded") && remainingRefundable > 0 && ( <> {!refundOpen ? ( ) : (
{t("refundRemainingHint", { max: remainingRefundable.toFixed(2), })}
setRefundAmount(e.target.value)} className="w-32 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono" autoFocus /> {t("refundAmountInclVatHint")}
setRefundReason(e.target.value)} maxLength={500} className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm" />
)} )}
{actionError && (
{actionError}
)} {invoice.paidAt && (
{t("paidOnLabel")}: {invoice.paidAt} · {invoice.paidBy} ·{" "} {invoice.paidMethodDetail}
)} {/* Phase 7 — void/refund summary lines, shown when applicable. Surfaces the auditing context that the columns alone don't (who voided, what the reason was, how much has been refunded vs how much remains). */} {invoice.voidedAt && (
{t("voidedOnLabel")}: {invoice.voidedAt} · {invoice.voidedBy} {invoice.voidReason ? ` · ${invoice.voidReason}` : ""}
)} {invoice.refundedTotalChf > 0 && (
{t("refundedTotalLabel")}: CHF{" "} {invoice.refundedTotalChf.toFixed(2)} ·{" "} {t("refundedRemainingLabel")}: CHF{" "} {remainingRefundable.toFixed(2)}
)}
{/* Phase 7 — linked credit notes panel. Hidden when there are none (most invoices). When present, lists each credit note with kind, amount, reason, issued date, and PDF download. */} {creditNotes.length > 0 && ( {t("creditNotesPanelTitle")} {creditNotes.map((cn) => ( ))}
{t("creditNoteNumberHeader")} {t("creditNoteKindHeader")} {t("creditNoteAmountHeader")} {t("creditNoteReasonHeader")} {t("creditNoteIssuedHeader")} {t("creditNotePdfHeader")}
{cn.creditNoteNumber} {t(`creditNoteKind_${cn.kind}` as any)} CHF {cn.amountChf.toFixed(2)} {cn.reason ?? "—"} {cn.issuedAt.slice(0, 10)} {cn.hasPdf ? ( {t("downloadPdfBtn")} ) : ( {t("creditNoteNoPdf")} )}
)} {/* Lines */} {t("lineItemsTitle")} {tenantOrder.map((tenantKey) => { const tenantLines = linesByTenant.get(tenantKey)!; return ( {tenantKey && ( )} {tenantLines.map((ln) => ( ))} ); })}
{t("descCol")} {t("qtyCol")} {t("unitPriceCol")} {t("amountCol")}
{tenantKey}
{ln.description}
{ln.kind}
{ln.quantity} {ln.unitLabel ? ` ${ln.unitLabel}` : ""} {ln.unitPriceChf.toFixed(4)} {ln.amountChf.toFixed(2)}
{t("subtotal")} CHF {invoice.subtotalChf.toFixed(2)}
{t("vat")} ({invoice.vatRate.toFixed(2)}%) CHF {invoice.vatAmountChf.toFixed(2)}
{t("total")} CHF {invoice.totalChf.toFixed(2)}
{/* Billing snapshot */} {t("billToSnapshotTitle")}
{invoice.billingSnapshot.companyName}
{invoice.billingSnapshot.streetAddress}
{invoice.billingSnapshot.postalCode}{" "} {invoice.billingSnapshot.city}
{invoice.billingSnapshot.country}
{invoice.billingSnapshot.vatNumber && (
VAT: {invoice.billingSnapshot.vatNumber}
)}
{invoice.billingSnapshot.billingEmail}
); } function StatusPill({ status }: { status: InvoiceStatus }) { const t = useTranslations("adminBilling"); const color = status === "paid" ? "bg-success/15 text-success" : status === "overdue" ? "bg-error/15 text-error" : status === "void" || status === "uncollectible" ? "bg-text-muted/15 text-text-muted" : status === "partially_refunded" || status === "fully_refunded" ? "bg-error/15 text-error" : "bg-accent/15 text-accent"; return ( {t(`status_${status}`)} ); }