Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 28s
Some checks failed
Build and Push / build (push) Failing after 28s
This commit is contained in:
307
src/components/admin/billing/invoice-detail-view.tsx
Normal file
307
src/components/admin/billing/invoice-detail-view.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
"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}` as any)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user