Add searchable and flattened export, history re-export and diagnostics self-test

issueAndArchive keeps an issued invoice even when rendering or archiving
fails (Issued - not archived, with a Render & archive action). The export
menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is
the invoice number alone; flatten failures offer Retry or searchable export
and never silently lower the resolution. History re-exports the original
as issued by default and warns when a re-render's layout fingerprint
differs. The render client gains a FIFO assemble lane that previews cannot
supersede. A Diagnostics panel runs fixtures in the real worker and
compares against goldens generated from Node.
This commit is contained in:
2026-10-04 06:11:23 +05:30
parent 2ade618fb5
commit 98e9e5860f
30 changed files with 2727 additions and 154 deletions
+100 -31
View File
@@ -4,6 +4,8 @@ import {
InlineLoading,
InlineNotification,
Modal,
OverflowMenu,
OverflowMenuItem,
Table,
TableBody,
TableCell,
@@ -14,10 +16,13 @@ import {
Tag,
TextInput,
} from "@carbon/react";
import { Download, Close } from "@carbon/icons-react";
import { api } from "../lib/api";
import { formatAmount, formatDate } from "../lib/format";
import { exportInvoicePdf } from "../lib/pdf";
import { createExportDeps } from "../lib/exportDeps";
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
import type { ExportMode } from "../lib/filename";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import ExportFeedback from "../components/ExportFeedback";
import type { InvoiceSummary, Settings } from "../lib/types";
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
@@ -27,6 +32,14 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
const [busyId, setBusyId] = useState<number | null>(null);
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
const [reason, setReason] = useState("");
// Whether each invoice has an archived PDF; unknown (absent) until its status was read.
const [archived, setArchived] = useState<Record<number, boolean>>({});
const [notice, setNotice] = useState<string | null>(null);
const exp = useInvoiceExport({
settings,
bank: null,
onInvoiceUpdated: (inv) => setArchived((a) => ({ ...a, [inv.id]: Boolean(inv.archivedPdfSha256) })),
});
const load = async () => {
try {
@@ -44,12 +57,53 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
if (active) void load();
}, [active]);
const onExport = async (id: number) => {
// Read each invoice's archive status in the background; it is a local call per row.
useEffect(() => {
let stop = false;
void (async () => {
for (const row of rows) {
if (stop) return;
try {
const status = await api.archiveStatus(row.id);
if (!stop) setArchived((a) => ({ ...a, [row.id]: status.archived }));
} catch {
// Unknown stays unknown: the row just shows no tag.
}
}
})();
return () => {
stop = true;
};
}, [rows]);
const onExport = async (id: number, mode: ExportMode, source: ExportSource) => {
setBusyId(id);
setError(null);
setNotice(null);
try {
// Fetched now, not at list time: the row summary does not carry the archive fields.
const invoice = await api.getInvoice(id);
await exp.start(invoice, mode, source);
} catch (e) {
setError(String(e));
} finally {
setBusyId(null);
}
};
const onArchive = async (id: number) => {
setBusyId(id);
setError(null);
setNotice(null);
try {
const invoice = await api.getInvoice(id);
await exportInvoicePdf(invoice, settings, null);
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
if (outcome.archived) {
setArchived((a) => ({ ...a, [id]: true }));
setNotice(`Invoice ${invoice.number} is archived.`);
} else {
setError(`Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
}
} catch (e) {
setError(String(e));
} finally {
@@ -79,6 +133,9 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{notice ? <InlineNotification kind="success" lowContrast title={notice} onCloseButtonClick={() => setNotice(null)} /> : null}
<ExportFeedback exp={exp} />
{loading ? (
<InlineLoading description="Loading invoices" />
) : rows.length === 0 ? (
@@ -104,38 +161,50 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
<TableCell>{row.clientName || "—"}</TableCell>
<TableCell>₹{formatAmount(row.total)}</TableCell>
<TableCell>
{row.status === "cancelled" ? (
<Tag type="red" size="sm">
Cancelled
</Tag>
) : (
row.status
)}
<div className="voiced-header-action">
{row.status === "cancelled" ? (
<Tag type="red" size="sm">
Cancelled
</Tag>
) : (
row.status
)}
{archived[row.id] === false ? (
<Tag type="warm-gray" size="sm">
Not archived
</Tag>
) : null}
</div>
</TableCell>
<TableCell>
<div className="voiced-header-action">
<Button
kind="ghost"
size="sm"
renderIcon={Download}
onClick={() => onExport(row.id)}
disabled={busyId === row.id}
>
{busyId === row.id ? "Exporting…" : "PDF"}
</Button>
{row.status === "issued" ? (
<Button
kind="ghost"
size="sm"
renderIcon={Close}
onClick={() => {
setReason("");
setCancelTarget(row);
}}
>
Cancel
{archived[row.id] === false ? (
<Button kind="ghost" size="sm" onClick={() => void onArchive(row.id)} disabled={busyId !== null || exp.busy}>
{busyId === row.id ? "Working…" : "Render & archive"}
</Button>
) : null}
<OverflowMenu
aria-label={`Actions for invoice ${row.number}`}
iconDescription="Actions"
flipped
size="sm"
disabled={busyId !== null || exp.busy}
>
<OverflowMenuItem itemText="Export PDF (original as issued)" onClick={() => void onExport(row.id, "searchable", "original")} />
<OverflowMenuItem itemText="Export flattened PDF" onClick={() => void onExport(row.id, "flattened", "original")} />
<OverflowMenuItem itemText="Re-render with current template" onClick={() => void onExport(row.id, "searchable", "rerender")} />
{row.status === "issued" ? (
<OverflowMenuItem
itemText="Cancel invoice"
hasDivider
isDelete
onClick={() => {
setReason("");
setCancelTarget(row);
}}
/>
) : null}
</OverflowMenu>
</div>
</TableCell>
</TableRow>