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
+94
View File
@@ -0,0 +1,94 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { createExportDeps } from "../lib/exportDeps";
import { exportInvoice, isExportCancelled, isFlattenFailure, type ExportProgress, type ExportResult, type ExportSource } from "../lib/exportFlow";
import type { ExportMode } from "../lib/filename";
import type { BankAccount, Invoice, Settings } from "../lib/types";
export interface ExportRequestState {
invoice: Invoice;
mode: ExportMode;
source: ExportSource;
}
export interface ExportFailure {
message: string;
/** The flatten itself failed: the modal offers Retry and "Export searchable instead". */
flatten: boolean;
}
export interface InvoiceExport {
busy: boolean;
progress: ExportProgress | null;
request: ExportRequestState | null;
error: ExportFailure | null;
result: ExportResult | null;
start(invoice: Invoice, mode: ExportMode, source: ExportSource): Promise<ExportResult | null>;
cancel(): void;
retry(): void;
exportSearchableInstead(): void;
dismiss(): void;
}
/**
* Runs exports one at a time and keeps what the UI shows: progress, the last result, the last error and
* the request, so Retry and "Export searchable instead" can repeat it. `onInvoiceUpdated` hears about an
* invoice that was archived by the export.
*/
export function useInvoiceExport(opts: { settings: Settings; bank: BankAccount | null; onInvoiceUpdated?: (invoice: Invoice) => void }): InvoiceExport {
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState<ExportProgress | null>(null);
const [request, setRequest] = useState<ExportRequestState | null>(null);
const [error, setError] = useState<ExportFailure | null>(null);
const [result, setResult] = useState<ExportResult | null>(null);
const controller = useRef<AbortController | null>(null);
const running = useRef(false);
const latest = useRef(opts);
latest.current = opts;
const start = useCallback(async (invoice: Invoice, mode: ExportMode, source: ExportSource) => {
if (running.current) return null;
running.current = true;
const ctl = new AbortController();
controller.current = ctl;
setRequest({ invoice, mode, source });
setBusy(true);
setError(null);
setResult(null);
setProgress({ phase: "rendering" });
try {
const deps = createExportDeps({ settings: latest.current.settings, bank: latest.current.bank });
const res = await exportInvoice({ invoice, mode, source, signal: ctl.signal, onProgress: setProgress }, deps);
if (res) {
setResult(res);
if (res.invoice !== invoice) latest.current.onInvoiceUpdated?.(res.invoice);
}
return res;
} catch (e) {
if (!isExportCancelled(e)) setError({ message: e instanceof Error ? e.message : String(e), flatten: isFlattenFailure(e) });
return null;
} finally {
running.current = false;
controller.current = null;
setBusy(false);
setProgress(null);
}
}, []);
const cancel = useCallback(() => controller.current?.abort(), []);
const dismiss = useCallback(() => {
setError(null);
setResult(null);
}, []);
const retry = useCallback(() => {
if (request) void start(request.invoice, request.mode, request.source);
}, [request, start]);
const exportSearchableInstead = useCallback(() => {
if (request) void start(request.invoice, "searchable", request.source);
}, [request, start]);
return useMemo(
() => ({ busy, progress, request, error, result, start, cancel, retry, exportSearchableInstead, dismiss }),
[busy, progress, request, error, result, start, cancel, retry, exportSearchableInstead, dismiss],
);
}