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:
@@ -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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user