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,
|
||||
bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_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> {
|
||||
Ok(Invoice {
|
||||
@@ -55,6 +55,7 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
||||
cancelled_at: row.get(31)?,
|
||||
cancel_reason: row.get(32)?,
|
||||
archived_pdf_sha256: row.get(33)?,
|
||||
render_prefs: row.get(34)?,
|
||||
items: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -922,6 +923,7 @@ mod tests {
|
||||
.query_row("SELECT render_prefs FROM invoices", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(prefs, "{\"theme\":\"plain\"}");
|
||||
assert_eq!(inv.render_prefs, prefs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,7 +6,7 @@ use tauri::State;
|
||||
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,
|
||||
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> {
|
||||
Ok(Settings {
|
||||
@@ -30,6 +30,7 @@ pub(crate) fn map_settings(row: &Row) -> rusqlite::Result<Settings> {
|
||||
default_hsn_sac: row.get(17)?,
|
||||
signatory_name: row.get(18)?,
|
||||
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,
|
||||
default_tax_type = ?12, payment_terms_days = ?13, currency = ?14,
|
||||
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"#,
|
||||
params![
|
||||
settings.vendor_name,
|
||||
@@ -77,6 +78,7 @@ pub fn save_settings(state: State<AppState>, settings: Settings) -> Result<Setti
|
||||
settings.default_hsn_sac,
|
||||
settings.signatory_name,
|
||||
settings.signatory_designation,
|
||||
settings.render_prefs,
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -230,6 +232,26 @@ mod tests {
|
||||
(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]
|
||||
fn deleting_the_default_bank_promotes_the_lowest_remaining() {
|
||||
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";
|
||||
|
||||
/// 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)]
|
||||
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> {
|
||||
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.
|
||||
@@ -536,6 +541,22 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
|
||||
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) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(V1_SCHEMA).unwrap();
|
||||
|
||||
@@ -23,6 +23,9 @@ pub struct Settings {
|
||||
pub default_hsn_sac: String,
|
||||
pub signatory_name: 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)]
|
||||
@@ -194,6 +197,8 @@ pub struct Invoice {
|
||||
pub cancelled_at: Option<String>,
|
||||
pub cancel_reason: 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>,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user