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:
+67
-63
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user