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:
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 & payments</Tab>
|
||||||
|
<Tab>Invoice defaults</Tab>
|
||||||
|
<Tab>Numbering</Tab>
|
||||||
|
<Tab>Templates & 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"}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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(() => {});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(`AP/${new Date().getFullYear()}-`);
|
const [prefix, setPrefix] = useState(`AP/${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
|
||||||
|
|||||||
Reference in New Issue
Block a user