feat(ui): add Send/Re-send button on invoices page and order block
This commit is contained in:
@@ -234,13 +234,16 @@ function FilterChip({
|
||||
|
||||
function OrderRow({ order }: { order: RecentOrder }) {
|
||||
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
|
||||
const sendFetcher = useFetcher<{ ok: boolean; error?: string }>();
|
||||
const isBusy = fetcher.state !== "idle";
|
||||
const isSending = sendFetcher.state !== "idle";
|
||||
const isCancelReissue = order.hasInvoice && order.invoiceSent;
|
||||
const buttonLabel = !order.hasInvoice
|
||||
? "Generate"
|
||||
: order.invoiceSent
|
||||
? "Cancel & reissue"
|
||||
: "Regenerate";
|
||||
const sendLabel = order.invoiceSent ? "Re-send" : "Send";
|
||||
|
||||
return (
|
||||
<s-table-row>
|
||||
@@ -271,6 +274,9 @@ function OrderRow({ order }: { order: RecentOrder }) {
|
||||
{fetcher.data?.error ? (
|
||||
<s-text tone="critical">{fetcher.data.error}</s-text>
|
||||
) : null}
|
||||
{sendFetcher.data?.error ? (
|
||||
<s-text tone="critical">{sendFetcher.data.error}</s-text>
|
||||
) : null}
|
||||
</s-stack>
|
||||
) : (
|
||||
<s-text tone="neutral">—</s-text>
|
||||
@@ -289,13 +295,23 @@ function OrderRow({ order }: { order: RecentOrder }) {
|
||||
) : null}
|
||||
<s-button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || isSending}
|
||||
variant={order.hasInvoice ? "secondary" : "primary"}
|
||||
tone={isCancelReissue ? "critical" : "auto"}
|
||||
>
|
||||
{isBusy ? "Working…" : buttonLabel}
|
||||
</s-button>
|
||||
</fetcher.Form>
|
||||
<sendFetcher.Form method="post" action={`/api/orders/${order.numericId}/invoice`}>
|
||||
<input type="hidden" name="action" value="send" />
|
||||
<s-button
|
||||
type="submit"
|
||||
disabled={isBusy || isSending}
|
||||
variant={order.hasInvoice && !order.invoiceSent ? "primary" : "secondary"}
|
||||
>
|
||||
{isSending ? "Sending…" : sendLabel}
|
||||
</s-button>
|
||||
</sendFetcher.Form>
|
||||
</s-stack>
|
||||
</s-table-cell>
|
||||
</s-table-row>
|
||||
|
||||
@@ -30,11 +30,16 @@ function Extension() {
|
||||
const [payload, setPayload] = useState<Payload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<null | "generate" | "send" | "cancel_reissue">(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionInfo, setActionInfo] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${orderId}/invoice`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -49,7 +54,36 @@ function Extension() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [orderId]);
|
||||
}, [orderId, reloadKey]);
|
||||
|
||||
async function trigger(action: "generate" | "send" | "cancel_reissue") {
|
||||
if (!orderId) return;
|
||||
setBusy(action);
|
||||
setActionError(null);
|
||||
setActionInfo(null);
|
||||
try {
|
||||
const body = new URLSearchParams({ action });
|
||||
const res = await fetch(`/api/orders/${orderId}/invoice`, { method: "POST", body });
|
||||
const txt = await res.text();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${txt.slice(0, 200)}`);
|
||||
setActionInfo(
|
||||
action === "send"
|
||||
? "Invoice email sent."
|
||||
: action === "cancel_reissue"
|
||||
? "Cancelled and reissued."
|
||||
: "Invoice generated.",
|
||||
);
|
||||
setReloadKey((k) => k + 1);
|
||||
} catch (e: any) {
|
||||
setActionError(e?.message ?? "Action failed");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const latest = payload?.latest;
|
||||
const hasInvoice = !!latest && !latest.cancelledAt;
|
||||
const sent = !!latest?.sentAt;
|
||||
|
||||
return (
|
||||
<s-admin-block heading="Invoice">
|
||||
@@ -57,22 +91,54 @@ function Extension() {
|
||||
<s-text>Loading…</s-text>
|
||||
) : error ? (
|
||||
<s-banner tone="critical">{error}</s-banner>
|
||||
) : !payload?.latest ? (
|
||||
<s-text>No invoice yet for this order.</s-text>
|
||||
) : (
|
||||
<s-stack gap="200">
|
||||
<s-text weight="bold">{payload.latest.invoiceNumber} (v{payload.latest.version})</s-text>
|
||||
<s-text>Issued {new Date(payload.latest.issuedAt).toLocaleDateString()}</s-text>
|
||||
<s-badge tone={payload.latest.sentAt ? "success" : "info"}>
|
||||
{payload.latest.sentAt ? "Sent" : "Not sent"}
|
||||
</s-badge>
|
||||
{payload.latest.pdfUrl ? (
|
||||
<s-link href={payload.latest.pdfUrl} target="_blank">View PDF</s-link>
|
||||
{latest ? (
|
||||
<s-stack gap="100">
|
||||
<s-text weight="bold">
|
||||
{latest.invoiceNumber} (v{latest.version})
|
||||
</s-text>
|
||||
<s-text>Issued {new Date(latest.issuedAt).toLocaleDateString()}</s-text>
|
||||
<s-badge tone={sent ? "success" : "info"}>{sent ? "Sent" : "Not sent"}</s-badge>
|
||||
{latest.pdfUrl ? (
|
||||
<s-link href={latest.pdfUrl} target="_blank">
|
||||
View PDF
|
||||
</s-link>
|
||||
) : null}
|
||||
{payload.history.length > 1 ? (
|
||||
<s-text tone="subdued">{payload.history.length} versions in history</s-text>
|
||||
{payload!.history.length > 1 ? (
|
||||
<s-text tone="subdued">{payload!.history.length} versions in history</s-text>
|
||||
) : null}
|
||||
</s-stack>
|
||||
) : (
|
||||
<s-text>No invoice yet for this order.</s-text>
|
||||
)}
|
||||
|
||||
{actionError ? <s-banner tone="critical">{actionError}</s-banner> : null}
|
||||
{actionInfo ? <s-banner tone="success">{actionInfo}</s-banner> : null}
|
||||
|
||||
<s-stack direction="inline" gap="100">
|
||||
{!hasInvoice ? (
|
||||
<s-button onClick={() => trigger("generate")} disabled={busy !== null}>
|
||||
{busy === "generate" ? "Generating…" : "Generate"}
|
||||
</s-button>
|
||||
) : !sent ? (
|
||||
<s-button onClick={() => trigger("generate")} disabled={busy !== null}>
|
||||
{busy === "generate" ? "Working…" : "Regenerate"}
|
||||
</s-button>
|
||||
) : (
|
||||
<s-button
|
||||
onClick={() => trigger("cancel_reissue")}
|
||||
disabled={busy !== null}
|
||||
tone="critical"
|
||||
>
|
||||
{busy === "cancel_reissue" ? "Working…" : "Cancel & reissue"}
|
||||
</s-button>
|
||||
)}
|
||||
<s-button onClick={() => trigger("send")} disabled={busy !== null}>
|
||||
{busy === "send" ? "Sending…" : sent ? "Re-send" : "Send"}
|
||||
</s-button>
|
||||
</s-stack>
|
||||
</s-stack>
|
||||
)}
|
||||
</s-admin-block>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user