This commit is contained in:
145
src/components/admin/billing/draft-list.tsx
Normal file
145
src/components/admin/billing/draft-list.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user