149 lines
5.5 KiB
TypeScript
149 lines
5.5 KiB
TypeScript
import { useTranslations, useFormatter } from "next-intl";
|
|
import { Card } from "@/components/ui/card";
|
|
import type { Invoice, InvoiceLine } from "@/types";
|
|
|
|
interface Props {
|
|
invoice: Invoice;
|
|
lines: InvoiceLine[];
|
|
}
|
|
|
|
const statusColors: Record<string, string> = {
|
|
open: "text-text-secondary bg-surface-3",
|
|
paid: "text-success bg-success/10",
|
|
overdue: "text-error bg-error/10",
|
|
void: "text-text-muted bg-surface-3",
|
|
};
|
|
|
|
/**
|
|
* Read-only invoice detail. Flat list of lines — no per-tenant
|
|
* grouping (one invoice per customer; the tenant context is
|
|
* already embedded in each line description).
|
|
*
|
|
* The download link points at /api/billing/invoices/[n]/pdf
|
|
* which serves the stored PDF blob inline. Customers using a
|
|
* link from their email will hit the same route via this page.
|
|
*/
|
|
export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
|
const t = useTranslations("customerBilling");
|
|
const fmt = useFormatter();
|
|
|
|
return (
|
|
<div className="space-y-6 animate-in">
|
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h1 className="font-display text-2xl font-semibold">
|
|
{invoice.invoiceNumber}
|
|
</h1>
|
|
<span
|
|
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
|
|
statusColors[invoice.status] ?? "text-text-muted bg-surface-3"
|
|
}`}
|
|
>
|
|
{t(`status.${invoice.status}` as any)}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-text-secondary">
|
|
{fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })}
|
|
<span className="text-text-muted mx-1">→</span>
|
|
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
|
|
</p>
|
|
</div>
|
|
<a
|
|
href={`/api/billing/invoices/${encodeURIComponent(invoice.invoiceNumber)}/pdf`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
|
|
>
|
|
{t("downloadPdf")}
|
|
</a>
|
|
</div>
|
|
|
|
<Card>
|
|
<div className="space-y-2 mb-4">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-text-muted">{t("billedToLabel")}</span>
|
|
<span>{invoice.billingSnapshot.companyName}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-text-muted">{t("issuedAtLabel")}</span>
|
|
<span>
|
|
{fmt.dateTime(new Date(invoice.issuedAt), { dateStyle: "medium" })}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-text-muted">{t("dueAtLabel")}</span>
|
|
<span>
|
|
{fmt.dateTime(new Date(invoice.dueAt), { dateStyle: "medium" })}
|
|
</span>
|
|
</div>
|
|
{invoice.status === "paid" && invoice.paidAt && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-text-muted">{t("paidAtLabel")}</span>
|
|
<span>
|
|
{fmt.dateTime(new Date(invoice.paidAt), { dateStyle: "medium" })}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<table className="w-full text-sm">
|
|
<thead className="text-xs text-text-muted text-left">
|
|
<tr>
|
|
<th className="pb-2">{t("descriptionCol")}</th>
|
|
<th className="pb-2 text-right">{t("qtyCol")}</th>
|
|
<th className="pb-2 text-right">{t("unitCol")}</th>
|
|
<th className="pb-2 text-right">{t("amountCol")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{lines.map((ln) => (
|
|
<tr key={ln.id} className="border-t border-border align-top">
|
|
<td className="py-2">{ln.description}</td>
|
|
<td className="py-2 text-right font-mono text-xs">
|
|
{ln.quantity}
|
|
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
|
</td>
|
|
<td className="py-2 text-right font-mono text-xs">
|
|
{ln.unitPriceChf.toFixed(2)}
|
|
</td>
|
|
<td className="py-2 text-right font-mono">
|
|
{ln.amountChf.toFixed(2)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="border-t border-border">
|
|
<td colSpan={3} className="pt-3 text-right text-text-muted">
|
|
{t("subtotalLabel")}
|
|
</td>
|
|
<td className="pt-3 text-right font-mono">
|
|
{invoice.subtotalChf.toFixed(2)}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td colSpan={3} className="pt-1 text-right text-text-muted">
|
|
{t("vatLabel", { rate: invoice.vatRate.toFixed(2) })}
|
|
</td>
|
|
<td className="pt-1 text-right font-mono">
|
|
{invoice.vatAmountChf.toFixed(2)}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td colSpan={3} className="pt-2 text-right font-semibold">
|
|
{t("totalLabel")}
|
|
</td>
|
|
<td className="pt-2 text-right font-mono font-semibold text-base">
|
|
CHF {invoice.totalChf.toFixed(2)}
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|