Files
pieced-portal/src/components/admin/billing/draft-list.tsx
admin ed915ec539
Some checks failed
Build and Push / build (push) Failing after 59s
Phase7b: Manual Invoice
2026-05-26 23:04:09 +02:00

146 lines
4.9 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import type { InvoiceDraftRecord } from "@/types";
interface Props {
drafts: InvoiceDraftRecord[];
/** Map ZITADEL org id → company name for friendlier display. */
orgNameMap: Record<string, string>;
}
/**
* Renders the drafts table with per-row Edit / Delete actions.
*
* The total preview is the algebraic sum of line amounts (the same
* formula billing.computeCustomInvoiceTotals uses for the subtotal,
* minus VAT — which we don't know without the org's billing
* snapshot). It's a hint, not authoritative; the real total
* appears when the draft is issued.
*
* Empty state shows a clear CTA so a fresh admin knows where to
* start.
*/
export function DraftList({ drafts, orgNameMap }: Props) {
const t = useTranslations("adminBilling");
const fmt = useFormatter();
const router = useRouter();
const [busyId, setBusyId] = useState<string | null>(null);
const onDelete = async (id: string) => {
if (!confirm(t("draftDeleteConfirm"))) return;
setBusyId(id);
try {
const res = await fetch(`/api/admin/billing/invoice-drafts/${id}`, {
method: "DELETE",
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
router.refresh();
} catch (e: any) {
alert(e.message);
} finally {
setBusyId(null);
}
};
if (drafts.length === 0) {
return (
<Card>
<div className="p-6 text-center">
<p className="text-text-secondary mb-4">{t("draftsEmpty")}</p>
<Link
href="/admin/billing/invoices/new"
className="inline-block px-4 py-2 rounded-md bg-accent text-white text-sm"
>
{t("newInvoiceBtn")}
</Link>
</div>
</Card>
);
}
return (
<Card>
<div className="flex justify-end p-3 border-b border-border">
<Link
href="/admin/billing/invoices/new"
className="inline-block px-3 py-1.5 rounded-md bg-accent text-white text-sm"
>
{t("newInvoiceBtn")}
</Link>
</div>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2 pl-3 pr-4">{t("draftOrgCol")}</th>
<th className="pb-2 pr-4">{t("draftIssueDateCol")}</th>
<th className="pb-2 pr-4 text-center">{t("draftLinesCol")}</th>
<th className="pb-2 pr-4 text-right">{t("draftSubtotalCol")}</th>
<th className="pb-2 pr-4">{t("draftUpdatedCol")}</th>
<th className="pb-2 pr-3 text-right">{t("draftActionsCol")}</th>
</tr>
</thead>
<tbody>
{drafts.map((d) => {
const subtotal = d.payload.lines.reduce(
(s, ln) =>
s +
Math.round(ln.quantity * ln.unitPriceChf * 100) / 100,
0
);
return (
<tr key={d.id} className="border-t border-border">
<td className="py-2 pl-3 pr-4">
<Link
href={`/admin/billing/invoice-drafts/${d.id}`}
className="hover:underline"
>
{orgNameMap[d.zitadelOrgId] ?? d.zitadelOrgId}
</Link>
</td>
<td className="py-2 pr-4 text-xs font-mono text-text-secondary whitespace-nowrap">
{d.payload.issueDate}
</td>
<td className="py-2 pr-4 text-center text-xs">
{d.payload.lines.length}
</td>
<td className="py-2 pr-4 text-right font-mono text-xs whitespace-nowrap">
CHF {subtotal.toFixed(2)}
</td>
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
{fmt.dateTime(new Date(d.updatedAt), {
dateStyle: "medium",
timeStyle: "short",
})}
</td>
<td className="py-2 pr-3 text-right">
<Link
href={`/admin/billing/invoice-drafts/${d.id}`}
className="text-accent hover:underline text-xs mr-3"
>
{t("editBtn")}
</Link>
<button
onClick={() => onDelete(d.id)}
disabled={busyId === d.id}
className="text-error hover:underline text-xs disabled:opacity-50"
>
{busyId === d.id ? t("deleting") : t("deleteBtn")}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</Card>
);
}