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:
+90
-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 = 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();
|
||||
|
||||
Reference in New Issue
Block a user