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:
@@ -17,7 +17,7 @@ const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, clien
|
|||||||
tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words,
|
||||||
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at,
|
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at,
|
||||||
doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at,
|
doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at,
|
||||||
cancel_reason, archived_pdf_sha256";
|
cancel_reason, archived_pdf_sha256, COALESCE(render_prefs, '')";
|
||||||
|
|
||||||
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
||||||
Ok(Invoice {
|
Ok(Invoice {
|
||||||
@@ -55,6 +55,7 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
|||||||
cancelled_at: row.get(31)?,
|
cancelled_at: row.get(31)?,
|
||||||
cancel_reason: row.get(32)?,
|
cancel_reason: row.get(32)?,
|
||||||
archived_pdf_sha256: row.get(33)?,
|
archived_pdf_sha256: row.get(33)?,
|
||||||
|
render_prefs: row.get(34)?,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -922,6 +923,7 @@ mod tests {
|
|||||||
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(prefs, "{\"theme\":\"plain\"}");
|
assert_eq!(prefs, "{\"theme\":\"plain\"}");
|
||||||
|
assert_eq!(inv.render_prefs, prefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use tauri::State;
|
|||||||
pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan,
|
||||||
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
|
vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate,
|
||||||
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
|
default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name,
|
||||||
signatory_designation";
|
signatory_designation, render_prefs";
|
||||||
|
|
||||||
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||||
Ok(Settings {
|
Ok(Settings {
|
||||||
@@ -30,6 +30,7 @@ pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
|||||||
default_hsn_sac: row.get(17)?,
|
default_hsn_sac: row.get(17)?,
|
||||||
signatory_name: row.get(18)?,
|
signatory_name: row.get(18)?,
|
||||||
signatory_designation: row.get(19)?,
|
signatory_designation: row.get(19)?,
|
||||||
|
render_prefs: row.get(20)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
|||||||
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
signature_path = ?9, default_bank_id = ?10, default_tax_rate = ?11,
|
||||||
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
||||||
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
|
onboarded = ?15, theme = ?16, gst_registration = ?17, default_hsn_sac = ?18,
|
||||||
signatory_name = ?19, signatory_designation = ?20
|
signatory_name = ?19, signatory_designation = ?20, render_prefs = ?21
|
||||||
WHERE id = 1"#,
|
WHERE id = 1"#,
|
||||||
params![
|
params![
|
||||||
settings.vendor_name,
|
settings.vendor_name,
|
||||||
@@ -77,6 +78,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
|||||||
settings.default_hsn_sac,
|
settings.default_hsn_sac,
|
||||||
settings.signatory_name,
|
settings.signatory_name,
|
||||||
settings.signatory_designation,
|
settings.signatory_designation,
|
||||||
|
settings.render_prefs,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -230,6 +232,26 @@ mod tests {
|
|||||||
(setting, flagged)
|
(setting, flagged)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_carry_render_prefs() {
|
||||||
|
let conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let read = |conn: &Connection| {
|
||||||
|
conn.query_row(
|
||||||
|
&format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS),
|
||||||
|
[],
|
||||||
|
map_settings,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(read(&conn).render_prefs, "");
|
||||||
|
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", params!["{\"version\":1}"])
|
||||||
|
.unwrap();
|
||||||
|
let settings = read(&conn);
|
||||||
|
assert_eq!(settings.render_prefs, "{\"version\":1}");
|
||||||
|
let json = serde_json::to_value(&settings).unwrap();
|
||||||
|
assert_eq!(json["renderPrefs"], "{\"version\":1}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
|
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
|
||||||
let mut conn = crate::db::open_in_memory().unwrap();
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
|||||||
+23
-2
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
|
|||||||
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
const BACKUP_PREFIX: &str = "voiced-pre-v";
|
||||||
|
|
||||||
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
/// Highest schema version, i.e. the number of entries in `migrations()`.
|
||||||
const LATEST_VERSION: i64 = 2;
|
const LATEST_VERSION: i64 = 3;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum DbError {
|
pub enum DbError {
|
||||||
@@ -169,8 +169,13 @@ CREATE TABLE IF NOT EXISTS invoice_drafts (
|
|||||||
);
|
);
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// Version 3: the user's default page setup (RenderPrefsV1 JSON; empty means built-in defaults).
|
||||||
|
const M3: &str = r#"
|
||||||
|
ALTER TABLE app_settings ADD COLUMN render_prefs TEXT NOT NULL DEFAULT '';
|
||||||
|
"#;
|
||||||
|
|
||||||
fn migrations() -> Migrations<'static> {
|
fn migrations() -> Migrations<'static> {
|
||||||
Migrations::new(vec![M::up(SCHEMA), M::up(M2)])
|
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3)])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
|
||||||
@@ -536,6 +541,22 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
|||||||
assert!(backups_in(&backups).is_empty());
|
assert!(backups_in(&backups).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_prefs_column_defaults_to_empty_and_round_trips() {
|
||||||
|
let conn = open_in_memory().unwrap();
|
||||||
|
let initial: String = conn
|
||||||
|
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(initial, "");
|
||||||
|
|
||||||
|
let json = r#"{"version":1,"templateId":"classic","pageSize":"LETTER","margins":"narrow"}"#;
|
||||||
|
conn.execute("UPDATE app_settings SET render_prefs = ?1 WHERE id = 1", [json]).unwrap();
|
||||||
|
let stored: String = conn
|
||||||
|
.query_row("SELECT render_prefs FROM app_settings WHERE id = 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored, json);
|
||||||
|
}
|
||||||
|
|
||||||
fn build_v1_database(path: &Path) {
|
fn build_v1_database(path: &Path) {
|
||||||
let conn = Connection::open(path).unwrap();
|
let conn = Connection::open(path).unwrap();
|
||||||
conn.execute_batch(V1_SCHEMA).unwrap();
|
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ pub struct Settings {
|
|||||||
pub default_hsn_sac: String,
|
pub default_hsn_sac: String,
|
||||||
pub signatory_name: String,
|
pub signatory_name: String,
|
||||||
pub signatory_designation: String,
|
pub signatory_designation: String,
|
||||||
|
/// The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults.
|
||||||
|
#[serde(default)]
|
||||||
|
pub render_prefs: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -194,6 +197,8 @@ pub struct Invoice {
|
|||||||
pub cancelled_at: Option<String>,
|
pub cancelled_at: Option<String>,
|
||||||
pub cancel_reason: String,
|
pub cancel_reason: String,
|
||||||
pub archived_pdf_sha256: Option<String>,
|
pub archived_pdf_sha256: Option<String>,
|
||||||
|
/// RenderPrefsV1 JSON frozen at issue; empty when none was stored.
|
||||||
|
pub render_prefs: String,
|
||||||
pub items: Vec<InvoiceItem>,
|
pub items: Vec<InvoiceItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
import { UNIT_LABELS } from "../lib/types";
|
|
||||||
import { computeLineAmount } from "../lib/invoice";
|
|
||||||
import { formatAmount, formatDate } from "../lib/format";
|
|
||||||
import type { InvoicePdfProps } from "../lib/pdfProps";
|
|
||||||
|
|
||||||
const money = (n: number) => `₹${formatAmount(n)}`;
|
|
||||||
|
|
||||||
/** On-screen preview that mirrors the exported PDF layout. */
|
|
||||||
export function InvoicePreview(props: InvoicePdfProps) {
|
|
||||||
const {
|
|
||||||
vendor,
|
|
||||||
bank,
|
|
||||||
logoDataUri,
|
|
||||||
signatureDataUri,
|
|
||||||
number,
|
|
||||||
invoiceDate,
|
|
||||||
dueDate,
|
|
||||||
clientName,
|
|
||||||
clientAddress,
|
|
||||||
clientGstin,
|
|
||||||
poNumber,
|
|
||||||
placeOfSupplyStateCode,
|
|
||||||
items,
|
|
||||||
subtotal,
|
|
||||||
discount,
|
|
||||||
taxType,
|
|
||||||
taxRate,
|
|
||||||
cgst,
|
|
||||||
sgst,
|
|
||||||
igst,
|
|
||||||
total,
|
|
||||||
amountInWords,
|
|
||||||
notes,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const isTaxInvoice = taxType !== "none";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inv-preview-wrap">
|
|
||||||
<div className="inv-paper">
|
|
||||||
<div className="inv-top">
|
|
||||||
<div className="inv-vendor">
|
|
||||||
{logoDataUri ? <img className="inv-vendor__logo" src={logoDataUri} alt="" /> : null}
|
|
||||||
<div>
|
|
||||||
<h2 className="inv-vendor__name">{vendor.vendorName || "Vendor"}</h2>
|
|
||||||
<p className="inv-vendor__line">{vendor.vendorAddress}</p>
|
|
||||||
{vendor.vendorEmail ? <p className="inv-vendor__line">{vendor.vendorEmail}</p> : null}
|
|
||||||
{vendor.vendorPhone ? <p className="inv-vendor__line">{vendor.vendorPhone}</p> : null}
|
|
||||||
{vendor.vendorPan ? <p className="inv-vendor__line">PAN: {vendor.vendorPan}</p> : null}
|
|
||||||
{vendor.vendorGstin ? <p className="inv-vendor__line">GSTIN: {vendor.vendorGstin}</p> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="inv-meta">
|
|
||||||
<h3 className="inv-meta__title">{isTaxInvoice ? "Tax Invoice" : "Invoice"}</h3>
|
|
||||||
<div className="inv-meta__row">
|
|
||||||
<span className="inv-meta__label">Invoice No.</span>
|
|
||||||
<span className="inv-meta__value">{number}</span>
|
|
||||||
</div>
|
|
||||||
<div className="inv-meta__row">
|
|
||||||
<span className="inv-meta__label">Date</span>
|
|
||||||
<span className="inv-meta__value">{formatDate(invoiceDate)}</span>
|
|
||||||
</div>
|
|
||||||
{dueDate ? (
|
|
||||||
<div className="inv-meta__row">
|
|
||||||
<span className="inv-meta__label">Due Date</span>
|
|
||||||
<span className="inv-meta__value">{formatDate(dueDate)}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inv-split">
|
|
||||||
<div className="inv-block" style={{ maxWidth: "60%" }}>
|
|
||||||
<p className="inv-block__label">Billed To</p>
|
|
||||||
<div className="inv-block__strong">{clientName || "—"}</div>
|
|
||||||
{clientAddress ? <div style={{ whiteSpace: "pre-line" }}>{clientAddress}</div> : null}
|
|
||||||
{clientGstin ? <div>GSTIN: {clientGstin}</div> : null}
|
|
||||||
{placeOfSupplyStateCode ? <div>Place of Supply: {placeOfSupplyStateCode}</div> : null}
|
|
||||||
</div>
|
|
||||||
{poNumber ? (
|
|
||||||
<div className="inv-block">
|
|
||||||
<p className="inv-block__label">PO Number</p>
|
|
||||||
<div className="inv-block__strong">{poNumber}</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table className="inv-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Description</th>
|
|
||||||
<th className="num">Unit Price</th>
|
|
||||||
<th className="num">Qty</th>
|
|
||||||
<th className="num">Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<tr key={item.id ?? index}>
|
|
||||||
<td>{item.description || "—"}</td>
|
|
||||||
<td className="num">
|
|
||||||
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="num">{item.mode === "rate" ? item.quantity : 1}</td>
|
|
||||||
<td className="num">{money(computeLineAmount(item))}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div className="inv-totals">
|
|
||||||
<div className="inv-totals__table">
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>Subtotal</span>
|
|
||||||
<span>{money(subtotal)}</span>
|
|
||||||
</div>
|
|
||||||
{discount > 0 ? (
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>Discount</span>
|
|
||||||
<span>- {money(discount)}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{taxType === "cgst_sgst" ? (
|
|
||||||
<>
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>CGST ({(taxRate / 2).toFixed(2)}%)</span>
|
|
||||||
<span>{money(cgst)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>SGST ({(taxRate / 2).toFixed(2)}%)</span>
|
|
||||||
<span>{money(sgst)}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{taxType === "igst" ? (
|
|
||||||
<div className="inv-totals__row">
|
|
||||||
<span>IGST ({taxRate.toFixed(2)}%)</span>
|
|
||||||
<span>{money(igst)}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="inv-totals__row inv-totals__row--grand">
|
|
||||||
<span>Total</span>
|
|
||||||
<span>{money(total)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="inv-words">Amount in words: {amountInWords}</p>
|
|
||||||
|
|
||||||
{notes ? (
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<p className="inv-block__label">Notes</p>
|
|
||||||
<div style={{ whiteSpace: "pre-line" }}>{notes}</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="inv-bottom">
|
|
||||||
<div className="inv-block" style={{ maxWidth: "55%" }}>
|
|
||||||
{bank ? (
|
|
||||||
<>
|
|
||||||
<p className="inv-block__label">Pay To</p>
|
|
||||||
<div className="inv-block__strong">{bank.accountName || vendor.vendorName}</div>
|
|
||||||
<div>{bank.bankName}</div>
|
|
||||||
{bank.accountNo ? <div>Account No.: {bank.accountNo}</div> : null}
|
|
||||||
{bank.branch ? <div>Branch: {bank.branch}</div> : null}
|
|
||||||
{bank.ifsc ? <div>IFSC: {bank.ifsc}</div> : null}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="inv-signature">
|
|
||||||
{signatureDataUri ? (
|
|
||||||
<>
|
|
||||||
<img className="inv-signature__img" src={signatureDataUri} alt="Signature" />
|
|
||||||
<div className="inv-signature__name">{vendor.vendorName}</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="inv-signature__name">{vendor.vendorName}</div>
|
|
||||||
<p className="inv-fineprint">Computer-generated invoice; no signature is required.</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inv-footer">
|
|
||||||
<span>{vendor.vendorName}</span>
|
|
||||||
<span>Generated with Voiced — a Bongbetic product</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { Button, ContentSwitcher, InlineLoading, Select, SelectItem, Switch } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import type { Settings } from "../lib/types";
|
||||||
|
import type { MarginPreset, PageId } from "../pdf/engine/geometry";
|
||||||
|
import { parsePrefs, prefsToJson, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
|
|
||||||
|
const PAGES: { id: PageId; label: string }[] = [
|
||||||
|
{ id: "A4", label: "A4" },
|
||||||
|
{ id: "LETTER", label: "US Letter" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MARGINS: { id: MarginPreset; label: string }[] = [
|
||||||
|
{ id: "template", label: "Template default" },
|
||||||
|
{ id: "narrow", label: "Narrow (0.5″)" },
|
||||||
|
{ id: "normal", label: "Normal (0.75″)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prefs: RenderPrefsV1;
|
||||||
|
onChange: (prefs: RenderPrefsV1) => void;
|
||||||
|
/** Live settings; `renderPrefs` is the saved default. */
|
||||||
|
settings: Settings;
|
||||||
|
/** Called after the default was saved so the app reloads its settings. */
|
||||||
|
onSaved: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Where a TemplatePicker goes once there is more than one template. */
|
||||||
|
templateSlot?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page size and margins for the invoice being edited, plus "Save as default" for new invoices. */
|
||||||
|
export default function PageSetupControls({ prefs, onChange, settings, onSaved, disabled, templateSlot }: Props) {
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const saved = prefsToJson(parsePrefs(settings.renderPrefs));
|
||||||
|
const isDefault = prefsToJson(prefs) === saved;
|
||||||
|
|
||||||
|
const saveDefault = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.saveSettings({ ...settings, renderPrefs: prefsToJson(prefs) });
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voiced-page-setup">
|
||||||
|
<div className="voiced-page-setup__row">
|
||||||
|
{templateSlot}
|
||||||
|
<div className="voiced-page-setup__field">
|
||||||
|
<span className="cds--label">Page size</span>
|
||||||
|
<ContentSwitcher
|
||||||
|
size="md"
|
||||||
|
selectedIndex={Math.max(0, PAGES.findIndex((p) => p.id === prefs.pageSize))}
|
||||||
|
onChange={(e) => !disabled && onChange({ ...prefs, pageSize: PAGES[e.index ?? 0].id })}
|
||||||
|
>
|
||||||
|
{PAGES.map((p) => (
|
||||||
|
<Switch key={p.id} name={p.id} text={p.label} disabled={disabled} />
|
||||||
|
))}
|
||||||
|
</ContentSwitcher>
|
||||||
|
</div>
|
||||||
|
<div className="voiced-page-setup__field">
|
||||||
|
<Select
|
||||||
|
id="page-margins"
|
||||||
|
labelText="Margins"
|
||||||
|
size="md"
|
||||||
|
disabled={disabled}
|
||||||
|
value={prefs.margins}
|
||||||
|
onChange={(e) => onChange({ ...prefs, margins: e.target.value as MarginPreset })}
|
||||||
|
>
|
||||||
|
{MARGINS.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id} text={m.label} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="voiced-page-setup__footer">
|
||||||
|
{saving ? (
|
||||||
|
<InlineLoading status="active" description="Saving…" />
|
||||||
|
) : (
|
||||||
|
<Button kind="tertiary" size="sm" disabled={isDefault} onClick={() => void saveDefault()}>
|
||||||
|
{isDefault ? "Saved as default" : "Save as default"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span className="voiced-muted voiced-page-setup__note">
|
||||||
|
{error ?? "This choice is frozen into each invoice when it is issued."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Button, ComposedModal, ContentSwitcher, InlineLoading, InlineNotification, ModalBody, ModalHeader, Popover, PopoverContent, Switch, Tag } from "@carbon/react";
|
||||||
|
import { Maximize, Warning } from "@carbon/icons-react";
|
||||||
|
import { cancelPageRender, closePdf, isRenderCancelled, openPdf, renderPageToCanvas, type PDFDocumentProxy } from "../lib/pdfjs";
|
||||||
|
import type { AuditIssue } from "../pdf/engine/audit";
|
||||||
|
|
||||||
|
export interface PdfPreviewProps {
|
||||||
|
bytes: Uint8Array | null;
|
||||||
|
pages: number;
|
||||||
|
issues: AuditIssue[];
|
||||||
|
rendering: boolean;
|
||||||
|
/** "main": rendering runs on the main thread, so the preview only updates when typing pauses. */
|
||||||
|
mode: "worker" | "main";
|
||||||
|
error: string | null;
|
||||||
|
/** Adds a "Focus preview" button (the modal itself is built in). */
|
||||||
|
onFocus?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Zoom = "fit" | "100" | "150";
|
||||||
|
const ZOOMS: { id: Zoom; label: string }[] = [
|
||||||
|
{ id: "fit", label: "Fit width" },
|
||||||
|
{ id: "100", label: "100%" },
|
||||||
|
{ id: "150", label: "150%" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Gap between pages and padding around them, in CSS px (matches .voiced-pdf-preview__pages). */
|
||||||
|
const PAGE_GAP = 16;
|
||||||
|
const PAD = 16;
|
||||||
|
const MAX_LIVE_CANVASES = 3;
|
||||||
|
/** Backing store is never below 2x the CSS size: at fit-width the text would be blurry at 1x. */
|
||||||
|
const MIN_BACKING = 2;
|
||||||
|
const CSS_PX_PER_PT = 96 / 72;
|
||||||
|
|
||||||
|
function useDocument(bytes: Uint8Array | null) {
|
||||||
|
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const current = useRef<PDFDocumentProxy | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!bytes) return;
|
||||||
|
let stale = false;
|
||||||
|
openPdf(bytes).then(
|
||||||
|
(opened) => {
|
||||||
|
if (stale) {
|
||||||
|
void closePdf(opened);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous = current.current;
|
||||||
|
current.current = opened;
|
||||||
|
setDoc(opened);
|
||||||
|
setLoadError(null);
|
||||||
|
// The old canvases are plain bitmaps, so the old document can go once the slots re-render.
|
||||||
|
if (previous) setTimeout(() => void closePdf(previous), 500);
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
if (!stale) setLoadError(err instanceof Error ? err.message : String(err));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
stale = true;
|
||||||
|
};
|
||||||
|
}, [bytes]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (current.current) void closePdf(current.current);
|
||||||
|
current.current = null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { doc, loadError };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDebounced<T>(value: T, ms: number): T {
|
||||||
|
const [v, setV] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setV(value), ms);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, ms]);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotProps {
|
||||||
|
doc: PDFDocumentProxy;
|
||||||
|
index: number;
|
||||||
|
live: boolean;
|
||||||
|
renderScale: number;
|
||||||
|
cssWidth: number;
|
||||||
|
cssHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page. A live slot renders into an offscreen canvas and swaps it in when done, so a re-render
|
||||||
|
* never blanks the page; a slot that is not live holds no canvas at all.
|
||||||
|
*/
|
||||||
|
function PageSlot({ doc, index, live, renderScale, cssWidth, cssHeight }: SlotProps) {
|
||||||
|
const host = useRef<HTMLDivElement>(null);
|
||||||
|
const key = useRef({}).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = host.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (!live) {
|
||||||
|
const old = el.firstElementChild as HTMLCanvasElement | null;
|
||||||
|
el.replaceChildren();
|
||||||
|
if (old) old.width = old.height = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dead = false;
|
||||||
|
const off = document.createElement("canvas");
|
||||||
|
off.className = "voiced-pdf-preview__canvas";
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const page = await doc.getPage(index + 1);
|
||||||
|
await renderPageToCanvas(page, off, renderScale, key);
|
||||||
|
if (dead) return;
|
||||||
|
const old = el.firstElementChild as HTMLCanvasElement | null;
|
||||||
|
el.replaceChildren(off);
|
||||||
|
if (old) old.width = old.height = 0;
|
||||||
|
} catch (err) {
|
||||||
|
if (!dead && !isRenderCancelled(err)) console.error("PDF page render failed", err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
dead = true;
|
||||||
|
cancelPageRender(key);
|
||||||
|
};
|
||||||
|
}, [doc, index, live, renderScale, key]);
|
||||||
|
|
||||||
|
return <div ref={host} className="voiced-pdf-preview__page" data-page={index} style={{ width: cssWidth, height: cssHeight }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewerProps {
|
||||||
|
bytes: Uint8Array | null;
|
||||||
|
/** Page count from the render, shown until the document has opened. */
|
||||||
|
pages: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zoom control, page indicator and the scrolling stack of page canvases. */
|
||||||
|
function PdfViewer({ bytes, pages, className }: ViewerProps) {
|
||||||
|
const { doc, loadError } = useDocument(bytes);
|
||||||
|
const [zoom, setZoom] = useState<Zoom>("fit");
|
||||||
|
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||||
|
const [fitWidth, setFitWidth] = useState(0);
|
||||||
|
const [live, setLive] = useState<number[]>([0]);
|
||||||
|
const [current, setCurrent] = useState(1);
|
||||||
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doc) return;
|
||||||
|
let stale = false;
|
||||||
|
void doc.getPage(1).then((p) => {
|
||||||
|
const v = p.getViewport({ scale: 1 });
|
||||||
|
if (!stale) setNatural({ w: v.width, h: v.height });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stale = true;
|
||||||
|
};
|
||||||
|
}, [doc]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scroller.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => setFitWidth(Math.max(0, el.clientWidth - 2 * PAD));
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pageCount = doc?.numPages ?? pages;
|
||||||
|
const cssWidth = natural ? (zoom === "fit" ? fitWidth : natural.w * CSS_PX_PER_PT * (Number(zoom) / 100)) : 0;
|
||||||
|
const cssHeight = natural ? (cssWidth * natural.h) / natural.w : 0;
|
||||||
|
const settledWidth = useDebounced(cssWidth, 150);
|
||||||
|
const renderScale = natural && settledWidth > 0 ? (settledWidth / natural.w) * Math.max(MIN_BACKING, window.devicePixelRatio || 1) : 0;
|
||||||
|
|
||||||
|
// Which pages are near the viewport; at most MAX_LIVE_CANVASES of them hold a canvas.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scroller.current;
|
||||||
|
if (!root || !doc || !natural) return;
|
||||||
|
const visible = new Set<number>();
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
const n = Number((e.target as HTMLElement).dataset.page);
|
||||||
|
if (e.isIntersecting) visible.add(n);
|
||||||
|
else visible.delete(n);
|
||||||
|
}
|
||||||
|
const next = [...visible].sort((a, b) => a - b).slice(0, MAX_LIVE_CANVASES);
|
||||||
|
setLive((prev) => (prev.length === next.length && prev.every((v, i) => v === next[i]) ? prev : next));
|
||||||
|
},
|
||||||
|
{ root, rootMargin: "200px 0px" },
|
||||||
|
);
|
||||||
|
root.querySelectorAll("[data-page]").forEach((el) => io.observe(el));
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [doc, natural, pageCount, cssHeight]);
|
||||||
|
|
||||||
|
// Pages are one size, so the page under the middle of the viewport follows from the scroll offset.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scroller.current;
|
||||||
|
if (!root || cssHeight <= 0) return;
|
||||||
|
let frame = 0;
|
||||||
|
const update = () => {
|
||||||
|
frame = 0;
|
||||||
|
const middle = root.scrollTop + root.clientHeight / 2 - PAD;
|
||||||
|
const n = Math.floor(middle / (cssHeight + PAGE_GAP)) + 1;
|
||||||
|
setCurrent(Math.min(Math.max(n, 1), Math.max(pageCount, 1)));
|
||||||
|
};
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!frame) frame = requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
root.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener("scroll", onScroll);
|
||||||
|
if (frame) cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [cssHeight, pageCount]);
|
||||||
|
|
||||||
|
const liveSet = useMemo(() => new Set(live), [live]);
|
||||||
|
const zoomIndex = ZOOMS.findIndex((z) => z.id === zoom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`voiced-pdf-viewer ${className ?? ""}`}>
|
||||||
|
<div className="voiced-pdf-viewer__bar">
|
||||||
|
<ContentSwitcher
|
||||||
|
size="sm"
|
||||||
|
className="voiced-pdf-viewer__zoom"
|
||||||
|
selectedIndex={zoomIndex}
|
||||||
|
onChange={(e) => setZoom(ZOOMS[e.index ?? 0].id)}
|
||||||
|
>
|
||||||
|
{ZOOMS.map((z) => (
|
||||||
|
<Switch key={z.id} name={z.id} text={z.label} />
|
||||||
|
))}
|
||||||
|
</ContentSwitcher>
|
||||||
|
<span className="voiced-pdf-viewer__page" aria-live="polite">
|
||||||
|
{pageCount > 0 ? `Page ${current} of ${pageCount}` : "No pages"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div ref={scroller} className="voiced-pdf-preview__scroll">
|
||||||
|
{loadError ? (
|
||||||
|
<InlineNotification lowContrast hideCloseButton kind="error" title="Cannot show the PDF" subtitle={loadError} />
|
||||||
|
) : null}
|
||||||
|
<div className="voiced-pdf-preview__pages">
|
||||||
|
{doc && natural && cssWidth > 0
|
||||||
|
? Array.from({ length: pageCount }, (_, i) => (
|
||||||
|
<PageSlot
|
||||||
|
key={i}
|
||||||
|
doc={doc}
|
||||||
|
index={i}
|
||||||
|
live={liveSet.has(i) && renderScale > 0}
|
||||||
|
renderScale={renderScale}
|
||||||
|
cssWidth={cssWidth}
|
||||||
|
cssHeight={cssHeight}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PdfPreview({ bytes, pages, issues, rendering, mode, error, onFocus }: PdfPreviewProps) {
|
||||||
|
const [issuesOpen, setIssuesOpen] = useState(false);
|
||||||
|
const [focusOpen, setFocusOpen] = useState(false);
|
||||||
|
const errors = issues.filter((i) => i.severity === "error").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voiced-pdf-preview">
|
||||||
|
<div className="voiced-pdf-preview__status">
|
||||||
|
<div className="voiced-pdf-preview__chips">
|
||||||
|
{rendering ? <InlineLoading status="active" description="Rendering…" /> : null}
|
||||||
|
{mode === "main" ? (
|
||||||
|
<Tag type="gray" size="sm">
|
||||||
|
Preview paused while typing
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="voiced-pdf-preview__actions">
|
||||||
|
{issues.length > 0 ? (
|
||||||
|
<Popover open={issuesOpen} align="bottom-end" dropShadow onRequestClose={() => setIssuesOpen(false)}>
|
||||||
|
<Button
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
renderIcon={Warning}
|
||||||
|
aria-expanded={issuesOpen}
|
||||||
|
onClick={() => setIssuesOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
{errors > 0 ? `Issues (${issues.length}, ${errors} error${errors === 1 ? "" : "s"})` : `Issues (${issues.length})`}
|
||||||
|
</Button>
|
||||||
|
<PopoverContent className="voiced-pdf-issues">
|
||||||
|
<ul className="voiced-pdf-issues__list">
|
||||||
|
{issues.map((issue, i) => (
|
||||||
|
<li key={i} className="voiced-pdf-issues__item">
|
||||||
|
<div className="voiced-pdf-issues__head">
|
||||||
|
<Tag type={issue.severity === "error" ? "red" : "gray"} size="sm" className={issue.severity === "error" ? undefined : "voiced-tag--warn"}>
|
||||||
|
{issue.code}
|
||||||
|
</Tag>
|
||||||
|
<span className="voiced-muted">Page {issue.page}</span>
|
||||||
|
</div>
|
||||||
|
<p className="voiced-pdf-issues__message">{issue.message}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
hasIconOnly
|
||||||
|
renderIcon={Maximize}
|
||||||
|
iconDescription="Focus preview"
|
||||||
|
tooltipPosition="left"
|
||||||
|
disabled={!bytes}
|
||||||
|
onClick={() => {
|
||||||
|
setFocusOpen(true);
|
||||||
|
onFocus?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <InlineNotification lowContrast hideCloseButton kind="error" title="Preview failed" subtitle={error} /> : null}
|
||||||
|
|
||||||
|
{bytes ? (
|
||||||
|
<PdfViewer bytes={bytes} pages={pages} />
|
||||||
|
) : (
|
||||||
|
<div className="voiced-pdf-preview__empty">{rendering ? "Rendering the first preview…" : "The preview appears here."}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ComposedModal open={focusOpen} size="lg" className="voiced-pdf-focus" onClose={() => setFocusOpen(false)}>
|
||||||
|
<ModalHeader title="Invoice preview" closeModal={() => setFocusOpen(false)} />
|
||||||
|
<ModalBody hasScrollingContent={false}>{focusOpen && bytes ? <PdfViewer bytes={bytes} pages={pages} className="voiced-pdf-viewer--focus" /> : null}</ModalBody>
|
||||||
|
</ComposedModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { getRenderClient, nextRenderJobId } from "../lib/renderClient";
|
||||||
|
import type { AuditIssue } from "../pdf/engine/audit";
|
||||||
|
import type { RenderModel } from "../pdf/model/build";
|
||||||
|
import type { RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
|
import { Superseded } from "../pdf/render/client";
|
||||||
|
|
||||||
|
/** Typing pauses this long before the model is re-rendered; the render itself takes about 1 s. */
|
||||||
|
export const MODEL_DEBOUNCE_MS = 800;
|
||||||
|
|
||||||
|
export interface InvoicePdfState {
|
||||||
|
bytes: Uint8Array | null;
|
||||||
|
pages: number;
|
||||||
|
issues: AuditIssue[];
|
||||||
|
rendering: boolean;
|
||||||
|
error: string | null;
|
||||||
|
mode: "worker" | "main";
|
||||||
|
lastMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL: InvoicePdfState = { bytes: null, pages: 0, issues: [], rendering: false, error: null, mode: "worker", lastMs: null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live PDF for a render model. Model changes are debounced; changes to the prefs render at once. A new
|
||||||
|
* request cancels the one in flight, the previous bytes stay in place while the next render runs, and
|
||||||
|
* a null model never renders (the last PDF stays). Pass a memoised model: a new object means a change.
|
||||||
|
*/
|
||||||
|
export function useInvoicePdf({ model, prefs }: { model: RenderModel | null; prefs: RenderPrefsV1 }): InvoicePdfState {
|
||||||
|
const [state, setState] = useState<InvoicePdfState>(INITIAL);
|
||||||
|
const latestJob = useRef(0);
|
||||||
|
const lastPrefsKey = useRef<string | null>(null);
|
||||||
|
const mounted = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
mounted.current = true;
|
||||||
|
return () => {
|
||||||
|
mounted.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const prefsKey = JSON.stringify(prefs);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!model) return;
|
||||||
|
const prefsChanged = lastPrefsKey.current !== prefsKey;
|
||||||
|
lastPrefsKey.current = prefsKey;
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
const id = nextRenderJobId();
|
||||||
|
latestJob.current = id;
|
||||||
|
setState((s) => ({ ...s, rendering: true }));
|
||||||
|
const client = getRenderClient();
|
||||||
|
client.render({ id, model, prefs: JSON.parse(prefsKey) as RenderPrefsV1 }).then(
|
||||||
|
(res) => {
|
||||||
|
if (!mounted.current || latestJob.current !== id) return;
|
||||||
|
setState({ bytes: res.bytes, pages: res.pages, issues: res.issues, rendering: false, error: null, mode: client.mode, lastMs: res.ms });
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
if (!mounted.current || latestJob.current !== id) return;
|
||||||
|
// Superseded while still the newest here means another caller (export) took the worker; keep
|
||||||
|
// the old PDF and let the next model or prefs change render again.
|
||||||
|
const error = err instanceof Superseded ? null : err instanceof Error ? err.message : String(err);
|
||||||
|
setState((s) => ({ ...s, rendering: false, error: error ?? s.error, mode: client.mode }));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (prefsChanged) start();
|
||||||
|
const timer = prefsChanged ? undefined : setTimeout(start, MODEL_DEBOUNCE_MS);
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [model, prefsKey]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
+1
-1
@@ -28,7 +28,7 @@ export const api = {
|
|||||||
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
deleteClient: (id: number) => invoke<void>("delete_client", { id }),
|
||||||
|
|
||||||
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
|
||||||
issueInvoice: (input: InvoiceInput, renderPrefs: Record<string, unknown>) =>
|
issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
|
||||||
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
invoke<Invoice>("issue_invoice", { input, renderPrefs }),
|
||||||
cancelInvoice: (id: number, reason: string) =>
|
cancelInvoice: (id: number, reason: string) =>
|
||||||
invoke<Invoice>("cancel_invoice", { id, reason }),
|
invoke<Invoice>("cancel_invoice", { id, reason }),
|
||||||
|
|||||||
+47
-62
@@ -1,13 +1,13 @@
|
|||||||
import { save } from "@tauri-apps/plugin-dialog";
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
import pkg from "../../package.json";
|
import pkg from "../../package.json";
|
||||||
import { DEFAULT_PREFS, type RenderPrefsV1 } from "../pdf/model/prefs";
|
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
import { buildRenderModel } from "../pdf/model/build";
|
import { buildRenderModel, type RenderModel } from "../pdf/model/build";
|
||||||
import { bankFromSnapshot, vendorFromSnapshot } from "../pdf/model/vendor";
|
import { vendorFromSnapshot } from "../pdf/model/vendor";
|
||||||
import { initBrowserRendering } from "../pdf/render/browserInit";
|
import { Superseded } from "../pdf/render/client";
|
||||||
import { renderInvoicePdf as renderModelPdf, type RenderInvoiceResult } from "../pdf/render/invoice";
|
import type { RenderResult } from "../pdf/render/jobs";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import { imageSizeFromDataUri } from "./imageSize";
|
import { imageSizeFromDataUri } from "./imageSize";
|
||||||
import type { InvoicePdfProps } from "./pdfProps";
|
import { getRenderClient, nextRenderJobId } from "./renderClient";
|
||||||
import type { BankAccount, Invoice, Settings } from "./types";
|
import type { BankAccount, Invoice, Settings } from "./types";
|
||||||
|
|
||||||
function bytesToBase64(bytes: Uint8Array): string {
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
@@ -17,68 +17,21 @@ function bytesToBase64(bytes: Uint8Array): string {
|
|||||||
return btoa(bin);
|
return btoa(bin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flat props for the on-screen preview; the PDF itself is built by renderInvoicePdf. */
|
|
||||||
export async function buildPdfProps(
|
|
||||||
invoice: Invoice,
|
|
||||||
settings: Settings,
|
|
||||||
bank: BankAccount | null,
|
|
||||||
): Promise<InvoicePdfProps> {
|
|
||||||
// Re-exports use the vendor details frozen at issue; live settings are only a fallback for
|
|
||||||
// legacy rows without a snapshot.
|
|
||||||
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
|
|
||||||
const logoDataUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
|
|
||||||
const signatureDataUri = invoice.signaturePath
|
|
||||||
? await api.readAssetDataUri(invoice.signaturePath)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const resolvedBank = bankFromSnapshot(invoice.bankSnapshot, bank);
|
|
||||||
|
|
||||||
return {
|
|
||||||
vendor,
|
|
||||||
bank: resolvedBank,
|
|
||||||
logoDataUri,
|
|
||||||
signatureDataUri,
|
|
||||||
number: invoice.number,
|
|
||||||
invoiceDate: invoice.invoiceDate,
|
|
||||||
dueDate: invoice.dueDate,
|
|
||||||
clientName: invoice.clientName,
|
|
||||||
clientAddress: invoice.clientAddress,
|
|
||||||
clientGstin: invoice.clientGstin,
|
|
||||||
poNumber: invoice.poNumber,
|
|
||||||
placeOfSupplyStateCode: invoice.placeOfSupplyStateCode,
|
|
||||||
items: invoice.items,
|
|
||||||
subtotal: invoice.subtotal,
|
|
||||||
discount: invoice.discount,
|
|
||||||
taxType: invoice.taxType,
|
|
||||||
taxRate: invoice.taxRate,
|
|
||||||
cgst: invoice.cgstAmount,
|
|
||||||
sgst: invoice.sgstAmount,
|
|
||||||
igst: invoice.igstAmount,
|
|
||||||
total: invoice.total,
|
|
||||||
amountInWords: invoice.amountInWords,
|
|
||||||
notes: invoice.notes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
|
/** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */
|
||||||
const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
|
const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders an issued invoice through the engine: model (supplier and bank from the snapshots frozen at
|
* The render model for an invoice whose images are already loaded (the live preview caches them).
|
||||||
* issue, live settings only as a fallback) -> frame -> template -> PDF bytes, with the layout audit
|
* Supplier and bank come from the snapshots frozen at issue; live settings are only a fallback.
|
||||||
* issues and fingerprint.
|
|
||||||
*/
|
*/
|
||||||
export async function renderInvoicePdf(
|
export function modelFromAssets(
|
||||||
invoice: Invoice,
|
invoice: Invoice,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
bank: BankAccount | null,
|
bank: BankAccount | null,
|
||||||
prefs: RenderPrefsV1 = DEFAULT_PREFS,
|
logoUri: string | null,
|
||||||
): Promise<RenderInvoiceResult> {
|
signatureDataUri: string | null,
|
||||||
await initBrowserRendering();
|
): RenderModel {
|
||||||
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
|
return buildRenderModel({
|
||||||
const logoUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
|
|
||||||
const signatureDataUri = invoice.signaturePath ? await api.readAssetDataUri(invoice.signaturePath) : null;
|
|
||||||
const model = buildRenderModel({
|
|
||||||
invoice,
|
invoice,
|
||||||
vendor: settings,
|
vendor: settings,
|
||||||
bank,
|
bank,
|
||||||
@@ -86,7 +39,39 @@ export async function renderInvoicePdf(
|
|||||||
signatureDataUri,
|
signatureDataUri,
|
||||||
appVersion: pkg.version,
|
appVersion: pkg.version,
|
||||||
});
|
});
|
||||||
return renderModelPdf({ model, prefs });
|
}
|
||||||
|
|
||||||
|
/** Loads the invoice's logo and signature, then builds its render model. */
|
||||||
|
export async function buildModelForInvoice(invoice: Invoice, settings: Settings, bank: BankAccount | null): Promise<RenderModel> {
|
||||||
|
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, settings);
|
||||||
|
const logoUri = vendor.logoPath ? await api.readAssetDataUri(vendor.logoPath) : null;
|
||||||
|
const signatureDataUri = invoice.signaturePath ? await api.readAssetDataUri(invoice.signaturePath) : null;
|
||||||
|
return modelFromAssets(invoice, settings, bank, logoUri, signatureDataUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page setup for an invoice: what was frozen at issue, else the user's current default. */
|
||||||
|
export const prefsForInvoice = (invoice: Invoice, settings: Settings): RenderPrefsV1 =>
|
||||||
|
parsePrefs(invoice.renderPrefs || settings.renderPrefs);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an issued invoice to PDF bytes through the shared render client (worker, or the main
|
||||||
|
* thread if the worker cannot start), with the layout audit issues and fingerprint.
|
||||||
|
*/
|
||||||
|
export async function renderInvoicePdf(
|
||||||
|
invoice: Invoice,
|
||||||
|
settings: Settings,
|
||||||
|
bank: BankAccount | null,
|
||||||
|
prefs: RenderPrefsV1 = prefsForInvoice(invoice, settings),
|
||||||
|
): Promise<RenderResult> {
|
||||||
|
const model = await buildModelForInvoice(invoice, settings, bank);
|
||||||
|
// The live preview shares the client; if it takes the worker first this render is superseded, so go again.
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
return await getRenderClient().render({ id: nextRenderJobId(), model, prefs });
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof Superseded) || attempt >= 4) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Asks for a save location and writes the bytes there; null when the dialog is cancelled. */
|
/** Asks for a save location and writes the bytes there; null when the dialog is cancelled. */
|
||||||
@@ -105,7 +90,7 @@ export async function exportInvoicePdf(
|
|||||||
invoice: Invoice,
|
invoice: Invoice,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
bank: BankAccount | null,
|
bank: BankAccount | null,
|
||||||
prefs: RenderPrefsV1 = DEFAULT_PREFS,
|
prefs?: RenderPrefsV1,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const { bytes } = await renderInvoicePdf(invoice, settings, bank, prefs);
|
const { bytes } = await renderInvoicePdf(invoice, settings, bank, prefs);
|
||||||
return savePdfBytes(bytes, invoice.number);
|
return savePdfBytes(bytes, invoice.number);
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { BankAccount, InvoiceItem, Settings, TaxType } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The flat invoice props the on-screen preview (components/InvoicePreview.tsx) renders from. The PDF no
|
|
||||||
* longer uses them: it goes invoice -> buildRenderModel -> template (see lib/pdf.tsx).
|
|
||||||
*/
|
|
||||||
export interface InvoicePdfProps {
|
|
||||||
vendor: Settings;
|
|
||||||
bank: BankAccount | null;
|
|
||||||
logoDataUri: string | null;
|
|
||||||
signatureDataUri: string | null;
|
|
||||||
number: string;
|
|
||||||
invoiceDate: string;
|
|
||||||
dueDate: string;
|
|
||||||
clientName: string;
|
|
||||||
clientAddress: string;
|
|
||||||
clientGstin: string;
|
|
||||||
poNumber: string;
|
|
||||||
placeOfSupplyStateCode: string;
|
|
||||||
items: InvoiceItem[];
|
|
||||||
subtotal: number;
|
|
||||||
discount: number;
|
|
||||||
taxType: TaxType;
|
|
||||||
taxRate: number;
|
|
||||||
cgst: number;
|
|
||||||
sgst: number;
|
|
||||||
igst: number;
|
|
||||||
total: number;
|
|
||||||
amountInWords: string;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { getDocument, GlobalWorkerOptions, type PDFDocumentProxy, type PDFPageProxy, type RenderTask } from "pdfjs-dist";
|
||||||
|
import { installStreamAsyncIterator } from "./streamPolyfill";
|
||||||
|
|
||||||
|
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||||
|
installStreamAsyncIterator();
|
||||||
|
|
||||||
|
export type { PDFDocumentProxy, PDFPageProxy };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens PDF bytes. The bytes are copied because pdf.js transfers the buffer to its worker, which
|
||||||
|
* would detach the caller's array. Glyphs are drawn as paths (`disableFontFace`) so the preview looks
|
||||||
|
* the same on WebKitGTK and WebView2. (pdf.js 6 dropped `isEvalSupported`; it no longer evaluates font code.)
|
||||||
|
*/
|
||||||
|
export function openPdf(bytes: Uint8Array): Promise<PDFDocumentProxy> {
|
||||||
|
return getDocument({ data: bytes.slice(), disableFontFace: true }).promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases the document and its worker resources. */
|
||||||
|
export function closePdf(doc: PDFDocumentProxy): Promise<void> {
|
||||||
|
return doc.loadingTask.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = new WeakMap<object, RenderTask>();
|
||||||
|
|
||||||
|
/** Cancels whatever render is running for `slot` (a canvas, or any object the caller keys on). */
|
||||||
|
export function cancelPageRender(slot: object): void {
|
||||||
|
tasks.get(slot)?.cancel();
|
||||||
|
tasks.delete(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws `page` into `canvas` at `scale` device pixels per PDF point, sizing the backing store to
|
||||||
|
* match. Starting a render for a slot cancels its previous one; a cancelled render rejects with
|
||||||
|
* pdf.js's RenderingCancelledException (see isRenderCancelled).
|
||||||
|
*/
|
||||||
|
export async function renderPageToCanvas(page: PDFPageProxy, canvas: HTMLCanvasElement, scale: number, slot: object = canvas): Promise<void> {
|
||||||
|
cancelPageRender(slot);
|
||||||
|
const viewport = page.getViewport({ scale });
|
||||||
|
canvas.width = Math.ceil(viewport.width);
|
||||||
|
canvas.height = Math.ceil(viewport.height);
|
||||||
|
const task = page.render({ canvas, viewport });
|
||||||
|
tasks.set(slot, task);
|
||||||
|
try {
|
||||||
|
await task.promise;
|
||||||
|
} finally {
|
||||||
|
if (tasks.get(slot) === task) tasks.delete(slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isRenderCancelled = (err: unknown): boolean => (err as { name?: string } | null)?.name === "RenderingCancelledException";
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createAutoClient, type RenderClient } from "../pdf/render/client";
|
||||||
|
|
||||||
|
let client: RenderClient | null = null;
|
||||||
|
let lastId = 0;
|
||||||
|
|
||||||
|
/** The one render client (and so one worker) shared by the preview and export. */
|
||||||
|
export function getRenderClient(): RenderClient {
|
||||||
|
client ??= createAutoClient();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nextRenderJobId = (): number => ++lastId;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* WebKitGTK (2.50) lacks ReadableStream.prototype[Symbol.asyncIterator], which pdf.js uses in
|
||||||
|
* getTextContent. Installs a minimal version when it is missing; a no-op elsewhere.
|
||||||
|
*/
|
||||||
|
export function installStreamAsyncIterator(): void {
|
||||||
|
const proto = (globalThis as { ReadableStream?: { prototype: object } }).ReadableStream?.prototype as
|
||||||
|
| Record<symbol, unknown>
|
||||||
|
| undefined;
|
||||||
|
if (!proto || Symbol.asyncIterator in proto) return;
|
||||||
|
proto[Symbol.asyncIterator] = function (this: ReadableStream<unknown>) {
|
||||||
|
const reader = this.getReader();
|
||||||
|
const iterator = {
|
||||||
|
next: () => reader.read(),
|
||||||
|
return: async () => {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
reader.releaseLock();
|
||||||
|
return { done: true as const, value: undefined };
|
||||||
|
},
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
return iterator;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return iterator;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ export interface Settings {
|
|||||||
defaultHsnSac: string;
|
defaultHsnSac: string;
|
||||||
signatoryName: string;
|
signatoryName: string;
|
||||||
signatoryDesignation: string;
|
signatoryDesignation: string;
|
||||||
|
/** The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults. */
|
||||||
|
renderPrefs: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BankAccount {
|
export interface BankAccount {
|
||||||
@@ -132,6 +134,8 @@ export interface Invoice {
|
|||||||
cancelledAt: string | null;
|
cancelledAt: string | null;
|
||||||
cancelReason: string;
|
cancelReason: string;
|
||||||
archivedPdfSha256: string | null;
|
archivedPdfSha256: string | null;
|
||||||
|
/** RenderPrefsV1 JSON frozen at issue; empty when none was stored. */
|
||||||
|
renderPrefs: string;
|
||||||
items: InvoiceItem[];
|
items: InvoiceItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const vendor: Settings = {
|
|||||||
defaultHsnSac: "",
|
defaultHsnSac: "",
|
||||||
signatoryName: "",
|
signatoryName: "",
|
||||||
signatoryDesignation: "",
|
signatoryDesignation: "",
|
||||||
|
renderPrefs: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const bank: BankAccount = {
|
const bank: BankAccount = {
|
||||||
@@ -77,6 +78,7 @@ const base: Invoice = {
|
|||||||
cancelledAt: null,
|
cancelledAt: null,
|
||||||
cancelReason: "",
|
cancelReason: "",
|
||||||
archivedPdfSha256: null,
|
archivedPdfSha256: null,
|
||||||
|
renderPrefs: "",
|
||||||
items,
|
items,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createAutoClient,
|
||||||
|
createMainThreadClient,
|
||||||
|
createRenderClient,
|
||||||
|
RenderFailed,
|
||||||
|
RenderTimeout,
|
||||||
|
Superseded,
|
||||||
|
WorkerUnavailable,
|
||||||
|
type WorkerLike,
|
||||||
|
} from "./client";
|
||||||
|
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
|
||||||
|
|
||||||
|
class FakeWorker implements WorkerLike {
|
||||||
|
onmessage: WorkerLike["onmessage"] = null;
|
||||||
|
onerror: WorkerLike["onerror"] = null;
|
||||||
|
sent: WorkerRequest[] = [];
|
||||||
|
terminated = false;
|
||||||
|
postMessage(message: WorkerRequest) {
|
||||||
|
this.sent.push(message);
|
||||||
|
}
|
||||||
|
terminate() {
|
||||||
|
this.terminated = true;
|
||||||
|
}
|
||||||
|
emit(data: WorkerMessage) {
|
||||||
|
this.onmessage?.({ data });
|
||||||
|
}
|
||||||
|
ready() {
|
||||||
|
this.emit({ type: "ready" });
|
||||||
|
}
|
||||||
|
reply(id: number) {
|
||||||
|
this.emit({ type: "result", result: result(id) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (id: number): RenderResult => ({
|
||||||
|
id,
|
||||||
|
bytes: new Uint8Array([id]),
|
||||||
|
pages: 1,
|
||||||
|
issues: [],
|
||||||
|
fingerprint: `fp${id}`,
|
||||||
|
ms: 5,
|
||||||
|
});
|
||||||
|
const job = (id: number): RenderJob => ({ id, model: {} as RenderJob["model"], prefs: {} as RenderJob["prefs"] });
|
||||||
|
|
||||||
|
function setup(timeoutMs = 1000) {
|
||||||
|
const workers: FakeWorker[] = [];
|
||||||
|
const client = createRenderClient({
|
||||||
|
timeoutMs,
|
||||||
|
workerFactory: () => {
|
||||||
|
const w = new FakeWorker();
|
||||||
|
workers.push(w);
|
||||||
|
return w;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { client, workers };
|
||||||
|
}
|
||||||
|
|
||||||
|
const settled = (p: Promise<unknown>) => p.then(() => "ok", (e) => e);
|
||||||
|
|
||||||
|
describe("RenderClient", () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it("sends nothing until the worker is ready, then resolves with the result", async () => {
|
||||||
|
const { client, workers } = setup();
|
||||||
|
const p = client.render(job(1));
|
||||||
|
expect(workers).toHaveLength(1);
|
||||||
|
expect(workers[0].sent).toHaveLength(0);
|
||||||
|
workers[0].ready();
|
||||||
|
expect(workers[0].sent.map((m) => m.job.id)).toEqual([1]);
|
||||||
|
workers[0].reply(1);
|
||||||
|
expect((await p).fingerprint).toBe("fp1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a new call supersedes the one in flight, and only the newest queued job runs next", async () => {
|
||||||
|
const s = setup();
|
||||||
|
const a = s.client.render(job(1));
|
||||||
|
s.workers[0].ready();
|
||||||
|
const b = s.client.render(job(2));
|
||||||
|
const c = s.client.render(job(3));
|
||||||
|
expect(await settled(a)).toBeInstanceOf(Superseded);
|
||||||
|
expect(await settled(b)).toBeInstanceOf(Superseded);
|
||||||
|
// The worker is still busy with job 1: nothing else was posted.
|
||||||
|
expect(s.workers[0].sent.map((m) => m.job.id)).toEqual([1]);
|
||||||
|
s.workers[0].reply(1); // result of the superseded job is discarded
|
||||||
|
expect(s.workers[0].sent.map((m) => m.job.id)).toEqual([1, 3]);
|
||||||
|
s.workers[0].reply(3);
|
||||||
|
expect((await c).id).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the superseded in-flight promise immediately", async () => {
|
||||||
|
const { client, workers } = setup();
|
||||||
|
const a = client.render(job(1));
|
||||||
|
workers[0].ready();
|
||||||
|
const b = client.render(job(2));
|
||||||
|
expect(await settled(a)).toBeInstanceOf(Superseded);
|
||||||
|
workers[0].reply(1);
|
||||||
|
workers[0].reply(2);
|
||||||
|
expect((await b).id).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a render error from the worker and keeps going", async () => {
|
||||||
|
const { client, workers } = setup();
|
||||||
|
const a = client.render(job(1));
|
||||||
|
workers[0].ready();
|
||||||
|
workers[0].emit({ type: "error", id: 1, message: "boom", stack: "at worker" });
|
||||||
|
const err = await settled(a);
|
||||||
|
expect(err).toBeInstanceOf(RenderFailed);
|
||||||
|
expect((err as Error).message).toBe("boom");
|
||||||
|
const b = client.render(job(2));
|
||||||
|
workers[0].reply(2);
|
||||||
|
expect((await b).id).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("watchdog: terminates, respawns, rejects with RenderTimeout, then serves the next job", async () => {
|
||||||
|
const { client, workers } = setup(1000);
|
||||||
|
const a = client.render(job(1));
|
||||||
|
workers[0].ready();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(await settled(a)).toBeInstanceOf(RenderTimeout);
|
||||||
|
expect(workers[0].terminated).toBe(true);
|
||||||
|
expect(workers).toHaveLength(2);
|
||||||
|
|
||||||
|
const b = client.render(job(2));
|
||||||
|
workers[1].ready();
|
||||||
|
expect(workers[1].sent.map((m) => m.job.id)).toEqual([2]);
|
||||||
|
workers[1].reply(2);
|
||||||
|
expect((await b).id).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("watchdog on a superseded job still replaces the worker and then runs the queued job", async () => {
|
||||||
|
const { client, workers } = setup(1000);
|
||||||
|
const a = client.render(job(1));
|
||||||
|
workers[0].ready();
|
||||||
|
const b = client.render(job(2));
|
||||||
|
expect(await settled(a)).toBeInstanceOf(Superseded);
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(workers[0].terminated).toBe(true);
|
||||||
|
workers[1].ready();
|
||||||
|
expect(workers[1].sent.map((m) => m.job.id)).toEqual([2]);
|
||||||
|
workers[1].reply(2);
|
||||||
|
expect((await b).id).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a start-up error before ready rejects with WorkerUnavailable, and so do later calls", async () => {
|
||||||
|
const { client, workers } = setup();
|
||||||
|
const a = client.render(job(1));
|
||||||
|
workers[0].emit({ type: "error", id: null, message: "fonts missing" });
|
||||||
|
const err = await settled(a);
|
||||||
|
expect(err).toBeInstanceOf(WorkerUnavailable);
|
||||||
|
expect((err as Error).message).toContain("fonts missing");
|
||||||
|
expect(workers[0].terminated).toBe(true);
|
||||||
|
expect(await settled(client.render(job(2)))).toBeInstanceOf(WorkerUnavailable);
|
||||||
|
expect(workers).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a worker script error before ready is WorkerUnavailable; there is no timer fallback", async () => {
|
||||||
|
const { client, workers } = setup(1000);
|
||||||
|
const a = client.render(job(1));
|
||||||
|
// A silent worker that never says ready just waits: no timer turns that into a failure.
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
let done = false;
|
||||||
|
void settled(a).then(() => (done = true));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(done).toBe(false);
|
||||||
|
workers[0].onerror?.({ message: "SyntaxError" });
|
||||||
|
expect(await settled(a)).toBeInstanceOf(WorkerUnavailable);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a factory that throws is WorkerUnavailable", async () => {
|
||||||
|
const client = createRenderClient({
|
||||||
|
workerFactory: () => {
|
||||||
|
throw new Error("no Worker here");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await settled(client.render(job(1)))).toBeInstanceOf(WorkerUnavailable);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispose terminates the worker and rejects what is outstanding", async () => {
|
||||||
|
const { client, workers } = setup();
|
||||||
|
const a = client.render(job(1));
|
||||||
|
workers[0].ready();
|
||||||
|
const b = client.render(job(2));
|
||||||
|
client.dispose();
|
||||||
|
expect(workers[0].terminated).toBe(true);
|
||||||
|
expect(await settled(a)).toBeInstanceOf(Superseded);
|
||||||
|
expect(String(await settled(b))).toContain("disposed");
|
||||||
|
expect(String(await settled(client.render(job(3))))).toContain("disposed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("main-thread client and fallback", () => {
|
||||||
|
it("runs jobs serially with latest-wins", async () => {
|
||||||
|
const gates: Array<() => void> = [];
|
||||||
|
const seen: number[] = [];
|
||||||
|
const client = createMainThreadClient(
|
||||||
|
(j) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
seen.push(j.id);
|
||||||
|
gates.push(() => resolve(result(j.id)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(client.mode).toBe("main");
|
||||||
|
const a = client.render(job(1));
|
||||||
|
const b = client.render(job(2));
|
||||||
|
const c = client.render(job(3));
|
||||||
|
expect(await settled(a)).toBeInstanceOf(Superseded);
|
||||||
|
expect(await settled(b)).toBeInstanceOf(Superseded);
|
||||||
|
gates[0]();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(seen).toEqual([1, 3]);
|
||||||
|
gates[1]();
|
||||||
|
expect((await c).id).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the auto client moves to the main thread when the worker cannot start", async () => {
|
||||||
|
const client = createAutoClient({
|
||||||
|
workerFactory: () => {
|
||||||
|
throw new Error("no worker");
|
||||||
|
},
|
||||||
|
mainExecutor: async (j) => result(j.id),
|
||||||
|
});
|
||||||
|
expect(client.mode).toBe("worker");
|
||||||
|
expect((await client.render(job(7))).id).toBe(7);
|
||||||
|
expect(client.mode).toBe("main");
|
||||||
|
expect((await client.render(job(8))).id).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
|
||||||
|
|
||||||
|
/** A newer render call replaced this one before it finished (or before it started). */
|
||||||
|
export class Superseded extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Render superseded by a newer request");
|
||||||
|
this.name = "Superseded";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The watchdog fired: the worker was terminated and replaced. */
|
||||||
|
export class RenderTimeout extends Error {
|
||||||
|
constructor(ms: number) {
|
||||||
|
super(`The PDF render did not finish within ${Math.round(ms / 1000)} s and was stopped`);
|
||||||
|
this.name = "RenderTimeout";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The worker could not start (script, WASM or font failure). Rendering must move to the main thread. */
|
||||||
|
export class WorkerUnavailable extends Error {
|
||||||
|
constructor(detail: string) {
|
||||||
|
super(`The render worker could not start: ${detail}`);
|
||||||
|
this.name = "WorkerUnavailable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The render itself threw inside the worker. */
|
||||||
|
export class RenderFailed extends Error {
|
||||||
|
constructor(message: string, workerStack?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "RenderFailed";
|
||||||
|
if (workerStack) this.stack = workerStack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The part of Worker the client uses, so tests can supply a fake. */
|
||||||
|
export interface WorkerLike {
|
||||||
|
postMessage(message: WorkerRequest): void;
|
||||||
|
terminate(): void;
|
||||||
|
onmessage: ((event: { data: WorkerMessage }) => void) | null;
|
||||||
|
onerror: ((event: { message?: string }) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderClient {
|
||||||
|
/** Resolves with the newest request's result; an older call rejects with Superseded. */
|
||||||
|
render(job: RenderJob): Promise<RenderResult>;
|
||||||
|
dispose(): void;
|
||||||
|
readonly mode: "worker" | "main";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderClientOptions {
|
||||||
|
workerFactory?: () => WorkerLike;
|
||||||
|
/** Per-job watchdog. react-pdf's paginate loop is synchronous, so a layout bug hangs the worker for good. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultWorkerFactory = (): WorkerLike =>
|
||||||
|
new Worker(new URL("./render.worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike;
|
||||||
|
|
||||||
|
interface Slot {
|
||||||
|
job: RenderJob;
|
||||||
|
resolve: (r: RenderResult) => void;
|
||||||
|
reject: (e: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Running extends Slot {
|
||||||
|
/** Its caller was already told; the worker cannot be interrupted, so the job just runs out. */
|
||||||
|
superseded: boolean;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders in a module worker. Only the newest request matters: a new call rejects the one in flight
|
||||||
|
* (the worker keeps going, react-pdf cannot be aborted) and replaces any queued one, so after the
|
||||||
|
* running job finishes at most one job, the newest, runs. The handshake is the worker's `ready`
|
||||||
|
* message; a start-up error rejects with WorkerUnavailable and there is no timer-based fallback.
|
||||||
|
*/
|
||||||
|
export function createRenderClient(opts: RenderClientOptions = {}): RenderClient {
|
||||||
|
const { workerFactory = defaultWorkerFactory, timeoutMs = 45000 } = opts;
|
||||||
|
let worker: WorkerLike | null = null;
|
||||||
|
let ready = false;
|
||||||
|
let unavailable: WorkerUnavailable | null = null;
|
||||||
|
let disposed = false;
|
||||||
|
let running: Running | null = null;
|
||||||
|
let pending: Slot | null = null;
|
||||||
|
|
||||||
|
const stopWorker = () => {
|
||||||
|
const w = worker;
|
||||||
|
worker = null;
|
||||||
|
ready = false;
|
||||||
|
if (w) {
|
||||||
|
w.onmessage = null;
|
||||||
|
w.onerror = null;
|
||||||
|
w.terminate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const settleRunning = (outcome: (slot: Running) => void) => {
|
||||||
|
const cur = running;
|
||||||
|
if (!cur) return;
|
||||||
|
running = null;
|
||||||
|
clearTimeout(cur.timer);
|
||||||
|
if (!cur.superseded) outcome(cur);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fail = (err: WorkerUnavailable) => {
|
||||||
|
unavailable = err;
|
||||||
|
stopWorker();
|
||||||
|
settleRunning((s) => s.reject(err));
|
||||||
|
pending?.reject(err);
|
||||||
|
pending = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pump = () => {
|
||||||
|
if (!ready || !worker || running || !pending || disposed) return;
|
||||||
|
const slot = pending;
|
||||||
|
pending = null;
|
||||||
|
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
|
||||||
|
worker.postMessage({ type: "render", job: slot.job });
|
||||||
|
};
|
||||||
|
|
||||||
|
const spawn = () => {
|
||||||
|
let w: WorkerLike;
|
||||||
|
try {
|
||||||
|
w = workerFactory();
|
||||||
|
} catch (e) {
|
||||||
|
fail(new WorkerUnavailable(e instanceof Error ? e.message : String(e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
worker = w;
|
||||||
|
ready = false;
|
||||||
|
w.onmessage = (event) => {
|
||||||
|
if (w !== worker) return;
|
||||||
|
const msg = event.data;
|
||||||
|
if (msg.type === "ready") {
|
||||||
|
ready = true;
|
||||||
|
pump();
|
||||||
|
} else if (msg.type === "result") {
|
||||||
|
if (running?.job.id !== msg.result.id) return;
|
||||||
|
settleRunning((s) => s.resolve(msg.result));
|
||||||
|
pump();
|
||||||
|
} else if (msg.id === null) {
|
||||||
|
if (!ready) fail(new WorkerUnavailable(msg.message));
|
||||||
|
} else if (running?.job.id === msg.id) {
|
||||||
|
settleRunning((s) => s.reject(new RenderFailed(msg.message, msg.stack)));
|
||||||
|
pump();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
w.onerror = (event) => {
|
||||||
|
if (w !== worker) return;
|
||||||
|
const detail = event.message || "script error";
|
||||||
|
if (!ready) return fail(new WorkerUnavailable(detail));
|
||||||
|
// A crash mid-job: replace the worker so later jobs have a live one.
|
||||||
|
settleRunning((s) => s.reject(new RenderFailed(detail)));
|
||||||
|
stopWorker();
|
||||||
|
spawn();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function onTimeout() {
|
||||||
|
settleRunning((s) => s.reject(new RenderTimeout(timeoutMs)));
|
||||||
|
stopWorker();
|
||||||
|
spawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "worker",
|
||||||
|
render(job) {
|
||||||
|
if (disposed) return Promise.reject(new Error("The render client was disposed"));
|
||||||
|
if (unavailable) return Promise.reject(unavailable);
|
||||||
|
return new Promise<RenderResult>((resolve, reject) => {
|
||||||
|
pending?.reject(new Superseded());
|
||||||
|
if (running && !running.superseded) {
|
||||||
|
running.superseded = true;
|
||||||
|
running.reject(new Superseded());
|
||||||
|
}
|
||||||
|
pending = { job, resolve, reject };
|
||||||
|
if (!worker) spawn();
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (disposed) return;
|
||||||
|
disposed = true;
|
||||||
|
const err = new Error("The render client was disposed");
|
||||||
|
settleRunning((s) => s.reject(err));
|
||||||
|
pending?.reject(err);
|
||||||
|
pending = null;
|
||||||
|
stopWorker();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders one job on this thread. */
|
||||||
|
export type MainExecutor = (job: RenderJob) => Promise<RenderResult>;
|
||||||
|
|
||||||
|
// Loaded on demand so react-pdf stays out of the main bundle unless the worker is unavailable.
|
||||||
|
const runOnMainThread: MainExecutor = async (job) => {
|
||||||
|
const [{ initBrowserRendering }, { renderInvoicePdf }] = await Promise.all([import("./browserInit"), import("./invoice")]);
|
||||||
|
await initBrowserRendering();
|
||||||
|
// Let pending input and paint run before the long synchronous layout.
|
||||||
|
await new Promise<void>((r) => setTimeout(r, 0));
|
||||||
|
const t0 = performance.now();
|
||||||
|
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
bytes: out.bytes,
|
||||||
|
pages: out.pages,
|
||||||
|
issues: out.issues,
|
||||||
|
fingerprint: out.fingerprint,
|
||||||
|
ms: Math.round(performance.now() - t0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Same interface, same latest-wins rule, on the main thread (nothing can interrupt it, so no watchdog). */
|
||||||
|
export function createMainThreadClient(execute: MainExecutor = runOnMainThread): RenderClient {
|
||||||
|
let disposed = false;
|
||||||
|
let running: (Slot & { superseded: boolean }) | null = null;
|
||||||
|
let pending: Slot | null = null;
|
||||||
|
|
||||||
|
const pump = async () => {
|
||||||
|
if (running || !pending || disposed) return;
|
||||||
|
const slot = { ...pending, superseded: false };
|
||||||
|
pending = null;
|
||||||
|
running = slot;
|
||||||
|
try {
|
||||||
|
const result = await execute(slot.job);
|
||||||
|
if (!slot.superseded) slot.resolve(result);
|
||||||
|
} catch (e) {
|
||||||
|
if (!slot.superseded) slot.reject(e);
|
||||||
|
}
|
||||||
|
running = null;
|
||||||
|
void pump();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "main",
|
||||||
|
render(job) {
|
||||||
|
if (disposed) return Promise.reject(new Error("The render client was disposed"));
|
||||||
|
return new Promise<RenderResult>((resolve, reject) => {
|
||||||
|
pending?.reject(new Superseded());
|
||||||
|
if (running && !running.superseded) {
|
||||||
|
running.superseded = true;
|
||||||
|
running.reject(new Superseded());
|
||||||
|
}
|
||||||
|
pending = { job, resolve, reject };
|
||||||
|
void pump();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
disposed = true;
|
||||||
|
const err = new Error("The render client was disposed");
|
||||||
|
pending?.reject(err);
|
||||||
|
pending = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worker first; the first WorkerUnavailable switches to the main thread for good. `mode` tells the UI
|
||||||
|
* which one is active (main thread: the preview is paused while typing).
|
||||||
|
*/
|
||||||
|
export function createAutoClient(
|
||||||
|
opts: RenderClientOptions & { mainExecutor?: MainExecutor } = {},
|
||||||
|
): RenderClient {
|
||||||
|
const primary = createRenderClient(opts);
|
||||||
|
let main: RenderClient | null = null;
|
||||||
|
return {
|
||||||
|
get mode() {
|
||||||
|
return main ? "main" : "worker";
|
||||||
|
},
|
||||||
|
render(job) {
|
||||||
|
if (main) return main.render(job);
|
||||||
|
return primary.render(job).catch((err) => {
|
||||||
|
if (!(err instanceof WorkerUnavailable)) throw err;
|
||||||
|
main ??= createMainThreadClient(opts.mainExecutor);
|
||||||
|
primary.dispose();
|
||||||
|
return main.render(job);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
primary.dispose();
|
||||||
|
main?.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { AuditIssue } from "../engine/audit";
|
||||||
|
import type { RenderModel } from "../model/build";
|
||||||
|
import type { RenderPrefsV1 } from "../model/prefs";
|
||||||
|
|
||||||
|
// Everything here crosses postMessage, so it stays plain data: no functions, classes or Maps.
|
||||||
|
|
||||||
|
export interface RenderJob {
|
||||||
|
id: number;
|
||||||
|
model: RenderModel;
|
||||||
|
prefs: RenderPrefsV1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderResult {
|
||||||
|
id: number;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
pages: number;
|
||||||
|
issues: AuditIssue[];
|
||||||
|
fingerprint: string;
|
||||||
|
/** Wall time of the render itself, measured where it ran. */
|
||||||
|
ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkerRequest = { type: "render"; job: RenderJob };
|
||||||
|
|
||||||
|
export type WorkerMessage =
|
||||||
|
| { type: "ready" }
|
||||||
|
| { type: "result"; result: RenderResult }
|
||||||
|
/** `id` is null for a failure while starting up (fonts, WASM), before any job ran. */
|
||||||
|
| { type: "error"; id: number | null; message: string; stack?: string };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Browser-only module worker: the one place react-pdf runs in the app. Not importable from Node tests.
|
||||||
|
import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
|
||||||
|
import { initBrowserRendering } from "./browserInit";
|
||||||
|
import { renderInvoicePdf } from "./invoice";
|
||||||
|
import type { WorkerMessage, WorkerRequest } from "./jobs";
|
||||||
|
|
||||||
|
interface WorkerScope {
|
||||||
|
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
|
||||||
|
postMessage(message: WorkerMessage, options?: { transfer: Transferable[] }): void;
|
||||||
|
}
|
||||||
|
const scope = self as unknown as WorkerScope;
|
||||||
|
|
||||||
|
installStreamAsyncIterator();
|
||||||
|
|
||||||
|
const post = (message: WorkerMessage, transfer: Transferable[] = []) => scope.postMessage(message, { transfer });
|
||||||
|
|
||||||
|
const failure = (id: number | null, err: unknown): WorkerMessage => ({
|
||||||
|
type: "error",
|
||||||
|
id,
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
stack: err instanceof Error ? err.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const started = initBrowserRendering();
|
||||||
|
|
||||||
|
scope.onmessage = async (event) => {
|
||||||
|
const request = event.data;
|
||||||
|
if (request?.type !== "render") return;
|
||||||
|
const { job } = request;
|
||||||
|
try {
|
||||||
|
await started.catch(() => initBrowserRendering());
|
||||||
|
const t0 = performance.now();
|
||||||
|
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
|
||||||
|
const ms = Math.round(performance.now() - t0);
|
||||||
|
// Hand the buffer over instead of copying it; a view onto part of a larger buffer is copied first.
|
||||||
|
const whole = out.bytes.byteOffset === 0 && out.bytes.byteLength === out.bytes.buffer.byteLength;
|
||||||
|
const bytes = whole ? out.bytes : out.bytes.slice();
|
||||||
|
post(
|
||||||
|
{ type: "result", result: { id: job.id, bytes, pages: out.pages, issues: out.issues, fingerprint: out.fingerprint, ms } },
|
||||||
|
[bytes.buffer],
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
post(failure(job.id, err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
started.then(
|
||||||
|
() => post({ type: "ready" }),
|
||||||
|
(err) => post(failure(null, err)),
|
||||||
|
);
|
||||||
@@ -50,6 +50,7 @@ const baseVendor: Settings = {
|
|||||||
defaultHsnSac: "",
|
defaultHsnSac: "",
|
||||||
signatoryName: "",
|
signatoryName: "",
|
||||||
signatoryDesignation: "",
|
signatoryDesignation: "",
|
||||||
|
renderPrefs: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FIXTURE_BANK: BankAccount = {
|
export const FIXTURE_BANK: BankAccount = {
|
||||||
@@ -210,6 +211,7 @@ export function buildFixture(spec: FixtureSpec): BuiltFixture {
|
|||||||
cancelledAt: null,
|
cancelledAt: null,
|
||||||
cancelReason: "",
|
cancelReason: "",
|
||||||
archivedPdfSha256: null,
|
archivedPdfSha256: null,
|
||||||
|
renderPrefs: "",
|
||||||
items,
|
items,
|
||||||
...spec.invoice,
|
...spec.invoice,
|
||||||
};
|
};
|
||||||
|
|||||||
+134
-182
@@ -186,218 +186,170 @@ body {
|
|||||||
color: var(--cds-text-secondary);
|
color: var(--cds-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Invoice preview ("paper") ---------- */
|
/* ---------- PDF preview column ---------- */
|
||||||
.inv-preview-wrap {
|
.voiced-page-setup {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-page-setup__row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-page-setup__field {
|
||||||
|
min-width: 11rem;
|
||||||
|
flex: 1 1 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-page-setup__field .cds--label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-page-setup__footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-page-setup__note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-pdf-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-pdf-preview__status {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.25rem 1rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-pdf-preview__chips,
|
||||||
|
.voiced-pdf-preview__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-pdf-preview__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
height: 12rem;
|
||||||
|
background: var(--cds-layer-accent);
|
||||||
|
color: var(--cds-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inv-paper {
|
.voiced-pdf-viewer {
|
||||||
background: #ffffff;
|
|
||||||
color: #161616;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 820px;
|
|
||||||
padding: 40px 44px;
|
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
|
||||||
font-family: 'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-top {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 220px;
|
|
||||||
gap: 24px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-vendor {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
gap: 0.5rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inv-vendor > div {
|
.voiced-pdf-viewer__bar {
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-vendor__logo {
|
|
||||||
max-height: 56px;
|
|
||||||
max-width: 220px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-vendor__name {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-vendor__line {
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-line;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-meta {
|
|
||||||
text-align: right;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-meta__title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-meta__row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inv-meta__label {
|
.voiced-pdf-viewer__zoom {
|
||||||
color: #525252;
|
flex: 0 1 18rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inv-meta__value {
|
.voiced-pdf-viewer__page {
|
||||||
font-weight: 600;
|
font-size: 0.875rem;
|
||||||
}
|
color: var(--cds-text-secondary);
|
||||||
|
|
||||||
.inv-split {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 24px;
|
|
||||||
margin-top: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-block {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-block__label {
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #525252;
|
|
||||||
margin: 0 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-block__strong {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-table th {
|
|
||||||
text-align: left;
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #525252;
|
|
||||||
border-bottom: 1.5px solid #161616;
|
|
||||||
padding: 6px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-table td {
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-table .num {
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inv-totals {
|
.voiced-pdf-preview__scroll {
|
||||||
margin-top: 16px;
|
height: calc(100vh - 25rem);
|
||||||
|
min-height: 20rem;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--cds-layer-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-pdf-viewer--focus .voiced-pdf-preview__scroll {
|
||||||
|
height: calc(100vh - 12rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The wrapper is as wide as the widest page so a zoomed page scrolls instead of being clipped. */
|
||||||
|
.voiced-pdf-preview__pages {
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 100%;
|
||||||
|
width: max-content;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inv-totals__table {
|
.voiced-pdf-preview__page {
|
||||||
width: 320px;
|
flex: none;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inv-totals__row {
|
.voiced-pdf-preview__canvas {
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-totals__row--grand {
|
|
||||||
border-top: 1.5px solid #161616;
|
|
||||||
margin-top: 4px;
|
|
||||||
padding-top: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-words {
|
|
||||||
margin-top: 16px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-bottom {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 24px;
|
|
||||||
margin-top: 32px;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-signature {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-signature__img {
|
|
||||||
max-height: 64px;
|
|
||||||
max-width: 220px;
|
|
||||||
object-fit: contain;
|
|
||||||
display: block;
|
display: block;
|
||||||
margin-left: auto;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inv-signature__name {
|
.voiced-pdf-focus .cds--modal-container {
|
||||||
margin-top: 6px;
|
width: 96vw;
|
||||||
font-weight: 600;
|
max-width: 96vw;
|
||||||
border-top: 1px solid #8d8d8d;
|
height: 94vh;
|
||||||
padding-top: 4px;
|
max-height: 94vh;
|
||||||
display: inline-block;
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inv-fineprint {
|
.voiced-pdf-issues {
|
||||||
margin-top: 8px;
|
width: 22rem;
|
||||||
font-size: 10px;
|
max-width: 80vw;
|
||||||
color: #6f6f6f;
|
max-height: 20rem;
|
||||||
text-align: right;
|
overflow: auto;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inv-footer {
|
.voiced-pdf-issues__list {
|
||||||
margin-top: 28px;
|
list-style: none;
|
||||||
padding-top: 10px;
|
margin: 0;
|
||||||
border-top: 1px solid #e0e0e0;
|
padding: 0;
|
||||||
font-size: 10px;
|
}
|
||||||
color: #6f6f6f;
|
|
||||||
|
.voiced-pdf-issues__item + .voiced-pdf-issues__item {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-pdf-issues__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
.voiced-pdf-issues__message {
|
||||||
.inv-paper {
|
margin: 0.25rem 0 0;
|
||||||
box-shadow: none;
|
font-size: 0.875rem;
|
||||||
max-width: none;
|
}
|
||||||
}
|
|
||||||
|
.voiced-tag--warn.cds--tag {
|
||||||
|
background-color: var(--cds-support-warning);
|
||||||
|
color: #161616;
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-32
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActionableNotification,
|
ActionableNotification,
|
||||||
Button,
|
Button,
|
||||||
@@ -51,12 +51,17 @@ import { computeLineAmount, computeTotals } from "../lib/invoice";
|
|||||||
import { amountInWords } from "../lib/numberToWords";
|
import { amountInWords } from "../lib/numberToWords";
|
||||||
import { gstinFullError } from "../lib/validators";
|
import { gstinFullError } from "../lib/validators";
|
||||||
import { addDays, formatAmount, todayIso } from "../lib/format";
|
import { addDays, formatAmount, todayIso } from "../lib/format";
|
||||||
import { exportInvoicePdf } from "../lib/pdf";
|
import { exportInvoicePdf, modelFromAssets, prefsForInvoice } from "../lib/pdf";
|
||||||
|
import { formToPreviewInvoice } from "../lib/previewInvoice";
|
||||||
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
|
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
|
||||||
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
|
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
|
||||||
import { InvoicePreview } from "../components/InvoicePreview";
|
import PageSetupControls from "../components/PageSetupControls";
|
||||||
|
import { useInvoicePdf } from "../hooks/useInvoicePdf";
|
||||||
|
import { parsePrefs, type RenderPrefsV1 } from "../pdf/model/prefs";
|
||||||
import { ImagePicker } from "../components/ImagePicker";
|
import { ImagePicker } from "../components/ImagePicker";
|
||||||
import type { InvoicePdfProps } from "../lib/pdfProps";
|
|
||||||
|
// pdf.js is only needed once a preview exists, so it loads on demand.
|
||||||
|
const PdfPreview = lazy(() => import("../components/PdfPreview"));
|
||||||
|
|
||||||
interface NewInvoiceProps {
|
interface NewInvoiceProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
@@ -75,7 +80,7 @@ const formatStamp = (iso: string) => {
|
|||||||
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
|
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NewInvoice({ settings, active, onActivate }: NewInvoiceProps) {
|
export default function NewInvoice({ settings, onSettingsChange, active, onActivate }: NewInvoiceProps) {
|
||||||
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
|
||||||
const blankState = (): InvoiceFormState => {
|
const blankState = (): InvoiceFormState => {
|
||||||
const t = todayIso();
|
const t = todayIso();
|
||||||
@@ -330,32 +335,25 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP
|
|||||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
}, [unsaved]);
|
}, [unsaved]);
|
||||||
|
|
||||||
const previewProps: InvoicePdfProps = {
|
// Page setup for the invoice being edited; an issued invoice shows what was frozen at issue.
|
||||||
vendor: settings,
|
const [prefs, setPrefs] = useState<RenderPrefsV1>(() => parsePrefs(settings.renderPrefs));
|
||||||
bank,
|
useEffect(() => setPrefs(parsePrefs(settings.renderPrefs)), [settings.renderPrefs]);
|
||||||
logoDataUri: logoUri,
|
const activePrefs = useMemo(() => (issued ? prefsForInvoice(issued, settings) : prefs), [issued, prefs, settings]);
|
||||||
signatureDataUri: signatureUri,
|
|
||||||
number: issued?.number ?? (number || "DRAFT"),
|
|
||||||
invoiceDate,
|
|
||||||
dueDate,
|
|
||||||
clientName,
|
|
||||||
clientAddress,
|
|
||||||
clientGstin,
|
|
||||||
poNumber,
|
|
||||||
placeOfSupplyStateCode: placeOfSupply,
|
|
||||||
items,
|
|
||||||
subtotal: totals.subtotal,
|
|
||||||
discount: totals.discount,
|
|
||||||
taxType: effectiveTaxType,
|
|
||||||
taxRate,
|
|
||||||
cgst: totals.cgst,
|
|
||||||
sgst: totals.sgst,
|
|
||||||
igst: totals.igst,
|
|
||||||
total: totals.total,
|
|
||||||
amountInWords: words,
|
|
||||||
notes,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// The preview renders the real PDF from a draft-status Invoice built from the form. It is skipped
|
||||||
|
// while this view is hidden (it stays mounted) so typing elsewhere never starts a render.
|
||||||
|
const previewModel = useMemo(() => {
|
||||||
|
if (!active) return null;
|
||||||
|
const invoice =
|
||||||
|
issued ??
|
||||||
|
formToPreviewInvoice(
|
||||||
|
{ invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items },
|
||||||
|
number || "DRAFT",
|
||||||
|
settings,
|
||||||
|
bank,
|
||||||
|
);
|
||||||
|
return modelFromAssets(invoice, settings, bank, logoUri, signatureUri);
|
||||||
|
}, [active, issued, invoiceDate, dueDate, clientId, clientName, clientAddress, clientGstin, poNumber, placeOfSupply, discount, taxRate, reverseCharge, signaturePath, notes, items, number, settings, bank, logoUri, signatureUri]);
|
||||||
const updateItem = (index: number, patch: Partial<InvoiceItem>) =>
|
const updateItem = (index: number, patch: Partial<InvoiceItem>) =>
|
||||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
|
|
||||||
@@ -567,7 +565,7 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP
|
|||||||
draftId: draftIdRef.current,
|
draftId: draftIdRef.current,
|
||||||
items: items.map((item, index) => ({ ...item, sortOrder: index })),
|
items: items.map((item, index) => ({ ...item, sortOrder: index })),
|
||||||
};
|
};
|
||||||
invoice = await api.issueInvoice(input, {});
|
invoice = await api.issueInvoice(input, activePrefs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
issuedRef.current = false;
|
issuedRef.current = false;
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
@@ -593,6 +591,8 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP
|
|||||||
|
|
||||||
const otherDrafts = drafts.filter((d) => d.id !== draftId);
|
const otherDrafts = drafts.filter((d) => d.id !== draftId);
|
||||||
const busy = saving || exporting;
|
const busy = saving || exporting;
|
||||||
|
// 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 });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1031,7 +1031,17 @@ export default function NewInvoice({ settings, active, onActivate }: NewInvoiceP
|
|||||||
<Column sm={4} md={8} lg={7}>
|
<Column sm={4} md={8} lg={7}>
|
||||||
<div style={{ position: "sticky", top: "3rem" }}>
|
<div style={{ position: "sticky", top: "3rem" }}>
|
||||||
<h3 style={{ marginBottom: "0.75rem" }}>Preview</h3>
|
<h3 style={{ marginBottom: "0.75rem" }}>Preview</h3>
|
||||||
<InvoicePreview {...previewProps} />
|
<PageSetupControls prefs={activePrefs} onChange={setPrefs} settings={settings} onSaved={onSettingsChange} disabled={locked} />
|
||||||
|
<Suspense fallback={<InlineLoading description="Loading preview…" />}>
|
||||||
|
<PdfPreview
|
||||||
|
bytes={pdf.bytes}
|
||||||
|
pages={pdf.pages}
|
||||||
|
issues={pdf.issues}
|
||||||
|
rendering={pdf.rendering}
|
||||||
|
mode={pdf.mode}
|
||||||
|
error={pdf.error}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</Column>
|
</Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
Reference in New Issue
Block a user