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
+298 -25
View File
@@ -1,8 +1,16 @@
use crate::gst;
use crate::models::Client; use crate::models::Client;
use crate::AppState; use crate::AppState;
use rusqlite::{params, Row}; use rusqlite::{params, Connection, Row};
use tauri::State; 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<Client> { fn map_client(row: &Row) -> rusqlite::Result<Client> {
Ok(Client { Ok(Client {
id: Some(row.get(0)?), id: Some(row.get(0)?),
@@ -12,17 +20,51 @@ fn map_client(row: &Row) -> rusqlite::Result<Client> {
state_code: row.get(4)?, state_code: row.get(4)?,
po_number: row.get(5)?, po_number: row.get(5)?,
created_at: row.get(6)?, 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] /// Composes the stored multi-line address from the structured parts: line 1, line 2, then
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> { /// "City, State - Pincode". Must stay in step with `composeAddress` in src/lib/clientAddress.ts.
let conn = state.db.lock().map_err(|e| e.to_string())?; 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::<Vec<_>>()
.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::<Vec<_>>()
.join("\n")
}
pub fn list_clients_impl(conn: &Connection) -> Result<Vec<Client>, String> {
let mut stmt = conn let mut stmt = conn
.prepare( .prepare(&format!(
"SELECT id, name, address, gstin, state_code, po_number, created_at "SELECT {CLIENT_COLS} FROM clients c ORDER BY c.name COLLATE NOCASE ASC"
FROM clients ORDER BY name COLLATE NOCASE ASC", ))
)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let rows = stmt let rows = stmt
.query_map([], map_client) .query_map([], map_client)
@@ -32,29 +74,101 @@ pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
Ok(rows) Ok(rows)
} }
#[tauri::command] pub fn save_client_impl(conn: &Connection, client: Client) -> Result<Client, String> {
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> { let name = client.name.trim().to_string();
let conn = state.db.lock().map_err(|e| e.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 { let id = match client.id {
Some(id) => { Some(id) => {
conn.execute( let n = conn
"UPDATE clients SET name=?1, address=?2, gstin=?3, state_code=?4, po_number=?5 WHERE id=?6", .execute(
params![client.name, client.address, client.gstin, client.state_code, client.po_number, id], "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())?; .map_err(|e| e.to_string())?;
if n == 0 {
return Err("Client not found".into());
}
id id
} }
None => { None => {
conn.execute( conn.execute(
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at) "INSERT INTO clients (name, address, gstin, state_code, po_number, created_at,
VALUES (?1, ?2, ?3, ?4, ?5, ?6)", 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![ params![
client.name, name,
client.address, address,
client.gstin, gstin,
client.state_code, state_code,
client.po_number, 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())?; .map_err(|e| e.to_string())?;
@@ -62,17 +176,176 @@ pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, Str
} }
}; };
conn.query_row( conn.query_row(
"SELECT id, name, address, gstin, state_code, po_number, created_at FROM clients WHERE id = ?1", &format!("SELECT {CLIENT_COLS} FROM clients c WHERE c.id = ?1"),
params![id], params![id],
map_client, map_client,
) )
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command] /// Deleting is allowed even when invoices reference the client: invoices carry their own
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> { /// name/address/GSTIN snapshot and `invoices.client_id` has no foreign key, so issued invoices are
let conn = state.db.lock().map_err(|e| e.to_string())?; /// 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]) conn.execute("DELETE FROM clients WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
#[tauri::command]
pub fn list_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_clients_impl(&conn)
}
#[tauri::command]
pub fn save_client(state: State<AppState>, client: Client) -> Result<Client, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
save_client_impl(&conn, client)
}
#[tauri::command]
pub fn delete_client(state: State<AppState>, 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"));
}
}
+4 -3
View File
@@ -338,15 +338,16 @@ pub fn issue_invoice_impl(
Some(id) => Some(id), Some(id) => Some(id),
None if input.save_client && !input.client_name.trim().is_empty() => { None if input.save_client && !input.client_name.trim().is_empty() => {
tx.execute( tx.execute(
"INSERT INTO clients (name, address, gstin, state_code, po_number, created_at) "INSERT INTO clients (name, address, gstin, state_code, po_number, created_at, gst_category)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![ params![
input.client_name, input.client_name,
input.client_address, input.client_address,
client_gstin, client_gstin,
stored_pos, stored_pos,
input.po_number, input.po_number,
now now,
if client_gstin.is_empty() { "unregistered" } else { "registered_regular" }
], ],
) )
.map_err(db)?; .map_err(db)?;
+1
View File
@@ -5,6 +5,7 @@ pub mod files;
pub mod fonts; pub mod fonts;
pub mod invoice; pub mod invoice;
pub mod logo; pub mod logo;
pub mod presets;
pub mod raw; pub mod raw;
pub mod series; pub mod series;
pub mod settings; pub mod settings;
+177
View File
@@ -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<ItemPreset> {
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<Vec<ItemPreset>, 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::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
pub fn save_preset_impl(conn: &Connection, preset: ItemPreset) -> Result<ItemPreset, String> {
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<AppState>) -> Result<Vec<ItemPreset>, 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<AppState>, preset: ItemPreset) -> Result<ItemPreset, String> {
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<AppState>, 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());
}
}
+90 -2
View File
@@ -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 = 6; const LATEST_VERSION: i64 = 7;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum DbError { 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; 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> { 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. /// 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); 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) { 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();
+3
View File
@@ -121,6 +121,9 @@ pub fn run() {
commands::clients::list_clients, commands::clients::list_clients,
commands::clients::save_client, commands::clients::save_client,
commands::clients::delete_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::peek_next_invoice_number,
commands::invoice::issue_invoice, commands::invoice::issue_invoice,
commands::invoice::cancel_invoice, commands::invoice::cancel_invoice,
+44
View File
@@ -95,6 +95,50 @@ pub struct Client {
pub po_number: String, pub po_number: String,
#[serde(default)] #[serde(default)]
pub created_at: String, 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<i64>,
/// 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<i64>,
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)] #[derive(Debug, Clone, Serialize, Deserialize)]
+14 -4
View File
@@ -6,10 +6,20 @@ import { AppShell, type AppView } from "./components/AppShell";
import Onboarding from "./views/Onboarding"; import Onboarding from "./views/Onboarding";
import NewInvoice from "./views/NewInvoice"; import NewInvoice from "./views/NewInvoice";
import InvoiceHistory from "./views/InvoiceHistory"; 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"; import AppSettings from "./views/AppSettings";
export default function App() { export default function App() {
return (
<ToastProvider>
<AppInner />
</ToastProvider>
);
}
function AppInner() {
const toast = useToast();
const [settings, setSettings] = useState<Settings | null>(null); const [settings, setSettings] = useState<Settings | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -81,7 +91,7 @@ export default function App() {
try { try {
await api.saveSettings(next); await api.saveSettings(next);
} catch (e) { } catch (e) {
setError(String(e)); toast.error("Could not save the theme", String(e));
} }
}; };
@@ -98,8 +108,8 @@ export default function App() {
<div hidden={view !== "invoices"}> <div hidden={view !== "invoices"}>
<InvoiceHistory settings={settings} active={view === "invoices"} /> <InvoiceHistory settings={settings} active={view === "invoices"} />
</div> </div>
<div hidden={view !== "series"}> <div hidden={view !== "clients"}>
<SeriesSettings active={view === "series"} /> <Clients active={view === "clients"} />
</div> </div>
{view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />} {view === "settings" && <AppSettings settings={settings} onSaved={refreshSettings} />}
</AppShell> </AppShell>
+2 -2
View File
@@ -11,12 +11,12 @@ import {
import { Asleep, Light } from "@carbon/icons-react"; import { Asleep, Light } from "@carbon/icons-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
export type AppView = "new" | "invoices" | "series" | "settings"; export type AppView = "new" | "invoices" | "clients" | "settings";
const NAV: { id: AppView; label: string }[] = [ const NAV: { id: AppView; label: string }[] = [
{ id: "new", label: "New invoice" }, { id: "new", label: "New invoice" },
{ id: "invoices", label: "Invoices" }, { id: "invoices", label: "Invoices" },
{ id: "series", label: "Series" }, { id: "clients", label: "Clients" },
{ id: "settings", label: "Settings" }, { id: "settings", label: "Settings" },
]; ];
+224
View File
@@ -0,0 +1,224 @@
import { useEffect, useState, type RefObject } from "react";
import { Column, Grid, InlineNotification, Modal, Select, SelectItem, TextArea, TextInput } from "@carbon/react";
import { api } from "../lib/api";
import {
GST_CATEGORY_LABELS,
INDIAN_STATES,
blankClient,
type Client,
type GstCategory,
} from "../lib/types";
import { composeAddress, pincodeError, splitLegacyAddress } from "../lib/clientAddress";
import { clientGstinStateError } from "../lib/validators";
import { useToast } from "./ToastProvider";
interface ClientModalProps {
open: boolean;
/** The client to edit; null opens a blank one. */
client: Client | null;
onClose: () => void;
onSaved: (client: Client) => void;
/** Focus returns here when the modal closes. */
launcherRef?: RefObject<HTMLElement | null>;
}
/** Fills the structured address from the legacy free-text one when a client predates the new fields. */
export function prepareForEdit(client: Client): { form: Client; splitFromLegacy: boolean } {
const structured = client.addressLine1 || client.addressLine2 || client.city || client.pincode;
if (structured || !client.address.trim()) return { form: client, splitFromLegacy: false };
return { form: { ...client, ...splitLegacyAddress(client.address) }, splitFromLegacy: true };
}
export default function ClientModal({ open, client, onClose, onSaved, launcherRef }: ClientModalProps) {
const toast = useToast();
const [form, setForm] = useState<Client>(blankClient());
const [split, setSplit] = useState(false);
const [busy, setBusy] = useState(false);
const [touched, setTouched] = useState(false);
useEffect(() => {
if (!open) return;
const prepared = prepareForEdit(client ?? blankClient());
setForm(prepared.form);
setSplit(prepared.splitFromLegacy);
setBusy(false);
setTouched(false);
}, [open, client]);
const set = <K extends keyof Client>(key: K, value: Client[K]) => setForm((prev) => ({ ...prev, [key]: value }));
const nameProblem = form.name.trim() ? undefined : "Client name is required";
const gstinProblem = clientGstinStateError(form.gstin, form.stateCode);
const pinProblem = pincodeError(form.pincode);
const termsProblem =
form.paymentTermsDays !== null && (form.paymentTermsDays < 0 || form.paymentTermsDays > 365)
? "Between 0 and 365 days"
: undefined;
const blocking = nameProblem ?? gstinProblem ?? pinProblem ?? termsProblem;
const submit = async () => {
setTouched(true);
if (blocking) return;
setBusy(true);
const structured = form.addressLine1 || form.addressLine2 || form.city || form.pincode;
const toSave: Client = {
...form,
name: form.name.trim(),
gstin: form.gstin.trim().toUpperCase(),
address: structured
? composeAddress({
line1: form.addressLine1,
line2: form.addressLine2,
city: form.city,
stateCode: form.stateCode,
pincode: form.pincode,
})
: form.address,
};
try {
const saved = await api.saveClient(toSave);
toast.success(toSave.id === null ? "Client added" : "Client updated", saved.name);
onSaved(saved);
} catch (e) {
toast.error("Could not save client", String(e));
setBusy(false);
}
};
return (
<Modal
open={open}
modalHeading={client?.id ? "Edit client" : "New client"}
primaryButtonText={busy ? "Saving…" : "Save client"}
secondaryButtonText="Cancel"
primaryButtonDisabled={busy}
onRequestClose={onClose}
onRequestSubmit={() => void submit()}
launcherButtonRef={launcherRef}
size="md"
>
<Grid narrow>
{split ? (
<Column sm={4} md={8} lg={16}>
<InlineNotification
kind="info"
lowContrast
hideCloseButton
title="Address split automatically"
subtitle="This client has a free-text address. Check the lines below before saving."
/>
</Column>
) : null}
<Column sm={4} md={8} lg={16}>
<TextInput
id="cm-name"
labelText="Client name"
value={form.name}
onChange={(e) => set("name", e.target.value)}
invalid={touched && Boolean(nameProblem)}
invalidText={nameProblem}
data-modal-primary-focus
/>
</Column>
<Column sm={4} md={4} lg={8}>
<Select
id="cm-category"
labelText="GST category"
value={form.gstCategory}
onChange={(e) => set("gstCategory", e.target.value as GstCategory)}
>
{Object.entries(GST_CATEGORY_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
</Column>
<Column sm={4} md={4} lg={8}>
<TextInput
id="cm-gstin"
labelText="GSTIN (optional)"
value={form.gstin}
onChange={(e) => set("gstin", e.target.value.toUpperCase())}
invalid={Boolean(form.gstin.trim()) && Boolean(gstinProblem)}
invalidText={gstinProblem}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<TextInput
id="cm-line1"
labelText="Address line 1"
value={form.addressLine1}
onChange={(e) => set("addressLine1", e.target.value)}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<TextInput
id="cm-line2"
labelText="Address line 2"
value={form.addressLine2}
onChange={(e) => set("addressLine2", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput id="cm-city" labelText="City" value={form.city} onChange={(e) => set("city", e.target.value)} />
</Column>
<Column sm={4} md={4} lg={7}>
<Select
id="cm-state"
labelText="State (also the default place of supply)"
value={form.stateCode}
onChange={(e) => set("stateCode", e.target.value)}
>
<SelectItem value="" text="Not set" />
{INDIAN_STATES.map((s) => (
<SelectItem key={s.code} value={s.code} text={`${s.code} — ${s.name}`} />
))}
</Select>
</Column>
<Column sm={4} md={4} lg={4}>
<TextInput
id="cm-pin"
labelText="Pincode"
inputMode="numeric"
maxLength={6}
value={form.pincode}
onChange={(e) => set("pincode", e.target.value)}
invalid={Boolean(pinProblem)}
invalidText={pinProblem}
/>
</Column>
<Column sm={4} md={4} lg={8}>
<TextInput
id="cm-po"
labelText="Default PO number (optional)"
value={form.poNumber}
onChange={(e) => set("poNumber", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={8}>
<TextInput
id="cm-terms"
labelText="Payment terms (days, blank = default)"
inputMode="numeric"
value={form.paymentTermsDays === null ? "" : String(form.paymentTermsDays)}
onChange={(e) => {
const raw = e.target.value.trim();
set("paymentTermsDays", raw === "" ? null : Number.isFinite(Number(raw)) ? Math.trunc(Number(raw)) : null);
}}
invalid={Boolean(termsProblem)}
invalidText={termsProblem}
/>
</Column>
<Column sm={4} md={8} lg={16}>
<TextArea
id="cm-notes"
labelText="Default notes / payment terms text"
helperText="Copied into the invoice notes when this client is picked and the notes are empty."
rows={3}
value={form.defaultNotes}
onChange={(e) => set("defaultNotes", e.target.value)}
/>
</Column>
</Grid>
</Modal>
);
}
+246
View File
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
Column,
Grid,
InlineLoading,
Modal,
NumberInput,
Select,
SelectItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TextInput,
} from "@carbon/react";
import { Add, Edit, TrashCan } from "@carbon/icons-react";
import { api } from "../lib/api";
import { UNIT_LABELS, blankPreset, type ItemPreset, type LineMode, type LineUnit } from "../lib/types";
import { paiseToRupees, rupeesToPaise } from "../lib/presets";
import { formatAmount } from "../lib/format";
import { useToast } from "./ToastProvider";
/** Saved line items: description, HSN/SAC, type, unit and amount (kept as integer paise). */
export default function ItemPresetsPanel({ active }: { active: boolean }) {
const toast = useToast();
const [presets, setPresets] = useState<ItemPreset[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<ItemPreset | null>(null);
const [deleting, setDeleting] = useState<ItemPreset | null>(null);
const [busy, setBusy] = useState(false);
const [touched, setTouched] = useState(false);
const launcher = useRef<HTMLElement | null>(null);
const load = useCallback(async () => {
try {
setPresets(await api.listItemPresets());
} catch (e) {
toast.error("Could not load item presets", String(e));
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => {
if (active) void load();
}, [active, load]);
const openEditor = (preset: ItemPreset, trigger: HTMLElement) => {
launcher.current = trigger;
setTouched(false);
setEditing(preset);
};
const set = <K extends keyof ItemPreset>(key: K, value: ItemPreset[K]) =>
setEditing((prev) => (prev ? { ...prev, [key]: value } : prev));
const save = async () => {
if (!editing) return;
setTouched(true);
if (!editing.description.trim()) return;
setBusy(true);
try {
await api.saveItemPreset({ ...editing, description: editing.description.trim() });
toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
setEditing(null);
await load();
} catch (e) {
toast.error("Could not save preset", String(e));
} finally {
setBusy(false);
}
};
const remove = async () => {
if (!deleting || deleting.id === null) return;
setBusy(true);
try {
await api.deleteItemPreset(deleting.id);
toast.success("Preset deleted", deleting.description);
setDeleting(null);
await load();
} catch (e) {
toast.error("Could not delete preset", String(e));
} finally {
setBusy(false);
}
};
if (loading) return <InlineLoading description="Loading item presets" />;
return (
<div className="voiced-stack">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<p className="voiced-muted">Saved line items you can add to an invoice in one click.</p>
<Button
kind="tertiary"
size="sm"
renderIcon={Add}
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(blankPreset(), e.currentTarget)}
>
Add preset
</Button>
</div>
{presets.length === 0 ? (
<div className="voiced-empty">No item presets yet.</div>
) : (
<Table aria-label="Item presets">
<TableHead>
<TableRow>
<TableHeader>Description</TableHeader>
<TableHeader>HSN/SAC</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader>Amount</TableHeader>
<TableHeader>Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{presets.map((p) => (
<TableRow key={p.id ?? p.description}>
<TableCell>{p.description}</TableCell>
<TableCell>{p.hsnSac || "—"}</TableCell>
<TableCell>{p.mode === "rate" ? UNIT_LABELS[p.unit] : "Fixed amount"}</TableCell>
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
<TableCell>
<div className="voiced-header-action">
<Button
kind="ghost"
size="sm"
renderIcon={Edit}
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor({ ...p }, e.currentTarget)}
>
Edit
</Button>
<Button
kind="ghost"
size="sm"
renderIcon={TrashCan}
onClick={(e: React.MouseEvent<HTMLElement>) => {
launcher.current = e.currentTarget;
setDeleting(p);
}}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Modal
open={editing !== null}
modalHeading={editing?.id ? "Edit item preset" : "New item preset"}
primaryButtonText={busy ? "Saving…" : "Save preset"}
secondaryButtonText="Cancel"
primaryButtonDisabled={busy}
onRequestClose={() => setEditing(null)}
onRequestSubmit={() => void save()}
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
size="sm"
>
{editing ? (
<Grid narrow>
<Column sm={4} md={8} lg={16}>
<TextInput
id="ip-desc"
labelText="Description"
value={editing.description}
onChange={(e) => set("description", e.target.value)}
invalid={touched && !editing.description.trim()}
invalidText="Description is required"
data-modal-primary-focus
/>
</Column>
<Column sm={4} md={4} lg={8}>
<TextInput
id="ip-hsn"
labelText="HSN/SAC (optional)"
value={editing.hsnSac}
onChange={(e) => set("hsnSac", e.target.value)}
/>
</Column>
<Column sm={4} md={4} lg={8}>
<Select
id="ip-mode"
labelText="Type"
value={editing.mode}
onChange={(e) => set("mode", e.target.value as LineMode)}
>
<SelectItem value="fixed" text="Fixed amount" />
<SelectItem value="rate" text="Rate × quantity" />
</Select>
</Column>
{editing.mode === "rate" ? (
<Column sm={4} md={4} lg={8}>
<Select
id="ip-unit"
labelText="Unit"
value={editing.unit}
onChange={(e) => set("unit", e.target.value as LineUnit)}
>
{Object.entries(UNIT_LABELS).map(([value, text]) => (
<SelectItem key={value} value={value} text={text} />
))}
</Select>
</Column>
) : null}
<Column sm={4} md={4} lg={8}>
<NumberInput
id="ip-rate"
label={editing.mode === "rate" ? "Rate (₹)" : "Amount (₹)"}
min={0}
step={1}
value={paiseToRupees(editing.ratePaise)}
onChange={(_e, { value }) => set("ratePaise", Math.max(0, rupeesToPaise(Number(value))))}
/>
</Column>
</Grid>
) : null}
</Modal>
<Modal
danger
open={deleting !== null}
modalHeading="Delete item preset"
primaryButtonText={busy ? "Deleting…" : "Delete"}
secondaryButtonText="Cancel"
primaryButtonDisabled={busy}
onRequestClose={() => setDeleting(null)}
onRequestSubmit={() => void remove()}
launcherButtonRef={launcher as React.RefObject<HTMLElement>}
size="xs"
>
<p>
Delete the preset “{deleting?.description}”? Invoices already created from it are not affected.
</p>
</Modal>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { createContext, useContext, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react";
import { ToastNotification } from "@carbon/react";
import { createToastStore, type ToastStore } from "../lib/toastStore";
export interface ToastApi {
success: (title: string, subtitle?: string) => number;
info: (title: string, subtitle?: string) => number;
warning: (title: string, subtitle?: string) => number;
/** Errors stay until the user closes them. */
error: (title: string, subtitle?: string) => number;
dismiss: (id: number) => void;
}
const ToastContext = createContext<ToastApi | null>(null);
function apiFor(store: ToastStore): ToastApi {
return {
success: (t, s) => store.add("success", t, s),
info: (t, s) => store.add("info", t, s),
warning: (t, s) => store.add("warning", t, s),
error: (t, s) => store.add("error", t, s),
dismiss: store.dismiss,
};
}
/** Stacked top-right toasts. Place it inside the Carbon <Theme> so the tokens apply. */
export function ToastProvider({ children }: { children: ReactNode }) {
const storeRef = useRef<ToastStore | null>(null);
if (!storeRef.current) storeRef.current = createToastStore();
const store = storeRef.current;
const toasts = useSyncExternalStore(store.subscribe, store.getSnapshot);
const api = useMemo(() => apiFor(store), [store]);
return (
<ToastContext.Provider value={api}>
{children}
{/* The live region stays mounted so screen readers announce toasts as they are added. */}
<div className="voiced-toasts" aria-live="polite" aria-relevant="additions" role="region" aria-label="Notifications">
{toasts.map((t) => (
<ToastNotification
key={t.id}
kind={t.kind}
lowContrast
title={t.title}
subtitle={t.subtitle}
role={t.kind === "error" ? "alert" : "status"}
onClose={() => {
store.dismiss(t.id);
return false;
}}
onCloseButtonClick={() => store.dismiss(t.id)}
/>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastApi {
const api = useContext(ToastContext);
if (!api) throw new Error("useToast must be used inside <ToastProvider>");
return api;
}
+5
View File
@@ -7,6 +7,7 @@ import type {
InvoiceInput, InvoiceInput,
InvoiceSeries, InvoiceSeries,
InvoiceSummary, InvoiceSummary,
ItemPreset,
Settings, Settings,
} from "./types"; } from "./types";
import type { LogoAsset } from "./logo"; import type { LogoAsset } from "./logo";
@@ -36,6 +37,10 @@ export const api = {
saveClient: (client: Client) => invoke<Client>("save_client", { client }), saveClient: (client: Client) => invoke<Client>("save_client", { client }),
deleteClient: (id: number) => invoke<void>("delete_client", { id }), deleteClient: (id: number) => invoke<void>("delete_client", { id }),
listItemPresets: () => invoke<ItemPreset[]>("list_item_presets"),
saveItemPreset: (preset: ItemPreset) => invoke<ItemPreset>("save_item_preset", { preset }),
deleteItemPreset: (id: number) => invoke<void>("delete_item_preset", { id }),
peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"), peekNextInvoiceNumber: () => invoke<string>("peek_next_invoice_number"),
issueInvoice: (input: InvoiceInput, renderPrefs: object) => issueInvoice: (input: InvoiceInput, renderPrefs: object) =>
invoke<Invoice>("issue_invoice", { input, renderPrefs }), invoke<Invoice>("issue_invoice", { input, renderPrefs }),
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { composeAddress, pincodeError, splitLegacyAddress } from "./clientAddress";
describe("composeAddress", () => {
it("joins lines and the city/state/pincode line", () => {
expect(
composeAddress({ line1: "12 MG Road", line2: "Floor 2", city: "Bengaluru", stateCode: "29", pincode: "560001" }),
).toBe("12 MG Road\nFloor 2\nBengaluru, Karnataka - 560001");
});
it("skips empty parts", () => {
expect(composeAddress({ line1: " A ", line2: "", city: "", stateCode: "", pincode: "560001" })).toBe("A\n560001");
expect(composeAddress({ line1: "", line2: "", city: "Pune", stateCode: "", pincode: "" })).toBe("Pune");
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "29", pincode: "" })).toBe("Karnataka");
expect(composeAddress({ line1: "", line2: "", city: "", stateCode: "", pincode: "" })).toBe("");
});
});
describe("splitLegacyAddress", () => {
it("puts the first line in line1 and the rest in line2", () => {
expect(splitLegacyAddress("Old style\nfree text\r\nBangalore")).toEqual({
line1: "Old style",
line2: "free text, Bangalore",
});
expect(splitLegacyAddress("")).toEqual({ line1: "", line2: "" });
});
});
describe("pincodeError", () => {
it("accepts empty and 6 digits only", () => {
expect(pincodeError("")).toBeUndefined();
expect(pincodeError("560001")).toBeUndefined();
expect(pincodeError("56001")).toBeDefined();
expect(pincodeError("56000A")).toBeDefined();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { INDIAN_STATES } from "./types";
export interface AddressParts {
line1: string;
line2: string;
city: string;
stateCode: string;
pincode: string;
}
export const EMPTY_ADDRESS: AddressParts = { line1: "", line2: "", city: "", stateCode: "", pincode: "" };
/**
* The stored multi-line address: line 1, line 2, then "City, State - Pincode".
* Mirrors `compose_address` in src-tauri/src/commands/clients.rs, which is authoritative on save.
*/
export function composeAddress(parts: AddressParts): string {
const stateName = INDIAN_STATES.find((s) => s.code === parts.stateCode)?.name ?? "";
const place = [parts.city.trim(), stateName].filter(Boolean).join(", ");
const pin = parts.pincode.trim();
const last = place && pin ? `${place} - ${pin}` : place || pin;
return [parts.line1.trim(), parts.line2.trim(), last].filter(Boolean).join("\n");
}
/** Best effort for clients saved before structured fields existed: first line to line 1, the rest to line 2. */
export function splitLegacyAddress(address: string): Pick<AddressParts, "line1" | "line2"> {
const lines = address
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
return { line1: lines[0] ?? "", line2: lines.slice(1).join(", ") };
}
export function isPincode(value: string): boolean {
return /^[0-9]{6}$/.test(value.trim());
}
export function pincodeError(value: string): string | undefined {
if (!value.trim()) return undefined;
return isPincode(value) ? undefined : "Pincode is 6 digits";
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { itemFromPreset, paiseToRupees, rupeesToPaise } from "./presets";
import { blankPreset } from "./types";
describe("preset money helpers", () => {
it("rounds rupees to whole paise", () => {
expect(rupeesToPaise(1500.5)).toBe(150050);
expect(rupeesToPaise(0.1 + 0.2)).toBe(30);
expect(rupeesToPaise(19.99)).toBe(1999);
expect(rupeesToPaise(Number.NaN)).toBe(0);
expect(paiseToRupees(150050)).toBe(1500.5);
});
});
describe("itemFromPreset", () => {
it("fills a fixed line", () => {
const item = itemFromPreset({ ...blankPreset(), description: "Retainer", mode: "fixed", ratePaise: 250000, hsnSac: "9983" }, { hsnSac: "0000" });
expect(item).toMatchObject({ description: "Retainer", mode: "fixed", amount: 2500, rate: 0, quantity: 1, hsnSac: "9983" });
});
it("fills a rate line and falls back to the default HSN/SAC", () => {
const item = itemFromPreset({ ...blankPreset(), description: "Hours", mode: "rate", unit: "hour", ratePaise: 150050 }, { hsnSac: "998397" });
expect(item).toMatchObject({ mode: "rate", unit: "hour", rate: 1500.5, amount: 0, quantity: 1, hsnSac: "998397" });
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { InvoiceItem, ItemPreset } from "./types";
export const rupeesToPaise = (rupees: number): number => Math.round((Number(rupees) || 0) * 100);
export const paiseToRupees = (paise: number): number => paise / 100;
/**
* A new invoice line pre-filled from a preset. Invoice items store rupee floats, so the integer-paise
* preset amount is converted only here.
*/
export function itemFromPreset(preset: ItemPreset, defaults: { hsnSac: string }): InvoiceItem {
const rupees = paiseToRupees(preset.ratePaise);
return {
description: preset.description,
mode: preset.mode,
rate: preset.mode === "rate" ? rupees : 0,
unit: preset.unit,
quantity: 1,
amount: preset.mode === "fixed" ? rupees : 0,
sortOrder: 0,
hsnSac: preset.hsnSac || defaults.hsnSac,
};
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { SETTINGS_TABS, loadSettingsTab, parseSettingsTab, saveSettingsTab } from "./settingsTab";
describe("settings tab persistence", () => {
it("maps stored ids to indexes and falls back to the first tab", () => {
expect(parseSettingsTab("fonts")).toBe(SETTINGS_TABS.findIndex((t) => t.id === "fonts"));
expect(parseSettingsTab("nope")).toBe(0);
expect(parseSettingsTab(null)).toBe(0);
});
it("does not throw when storage is unavailable", () => {
// The node test environment has no localStorage at all.
expect(loadSettingsTab()).toBe(0);
expect(() => saveSettingsTab(2)).not.toThrow();
expect(() => saveSettingsTab(99)).not.toThrow();
});
});
+38
View File
@@ -0,0 +1,38 @@
const KEY = "voiced.settingsTab";
export const SETTINGS_TABS = [
{ id: "business", label: "Business" },
{ id: "branding", label: "Branding" },
{ id: "bank", label: "Bank & payments" },
{ id: "defaults", label: "Invoice defaults" },
{ id: "numbering", label: "Numbering" },
{ id: "templates", label: "Templates & export" },
{ id: "fonts", label: "Fonts" },
{ id: "data", label: "Data" },
] as const;
export type SettingsTabId = (typeof SETTINGS_TABS)[number]["id"];
/** Index of a stored tab id; 0 (Business) for anything unknown. */
export function parseSettingsTab(value: string | null | undefined): number {
const index = SETTINGS_TABS.findIndex((t) => t.id === value);
return index < 0 ? 0 : index;
}
/** The last Settings tab this viewer used. A per-viewer convenience only. */
export function loadSettingsTab(): number {
try {
return parseSettingsTab(localStorage.getItem(KEY));
} catch {
return 0;
}
}
export function saveSettingsTab(index: number): void {
try {
const tab = SETTINGS_TABS[index];
if (tab) localStorage.setItem(KEY, tab.id);
} catch {
// Not remembering is harmless.
}
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { AUTO_DISMISS_MS, MAX_TOASTS, createToastStore } from "./toastStore";
function fakeTimers() {
let next = 1;
const pending = new Map<number, { fn: () => void; ms: number }>();
return {
setTimer: (fn: () => void, ms: number) => {
const id = next++;
pending.set(id, { fn, ms });
return id;
},
clearTimer: (id: unknown) => void pending.delete(id as number),
fireAll: () => [...pending.entries()].forEach(([id, t]) => (pending.delete(id), t.fn())),
pending,
};
}
describe("toast store", () => {
it("auto-dismisses non-error toasts after the delay", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
store.add("success", "Saved");
store.add("info", "FYI");
expect([...timers.pending.values()].map((t) => t.ms)).toEqual([AUTO_DISMISS_MS, AUTO_DISMISS_MS]);
timers.fireAll();
expect(store.getSnapshot()).toHaveLength(0);
});
it("keeps errors until they are closed", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
const id = store.add("error", "Failed", "details");
expect(timers.pending.size).toBe(0);
timers.fireAll();
expect(store.getSnapshot()).toEqual([{ id, kind: "error", title: "Failed", subtitle: "details" }]);
store.dismiss(id);
expect(store.getSnapshot()).toHaveLength(0);
});
it("dismiss cancels the timer and is idempotent", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
const id = store.add("success", "x");
store.dismiss(id);
expect(timers.pending.size).toBe(0);
store.dismiss(id);
expect(store.getSnapshot()).toHaveLength(0);
});
it("notifies subscribers with a new snapshot reference per change", () => {
const store = createToastStore(fakeTimers());
let calls = 0;
const off = store.subscribe(() => calls++);
const before = store.getSnapshot();
store.add("info", "a");
expect(calls).toBe(1);
expect(store.getSnapshot()).not.toBe(before);
const same = store.getSnapshot();
store.dismiss(999);
expect(calls).toBe(1);
expect(store.getSnapshot()).toBe(same);
off();
store.add("info", "b");
expect(calls).toBe(1);
});
it("caps the stack and drops the oldest first, cancelling its timer", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
for (let i = 0; i < MAX_TOASTS + 2; i++) store.add("success", `t${i}`);
const titles = store.getSnapshot().map((t) => t.title);
expect(titles).toHaveLength(MAX_TOASTS);
expect(titles[0]).toBe("t2");
expect(timers.pending.size).toBe(MAX_TOASTS);
});
it("clear empties the stack and cancels timers", () => {
const timers = fakeTimers();
const store = createToastStore(timers);
store.add("success", "a");
store.add("error", "b");
store.clear();
expect(store.getSnapshot()).toHaveLength(0);
expect(timers.pending.size).toBe(0);
});
});
+83
View File
@@ -0,0 +1,83 @@
export type ToastKind = "success" | "info" | "warning" | "error";
export interface Toast {
id: number;
kind: ToastKind;
title: string;
subtitle?: string;
}
/** Success, info and warning toasts leave by themselves; errors stay until closed. */
export const AUTO_DISMISS_MS = 5000;
/** Oldest toasts are dropped beyond this, so a failing loop cannot fill the screen. */
export const MAX_TOASTS = 5;
type TimerId = unknown;
export interface ToastStoreOptions {
setTimer?: (fn: () => void, ms: number) => TimerId;
clearTimer?: (id: TimerId) => void;
}
export interface ToastStore {
add: (kind: ToastKind, title: string, subtitle?: string) => number;
dismiss: (id: number) => void;
clear: () => void;
subscribe: (listener: () => void) => () => void;
getSnapshot: () => readonly Toast[];
}
/** A tiny external store (useSyncExternalStore-compatible); the timers are injectable for tests. */
export function createToastStore(options: ToastStoreOptions = {}): ToastStore {
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
const clearTimer = options.clearTimer ?? ((id) => clearTimeout(id as ReturnType<typeof setTimeout>));
let toasts: readonly Toast[] = [];
let nextId = 1;
const timers = new Map<number, TimerId>();
const listeners = new Set<() => void>();
const emit = () => listeners.forEach((l) => l());
const drop = (id: number) => {
const timer = timers.get(id);
if (timer !== undefined) {
clearTimer(timer);
timers.delete(id);
}
};
const dismiss = (id: number) => {
drop(id);
if (!toasts.some((t) => t.id === id)) return;
toasts = toasts.filter((t) => t.id !== id);
emit();
};
return {
add(kind, title, subtitle) {
const id = nextId++;
toasts = [...toasts, { id, kind, title, subtitle }];
while (toasts.length > MAX_TOASTS) {
drop(toasts[0].id);
toasts = toasts.slice(1);
}
if (kind !== "error") timers.set(id, setTimer(() => dismiss(id), AUTO_DISMISS_MS));
emit();
return id;
},
dismiss,
clear() {
timers.forEach((t) => clearTimer(t));
timers.clear();
toasts = [];
emit();
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
getSnapshot: () => toasts,
};
}
+64
View File
@@ -58,16 +58,80 @@ export interface InvoiceSeries {
nextInvoiceNumber: string; nextInvoiceNumber: string;
} }
export type GstCategory = "registered_regular" | "unregistered" | "composition" | "sez" | "overseas";
/** Informational only; the tax heads still derive from supplier state and place of supply. */
export const GST_CATEGORY_LABELS: Record<GstCategory, string> = {
registered_regular: "Registered (regular)",
unregistered: "Unregistered",
composition: "Composition",
sez: "SEZ",
overseas: "Overseas",
};
export interface Client { export interface Client {
id: number | null; id: number | null;
name: string; name: string;
/** The composed multi-line address; what invoices store and print. */
address: string; address: string;
gstin: string; gstin: string;
/** Doubles as the address state and the default place of supply. */
stateCode: string; stateCode: string;
poNumber: string; poNumber: string;
createdAt: string; createdAt: string;
addressLine1: string;
addressLine2: string;
city: string;
pincode: string;
gstCategory: GstCategory;
/** Notes / payment terms text copied onto a new invoice when the client is picked. */
defaultNotes: string;
/** Null means the settings default. */
paymentTermsDays: number | null;
/** Read-only: how many invoices reference this client. */
invoiceCount?: number;
} }
export const blankClient = (): Client => ({
id: null,
name: "",
address: "",
gstin: "",
stateCode: "",
poNumber: "",
createdAt: "",
addressLine1: "",
addressLine2: "",
city: "",
pincode: "",
gstCategory: "unregistered",
defaultNotes: "",
paymentTermsDays: null,
});
/** A saved line item. `ratePaise` is the amount for "fixed" and the per-unit rate for "rate". */
export interface ItemPreset {
id: number | null;
description: string;
hsnSac: string;
mode: LineMode;
unit: LineUnit;
ratePaise: number;
sortOrder: number;
createdAt: string;
}
export const blankPreset = (): ItemPreset => ({
id: null,
description: "",
hsnSac: "",
mode: "fixed",
unit: "unit",
ratePaise: 0,
sortOrder: 0,
createdAt: "",
});
export interface InvoiceItem { export interface InvoiceItem {
id?: number | null; id?: number | null;
description: string; description: string;
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { gstinChecksumError, gstinFullError, vendorGstinError } from "./validators"; import { clientGstinStateError, gstinChecksumError, gstinFullError, vendorGstinError } from "./validators";
// Same GSTINs as the Rust tests in src-tauri/src/gst.rs. // Same GSTINs as the Rust tests in src-tauri/src/gst.rs.
describe("GSTIN checksum", () => { describe("GSTIN checksum", () => {
@@ -25,3 +25,19 @@ describe("GSTIN checksum", () => {
expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "27")).toBeUndefined(); expect(vendorGstinError("regular", "27AAPFU0939F1ZV", "AAPFU0939F", "27")).toBeUndefined();
}); });
}); });
describe("clientGstinStateError", () => {
it("allows empty and NA", () => {
expect(clientGstinStateError("", "29")).toBeUndefined();
expect(clientGstinStateError("NA", "29")).toBeUndefined();
});
it("checks shape and checksum", () => {
expect(clientGstinStateError("29ABCDE1234F1Z5", "29")).toMatch(/check digit/);
expect(clientGstinStateError("29ABC", "")).toMatch(/15-character/);
});
it("checks the state prefix only when a state is chosen", () => {
expect(clientGstinStateError("29ABCDE1234F1ZW", "29")).toBeUndefined();
expect(clientGstinStateError("29abcde1234f1zw", "")).toBeUndefined();
expect(clientGstinStateError("29ABCDE1234F1ZW", "27")).toMatch(/starts with 29/);
});
});
+15
View File
@@ -84,3 +84,18 @@ export function accountError(value: string): string | undefined {
export const isPan = (v: string) => PAN_RE.test(norm(v)); export const isPan = (v: string) => PAN_RE.test(norm(v));
export const isIfsc = (v: string) => IFSC_RE.test(norm(v)); export const isIfsc = (v: string) => IFSC_RE.test(norm(v));
export const isGstin = (v: string) => GSTIN_RE.test(norm(v)); export const isGstin = (v: string) => GSTIN_RE.test(norm(v));
/**
* Client GSTIN rules: shape, check digit, and that its state prefix matches the client's state when both
* are given. Empty (or "NA") is valid; a client may be unregistered.
*/
export function clientGstinStateError(gstin: string, stateCode: string): string | undefined {
const g = norm(gstin);
if (!g || g === "NA") return undefined;
const base = gstinFullError(g);
if (base) return base;
if (stateCode && g.slice(0, 2) !== stateCode) {
return `GSTIN starts with ${g.slice(0, 2)}, but the state is ${stateCode}`;
}
return undefined;
}
+17
View File
@@ -148,6 +148,23 @@ body {
display: none !important; display: none !important;
} }
.voiced-toasts {
position: fixed;
top: 3.5rem; /* below the 3rem Carbon header */
right: 1rem;
z-index: 9100;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: calc(100vw - 2rem);
pointer-events: none;
}
.voiced-toasts .cds--toast-notification {
pointer-events: auto;
margin: 0;
}
.voiced-page { .voiced-page {
padding: 2rem; padding: 2rem;
max-width: 1600px; max-width: 1600px;
+133 -101
View File
@@ -3,7 +3,6 @@ import {
Button, Button,
Column, Column,
Grid, Grid,
InlineNotification,
Link, Link,
NumberInput, NumberInput,
RadioButton, RadioButton,
@@ -19,6 +18,11 @@ import {
TableRow, TableRow,
TextArea, TextArea,
TextInput, TextInput,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
Tile, Tile,
Toggle, Toggle,
} from "@carbon/react"; } from "@carbon/react";
@@ -37,6 +41,9 @@ import { ImagePicker } from "../components/ImagePicker";
import { LogoBranding } from "../components/LogoBranding"; import { LogoBranding } from "../components/LogoBranding";
import DiagnosticsPanel from "../components/DiagnosticsPanel"; import DiagnosticsPanel from "../components/DiagnosticsPanel";
import { FontOverrideTable } from "../components/FontOverrideTable"; import { FontOverrideTable } from "../components/FontOverrideTable";
import { useToast } from "../components/ToastProvider";
import { SETTINGS_TABS, loadSettingsTab, saveSettingsTab } from "../lib/settingsTab";
import SeriesSettings from "./SeriesSettings";
const blankBank = (): BankAccount => ({ const blankBank = (): BankAccount => ({
id: null, id: null,
@@ -61,14 +68,20 @@ export default function AppSettings({
const [banks, setBanks] = useState<BankAccount[]>([]); const [banks, setBanks] = useState<BankAccount[]>([]);
const [editing, setEditing] = useState<BankAccount | null>(null); const [editing, setEditing] = useState<BankAccount | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const toast = useToast();
const [notice, setNotice] = useState<string | null>(null); // One form state for every tab and one Save button, so edits survive switching tabs.
const [tab, setTab] = useState(loadSettingsTab);
const selectTab = (index: number) => {
setTab(index);
saveSettingsTab(index);
};
const loadBanks = async () => { const loadBanks = async () => {
try { try {
setBanks(await api.listBanks()); setBanks(await api.listBanks());
} catch (e) { } catch (e) {
setError(String(e)); toast.error("Could not load bank accounts", String(e));
} }
}; };
@@ -92,18 +105,17 @@ export default function AppSettings({
const saveAll = async () => { const saveAll = async () => {
if (gstinProblem) { if (gstinProblem) {
setError(gstinProblem); selectTab(0);
toast.error("Settings not saved", gstinProblem);
return; return;
} }
setBusy(true); setBusy(true);
setError(null);
setNotice(null);
try { try {
await api.saveSettings(form); await api.saveSettings(form);
setNotice("Settings saved."); toast.success("Settings saved");
onSaved(); onSaved();
} catch (e) { } catch (e) {
setError(String(e)); toast.error("Could not save settings", String(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -112,7 +124,6 @@ export default function AppSettings({
const saveBank = async () => { const saveBank = async () => {
if (!editing) return; if (!editing) return;
setBusy(true); setBusy(true);
setError(null);
try { try {
const saved = await api.saveBank(editing); const saved = await api.saveBank(editing);
if (saved.isDefault || form.defaultBankId === null) { if (saved.isDefault || form.defaultBankId === null) {
@@ -120,8 +131,9 @@ export default function AppSettings({
} }
setEditing(null); setEditing(null);
await loadBanks(); await loadBanks();
toast.success("Bank account saved", "Click Save settings to keep a changed default account.");
} catch (e) { } catch (e) {
setError(String(e)); toast.error("Could not save bank account", String(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -139,30 +151,29 @@ export default function AppSettings({
if (form.defaultBankId === bank.id) set("defaultBankId", null); if (form.defaultBankId === bank.id) set("defaultBankId", null);
await loadBanks(); await loadBanks();
} catch (e) { } catch (e) {
setError(String(e)); toast.error("Could not delete bank account", String(e));
} }
}; };
return ( return (
<div className="voiced-page"> <div className="voiced-page">
<h1 className="voiced-page__title">Settings</h1> <h1 className="voiced-page__title">Settings</h1>
<p className="voiced-page__subtitle">Vendor details, defaults and bank accounts.</p> <p className="voiced-page__subtitle">Business details, branding, defaults and bank accounts.</p>
{error ? (
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{notice ? (
<InlineNotification
kind="success"
lowContrast
title="Saved"
subtitle={notice}
onCloseButtonClick={() => setNotice(null)}
/>
) : null}
<Tabs selectedIndex={tab} onChange={({ selectedIndex }) => selectTab(selectedIndex)}>
<TabList aria-label="Settings sections" contained scrollIntoView>
<Tab>Business</Tab>
<Tab>Branding</Tab>
<Tab>Bank &amp; payments</Tab>
<Tab>Invoice defaults</Tab>
<Tab>Numbering</Tab>
<Tab>Templates &amp; export</Tab>
<Tab>Fonts</Tab>
<Tab>Data</Tab>
</TabList>
<TabPanels>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}> <section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Vendor details</h3>
<Grid narrow> <Grid narrow>
<Column sm={4} md={8} lg={12}> <Column sm={4} md={8} lg={12}>
<RadioButtonGroup <RadioButtonGroup
@@ -277,9 +288,9 @@ export default function AppSettings({
</Column> </Column>
</Grid> </Grid>
</section> </section>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}> <section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Branding</h3>
<Tile> <Tile>
<LogoBranding <LogoBranding
idPrefix="s" idPrefix="s"
@@ -289,78 +300,8 @@ export default function AppSettings({
/> />
</Tile> </Tile>
</section> </section>
</TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}> <TabPanel>
<h3>Fonts</h3>
<Tile>
<FontOverrideTable />
</Tile>
</section>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<h3>Defaults</h3>
<Grid narrow>
{registered ? (
<>
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-taxRate"
label="Default tax rate (%)"
min={0}
max={100}
step={0.5}
value={form.defaultTaxRate}
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="s-hsn"
labelText="Default HSN/SAC"
value={form.defaultHsnSac}
onChange={(e) => set("defaultHsnSac", e.target.value)}
/>
</Column>
</>
) : null}
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-terms"
label="Payment terms (days)"
min={0}
max={365}
value={form.paymentTermsDays}
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
<Select
id="s-theme"
labelText="Theme"
value={form.theme}
onChange={(e) => set("theme", e.target.value)}
>
<SelectItem value="g10" text="Carbon light (g10)" />
<SelectItem value="g100" text="Carbon dark (g100)" />
</Select>
</Column>
{registered ? (
<Column sm={4} md={8} lg={16}>
<RadioButtonGroup
legendText="Default tax treatment"
name="s-taxType"
valueSelected={form.defaultTaxType}
onChange={(value) => set("defaultTaxType", value as TaxType)}
>
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
) : null}
</Grid>
</section>
<section className="voiced-stack" style={{ marginBottom: "2rem" }}> <section className="voiced-stack" style={{ marginBottom: "2rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}> <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h3>Bank accounts</h3> <h3>Bank accounts</h3>
@@ -487,6 +428,97 @@ export default function AppSettings({
</div> </div>
) : null} ) : null}
</section> </section>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<Grid narrow>
{registered ? (
<>
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-taxRate"
label="Default tax rate (%)"
min={0}
max={100}
step={0.5}
value={form.defaultTaxRate}
onChange={(_e, { value }) => set("defaultTaxRate", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={4} lg={5}>
<TextInput
id="s-hsn"
labelText="Default HSN/SAC"
value={form.defaultHsnSac}
onChange={(e) => set("defaultHsnSac", e.target.value)}
/>
</Column>
</>
) : null}
<Column sm={4} md={4} lg={5}>
<NumberInput
id="s-terms"
label="Payment terms (days)"
min={0}
max={365}
value={form.paymentTermsDays}
onChange={(_e, { value }) => set("paymentTermsDays", Number(value) || 0)}
/>
</Column>
<Column sm={4} md={8} lg={6}>
<Select
id="s-theme"
labelText="Theme"
value={form.theme}
onChange={(e) => set("theme", e.target.value)}
>
<SelectItem value="g10" text="Carbon light (g10)" />
<SelectItem value="g100" text="Carbon dark (g100)" />
</Select>
</Column>
{registered ? (
<Column sm={4} md={8} lg={16}>
<RadioButtonGroup
legendText="Default tax treatment"
name="s-taxType"
valueSelected={form.defaultTaxType}
onChange={(value) => set("defaultTaxType", value as TaxType)}
>
<RadioButton id="s-tt-cgst" labelText="CGST + SGST (within state)" value="cgst_sgst" />
<RadioButton id="s-tt-igst" labelText="IGST (inter-state)" value="igst" />
<RadioButton id="s-tt-none" labelText="No tax" value="none" />
</RadioButtonGroup>
</Column>
) : null}
</Grid>
</section>
</TabPanel>
<TabPanel>
<SeriesSettings embedded active={tab === SETTINGS_TABS.findIndex((t) => t.id === "numbering")} />
</TabPanel>
<TabPanel>
<section className="voiced-stack">
<p>
The template, page size and margins are chosen next to the preview on the New invoice screen. The
default page setup is saved from there.
</p>
<p className="voiced-muted">Export mode (searchable or flattened) is chosen when you issue an invoice.</p>
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack" style={{ marginBottom: "2.5rem" }}>
<Tile>
<FontOverrideTable />
</Tile>
</section>
</TabPanel>
<TabPanel>
<section className="voiced-stack">
<p>Backup and export arrive in the next step.</p>
</section>
</TabPanel>
</TabPanels>
</Tabs>
<Button renderIcon={Save} onClick={saveAll} disabled={busy}> <Button renderIcon={Save} onClick={saveAll} disabled={busy}>
{busy ? "Saving…" : "Save settings"} {busy ? "Saving…" : "Save settings"}
+246
View File
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
DataTable,
InlineLoading,
Modal,
Tab,
TabList,
TabPanel,
TabPanels,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
TableToolbar,
TableToolbarContent,
TableToolbarSearch,
Tabs,
Tag,
} from "@carbon/react";
import { Add, Edit, TrashCan } from "@carbon/icons-react";
import { api } from "../lib/api";
import { GST_CATEGORY_LABELS, INDIAN_STATES, type Client } from "../lib/types";
import ClientModal from "../components/ClientModal";
import ItemPresetsPanel from "../components/ItemPresetsPanel";
import { useToast } from "../components/ToastProvider";
const HEADERS = [
{ key: "name", header: "Name" },
{ key: "gstin", header: "GSTIN" },
{ key: "category", header: "GST category" },
{ key: "place", header: "City / State" },
{ key: "invoices", header: "Invoices" },
{ key: "actions", header: "" },
];
const stateName = (code: string) => INDIAN_STATES.find((s) => s.code === code)?.name ?? "";
export default function Clients({ active }: { active: boolean }) {
const toast = useToast();
const [clients, setClients] = useState<Client[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Client | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [deleting, setDeleting] = useState<Client | null>(null);
const [busy, setBusy] = useState(false);
const launcher = useRef<HTMLElement | null>(null);
const load = useCallback(async () => {
try {
setClients(await api.listClients());
} catch (e) {
toast.error("Could not load clients", String(e));
} finally {
setLoading(false);
}
}, [toast]);
// The view stays mounted, so refetch whenever it is shown again.
useEffect(() => {
if (active) void load();
}, [active, load]);
const openEditor = (client: Client | null, trigger: HTMLElement) => {
launcher.current = trigger;
setEditing(client);
setModalOpen(true);
};
const confirmDelete = async () => {
if (!deleting || deleting.id === null) return;
setBusy(true);
try {
await api.deleteClient(deleting.id);
toast.success("Client deleted", deleting.name);
setDeleting(null);
await load();
} catch (e) {
toast.error("Could not delete client", String(e));
} finally {
setBusy(false);
}
};
const byId = new Map(clients.map((c) => [String(c.id), c]));
const rows = clients.map((c) => ({
id: String(c.id),
name: c.name,
gstin: c.gstin || "—",
category: GST_CATEGORY_LABELS[c.gstCategory] ?? c.gstCategory,
place: [c.city, stateName(c.stateCode)].filter(Boolean).join(", ") || "—",
invoices: c.invoiceCount ?? 0,
actions: "",
}));
const clientsTable = loading ? (
<InlineLoading description="Loading clients" />
) : (
<DataTable rows={rows} headers={HEADERS} isSortable>
{({ rows: tableRows, headers, getHeaderProps, getRowProps, getTableProps, onInputChange }) => (
<TableContainer>
<TableToolbar>
<TableToolbarContent>
<TableToolbarSearch
persistent
placeholder="Search clients"
labelText="Search clients"
onChange={(e) => onInputChange(e as React.ChangeEvent<HTMLInputElement>)}
/>
<Button
renderIcon={Add}
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(null, e.currentTarget)}
>
Add client
</Button>
</TableToolbarContent>
</TableToolbar>
<Table {...getTableProps()} aria-label="Clients">
<TableHead>
<TableRow>
{headers.map((header) => {
const { key, ...headerProps } = getHeaderProps({ header });
return header.key === "actions" ? (
<TableHeader key={key} aria-label="Actions" />
) : (
<TableHeader key={key} {...headerProps}>
{header.header}
</TableHeader>
);
})}
</TableRow>
</TableHead>
<TableBody>
{tableRows.map((row) => {
const client = byId.get(row.id);
const { key, ...rowProps } = getRowProps({ row });
return (
<TableRow key={key} {...rowProps}>
{row.cells.map((cell) => {
if (cell.info.header === "actions") {
return (
<TableCell key={cell.id}>
{client ? (
<div className="voiced-header-action">
<Button
kind="ghost"
size="sm"
renderIcon={Edit}
onClick={(e: React.MouseEvent<HTMLElement>) => openEditor(client, e.currentTarget)}
>
Edit
</Button>
<Button
kind="ghost"
size="sm"
renderIcon={TrashCan}
onClick={(e: React.MouseEvent<HTMLElement>) => {
launcher.current = e.currentTarget;
setDeleting(client);
}}
>
Delete
</Button>
</div>
) : null}
</TableCell>
);
}
if (cell.info.header === "category" && client) {
return (
<TableCell key={cell.id}>
<Tag type={client.gstCategory === "unregistered" ? "gray" : "blue"} size="sm">
{cell.value}
</Tag>
</TableCell>
);
}
return <TableCell key={cell.id}>{cell.value}</TableCell>;
})}
</TableRow>
);
})}
</TableBody>
</Table>
{clients.length === 0 ? <div className="voiced-empty">No clients yet. Add one to reuse it on invoices.</div> : null}
</TableContainer>
)}
</DataTable>
);
const count = deleting?.invoiceCount ?? 0;
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Clients</h1>
<p className="voiced-page__subtitle">Saved clients and item presets for faster invoicing.</p>
<Tabs>
<TabList aria-label="Clients and presets" contained>
<Tab>Clients</Tab>
<Tab>Item presets</Tab>
</TabList>
<TabPanels>
<TabPanel>{clientsTable}</TabPanel>
<TabPanel>
<ItemPresetsPanel active={active} />
</TabPanel>
</TabPanels>
</Tabs>
<ClientModal
open={modalOpen}
client={editing}
launcherRef={launcher}
onClose={() => setModalOpen(false)}
onSaved={() => {
setModalOpen(false);
void load();
}}
/>
<Modal
danger
open={deleting !== null}
modalHeading="Delete client"
primaryButtonText={busy ? "Deleting…" : "Delete"}
secondaryButtonText="Cancel"
primaryButtonDisabled={busy}
onRequestClose={() => setDeleting(null)}
onRequestSubmit={() => void confirmDelete()}
launcherButtonRef={launcher}
size="xs"
>
<p>
Delete “{deleting?.name}”?{" "}
{count > 0
? `${count} invoice${count === 1 ? "" : "s"} refer${count === 1 ? "s" : ""} to this client. They keep their own copy of the client's name, address and GSTIN, so they are not changed.`
: "No invoices refer to this client."}
</p>
</Modal>
</div>
);
}
+9 -19
View File
@@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
import { import {
Button, Button,
InlineLoading, InlineLoading,
InlineNotification,
Modal, Modal,
OverflowMenu, OverflowMenu,
OverflowMenuItem, OverflowMenuItem,
@@ -24,17 +23,17 @@ import type { ExportMode } from "../lib/filename";
import { useInvoiceExport } from "../hooks/useInvoiceExport"; import { useInvoiceExport } from "../hooks/useInvoiceExport";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import type { InvoiceSummary, Settings } from "../lib/types"; import type { InvoiceSummary, Settings } from "../lib/types";
import { useToast } from "../components/ToastProvider";
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) { export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
const toast = useToast();
const [rows, setRows] = useState<InvoiceSummary[]>([]); const [rows, setRows] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<number | null>(null); const [busyId, setBusyId] = useState<number | null>(null);
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null); const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
// Whether each invoice has an archived PDF; unknown (absent) until its status was read. // Whether each invoice has an archived PDF; unknown (absent) until its status was read.
const [archived, setArchived] = useState<Record<number, boolean>>({}); const [archived, setArchived] = useState<Record<number, boolean>>({});
const [notice, setNotice] = useState<string | null>(null);
const exp = useInvoiceExport({ const exp = useInvoiceExport({
settings, settings,
bank: null, bank: null,
@@ -44,9 +43,8 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
const load = async () => { const load = async () => {
try { try {
setRows(await api.listInvoices()); setRows(await api.listInvoices());
setError(null);
} catch (e) { } catch (e) {
setError(String(e)); toast.error("Could not load invoices", String(e));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -78,14 +76,12 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
const onExport = async (id: number, mode: ExportMode, source: ExportSource) => { const onExport = async (id: number, mode: ExportMode, source: ExportSource) => {
setBusyId(id); setBusyId(id);
setError(null);
setNotice(null);
try { try {
// Fetched now, not at list time: the row summary does not carry the archive fields. // Fetched now, not at list time: the row summary does not carry the archive fields.
const invoice = await api.getInvoice(id); const invoice = await api.getInvoice(id);
await exp.start(invoice, mode, source); await exp.start(invoice, mode, source);
} catch (e) { } catch (e) {
setError(String(e)); toast.error("Export failed", String(e));
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
@@ -93,19 +89,17 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
const onArchive = async (id: number) => { const onArchive = async (id: number) => {
setBusyId(id); setBusyId(id);
setError(null);
setNotice(null);
try { try {
const invoice = await api.getInvoice(id); const invoice = await api.getInvoice(id);
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null })); const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
if (outcome.archived) { if (outcome.archived) {
setArchived((a) => ({ ...a, [id]: true })); setArchived((a) => ({ ...a, [id]: true }));
setNotice(`Invoice ${invoice.number} is archived.`); toast.success(`Invoice ${invoice.number} is archived`);
} else { } else {
setError(`Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`); toast.error("Could not archive the PDF", `Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
} }
} catch (e) { } catch (e) {
setError(String(e)); toast.error("Could not archive the PDF", String(e));
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
@@ -115,12 +109,13 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
if (!cancelTarget) return; if (!cancelTarget) return;
try { try {
await api.cancelInvoice(cancelTarget.id, reason); await api.cancelInvoice(cancelTarget.id, reason);
toast.success(`Invoice ${cancelTarget.number} cancelled`);
setCancelTarget(null); setCancelTarget(null);
setReason(""); setReason("");
await load(); await load();
} catch (e) { } catch (e) {
setCancelTarget(null); setCancelTarget(null);
setError(String(e)); toast.error("Could not cancel the invoice", String(e));
} }
}; };
@@ -129,11 +124,6 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
<h1 className="voiced-page__title">Invoices</h1> <h1 className="voiced-page__title">Invoices</h1>
<p className="voiced-page__subtitle">Every invoice you have issued, newest first.</p> <p className="voiced-page__subtitle">Every invoice you have issued, newest first.</p>
{error ? (
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{notice ? <InlineNotification kind="success" lowContrast title={notice} onCloseButtonClick={() => setNotice(null)} /> : null}
<ExportFeedback exp={exp} /> <ExportFeedback exp={exp} />
{loading ? ( {loading ? (
+79 -18
View File
@@ -2,6 +2,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } fro
import { import {
ActionableNotification, ActionableNotification,
Button, Button,
ComboBox,
Column, Column,
Dropdown, Dropdown,
Grid, Grid,
@@ -37,6 +38,7 @@ import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
import type { import type {
BankAccount, BankAccount,
Client, Client,
ItemPreset,
DraftSummary, DraftSummary,
Invoice, Invoice,
InvoiceInput, InvoiceInput,
@@ -68,6 +70,9 @@ import { useUserFonts } from "../hooks/useUserFonts";
import { withUserFonts } from "../lib/fontImport"; import { withUserFonts } from "../lib/fontImport";
import { ImagePicker } from "../components/ImagePicker"; import { ImagePicker } from "../components/ImagePicker";
import { loadLogoImages, type LogoImages } from "../lib/logo"; import { loadLogoImages, type LogoImages } from "../lib/logo";
import ClientModal from "../components/ClientModal";
import { useToast } from "../components/ToastProvider";
import { itemFromPreset } from "../lib/presets";
// pdf.js is only needed once a preview exists, so it loads on demand. // pdf.js is only needed once a preview exists, so it loads on demand.
const PdfPreview = lazy(() => import("../components/PdfPreview")); const PdfPreview = lazy(() => import("../components/PdfPreview"));
@@ -90,6 +95,7 @@ const formatStamp = (iso: string) => {
}; };
export default function NewInvoice({ settings, onSettingsChange, active, onActivate }: NewInvoiceProps) { export default function NewInvoice({ settings, onSettingsChange, active, onActivate }: NewInvoiceProps) {
const toast = useToast();
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();
@@ -118,6 +124,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
const [number, setNumber] = useState(""); const [number, setNumber] = useState("");
const [clients, setClients] = useState<Client[]>([]); const [clients, setClients] = useState<Client[]>([]);
const [presets, setPresets] = useState<ItemPreset[]>([]);
const [clientModalOpen, setClientModalOpen] = useState(false);
const newClientButton = useRef<HTMLButtonElement | null>(null);
const [clientId, setClientId] = useState<number | null>(null); const [clientId, setClientId] = useState<number | null>(null);
const [clientName, setClientName] = useState(""); const [clientName, setClientName] = useState("");
const [clientAddress, setClientAddress] = useState(""); const [clientAddress, setClientAddress] = useState("");
@@ -188,7 +197,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setDrafts(draftList); setDrafts(draftList);
setNoticeOpen(draftList.length > 0); setNoticeOpen(draftList.length > 0);
} catch (e) { } catch (e) {
if (active) setError(String(e)); if (active) toast.error("Could not load invoice data", String(e));
} }
})(); })();
return () => { return () => {
@@ -196,6 +205,13 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
}; };
}, []); }, []);
// The view stays mounted, so pick up clients and presets edited on the Clients page when it is shown again.
useEffect(() => {
if (!active) return;
api.listClients().then(setClients).catch(() => {});
api.listItemPresets().then(setPresets).catch(() => {});
}, [active]);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
if (!settings.logoPath) { if (!settings.logoPath) {
@@ -333,6 +349,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
return next; return next;
}, []); }, []);
// A failed autosave is reported once per failure streak (the message clears on the next success).
useEffect(() => {
if (draftError) toast.warning("Draft not saved", draftError);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftError]);
useEffect(() => { useEffect(() => {
if (!unsaved) return; if (!unsaved) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -390,6 +412,15 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
setClientGstin(selected.gstin); setClientGstin(selected.gstin);
setPoNumber(selected.poNumber); setPoNumber(selected.poNumber);
if (selected.stateCode) setPlaceOfSupply(selected.stateCode); if (selected.stateCode) setPlaceOfSupply(selected.stateCode);
// Client defaults fill gaps only: typed notes are never overwritten.
if (selected.defaultNotes && !notes.trim()) setNotes(selected.defaultNotes);
if (selected.paymentTermsDays !== null) setDueDate(addDays(invoiceDate, selected.paymentTermsDays));
};
const addPreset = (preset: ItemPreset) => {
const line = itemFromPreset(preset, { hsnSac: settings.defaultHsnSac });
const isBlank = (i: InvoiceItem) => !i.description.trim() && !i.amount && !i.rate;
setItems((prev) => (prev.length === 1 && isBlank(prev[0]) ? [line] : [...prev, line]));
}; };
const applyState = (s: InvoiceFormState) => { const applyState = (s: InvoiceFormState) => {
@@ -527,7 +558,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
void refreshNumber(); void refreshNumber();
void refreshDrafts(); void refreshDrafts();
} catch (e) { } catch (e) {
setError(String(e)); toast.error("Could not open the draft", String(e));
} }
}; };
@@ -535,7 +566,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
try { try {
await api.deleteDraft(id); await api.deleteDraft(id);
} catch (e) { } catch (e) {
setError(String(e)); toast.error("Could not delete the draft", String(e));
} }
await refreshDrafts(); await refreshDrafts();
}; };
@@ -596,7 +627,7 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank })); outcome = await issueAndArchive(input, activePrefs, createExportDeps({ settings, bank }));
} catch (e) { } catch (e) {
issuedRef.current = false; issuedRef.current = false;
setError(String(e)); toast.error("Could not issue the invoice", String(e));
setSaving(false); setSaving(false);
return; return;
} }
@@ -681,20 +712,11 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
<InlineNotification <InlineNotification
kind="error" kind="error"
lowContrast lowContrast
title="Could not save invoice" title="Fix this before issuing"
subtitle={error} subtitle={error}
onCloseButtonClick={() => setError(null)} onCloseButtonClick={() => setError(null)}
/> />
) : null} ) : null}
{draftError ? (
<InlineNotification
kind="warning"
lowContrast
title="Draft not saved"
subtitle={draftError}
onCloseButtonClick={() => setDraftError(null)}
/>
) : null}
{!locked && noticeOpen && !dirty && otherDrafts.length > 0 ? ( {!locked && noticeOpen && !dirty && otherDrafts.length > 0 ? (
<ActionableNotification <ActionableNotification
inline inline
@@ -766,16 +788,30 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
</Column> </Column>
<Column sm={4} md={8} lg={8}> <Column sm={4} md={8} lg={8}>
<Dropdown <ComboBox
id="savedClient" id="savedClient"
disabled={locked} disabled={locked}
titleText="Saved client (optional)" titleText="Saved client (optional)"
label="Select a saved client to autofill" placeholder="Type to search saved clients"
items={clients} items={clients}
itemToString={(item) => (item ? item.name : "")} itemToString={(item: Client | null) => (item ? item.name : "")}
selectedItem={clients.find((c) => c.id === clientId) ?? null} selectedItem={clients.find((c) => c.id === clientId) ?? null}
onChange={({ selectedItem }) => selectedItem && applyClient(selectedItem)} shouldFilterItem={({ item, inputValue }: { item: Client; inputValue: string | null }) => {
const q = (inputValue ?? "").trim().toLowerCase();
return !q || item.name.toLowerCase().includes(q) || item.gstin.toLowerCase().includes(q);
}}
onChange={({ selectedItem }: { selectedItem?: Client | null }) => selectedItem && applyClient(selectedItem)}
/> />
<Button
ref={newClientButton}
kind="ghost"
size="sm"
renderIcon={Add}
disabled={locked}
onClick={() => setClientModalOpen(true)}
>
Create client
</Button>
</Column> </Column>
<Column sm={4} md={8} lg={8}> <Column sm={4} md={8} lg={8}>
<TextInput <TextInput
@@ -845,6 +881,20 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
<div> <div>
<h3 style={{ marginBottom: "0.75rem" }}>Line items</h3> <h3 style={{ marginBottom: "0.75rem" }}>Line items</h3>
{presets.length > 0 ? (
<div style={{ maxWidth: "24rem", marginBottom: "0.75rem" }}>
<Dropdown
id="addFromPreset"
disabled={locked}
titleText="Add from preset"
label="Choose a saved item"
items={presets}
itemToString={(item: ItemPreset | null) => (item ? item.description : "")}
selectedItem={null}
onChange={({ selectedItem }: { selectedItem?: ItemPreset | null }) => selectedItem && addPreset(selectedItem)}
/>
</div>
) : null}
{items.map((item, index) => ( {items.map((item, index) => (
<div <div
key={index} key={index}
@@ -1134,6 +1184,17 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
</TableBody> </TableBody>
</Table> </Table>
</Modal> </Modal>
<ClientModal
open={clientModalOpen}
client={null}
launcherRef={newClientButton}
onClose={() => setClientModalOpen(false)}
onSaved={(saved) => {
setClientModalOpen(false);
applyClient(saved);
api.listClients().then(setClients).catch(() => {});
}}
/>
</> </>
); );
} }
+19 -27
View File
@@ -4,7 +4,6 @@ import {
Column, Column,
Grid, Grid,
InlineLoading, InlineLoading,
InlineNotification,
NumberInput, NumberInput,
Table, Table,
TableBody, TableBody,
@@ -19,8 +18,17 @@ import {
import { confirm } from "@tauri-apps/plugin-dialog"; import { confirm } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { InvoiceSeries } from "../lib/types"; import type { InvoiceSeries } from "../lib/types";
import { useToast } from "../components/ToastProvider";
export default function SeriesSettings({ active: visible }: { active: boolean }) { export default function SeriesSettings({
active: visible,
embedded = false,
}: {
active: boolean;
/** Rendered inside Settings: no page wrapper or title. */
embedded?: boolean;
}) {
const toast = useToast();
const [active, setActive] = useState<InvoiceSeries | null>(null); const [active, setActive] = useState<InvoiceSeries | null>(null);
const [all, setAll] = useState<InvoiceSeries[]>([]); const [all, setAll] = useState<InvoiceSeries[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -28,8 +36,6 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`); const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`);
const [padding, setPadding] = useState(3); const [padding, setPadding] = useState(3);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const seeded = useRef(false); const seeded = useRef(false);
@@ -44,9 +50,8 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
setPadding(current.padding); setPadding(current.padding);
seeded.current = true; seeded.current = true;
} }
setError(null);
} catch (e) { } catch (e) {
setError(String(e)); toast.error("Could not load series", String(e));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -65,47 +70,34 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
); );
if (!ok) return; if (!ok) return;
setBusy(true); setBusy(true);
setError(null);
setNotice(null);
try { try {
const created = await api.startNewSeries(prefix, padding); const created = await api.startNewSeries(prefix, padding);
setNotice(`New series started. Next invoice: ${created.nextInvoiceNumber}`); toast.success("New series started", `Next invoice: ${created.nextInvoiceNumber}`);
await load(); await load();
} catch (e) { } catch (e) {
setError(String(e)); toast.error("Could not start a new series", String(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
}; };
const pageClass = embedded ? undefined : "voiced-page";
if (loading) { if (loading) {
return ( return (
<div className="voiced-page"> <div className={pageClass}>
<InlineLoading description="Loading series" /> <InlineLoading description="Loading series" />
</div> </div>
); );
} }
return ( return (
<div className="voiced-page"> <div className={pageClass}>
<h1 className="voiced-page__title">Invoice series</h1> {embedded ? null : <h1 className="voiced-page__title">Invoice series</h1>}
<p className="voiced-page__subtitle"> <p className={embedded ? "voiced-muted" : "voiced-page__subtitle"}>
The active series drives the automatic invoice numbers. Start a new series to reset the counter. The active series drives the automatic invoice numbers. Start a new series to reset the counter.
</p> </p>
{error ? (
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
) : null}
{notice ? (
<InlineNotification
kind="success"
lowContrast
title="Done"
subtitle={notice}
onCloseButtonClick={() => setNotice(null)}
/>
) : null}
<Grid narrow style={{ marginBottom: "2rem" }}> <Grid narrow style={{ marginBottom: "2rem" }}>
<Column sm={4} md={4} lg={6}> <Column sm={4} md={4} lg={6}>
<TextInput <TextInput