Files
pieced-portal/src/components/admin/billing/invoice-detail-view.tsx
admin d4fcc33bc1
Some checks failed
Build and Push / build (push) Failing after 45s
Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
2026-05-24 14:12:26 +02:00

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>
);
}