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 7162dac6e7
commit f1b7502e9d
30 changed files with 2727 additions and 154 deletions
+67 -63
View File
@@ -28,7 +28,6 @@ import {
DocumentAdd,
Download,
Keyboard,
Renew,
Save,
TrashCan,
} from "@carbon/icons-react";
@@ -51,12 +50,19 @@ import { computeLineAmount, computeTotals } from "../lib/invoice";
import { amountInWords } from "../lib/numberToWords";
import { gstinFullError } from "../lib/validators";
import { addDays, formatAmount, todayIso } from "../lib/format";
import { exportInvoicePdf, modelFromAssets, prefsForInvoice } from "../lib/pdf";
import { modelFromAssets, prefsForInvoice } from "../lib/pdf";
import { formToPreviewInvoice } from "../lib/previewInvoice";
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
import PageSetupControls from "../components/PageSetupControls";
import { useInvoicePdf } from "../hooks/useInvoicePdf";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import ExportButton, { ExportModePicker } from "../components/ExportButton";
import ExportFeedback from "../components/ExportFeedback";
import { createExportDeps } from "../lib/exportDeps";
import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
import { loadExportMode } from "../lib/exportMode";
import type { ExportMode } from "../lib/filename";
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
import { ImagePicker } from "../components/ImagePicker";
@@ -138,10 +144,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
// Issued invoice shown read-only after a successful issue. Its number is consumed, so the
// form never issues again until the user starts a new one.
const [issued, setIssued] = useState<Invoice | null>(null);
const [exporting, setExporting] = useState(false);
const [exportedPath, setExportedPath] = useState<string | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
// What the backend recorded when the issued PDF was archived; null until that worked.
const [archive, setArchive] = useState<{ sha256: string; fingerprint: string } | null>(null);
const [archiveError, setArchiveError] = useState<string | null>(null);
const [archiving, setArchiving] = useState(false);
const [exportMode, setExportMode] = useState<ExportMode>(loadExportMode);
const locked = issued !== null;
// The issued invoice as the export flow needs it: with its archive fields once archived.
const exportable = useMemo<Invoice | null>(
() => (issued ? { ...issued, archivedPdfSha256: archive?.sha256 ?? issued.archivedPdfSha256, archivedFingerprint: archive?.fingerprint ?? issued.archivedFingerprint ?? null } : null),
[issued, archive],
);
// Drafts.
const [draftId, setDraftId] = useState<number | null>(null);
@@ -392,8 +405,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
const clearIssued = () => {
issuedRef.current = false;
setIssued(null);
setExportedPath(null);
setExportError(null);
setArchive(null);
setArchiveError(null);
exp.dismiss();
setError(null);
};
@@ -517,19 +531,24 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
await refreshDrafts();
};
const runExport = async (invoice: Invoice) => {
setExporting(true);
setExportError(null);
try {
const path = await exportInvoicePdf(invoice, settings, bank);
setExportedPath(path ?? null);
} catch (e) {
setExportError(String(e));
} finally {
setExporting(false);
const noteArchived = (invoice: Invoice) => {
if (invoice.archivedPdfSha256) {
setArchive({ sha256: invoice.archivedPdfSha256, fingerprint: invoice.archivedFingerprint ?? "" });
setArchiveError(null);
}
};
const exp = useInvoiceExport({ settings, bank, onInvoiceUpdated: noteArchived });
const retryArchive = async () => {
if (!exportable || archiving) return;
setArchiving(true);
const outcome = await renderAndArchive(exportable, createExportDeps({ settings, bank }));
if (outcome.archived) noteArchived(outcome.invoice);
else setArchiveError(outcome.error ?? "The PDF could not be archived.");
setArchiving(false);
};
const onSave = async () => {
if (locked || saving) return;
if (clientGstinError) {
@@ -538,9 +557,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
}
setSaving(true);
setError(null);
setExportedPath(null);
setExportError(null);
let invoice: Invoice;
exp.dismiss();
const mode = loadExportMode();
let outcome: Awaited<ReturnType<typeof issueAndArchive>>;
try {
// Let a pending autosave land first so the issue call knows which draft to delete.
await chain.current;
@@ -565,19 +584,22 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
draftId: draftIdRef.current,
items: items.map((item, index) => ({ ...item, sortOrder: index })),
};
invoice = await api.issueInvoice(input, activePrefs);
outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank }));
} catch (e) {
issuedRef.current = false;
setError(String(e));
setSaving(false);
return;
}
// The number is consumed from here on. A failed export must never lead to a second issue.
setIssued(invoice);
// The number is consumed from here on. A failed archive or export must never lead to a second issue.
setIssued(outcome.invoice);
if (outcome.archived) noteArchived(outcome.invoice);
else setArchiveError(outcome.error ?? "The PDF could not be archived.");
detachDraft();
api.listClients().then(setClients).catch(() => {});
void refreshDrafts();
await runExport(invoice);
// Unarchived invoices export by rendering and archiving first (see exportInvoice), so this still works.
await exp.start(outcome.invoice, mode, "original");
setSaving(false);
};
@@ -590,7 +612,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
});
const otherDrafts = drafts.filter((d) => d.id !== draftId);
const busy = saving || exporting;
const busy = saving || exp.busy || archiving;
// Yield the shared render worker to the export; the model changes back when it is done and re-renders.
const pdf = useInvoicePdf({ model: busy ? null : previewModel, prefs: activePrefs });
@@ -637,9 +659,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
<Button kind="tertiary" renderIcon={Save} onClick={saveDraftNow} disabled={busy || !dirty}>
Save draft
</Button>
<Button renderIcon={Download} onClick={onSave} disabled={busy}>
{saving ? "Saving…" : "Save & export PDF"}
</Button>
<div className="voiced-export-split">
<Button renderIcon={Download} onClick={onSave} disabled={busy} title={`Issues the invoice and exports the ${exportMode} PDF`}>
{saving ? "Issuing…" : "Issue & export"}
</Button>
<ExportModePicker mode={exportMode} onChange={setExportMode} disabled={busy} />
</div>
</div>
)}
</div>
@@ -673,48 +698,27 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
onCloseButtonClick={() => setNoticeOpen(false)}
/>
) : null}
{locked ? (
{locked && exportable ? (
<>
{exportError ? (
<InlineNotification
kind="error"
{!exportable.archivedPdfSha256 ? (
<ActionableNotification
inline
kind="warning"
lowContrast
hideCloseButton
title={`Invoice ${issued.number} was issued, but the PDF export failed`}
subtitle={`${exportError} The number is already used; retry the export, do not issue again.`}
title="Issued — PDF not archived yet"
subtitle={`${archiveError ?? "The PDF was not archived."} The invoice is safe; it is not issued again. You can still export.`}
actionButtonLabel={archiving ? "Archiving…" : "Render & archive"}
onActionButtonClick={() => void retryArchive()}
/>
) : exportedPath ? (
<InlineNotification
kind="success"
lowContrast
hideCloseButton
title={`Invoice ${issued.number} issued`}
subtitle={exportedPath}
/>
) : exporting ? (
<InlineLoading description="Exporting PDF…" />
) : (
<InlineNotification
kind="info"
lowContrast
hideCloseButton
title={`Invoice ${issued.number} issued`}
subtitle="The PDF was not saved. Use Export again to save it."
/>
)}
) : null}
<ExportFeedback exp={exp} />
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
<Button
kind={exportError ? "primary" : "tertiary"}
renderIcon={exportError ? Renew : Download}
onClick={() => void runExport(issued)}
disabled={exporting}
>
{exporting ? "Exporting…" : exportError ? "Retry export" : "Export again"}
</Button>
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={exporting}>
<ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} />
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={busy}>
Duplicate as new
</Button>
<Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={exporting}>
<Button kind="tertiary" renderIcon={DocumentAdd} onClick={() => void newInvoice()} disabled={busy}>
New invoice
</Button>
</div>