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.
This commit is contained in:
2026-10-04 17:29:46 +05:30
parent 9e17ba3479
commit 3510cc4260
30 changed files with 2119 additions and 204 deletions
+90 -2
View File
@@ -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<i64>) = 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<Row> = 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::<Result<_, _>>()
.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();