Files
pieced-portal/src/components/admin/billing/invoice-detail-view.tsx
admin f2a9637058
All checks were successful
Build and Push / build (push) Successful in 2m25s
mobile nav, locale-preserving navigation, accent button contrast
2026-05-29 22:12:51 +02:00

640 lines
24 KiB
TypeScript

"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<string | null, typeof lines>();
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 (
<div className="space-y-4 animate-in">
<div className="flex items-end justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold accent-rule">
{invoice.invoiceNumber}
</h1>
<div className="flex items-center gap-3 mt-3 text-sm">
<StatusPill status={invoice.status} />
{invoice.periodStart && invoice.periodEnd && (
<>
<span className="text-text-muted">
{invoice.periodStart} {invoice.periodEnd}
</span>
<span className="text-text-muted">·</span>
</>
)}
<span className="text-text-muted">
{t("dueOnLabel")}: {invoice.dueAt}
</span>
<span className="text-text-muted">·</span>
<span className="text-text-muted font-mono text-xs">
{invoice.locale}
</span>
</div>
</div>
<div className="text-right">
<div className="text-xs text-text-muted">{t("totalLabel")}</div>
<div className="text-2xl font-semibold font-mono">
CHF {invoice.totalChf.toFixed(2)}
</div>
</div>
</div>
{/* Action bar */}
<Card>
<div className="flex flex-wrap items-center gap-3">
{invoice.hasPdf && (
<a
href={`/api/admin/billing/invoices/${invoice.id}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-md border border-border text-sm hover:bg-surface-2"
>
{t("downloadPdfBtn")}
</a>
)}
{(invoice.status === "open" || invoice.status === "overdue") && (
<>
{!noteOpen ? (
<button
onClick={() => setNoteOpen(true)}
disabled={busyAction !== null}
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
>
{t("markPaidBtn")}
</button>
) : (
<div className="flex items-center gap-2 flex-grow">
<input
type="text"
placeholder={t("paidNotePlaceholder")}
value={noteInput}
onChange={(e) => setNoteInput(e.target.value)}
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
autoFocus
/>
<button
onClick={markPaid}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
>
{busyAction === "mark-paid" ? t("saving") : t("confirm")}
</button>
<button
onClick={() => {
setNoteOpen(false);
setNoteInput("");
}}
className="px-3 py-1.5 rounded-md border border-border text-sm"
>
{t("cancel")}
</button>
</div>
)}
</>
)}
{/* 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 ? (
<button
onClick={() => {
setVoidOpen(true);
setNoteOpen(false);
setRefundOpen(false);
}}
disabled={busyAction !== null}
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
>
{t("voidBtn")}
</button>
) : (
<div className="flex items-center gap-2 flex-grow">
<input
type="text"
placeholder={t("voidReasonPlaceholder")}
value={voidReason}
onChange={(e) => 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
/>
<button
onClick={voidInvoice}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
>
{busyAction === "void" ? t("saving") : t("confirmVoid")}
</button>
<button
onClick={() => {
setVoidOpen(false);
setVoidReason("");
}}
className="px-3 py-1.5 rounded-md border border-border text-sm"
>
{t("cancel")}
</button>
</div>
)}
</>
)}
{/* 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 ? (
<button
onClick={() => {
setRefundOpen(true);
setNoteOpen(false);
setVoidOpen(false);
setRefundAmount(remainingRefundable.toFixed(2));
}}
disabled={busyAction !== null}
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
>
{t("refundBtn")}
</button>
) : (
<div className="flex flex-col gap-2 flex-grow">
<div className="text-xs text-text-muted">
{t("refundRemainingHint", {
max: remainingRefundable.toFixed(2),
})}
</div>
<div className="flex items-center gap-4 flex-wrap">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-text-muted">
{t("refundAmountLabel")}
</label>
<input
type="number"
step="0.01"
min="0.01"
max={remainingRefundable}
placeholder="CHF"
value={refundAmount}
onChange={(e) => 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
/>
<span className="text-[10px] text-text-muted italic">
{t("refundAmountInclVatHint")}
</span>
</div>
<div className="flex flex-col gap-1 flex-grow min-w-[200px]">
<label className="text-[10px] uppercase tracking-wider text-text-muted">
{t("refundReasonLabel")}
</label>
<input
type="text"
placeholder={t("refundReasonPlaceholder")}
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
maxLength={500}
className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
/>
</div>
<div className="flex items-center gap-2 self-end">
<button
onClick={refundInvoice}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
>
{busyAction === "refund"
? t("saving")
: t("confirmRefund")}
</button>
<button
onClick={() => {
setRefundOpen(false);
setRefundAmount("");
setRefundReason("");
}}
className="px-3 py-1.5 rounded-md border border-border text-sm"
>
{t("cancel")}
</button>
</div>
</div>
</div>
)}
</>
)}
<button
onClick={deleteInvoice}
disabled={busyAction !== null}
className="ml-auto px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
title={t("deleteHint")}
>
{busyAction === "delete" ? t("deleting") : t("deleteBtn")}
</button>
</div>
{actionError && (
<div className="mt-3 text-sm text-error">{actionError}</div>
)}
{invoice.paidAt && (
<div className="mt-3 text-xs text-text-muted">
{t("paidOnLabel")}: {invoice.paidAt} · {invoice.paidBy} ·{" "}
{invoice.paidMethodDetail}
</div>
)}
{/* 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 && (
<div className="mt-3 text-xs text-text-muted">
{t("voidedOnLabel")}: {invoice.voidedAt} · {invoice.voidedBy}
{invoice.voidReason ? ` · ${invoice.voidReason}` : ""}
</div>
)}
{invoice.refundedTotalChf > 0 && (
<div className="mt-3 text-xs text-text-muted">
{t("refundedTotalLabel")}: CHF{" "}
{invoice.refundedTotalChf.toFixed(2)} ·{" "}
{t("refundedRemainingLabel")}: CHF{" "}
{remainingRefundable.toFixed(2)}
</div>
)}
</Card>
{/* 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 && (
<Card>
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2 pr-4">{t("creditNoteNumberHeader")}</th>
<th className="pb-2 pr-4">{t("creditNoteKindHeader")}</th>
<th className="pb-2 pr-4 text-right">
{t("creditNoteAmountHeader")}
</th>
<th className="pb-2 pr-4">{t("creditNoteReasonHeader")}</th>
<th className="pb-2 pr-4">{t("creditNoteIssuedHeader")}</th>
<th className="pb-2 text-right">{t("creditNotePdfHeader")}</th>
</tr>
</thead>
<tbody>
{creditNotes.map((cn) => (
<tr key={cn.id} className="border-t border-border">
<td className="py-2 pr-4 font-mono text-xs">
{cn.creditNoteNumber}
</td>
<td className="py-2 pr-4">
<span className="px-2 py-0.5 rounded text-xs text-error bg-error/10">
{t(`creditNoteKind_${cn.kind}` as any)}
</span>
</td>
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
CHF {cn.amountChf.toFixed(2)}
</td>
<td className="py-2 pr-4 text-text-secondary text-xs">
{cn.reason ?? "—"}
</td>
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
{cn.issuedAt.slice(0, 10)}
</td>
<td className="py-2 text-right">
{cn.hasPdf ? (
<a
href={`/api/credit-notes/${encodeURIComponent(
cn.creditNoteNumber
)}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline text-xs"
>
{t("downloadPdfBtn")}
</a>
) : (
<span className="text-text-muted text-xs italic">
{t("creditNoteNoPdf")}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
{/* Lines */}
<Card>
<CardHeader>{t("lineItemsTitle")}</CardHeader>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("descCol")}</th>
<th className="pb-2 text-right">{t("qtyCol")}</th>
<th className="pb-2 text-right">{t("unitPriceCol")}</th>
<th className="pb-2 text-right">{t("amountCol")}</th>
</tr>
</thead>
<tbody>
{tenantOrder.map((tenantKey) => {
const tenantLines = linesByTenant.get(tenantKey)!;
return (
<Fragment key={tenantKey ?? "_org"}>
{tenantKey && (
<tr>
<td colSpan={4} className="pt-3 pb-1">
<span className="text-xs font-semibold text-accent">
{tenantKey}
</span>
</td>
</tr>
)}
{tenantLines.map((ln) => (
<tr key={ln.id} className="border-t border-border">
<td className="py-1.5">
<div>{ln.description}</div>
<div className="text-xs text-text-muted font-mono">
{ln.kind}
</div>
</td>
<td className="py-1.5 text-right">
{ln.quantity}
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
</td>
<td className="py-1.5 text-right font-mono text-xs">
{ln.unitPriceChf.toFixed(4)}
</td>
<td className="py-1.5 text-right">
{ln.amountChf.toFixed(2)}
</td>
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">{t("subtotal")}</span>
<span>CHF {invoice.subtotalChf.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">
{t("vat")} ({invoice.vatRate.toFixed(2)}%)
</span>
<span>CHF {invoice.vatAmountChf.toFixed(2)}</span>
</div>
<div className="flex justify-between pt-1 border-t border-border font-semibold">
<span>{t("total")}</span>
<span>CHF {invoice.totalChf.toFixed(2)}</span>
</div>
</div>
</Card>
{/* Billing snapshot */}
<Card>
<CardHeader>{t("billToSnapshotTitle")}</CardHeader>
<div className="text-sm space-y-1">
<div className="font-semibold">
{invoice.billingSnapshot.companyName}
</div>
<div>{invoice.billingSnapshot.streetAddress}</div>
<div>
{invoice.billingSnapshot.postalCode}{" "}
{invoice.billingSnapshot.city}
</div>
<div>{invoice.billingSnapshot.country}</div>
{invoice.billingSnapshot.vatNumber && (
<div className="text-text-muted">
VAT: {invoice.billingSnapshot.vatNumber}
</div>
)}
<div className="text-text-muted">
{invoice.billingSnapshot.billingEmail}
</div>
</div>
</Card>
</div>
);
}
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 (
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
>
{t(`status_${status}`)}
</span>
);
}