From db2075c409706f7a523ffe183c9ffd90a82d0a56 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 17:29:46 +0530 Subject: [PATCH] Add toasts, Settings tabs, Clients page and item presets (Phase E1) - Global Carbon toast stack replaces scattered inline notices; errors persist until closed. - Settings is split into eight tabs with one Save and a remembered last tab; Numbering embeds the series settings. - New Clients page with a DataTable, shared add/edit modal, structured addresses, GST category (informational), per-client notes and payment terms. The composed address string is kept for back-compat. Deleting a client warns about invoice count; issued invoices keep their own snapshot. - Migration M7 adds the client columns and item_presets; existing clients with a GSTIN are backfilled to registered_regular. - New invoice form: client ComboBox with type-ahead and inline create, client defaults applied on selection, and an Add from preset control. - Rust tests for M7 (fresh and v6 upgrade), client validation and CRUD, presets CRUD; vitest for the toast store and new helpers. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet. --- src-tauri/src/commands/clients.rs | 327 +++++++++++++++++++++++++--- src-tauri/src/commands/invoice.rs | 7 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/presets.rs | 177 +++++++++++++++ src-tauri/src/db.rs | 92 +++++++- src-tauri/src/lib.rs | 3 + src-tauri/src/models.rs | 44 ++++ src/App.tsx | 18 +- src/components/AppShell.tsx | 4 +- src/components/ClientModal.tsx | 224 +++++++++++++++++++ src/components/ItemPresetsPanel.tsx | 246 +++++++++++++++++++++ src/components/ToastProvider.tsx | 63 ++++++ src/lib/api.ts | 5 + src/lib/clientAddress.test.ts | 35 +++ src/lib/clientAddress.ts | 41 ++++ src/lib/presets.test.ts | 24 ++ src/lib/presets.ts | 22 ++ src/lib/settingsTab.test.ts | 17 ++ src/lib/settingsTab.ts | 38 ++++ src/lib/toastStore.test.ts | 87 ++++++++ src/lib/toastStore.ts | 83 +++++++ src/lib/types.ts | 64 ++++++ src/lib/validators.test.ts | 18 +- src/lib/validators.ts | 15 ++ src/styles/carbon.scss | 17 ++ src/views/AppSettings.tsx | 234 +++++++++++--------- src/views/Clients.tsx | 246 +++++++++++++++++++++ src/views/InvoiceHistory.tsx | 28 +-- src/views/NewInvoice.tsx | 97 +++++++-- src/views/SeriesSettings.tsx | 46 ++-- 30 files changed, 2119 insertions(+), 204 deletions(-) create mode 100644 src-tauri/src/commands/presets.rs create mode 100644 src/components/ClientModal.tsx create mode 100644 src/components/ItemPresetsPanel.tsx create mode 100644 src/components/ToastProvider.tsx create mode 100644 src/lib/clientAddress.test.ts create mode 100644 src/lib/clientAddress.ts create mode 100644 src/lib/presets.test.ts create mode 100644 src/lib/presets.ts create mode 100644 src/lib/settingsTab.test.ts create mode 100644 src/lib/settingsTab.ts create mode 100644 src/lib/toastStore.test.ts create mode 100644 src/lib/toastStore.ts create mode 100644 src/views/Clients.tsx diff --git a/src-tauri/src/commands/clients.rs b/src-tauri/src/commands/clients.rs index bee92ef..bd79181 100644 --- a/src-tauri/src/commands/clients.rs +++ b/src-tauri/src/commands/clients.rs @@ -1,8 +1,16 @@ +use crate::gst; use crate::models::Client; use crate::AppState; -use rusqlite::{params, Row}; +use rusqlite::{params, Connection, Row}; use tauri::State; +const CATEGORIES: &[&str] = &["registered_regular", "unregistered", "composition", "sez", "overseas"]; + +const CLIENT_COLS: &str = "c.id, c.name, c.address, c.gstin, c.state_code, c.po_number, c.created_at, + c.address_line1, c.address_line2, c.city, c.pincode, c.gst_category, c.default_notes, + c.payment_terms_days, + (SELECT COUNT(*) FROM invoices i WHERE i.client_id = c.id)"; + fn map_client(row: &Row) -> rusqlite::Result { Ok(Client { id: Some(row.get(0)?), @@ -12,17 +20,51 @@ fn map_client(row: &Row) -> rusqlite::Result { state_code: row.get(4)?, po_number: row.get(5)?, created_at: row.get(6)?, + address_line1: row.get(7)?, + address_line2: row.get(8)?, + city: row.get(9)?, + pincode: row.get(10)?, + gst_category: row.get(11)?, + default_notes: row.get(12)?, + payment_terms_days: row.get(13)?, + invoice_count: row.get(14)?, }) } -#[tauri::command] -pub fn list_clients(state: State) -> Result, String> { - let conn = state.db.lock().map_err(|e| e.to_string())?; +/// Composes the stored multi-line address from the structured parts: line 1, line 2, then +/// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts. +pub fn compose_address(line1: &str, line2: &str, city: &str, state_code: &str, pincode: &str) -> String { + let state_name = gst::STATES + .iter() + .find(|(c, _)| *c == state_code) + .map(|(_, n)| *n) + .unwrap_or(""); + let place = [city.trim(), state_name] + .iter() + .filter(|p| !p.is_empty()) + .cloned() + .collect::>() + .join(", "); + let pin = pincode.trim(); + let last = match (place.is_empty(), pin.is_empty()) { + (false, false) => format!("{place} - {pin}"), + (false, true) => place, + (true, false) => pin.to_string(), + (true, true) => String::new(), + }; + [line1.trim(), line2.trim(), last.as_str()] + .iter() + .filter(|l| !l.is_empty()) + .cloned() + .collect::>() + .join("\n") +} + +pub fn list_clients_impl(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare( - "SELECT id, name, address, gstin, state_code, po_number, created_at - FROM clients ORDER BY name COLLATE NOCASE ASC", - ) + .prepare(&format!( + "SELECT {CLIENT_COLS} FROM clients c ORDER BY c.name COLLATE NOCASE ASC" + )) .map_err(|e| e.to_string())?; let rows = stmt .query_map([], map_client) @@ -32,29 +74,101 @@ pub fn list_clients(state: State) -> Result, String> { Ok(rows) } -#[tauri::command] -pub fn save_client(state: State, client: Client) -> Result { - let conn = state.db.lock().map_err(|e| e.to_string())?; +pub fn save_client_impl(conn: &Connection, client: Client) -> Result { + let name = client.name.trim().to_string(); + if name.is_empty() { + return Err("Client name is required".into()); + } + let gstin = match client.gstin.trim().to_ascii_uppercase().as_str() { + "" | "NA" => String::new(), + g => { + gst::validate_gstin(g).map_err(|e| format!("Client GSTIN: {e}"))?; + g.to_string() + } + }; + let state_code = client.state_code.trim().to_string(); + if !state_code.is_empty() && !gst::state_exists(&state_code) { + return Err(format!("\"{state_code}\" is not a valid GST state code")); + } + if !gstin.is_empty() && !state_code.is_empty() && gstin[0..2] != state_code { + return Err(format!( + "GSTIN starts with state code {}, but the client's state is {}", + &gstin[0..2], + state_code + )); + } + if !CATEGORIES.contains(&client.gst_category.as_str()) { + return Err(format!("Unknown GST category \"{}\"", client.gst_category)); + } + let pincode = client.pincode.trim().to_string(); + if !pincode.is_empty() && !(pincode.len() == 6 && pincode.bytes().all(|b| b.is_ascii_digit())) { + return Err("Pincode must be 6 digits".into()); + } + if let Some(days) = client.payment_terms_days { + if !(0..=365).contains(&days) { + return Err("Payment terms must be between 0 and 365 days".into()); + } + } + let line1 = client.address_line1.trim().to_string(); + let line2 = client.address_line2.trim().to_string(); + let city = client.city.trim().to_string(); + // The structured parts win when present; otherwise the incoming (legacy or free-text) address is kept. + let address = if line1.is_empty() && line2.is_empty() && city.is_empty() && pincode.is_empty() { + client.address.clone() + } else { + compose_address(&line1, &line2, &city, &state_code, &pincode) + }; + let id = match client.id { Some(id) => { - conn.execute( - "UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5 WHERE id=?6", - params![client.name, client.address, client.gstin, client.state_code, client.po_number, id], - ) - .map_err(|e| e.to_string())?; + let n = conn + .execute( + "UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5, + address_line1=?6, address_line2=?7, city=?8, pincode=?9, gst_category=?10, + default_notes=?11, payment_terms_days=?12 + WHERE id=?13", + params![ + name, + address, + gstin, + state_code, + client.po_number, + line1, + line2, + city, + pincode, + client.gst_category, + client.default_notes, + client.payment_terms_days, + id + ], + ) + .map_err(|e| e.to_string())?; + if n == 0 { + return Err("Client not found".into()); + } id } None => { conn.execute( - "INSERT INTO clients (name, address, gstin, state_code, po_number, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO clients (name, address, gstin, state_code, po_number, created_at, + address_line1, address_line2, city, pincode, gst_category, default_notes, + payment_terms_days) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ - client.name, - client.address, - client.gstin, - client.state_code, + name, + address, + gstin, + state_code, client.po_number, - chrono::Utc::now().to_rfc3339() + chrono::Utc::now().to_rfc3339(), + line1, + line2, + city, + pincode, + client.gst_category, + client.default_notes, + client.payment_terms_days ], ) .map_err(|e| e.to_string())?; @@ -62,17 +176,176 @@ pub fn save_client(state: State, client: Client) -> Result, id: i64) -> Result<(), String> { - let conn = state.db.lock().map_err(|e| e.to_string())?; +/// Deleting is allowed even when invoices reference the client: invoices carry their own +/// name/address/GSTIN snapshot and `invoices.client_id` has no foreign key, so issued invoices are +/// untouched (their id simply no longer resolves). The UI warns with `invoice_count` first. +pub fn delete_client_impl(conn: &Connection, id: i64) -> Result<(), String> { conn.execute("DELETE FROM clients WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; Ok(()) } + +#[tauri::command] +pub fn list_clients(state: State) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + list_clients_impl(&conn) +} + +#[tauri::command] +pub fn save_client(state: State, client: Client) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + save_client_impl(&conn, client) +} + +#[tauri::command] +pub fn delete_client(state: State, id: i64) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + delete_client_impl(&conn, id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::open_in_memory; + + fn blank(name: &str) -> Client { + serde_json::from_value(serde_json::json!({ "id": null, "name": name })).unwrap() + } + + #[test] + fn compose_address_orders_parts() { + assert_eq!( + compose_address("12 MG Road", "Floor 2", "Bengaluru", "29", "560001"), + "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001" + ); + assert_eq!(compose_address("", "", "", "", ""), ""); + assert_eq!(compose_address(" A ", "", "", "", "560001"), "A\n560001"); + assert_eq!(compose_address("", "", "Pune", "", ""), "Pune"); + } + + #[test] + fn structured_round_trip_composes_address() { + let conn = open_in_memory().unwrap(); + let mut c = blank("Acme"); + c.address_line1 = "12 MG Road".into(); + c.address_line2 = "Floor 2".into(); + c.city = "Bengaluru".into(); + c.pincode = "560001".into(); + c.state_code = "29".into(); + c.gstin = "29abcde1234f1zw".into(); + c.gst_category = "registered_regular".into(); + c.default_notes = "Net 15".into(); + c.payment_terms_days = Some(15); + c.po_number = "PO-9".into(); + let saved = save_client_impl(&conn, c).unwrap(); + assert!(saved.id.is_some()); + assert_eq!(saved.address, "12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001"); + assert_eq!(saved.gstin, "29ABCDE1234F1ZW"); + let listed = list_clients_impl(&conn).unwrap(); + assert_eq!(listed.len(), 1); + let l = &listed[0]; + assert_eq!(l.address_line1, "12 MG Road"); + assert_eq!(l.gst_category, "registered_regular"); + assert_eq!(l.default_notes, "Net 15"); + assert_eq!(l.payment_terms_days, Some(15)); + assert_eq!(l.invoice_count, 0); + } + + #[test] + fn legacy_address_is_kept_when_no_structured_parts() { + let conn = open_in_memory().unwrap(); + let mut c = blank("Legacy"); + c.address = "Old style\nfree text".into(); + let saved = save_client_impl(&conn, c).unwrap(); + assert_eq!(saved.address, "Old style\nfree text"); + assert_eq!(saved.gst_category, "unregistered"); + // Editing a field other than the address leaves it alone. + let mut edit = saved.clone(); + edit.po_number = "PO-1".into(); + let again = save_client_impl(&conn, edit).unwrap(); + assert_eq!(again.address, "Old style\nfree text"); + assert_eq!(again.id, saved.id); + } + + #[test] + fn update_changes_the_row_and_unknown_id_fails() { + let conn = open_in_memory().unwrap(); + let saved = save_client_impl(&conn, blank("A")).unwrap(); + let mut edit = saved.clone(); + edit.name = "B".into(); + edit.gst_category = "sez".into(); + let again = save_client_impl(&conn, edit).unwrap(); + assert_eq!((again.name.as_str(), again.gst_category.as_str()), ("B", "sez")); + assert_eq!(list_clients_impl(&conn).unwrap().len(), 1); + let mut ghost = blank("Ghost"); + ghost.id = Some(999); + assert!(save_client_impl(&conn, ghost).is_err()); + } + + #[test] + fn validation_rejects_bad_input() { + let conn = open_in_memory().unwrap(); + assert!(save_client_impl(&conn, blank(" ")).unwrap_err().contains("name")); + + let mut c = blank("X"); + c.gstin = "29ABCDE1234F1Z5".into(); // wrong check digit + assert!(save_client_impl(&conn, c).unwrap_err().contains("GSTIN")); + + let mut c = blank("X"); + c.gstin = "29ABCDE1234F1ZW".into(); + c.state_code = "27".into(); + assert!(save_client_impl(&conn, c).unwrap_err().contains("state")); + + let mut c = blank("X"); + c.gstin = "NA".into(); + assert_eq!(save_client_impl(&conn, c).unwrap().gstin, ""); + + let mut c = blank("X"); + c.state_code = "99".into(); + assert!(save_client_impl(&conn, c).is_err()); + + let mut c = blank("X"); + c.gst_category = "weird".into(); + assert!(save_client_impl(&conn, c).unwrap_err().contains("category")); + + let mut c = blank("X"); + c.pincode = "56001".into(); + assert!(save_client_impl(&conn, c).unwrap_err().contains("Pincode")); + + let mut c = blank("X"); + c.payment_terms_days = Some(400); + assert!(save_client_impl(&conn, c).is_err()); + // Only the valid "NA" client above was stored; every rejected save left no row behind. + assert_eq!(list_clients_impl(&conn).unwrap().len(), 1); + } + + #[test] + fn delete_keeps_referencing_invoices_and_count_is_listed() { + let conn = open_in_memory().unwrap(); + let saved = save_client_impl(&conn, blank("Referenced")).unwrap(); + let id = saved.id.unwrap(); + conn.execute( + "INSERT INTO invoices (number, invoice_date, client_id, client_name, client_address, created_at, updated_at) + VALUES ('T-1', '2026-01-01', ?1, 'Referenced', 'Snap addr', 'now', 'now')", + params![id], + ) + .unwrap(); + assert_eq!(list_clients_impl(&conn).unwrap()[0].invoice_count, 1); + + delete_client_impl(&conn, id).unwrap(); + assert!(list_clients_impl(&conn).unwrap().is_empty()); + let (name, addr): (String, String) = conn + .query_row("SELECT client_name, client_address FROM invoices WHERE number='T-1'", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .unwrap(); + assert_eq!((name.as_str(), addr.as_str()), ("Referenced", "Snap addr")); + } +} diff --git a/src-tauri/src/commands/invoice.rs b/src-tauri/src/commands/invoice.rs index be705c2..b0fca67 100644 --- a/src-tauri/src/commands/invoice.rs +++ b/src-tauri/src/commands/invoice.rs @@ -338,15 +338,16 @@ pub fn issue_invoice_impl( Some(id) => Some(id), None if input.save_client && !input.client_name.trim().is_empty() => { tx.execute( - "INSERT INTO clients (name, address, gstin, state_code, po_number, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO clients (name, address, gstin, state_code, po_number, created_at, gst_category) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ input.client_name, input.client_address, client_gstin, stored_pos, input.po_number, - now + now, + if client_gstin.is_empty() { "unregistered" } else { "registered_regular" } ], ) .map_err(db)?; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c2322a4..ad36bfe 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod files; pub mod fonts; pub mod invoice; pub mod logo; +pub mod presets; pub mod raw; pub mod series; pub mod settings; diff --git a/src-tauri/src/commands/presets.rs b/src-tauri/src/commands/presets.rs new file mode 100644 index 0000000..d1dcd1e --- /dev/null +++ b/src-tauri/src/commands/presets.rs @@ -0,0 +1,177 @@ +use crate::models::ItemPreset; +use crate::AppState; +use rusqlite::{params, Connection, Row}; +use tauri::State; + +const MODES: &[&str] = &["fixed", "rate"]; +const UNITS: &[&str] = &["second", "minute", "hour", "session", "unit"]; +/// Upper bound on a preset amount (paise); keeps later integer-paise arithmetic far from overflow. +const MAX_RATE_PAISE: i64 = 100_000_000_000; + +const COLS: &str = "id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at"; + +fn map_preset(row: &Row) -> rusqlite::Result { + Ok(ItemPreset { + id: Some(row.get(0)?), + description: row.get(1)?, + hsn_sac: row.get(2)?, + mode: row.get(3)?, + unit: row.get(4)?, + rate_paise: row.get(5)?, + sort_order: row.get(6)?, + created_at: row.get(7)?, + }) +} + +pub fn list_presets_impl(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare(&format!( + "SELECT {COLS} FROM item_presets ORDER BY sort_order ASC, description COLLATE NOCASE ASC, id ASC" + )) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], map_preset) + .map_err(|e| e.to_string())? + .collect::>>() + .map_err(|e| e.to_string())?; + Ok(rows) +} + +pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result { + let description = preset.description.trim().to_string(); + if description.is_empty() { + return Err("Preset description is required".into()); + } + if !MODES.contains(&preset.mode.as_str()) { + return Err(format!("Unknown line type \"{}\"", preset.mode)); + } + if !UNITS.contains(&preset.unit.as_str()) { + return Err(format!("Unknown unit \"{}\"", preset.unit)); + } + if !(0..=MAX_RATE_PAISE).contains(&preset.rate_paise) { + return Err("Amount is out of range".into()); + } + let hsn = preset.hsn_sac.trim().to_string(); + let id = match preset.id { + Some(id) => { + let n = conn + .execute( + "UPDATE item_presets SET description=?1, hsn_sac=?2, mode=?3, unit=?4, rate_paise=?5, sort_order=?6 + WHERE id=?7", + params![description, hsn, preset.mode, preset.unit, preset.rate_paise, preset.sort_order, id], + ) + .map_err(|e| e.to_string())?; + if n == 0 { + return Err("Preset not found".into()); + } + id + } + None => { + conn.execute( + "INSERT INTO item_presets (description, hsn_sac, mode, unit, rate_paise, sort_order, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + description, + hsn, + preset.mode, + preset.unit, + preset.rate_paise, + preset.sort_order, + chrono::Utc::now().to_rfc3339() + ], + ) + .map_err(|e| e.to_string())?; + conn.last_insert_rowid() + } + }; + conn.query_row( + &format!("SELECT {COLS} FROM item_presets WHERE id = ?1"), + params![id], + map_preset, + ) + .map_err(|e| e.to_string()) +} + +pub fn delete_preset_impl(conn: &Connection, id: i64) -> Result<(), String> { + conn.execute("DELETE FROM item_presets WHERE id = ?1", params![id]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn list_item_presets(state: State) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + list_presets_impl(&conn) +} + +#[tauri::command] +pub fn save_item_preset(state: State, preset: ItemPreset) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + save_preset_impl(&conn, preset) +} + +#[tauri::command] +pub fn delete_item_preset(state: State, id: i64) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + delete_preset_impl(&conn, id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::open_in_memory; + + fn preset(desc: &str) -> ItemPreset { + serde_json::from_value(serde_json::json!({ "id": null, "description": desc })).unwrap() + } + + #[test] + fn crud_round_trip_in_integer_paise() { + let conn = open_in_memory().unwrap(); + let mut p = preset(" Voice-over session "); + p.mode = "rate".into(); + p.unit = "hour".into(); + p.rate_paise = 150_050; + p.hsn_sac = " 998397 ".into(); + let saved = save_preset_impl(&conn, p).unwrap(); + assert_eq!(saved.description, "Voice-over session"); + assert_eq!(saved.hsn_sac, "998397"); + assert_eq!((saved.mode.as_str(), saved.unit.as_str(), saved.rate_paise), ("rate", "hour", 150_050)); + + let mut edit = saved.clone(); + edit.rate_paise = 200_000; + let again = save_preset_impl(&conn, edit).unwrap(); + assert_eq!(again.id, saved.id); + assert_eq!(again.rate_paise, 200_000); + + save_preset_impl(&conn, preset("Another")).unwrap(); + let listed = list_presets_impl(&conn).unwrap(); + assert_eq!(listed.len(), 2); + // Same sort_order: alphabetical. + assert_eq!(listed[0].description, "Another"); + + delete_preset_impl(&conn, saved.id.unwrap()).unwrap(); + assert_eq!(list_presets_impl(&conn).unwrap().len(), 1); + // Deleting a missing id is a no-op. + delete_preset_impl(&conn, 12345).unwrap(); + } + + #[test] + fn validation_rejects_bad_presets() { + let conn = open_in_memory().unwrap(); + assert!(save_preset_impl(&conn, preset(" ")).is_err()); + let mut p = preset("X"); + p.rate_paise = -1; + assert!(save_preset_impl(&conn, p).is_err()); + let mut p = preset("X"); + p.mode = "weird".into(); + assert!(save_preset_impl(&conn, p).is_err()); + let mut p = preset("X"); + p.unit = "decade".into(); + assert!(save_preset_impl(&conn, p).is_err()); + let mut ghost = preset("Ghost"); + ghost.id = Some(77); + assert!(save_preset_impl(&conn, ghost).is_err()); + assert!(list_presets_impl(&conn).unwrap().is_empty()); + } +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index d4a32cc..c460caf 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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 = 6; +const LATEST_VERSION: i64 = 7; #[derive(Debug, thiserror::Error)] pub enum DbError { @@ -217,8 +217,36 @@ CREATE INDEX idx_user_fonts_sha ON user_fonts(sha256); CREATE UNIQUE INDEX idx_user_fonts_slot ON user_fonts(face, weight, style) WHERE hidden = 0; "#; +/// Version 7: structured client addresses, GST category and per-client defaults, plus saved item presets. +/// `clients.address` stays the composed multi-line string (issued invoices and the PDF read it); the new +/// parts are an editing aid. The address state is `clients.state_code`, which is also the default place +/// of supply, so there is no second state column. `payment_terms_days` NULL means "use the settings default". +/// `item_presets.rate_paise` is the fixed amount for mode 'fixed' and the per-unit rate for mode 'rate'. +const M7: &str = r#" +ALTER TABLE clients ADD COLUMN address_line1 TEXT NOT NULL DEFAULT ''; +ALTER TABLE clients ADD COLUMN address_line2 TEXT NOT NULL DEFAULT ''; +ALTER TABLE clients ADD COLUMN city TEXT NOT NULL DEFAULT ''; +ALTER TABLE clients ADD COLUMN pincode TEXT NOT NULL DEFAULT ''; +ALTER TABLE clients ADD COLUMN gst_category TEXT NOT NULL DEFAULT 'unregistered' + CHECK (gst_category IN ('registered_regular', 'unregistered', 'composition', 'sez', 'overseas')); +ALTER TABLE clients ADD COLUMN default_notes TEXT NOT NULL DEFAULT ''; +ALTER TABLE clients ADD COLUMN payment_terms_days INTEGER; +UPDATE clients SET gst_category = 'registered_regular' WHERE gstin NOT IN ('', 'NA'); + +CREATE TABLE item_presets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + description TEXT NOT NULL, + hsn_sac TEXT NOT NULL DEFAULT '', + mode TEXT NOT NULL DEFAULT 'fixed' CHECK (mode IN ('fixed', 'rate')), + unit TEXT NOT NULL DEFAULT 'unit' CHECK (unit IN ('second', 'minute', 'hour', 'session', 'unit')), + rate_paise INTEGER NOT NULL DEFAULT 0 CHECK (rate_paise >= 0), + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); +"#; + fn migrations() -> Migrations<'static> { - Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)]) + Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7)]) } /// Open (creating if needed) the database at `path` and bring it to the latest schema. @@ -657,6 +685,66 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); assert_eq!(backups_in(&dir.path().join("backups")).len(), 1); } + #[test] + fn m7_fresh_database_has_client_and_preset_columns() { + let conn = open_in_memory().unwrap(); + conn.prepare( + "SELECT address_line1, address_line2, city, pincode, gst_category, default_notes, payment_terms_days FROM clients", + ) + .unwrap(); + conn.prepare("SELECT id, description, hsn_sac, mode, unit, rate_paise, sort_order, created_at FROM item_presets") + .unwrap(); + conn.execute( + "INSERT INTO clients (name, created_at) VALUES ('X', 'now')", + [], + ) + .unwrap(); + let (cat, terms): (String, Option) = conn + .query_row("SELECT gst_category, payment_terms_days FROM clients", [], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap(); + assert_eq!((cat.as_str(), terms), ("unregistered", None)); + // The category CHECK rejects unknown values. + assert!(conn + .execute("UPDATE clients SET gst_category = 'bogus'", []) + .is_err()); + } + + #[test] + fn m7_upgrade_from_v6_keeps_client_data_and_backfills_category() { + let mut conn = Connection::open_in_memory().unwrap(); + let all = vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6)]; + Migrations::new(all).to_latest(&mut conn).unwrap(); + assert_eq!(user_version(&conn), 6); + conn.execute( + "INSERT INTO clients (name, address, gstin, state_code, po_number, created_at) + VALUES ('Acme', 'Line one\nLine two', '29ABCDE1234F1Z5', '29', 'PO-1', 'now'), + ('Plain', '', '', '', '', 'now'), + ('Na', '', 'NA', '', '', 'now')", + [], + ) + .unwrap(); + + migrations().to_latest(&mut conn).unwrap(); + assert_eq!(user_version(&conn), 7); + + type Row = (String, String, String, String, String, String, String); + let rows: Vec = conn + .prepare("SELECT name, address, gstin, state_code, po_number, gst_category, address_line1 FROM clients ORDER BY id") + .unwrap() + .query_map([], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)) + }) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(rows[0].1, "Line one\nLine two"); + assert_eq!((rows[0].2.as_str(), rows[0].3.as_str(), rows[0].4.as_str()), ("29ABCDE1234F1Z5", "29", "PO-1")); + assert_eq!(rows[0].5, "registered_regular"); + assert_eq!(rows[0].6, ""); + assert_eq!(rows[1].5, "unregistered"); + assert_eq!(rows[2].5, "unregistered"); + } + fn build_v1_database(path: &Path) { let conn = Connection::open(path).unwrap(); conn.execute_batch(V1_SCHEMA).unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1246654..26f82b5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -121,6 +121,9 @@ pub fn run() { commands::clients::list_clients, commands::clients::save_client, commands::clients::delete_client, + commands::presets::list_item_presets, + commands::presets::save_item_preset, + commands::presets::delete_item_preset, commands::invoice::peek_next_invoice_number, commands::invoice::issue_invoice, commands::invoice::cancel_invoice, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 73df5c2..a4ea66b 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -95,6 +95,50 @@ pub struct Client { pub po_number: String, #[serde(default)] pub created_at: String, + #[serde(default)] + pub address_line1: String, + #[serde(default)] + pub address_line2: String, + #[serde(default)] + pub city: String, + #[serde(default)] + pub pincode: String, + /// registered_regular | unregistered | composition | sez | overseas. Informational only. + #[serde(default = "default_gst_category")] + pub gst_category: String, + /// Notes / payment terms text copied onto a new invoice when this client is picked. + #[serde(default)] + pub default_notes: String, + /// None = use the settings default. + #[serde(default)] + pub payment_terms_days: Option, + /// Number of invoices that reference this client; read-only, filled by list_clients. + #[serde(default)] + pub invoice_count: i64, +} + +fn default_gst_category() -> String { + "unregistered".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ItemPreset { + pub id: Option, + pub description: String, + #[serde(default)] + pub hsn_sac: String, + /// "fixed" = rate_paise is the line amount; "rate" = rate_paise is the rate per unit. + #[serde(default = "default_mode")] + pub mode: String, + #[serde(default = "default_unit")] + pub unit: String, + #[serde(default)] + pub rate_paise: i64, + #[serde(default)] + pub sort_order: i64, + #[serde(default)] + pub created_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/App.tsx b/src/App.tsx index eafa28a..283b4e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,10 +6,20 @@ import { AppShell, type AppView } from "./components/AppShell"; import Onboarding from "./views/Onboarding"; import NewInvoice from "./views/NewInvoice"; import InvoiceHistory from "./views/InvoiceHistory"; -import SeriesSettings from "./views/SeriesSettings"; +import Clients from "./views/Clients"; +import { ToastProvider, useToast } from "./components/ToastProvider"; import AppSettings from "./views/AppSettings"; export default function App() { + return ( + + + + ); +} + +function AppInner() { + const toast = useToast(); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -81,7 +91,7 @@ export default function App() { try { await api.saveSettings(next); } catch (e) { - setError(String(e)); + toast.error("Could not save the theme", String(e)); } }; @@ -98,8 +108,8 @@ export default function App() { -