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 }) {
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user