feat(ui): add Send/Re-send button on invoices page and order block

This commit is contained in:
Gerhard Scheikl
2026-05-09 17:09:33 +02:00
parent 227c00b3a0
commit cc159f9b6b
2 changed files with 97 additions and 15 deletions
+17 -1
View File
@@ -234,13 +234,16 @@ function FilterChip({
function OrderRow({ order }: { order: RecentOrder }) { function OrderRow({ order }: { order: RecentOrder }) {
const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>(); const fetcher = useFetcher<{ ok: boolean; error?: string; invoiceNumber?: string }>();
const sendFetcher = useFetcher<{ ok: boolean; error?: string }>();
const isBusy = fetcher.state !== "idle"; const isBusy = fetcher.state !== "idle";
const isSending = sendFetcher.state !== "idle";
const isCancelReissue = order.hasInvoice && order.invoiceSent; const isCancelReissue = order.hasInvoice && order.invoiceSent;
const buttonLabel = !order.hasInvoice const buttonLabel = !order.hasInvoice
? "Generate" ? "Generate"
: order.invoiceSent : order.invoiceSent
? "Cancel & reissue" ? "Cancel & reissue"
: "Regenerate"; : "Regenerate";
const sendLabel = order.invoiceSent ? "Re-send" : "Send";
return ( return (
<s-table-row> <s-table-row>
@@ -271,6 +274,9 @@ function OrderRow({ order }: { order: RecentOrder }) {
{fetcher.data?.error ? ( {fetcher.data?.error ? (
<s-text tone="critical">{fetcher.data.error}</s-text> <s-text tone="critical">{fetcher.data.error}</s-text>
) : null} ) : null}
{sendFetcher.data?.error ? (
<s-text tone="critical">{sendFetcher.data.error}</s-text>
) : null}
</s-stack> </s-stack>
) : ( ) : (
<s-text tone="neutral"></s-text> <s-text tone="neutral"></s-text>
@@ -289,13 +295,23 @@ function OrderRow({ order }: { order: RecentOrder }) {
) : null} ) : null}
<s-button <s-button
type="submit" type="submit"
disabled={isBusy} disabled={isBusy || isSending}
variant={order.hasInvoice ? "secondary" : "primary"} variant={order.hasInvoice ? "secondary" : "primary"}
tone={isCancelReissue ? "critical" : "auto"} tone={isCancelReissue ? "critical" : "auto"}
> >
{isBusy ? "Working…" : buttonLabel} {isBusy ? "Working…" : buttonLabel}
</s-button> </s-button>
</fetcher.Form> </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-stack>
</s-table-cell> </s-table-cell>
</s-table-row> </s-table-row>
@@ -30,11 +30,16 @@ function Extension() {
const [payload, setPayload] = useState<Payload | null>(null); const [payload, setPayload] = useState<Payload | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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(() => { useEffect(() => {
if (!orderId) return; if (!orderId) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true);
try { try {
const res = await fetch(`/api/orders/${orderId}/invoice`); const res = await fetch(`/api/orders/${orderId}/invoice`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -49,7 +54,36 @@ function Extension() {
return () => { return () => {
cancelled = true; 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 ( return (
<s-admin-block heading="Invoice"> <s-admin-block heading="Invoice">
@@ -57,22 +91,54 @@ function Extension() {
<s-text>Loading</s-text> <s-text>Loading</s-text>
) : error ? ( ) : error ? (
<s-banner tone="critical">{error}</s-banner> <s-banner tone="critical">{error}</s-banner>
) : !payload?.latest ? (
<s-text>No invoice yet for this order.</s-text>
) : ( ) : (
<s-stack gap="200"> <s-stack gap="200">
<s-text weight="bold">{payload.latest.invoiceNumber} (v{payload.latest.version})</s-text> {latest ? (
<s-text>Issued {new Date(payload.latest.issuedAt).toLocaleDateString()}</s-text> <s-stack gap="100">
<s-badge tone={payload.latest.sentAt ? "success" : "info"}> <s-text weight="bold">
{payload.latest.sentAt ? "Sent" : "Not sent"} {latest.invoiceNumber} (v{latest.version})
</s-badge> </s-text>
{payload.latest.pdfUrl ? ( <s-text>Issued {new Date(latest.issuedAt).toLocaleDateString()}</s-text>
<s-link href={payload.latest.pdfUrl} target="_blank">View PDF</s-link> <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} ) : null}
{payload.history.length > 1 ? ( {payload!.history.length > 1 ? (
<s-text tone="subdued">{payload.history.length} versions in history</s-text> <s-text tone="subdued">{payload!.history.length} versions in history</s-text>
) : null} ) : null}
</s-stack> </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> </s-admin-block>
); );