146 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|