Files
pieced-portal/src/components/billing/customer-invoice-detail.tsx
admin a680d6de9f
Some checks failed
Build and Push / build (push) Failing after 38s
Phase4: Stripe
2026-05-24 23:37:48 +02:00

161 lines
6.1 KiB
TypeScript

import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import type { Invoice, InvoiceLine } from "@/types";
import { PayInvoiceButton } from "./pay-invoice-button";
import { PaymentStatusBanner } from "./payment-status-banner";
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">
<PaymentStatusBanner />
<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>
<div className="flex items-start gap-2 flex-wrap">
{/* Phase 4: Pay-with-card available for open + overdue.
Paid/void/draft/uncollectible hide the button — the
API also enforces this, so client-side hiding is just
for the visible affordance. */}
{(invoice.status === "open" || invoice.status === "overdue") && (
<PayInvoiceButton invoiceNumber={invoice.invoiceNumber} />
)}
<a
href={`/api/billing/invoices/${encodeURIComponent(invoice.invoiceNumber)}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-md bg-surface-3 hover:bg-surface-2 border border-border text-sm font-medium transition-colors"
>
{t("downloadPdf")}
</a>
</div>
</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>
);
}