640 lines
24 KiB
TypeScript
640 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import { useState, Fragment } from "react";
|
|
import { useRouter } from "@/i18n/navigation";
|
|
import { useTranslations } from "next-intl";
|
|
import { Card, CardHeader } from "@/components/ui/card";
|
|
import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
|
|
|
|
interface Props {
|
|
detail: InvoiceDetail;
|
|
/**
|
|
* Phase 7: credit notes linked to this invoice (voids + refunds).
|
|
* Empty array when none. Passed from the server page; client
|
|
* doesn't re-fetch — router.refresh() rebuilds after actions.
|
|
*/
|
|
creditNotes?: CreditNote[];
|
|
}
|
|
|
|
/**
|
|
* Renders the invoice header (status, totals, action bar) then
|
|
* line items grouped by tenant, then billing snapshot. Actions are
|
|
* mark-paid (POST), void (POST), refund (POST), delete (DELETE),
|
|
* PDF download (link to /pdf).
|
|
*
|
|
* Phase 7 adds void + refund. The action bar shows:
|
|
* - status open/overdue → Mark paid, Void, Delete
|
|
* - status paid → Refund, Delete
|
|
* - status partially_refunded → Refund (for remainder), Delete
|
|
* - status fully_refunded / void → Delete only (read-only otherwise)
|
|
*
|
|
* On successful action we router.refresh() — the server-side page
|
|
* re-renders against the new DB state, including any new credit
|
|
* notes.
|
|
*/
|
|
export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
|
const t = useTranslations("adminBilling");
|
|
const router = useRouter();
|
|
const { invoice, lines } = detail;
|
|
|
|
const [busyAction, setBusyAction] = useState<
|
|
null | "mark-paid" | "delete" | "void" | "refund"
|
|
>(null);
|
|
const [actionError, setActionError] = useState("");
|
|
const [noteInput, setNoteInput] = useState("");
|
|
const [noteOpen, setNoteOpen] = useState(false);
|
|
|
|
// Phase 7 — void modal state
|
|
const [voidOpen, setVoidOpen] = useState(false);
|
|
const [voidReason, setVoidReason] = useState("");
|
|
|
|
// Phase 7 — refund modal state. Amount defaults to the full
|
|
// remaining refundable on open.
|
|
const [refundOpen, setRefundOpen] = useState(false);
|
|
const [refundAmount, setRefundAmount] = useState("");
|
|
const [refundReason, setRefundReason] = useState("");
|
|
|
|
const remainingRefundable =
|
|
Math.round(
|
|
(invoice.totalChf - invoice.refundedTotalChf) * 100
|
|
) / 100;
|
|
|
|
const markPaid = async () => {
|
|
setActionError("");
|
|
setBusyAction("mark-paid");
|
|
try {
|
|
const res = await fetch(
|
|
`/api/admin/billing/invoices/${invoice.id}/mark-paid`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ note: noteInput || undefined }),
|
|
}
|
|
);
|
|
const j = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
|
setNoteOpen(false);
|
|
setNoteInput("");
|
|
router.refresh();
|
|
} catch (e: any) {
|
|
setActionError(e.message);
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
};
|
|
|
|
const deleteInvoice = async () => {
|
|
if (!confirm(t("confirmDeleteInvoice", { num: invoice.invoiceNumber })))
|
|
return;
|
|
setActionError("");
|
|
setBusyAction("delete");
|
|
try {
|
|
const res = await fetch(`/api/admin/billing/invoices/${invoice.id}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (!res.ok) {
|
|
const j = await res.json().catch(() => ({}));
|
|
throw new Error(j.error || `HTTP ${res.status}`);
|
|
}
|
|
router.push("/admin/billing/invoices");
|
|
} catch (e: any) {
|
|
setActionError(e.message);
|
|
setBusyAction(null);
|
|
}
|
|
};
|
|
|
|
// Phase 7 — void: marks an unpaid invoice as cancelled and issues
|
|
// a credit note. Backend rejects if the invoice is paid (use
|
|
// refund) or already voided/refunded.
|
|
const voidInvoice = async () => {
|
|
if (!voidReason.trim()) {
|
|
setActionError(t("voidReasonRequired"));
|
|
return;
|
|
}
|
|
setActionError("");
|
|
setBusyAction("void");
|
|
try {
|
|
const res = await fetch(
|
|
`/api/admin/billing/invoices/${invoice.id}/void`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ reason: voidReason }),
|
|
}
|
|
);
|
|
const j = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
|
setVoidOpen(false);
|
|
setVoidReason("");
|
|
router.refresh();
|
|
} catch (e: any) {
|
|
setActionError(e.message);
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
};
|
|
|
|
// Phase 7 — refund: paid invoices only. Amount may be partial;
|
|
// backend caps at remaining refundable.
|
|
const refundInvoice = async () => {
|
|
const amt = parseFloat(refundAmount);
|
|
if (!isFinite(amt) || amt <= 0) {
|
|
setActionError(t("refundAmountInvalid"));
|
|
return;
|
|
}
|
|
if (amt - remainingRefundable > 0.005) {
|
|
setActionError(
|
|
t("refundAmountExceeds", {
|
|
max: remainingRefundable.toFixed(2),
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
if (!refundReason.trim()) {
|
|
setActionError(t("refundReasonRequired"));
|
|
return;
|
|
}
|
|
setActionError("");
|
|
setBusyAction("refund");
|
|
try {
|
|
const res = await fetch(
|
|
`/api/admin/billing/invoices/${invoice.id}/refund`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
amountChf: Math.round(amt * 100) / 100,
|
|
reason: refundReason,
|
|
}),
|
|
}
|
|
);
|
|
const j = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
|
setRefundOpen(false);
|
|
setRefundAmount("");
|
|
setRefundReason("");
|
|
router.refresh();
|
|
} catch (e: any) {
|
|
setActionError(e.message);
|
|
} finally {
|
|
setBusyAction(null);
|
|
}
|
|
};
|
|
|
|
// Group lines by tenant for display (matches PDF layout).
|
|
const linesByTenant = new Map<string | null, typeof lines>();
|
|
for (const ln of lines) {
|
|
const k = ln.tenantName;
|
|
if (!linesByTenant.has(k)) linesByTenant.set(k, []);
|
|
linesByTenant.get(k)!.push(ln);
|
|
}
|
|
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
|
|
if (a === null) return 1;
|
|
if (b === null) return -1;
|
|
return a.localeCompare(b);
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-4 animate-in">
|
|
<div className="flex items-end justify-between flex-wrap gap-3">
|
|
<div>
|
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
|
{invoice.invoiceNumber}
|
|
</h1>
|
|
<div className="flex items-center gap-3 mt-3 text-sm">
|
|
<StatusPill status={invoice.status} />
|
|
{invoice.periodStart && invoice.periodEnd && (
|
|
<>
|
|
<span className="text-text-muted">
|
|
{invoice.periodStart} → {invoice.periodEnd}
|
|
</span>
|
|
<span className="text-text-muted">·</span>
|
|
</>
|
|
)}
|
|
<span className="text-text-muted">
|
|
{t("dueOnLabel")}: {invoice.dueAt}
|
|
</span>
|
|
<span className="text-text-muted">·</span>
|
|
<span className="text-text-muted font-mono text-xs">
|
|
{invoice.locale}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-xs text-text-muted">{t("totalLabel")}</div>
|
|
<div className="text-2xl font-semibold font-mono">
|
|
CHF {invoice.totalChf.toFixed(2)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action bar */}
|
|
<Card>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{invoice.hasPdf && (
|
|
<a
|
|
href={`/api/admin/billing/invoices/${invoice.id}/pdf`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="px-4 py-2 rounded-md border border-border text-sm hover:bg-surface-2"
|
|
>
|
|
{t("downloadPdfBtn")}
|
|
</a>
|
|
)}
|
|
{(invoice.status === "open" || invoice.status === "overdue") && (
|
|
<>
|
|
{!noteOpen ? (
|
|
<button
|
|
onClick={() => setNoteOpen(true)}
|
|
disabled={busyAction !== null}
|
|
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
|
>
|
|
{t("markPaidBtn")}
|
|
</button>
|
|
) : (
|
|
<div className="flex items-center gap-2 flex-grow">
|
|
<input
|
|
type="text"
|
|
placeholder={t("paidNotePlaceholder")}
|
|
value={noteInput}
|
|
onChange={(e) => setNoteInput(e.target.value)}
|
|
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
onClick={markPaid}
|
|
disabled={busyAction !== null}
|
|
className="px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
|
>
|
|
{busyAction === "mark-paid" ? t("saving") : t("confirm")}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setNoteOpen(false);
|
|
setNoteInput("");
|
|
}}
|
|
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
|
>
|
|
{t("cancel")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{/* Phase 7 — Void: visible only for open/overdue invoices.
|
|
Same gating as Mark Paid but mutually exclusive with it
|
|
via the chosen action. Opens a small inline form so
|
|
the admin can enter a reason; reason is required and
|
|
lands on the credit-note PDF. */}
|
|
{(invoice.status === "open" || invoice.status === "overdue") && (
|
|
<>
|
|
{!voidOpen ? (
|
|
<button
|
|
onClick={() => {
|
|
setVoidOpen(true);
|
|
setNoteOpen(false);
|
|
setRefundOpen(false);
|
|
}}
|
|
disabled={busyAction !== null}
|
|
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
|
>
|
|
{t("voidBtn")}
|
|
</button>
|
|
) : (
|
|
<div className="flex items-center gap-2 flex-grow">
|
|
<input
|
|
type="text"
|
|
placeholder={t("voidReasonPlaceholder")}
|
|
value={voidReason}
|
|
onChange={(e) => setVoidReason(e.target.value)}
|
|
maxLength={500}
|
|
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
onClick={voidInvoice}
|
|
disabled={busyAction !== null}
|
|
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
|
>
|
|
{busyAction === "void" ? t("saving") : t("confirmVoid")}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setVoidOpen(false);
|
|
setVoidReason("");
|
|
}}
|
|
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
|
>
|
|
{t("cancel")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{/* Phase 7 — Refund: paid invoices, including ones already
|
|
partially refunded (as long as some refundable amount
|
|
remains). Opens an inline form with amount + reason.
|
|
The remaining-refundable hint helps admin pick the
|
|
right number. */}
|
|
{(invoice.status === "paid" ||
|
|
invoice.status === "partially_refunded") &&
|
|
remainingRefundable > 0 && (
|
|
<>
|
|
{!refundOpen ? (
|
|
<button
|
|
onClick={() => {
|
|
setRefundOpen(true);
|
|
setNoteOpen(false);
|
|
setVoidOpen(false);
|
|
setRefundAmount(remainingRefundable.toFixed(2));
|
|
}}
|
|
disabled={busyAction !== null}
|
|
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
|
>
|
|
{t("refundBtn")}
|
|
</button>
|
|
) : (
|
|
<div className="flex flex-col gap-2 flex-grow">
|
|
<div className="text-xs text-text-muted">
|
|
{t("refundRemainingHint", {
|
|
max: remainingRefundable.toFixed(2),
|
|
})}
|
|
</div>
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-[10px] uppercase tracking-wider text-text-muted">
|
|
{t("refundAmountLabel")}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0.01"
|
|
max={remainingRefundable}
|
|
placeholder="CHF"
|
|
value={refundAmount}
|
|
onChange={(e) => setRefundAmount(e.target.value)}
|
|
className="w-32 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
|
|
autoFocus
|
|
/>
|
|
<span className="text-[10px] text-text-muted italic">
|
|
{t("refundAmountInclVatHint")}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col gap-1 flex-grow min-w-[200px]">
|
|
<label className="text-[10px] uppercase tracking-wider text-text-muted">
|
|
{t("refundReasonLabel")}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder={t("refundReasonPlaceholder")}
|
|
value={refundReason}
|
|
onChange={(e) => setRefundReason(e.target.value)}
|
|
maxLength={500}
|
|
className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2 self-end">
|
|
<button
|
|
onClick={refundInvoice}
|
|
disabled={busyAction !== null}
|
|
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
|
>
|
|
{busyAction === "refund"
|
|
? t("saving")
|
|
: t("confirmRefund")}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setRefundOpen(false);
|
|
setRefundAmount("");
|
|
setRefundReason("");
|
|
}}
|
|
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
|
>
|
|
{t("cancel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={deleteInvoice}
|
|
disabled={busyAction !== null}
|
|
className="ml-auto px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
|
title={t("deleteHint")}
|
|
>
|
|
{busyAction === "delete" ? t("deleting") : t("deleteBtn")}
|
|
</button>
|
|
</div>
|
|
{actionError && (
|
|
<div className="mt-3 text-sm text-error">{actionError}</div>
|
|
)}
|
|
{invoice.paidAt && (
|
|
<div className="mt-3 text-xs text-text-muted">
|
|
{t("paidOnLabel")}: {invoice.paidAt} · {invoice.paidBy} ·{" "}
|
|
{invoice.paidMethodDetail}
|
|
</div>
|
|
)}
|
|
{/* Phase 7 — void/refund summary lines, shown when applicable.
|
|
Surfaces the auditing context that the columns alone don't
|
|
(who voided, what the reason was, how much has been
|
|
refunded vs how much remains). */}
|
|
{invoice.voidedAt && (
|
|
<div className="mt-3 text-xs text-text-muted">
|
|
{t("voidedOnLabel")}: {invoice.voidedAt} · {invoice.voidedBy}
|
|
{invoice.voidReason ? ` · ${invoice.voidReason}` : ""}
|
|
</div>
|
|
)}
|
|
{invoice.refundedTotalChf > 0 && (
|
|
<div className="mt-3 text-xs text-text-muted">
|
|
{t("refundedTotalLabel")}: CHF{" "}
|
|
{invoice.refundedTotalChf.toFixed(2)} ·{" "}
|
|
{t("refundedRemainingLabel")}: CHF{" "}
|
|
{remainingRefundable.toFixed(2)}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Phase 7 — linked credit notes panel. Hidden when there are
|
|
none (most invoices). When present, lists each credit note
|
|
with kind, amount, reason, issued date, and PDF download. */}
|
|
{creditNotes.length > 0 && (
|
|
<Card>
|
|
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
|
|
<table className="w-full text-sm">
|
|
<thead className="text-xs text-text-muted text-left">
|
|
<tr>
|
|
<th className="pb-2 pr-4">{t("creditNoteNumberHeader")}</th>
|
|
<th className="pb-2 pr-4">{t("creditNoteKindHeader")}</th>
|
|
<th className="pb-2 pr-4 text-right">
|
|
{t("creditNoteAmountHeader")}
|
|
</th>
|
|
<th className="pb-2 pr-4">{t("creditNoteReasonHeader")}</th>
|
|
<th className="pb-2 pr-4">{t("creditNoteIssuedHeader")}</th>
|
|
<th className="pb-2 text-right">{t("creditNotePdfHeader")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{creditNotes.map((cn) => (
|
|
<tr key={cn.id} className="border-t border-border">
|
|
<td className="py-2 pr-4 font-mono text-xs">
|
|
{cn.creditNoteNumber}
|
|
</td>
|
|
<td className="py-2 pr-4">
|
|
<span className="px-2 py-0.5 rounded text-xs text-error bg-error/10">
|
|
{t(`creditNoteKind_${cn.kind}` as any)}
|
|
</span>
|
|
</td>
|
|
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
|
|
CHF {cn.amountChf.toFixed(2)}
|
|
</td>
|
|
<td className="py-2 pr-4 text-text-secondary text-xs">
|
|
{cn.reason ?? "—"}
|
|
</td>
|
|
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
|
|
{cn.issuedAt.slice(0, 10)}
|
|
</td>
|
|
<td className="py-2 text-right">
|
|
{cn.hasPdf ? (
|
|
<a
|
|
href={`/api/credit-notes/${encodeURIComponent(
|
|
cn.creditNoteNumber
|
|
)}/pdf`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-accent hover:underline text-xs"
|
|
>
|
|
{t("downloadPdfBtn")}
|
|
</a>
|
|
) : (
|
|
<span className="text-text-muted text-xs italic">
|
|
{t("creditNoteNoPdf")}
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Lines */}
|
|
<Card>
|
|
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
|
<table className="w-full text-sm">
|
|
<thead className="text-xs text-text-muted text-left">
|
|
<tr>
|
|
<th className="pb-2">{t("descCol")}</th>
|
|
<th className="pb-2 text-right">{t("qtyCol")}</th>
|
|
<th className="pb-2 text-right">{t("unitPriceCol")}</th>
|
|
<th className="pb-2 text-right">{t("amountCol")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{tenantOrder.map((tenantKey) => {
|
|
const tenantLines = linesByTenant.get(tenantKey)!;
|
|
return (
|
|
<Fragment key={tenantKey ?? "_org"}>
|
|
{tenantKey && (
|
|
<tr>
|
|
<td colSpan={4} className="pt-3 pb-1">
|
|
<span className="text-xs font-semibold text-accent">
|
|
{tenantKey}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{tenantLines.map((ln) => (
|
|
<tr key={ln.id} className="border-t border-border">
|
|
<td className="py-1.5">
|
|
<div>{ln.description}</div>
|
|
<div className="text-xs text-text-muted font-mono">
|
|
{ln.kind}
|
|
</div>
|
|
</td>
|
|
<td className="py-1.5 text-right">
|
|
{ln.quantity}
|
|
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
|
</td>
|
|
<td className="py-1.5 text-right font-mono text-xs">
|
|
{ln.unitPriceChf.toFixed(4)}
|
|
</td>
|
|
<td className="py-1.5 text-right">
|
|
{ln.amountChf.toFixed(2)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-text-muted">{t("subtotal")}</span>
|
|
<span>CHF {invoice.subtotalChf.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-text-muted">
|
|
{t("vat")} ({invoice.vatRate.toFixed(2)}%)
|
|
</span>
|
|
<span>CHF {invoice.vatAmountChf.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex justify-between pt-1 border-t border-border font-semibold">
|
|
<span>{t("total")}</span>
|
|
<span>CHF {invoice.totalChf.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Billing snapshot */}
|
|
<Card>
|
|
<CardHeader>{t("billToSnapshotTitle")}</CardHeader>
|
|
<div className="text-sm space-y-1">
|
|
<div className="font-semibold">
|
|
{invoice.billingSnapshot.companyName}
|
|
</div>
|
|
<div>{invoice.billingSnapshot.streetAddress}</div>
|
|
<div>
|
|
{invoice.billingSnapshot.postalCode}{" "}
|
|
{invoice.billingSnapshot.city}
|
|
</div>
|
|
<div>{invoice.billingSnapshot.country}</div>
|
|
{invoice.billingSnapshot.vatNumber && (
|
|
<div className="text-text-muted">
|
|
VAT: {invoice.billingSnapshot.vatNumber}
|
|
</div>
|
|
)}
|
|
<div className="text-text-muted">
|
|
{invoice.billingSnapshot.billingEmail}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusPill({ status }: { status: InvoiceStatus }) {
|
|
const t = useTranslations("adminBilling");
|
|
const color =
|
|
status === "paid"
|
|
? "bg-success/15 text-success"
|
|
: status === "overdue"
|
|
? "bg-error/15 text-error"
|
|
: status === "void" || status === "uncollectible"
|
|
? "bg-text-muted/15 text-text-muted"
|
|
: status === "partially_refunded" || status === "fully_refunded"
|
|
? "bg-error/15 text-error"
|
|
: "bg-accent/15 text-accent";
|
|
return (
|
|
<span
|
|
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
|
|
>
|
|
{t(`status_${status}`)}
|
|
</span>
|
|
);
|
|
}
|