Replace the HTML preview with a worker-rendered PDF preview

Rendering moves into a module Web Worker behind a latest-wins RenderClient
with a watchdog and a handshake-based main-thread fallback. The preview
shows the real PDF through pdf.js at 2x with zoom, page indicator, audit
issues popover and a focus modal. A4/Letter and margin controls are frozen
into each invoice at issue and can be saved as the default (migration M3).
react-pdf and pdf.js are lazy-loaded; the main chunk shrinks from 1.8 MB
to 0.5 MB. The HTML twin and its styles are removed.
This commit is contained in:
2026-10-04 05:47:19 +05:30
parent a014a907e4
commit cedf4839bd
24 changed files with 1579 additions and 505 deletions
+98
View File
@@ -0,0 +1,98 @@
import type { BankAccount, Invoice, InvoiceItem, Settings, TaxType } from "./types";
import { computeLineAmount, computeTotals } from "./invoice";
import { amountInWords } from "./numberToWords";
/** The form fields the preview reads. */
export interface PreviewForm {
invoiceDate: string;
dueDate: string;
clientId: number | null;
clientName: string;
clientAddress: string;
clientGstin: string;
poNumber: string;
placeOfSupply: string;
discount: number;
taxRate: number;
reverseCharge: boolean;
signaturePath: string | null;
notes: string;
items: InvoiceItem[];
}
/**
* The tax treatment Rust derives from the supplier state and the place of supply
* (src-tauri/src/gst.rs); the form shows and sends the same value.
*/
export function deriveTaxType(settings: Settings, placeOfSupply: string): TaxType {
if (settings.gstRegistration === "unregistered") return "none";
return !placeOfSupply || placeOfSupply === settings.vendorStateCode ? "cgst_sgst" : "igst";
}
/** Mirrors vendor_snapshot() in src-tauri/src/commands/invoice.rs. */
export function vendorSnapshotJson(settings: Settings): string {
return JSON.stringify({
vendorName: settings.vendorName,
vendorAddress: settings.vendorAddress,
vendorEmail: settings.vendorEmail,
vendorPhone: settings.vendorPhone,
vendorPan: settings.vendorPan,
vendorGstin: settings.gstRegistration === "unregistered" ? "" : settings.vendorGstin.trim(),
vendorStateCode: settings.vendorStateCode,
logoPath: settings.logoPath,
signaturePath: settings.signaturePath,
gstRegistration: settings.gstRegistration,
signatoryName: settings.signatoryName,
signatoryDesignation: settings.signatoryDesignation,
});
}
/**
* A draft-status Invoice built from the live form, so the real renderer can draw it before anything
* is issued. Totals use the same paise rounding Rust stores. `number` is the next number (a peek),
* not yet consumed.
*/
export function formToPreviewInvoice(form: PreviewForm, number: string, settings: Settings, bank: BankAccount | null): Invoice {
const taxType = deriveTaxType(settings, form.placeOfSupply);
const registered = settings.gstRegistration !== "unregistered";
const totals = computeTotals(form.items, form.discount, taxType, form.taxRate);
const items = form.items.map((item, i) => ({ ...item, amount: computeLineAmount(item), sortOrder: i }));
return {
id: 0,
number,
seriesId: null,
invoiceDate: form.invoiceDate,
dueDate: form.dueDate,
clientId: form.clientId,
clientName: form.clientName,
clientAddress: form.clientAddress,
clientGstin: form.clientGstin,
poNumber: form.poNumber,
placeOfSupplyStateCode: form.placeOfSupply,
subtotal: totals.subtotal,
discount: totals.discount,
taxType,
taxRate: form.taxRate,
cgstAmount: totals.cgst,
sgstAmount: totals.sgst,
igstAmount: totals.igst,
total: totals.total,
amountInWords: amountInWords(totals.total),
bankAccountId: bank?.id ?? null,
bankSnapshot: bank ? JSON.stringify(bank) : "",
signaturePath: form.signaturePath,
notes: form.notes,
status: "draft",
createdAt: "",
updatedAt: "",
docType: registered ? "tax_invoice" : "invoice",
reverseCharge: registered && form.reverseCharge,
vendorSnapshot: vendorSnapshotJson(settings),
snapshotOrigin: "issued",
cancelledAt: null,
cancelReason: "",
archivedPdfSha256: null,
renderPrefs: "",
items,
};
}