308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useState, Fragment } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import { Card, CardHeader } from "@/components/ui/card";
|
|
import type { InvoiceDetail, InvoiceStatus } from "@/types";
|
|
|
|
interface Props {
|
|
detail: InvoiceDetail;
|
|
}
|
|
|
|
/**
|
|
* Renders the invoice header (status, totals, action bar) then
|
|
* line items grouped by tenant, then billing snapshot. Actions are
|
|
* mark-paid (POST), delete (DELETE), PDF download (link to /pdf).
|
|
*
|
|
* On successful action we router.refresh() — the server-side page
|
|
* re-renders against the new DB state. For delete we navigate
|
|
* away first.
|
|
*/
|
|
export function InvoiceDetailView({ detail }: Props) {
|
|
const t = useTranslations("adminBilling");
|
|
const router = useRouter();
|
|
const { invoice, lines } = detail;
|
|
|
|
const [busyAction, setBusyAction] = useState<null | "mark-paid" | "delete">(
|
|
null
|
|
);
|
|
const [actionError, setActionError] = useState("");
|
|
const [noteInput, setNoteInput] = useState("");
|
|
const [noteOpen, setNoteOpen] = useState(false);
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
// 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} />
|
|
<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-white 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-white 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>
|
|
)}
|
|
</>
|
|
)}
|
|
<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>
|
|
)}
|
|
</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"
|
|
: "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>
|
|
);
|
|
}
|