Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92f952b136 | ||
|
|
1623b879e2 | ||
|
|
3510cc4260 |
Generated
+20
@@ -4188,6 +4188,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-path"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -4307,6 +4313,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5182,6 +5189,19 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "8.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"flate2",
|
||||||
|
"indexmap 2.14.2",
|
||||||
|
"memchr",
|
||||||
|
"typed-path",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ thiserror = "2"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
|
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
.map_err(|e| e.to_string())?;
|
default_notes=?11, payment_terms_days=?12
|
||||||
|
WHERE id=?13",
|
||||||
|
params![
|
||||||
|
name,
|
||||||
|
address,
|
||||||
|
gstin,
|
||||||
|
state_code,
|
||||||
|
client.po_number,
|
||||||
|
line1,
|
||||||
|
line2,
|
||||||
|
city,
|
||||||
|
pincode,
|
||||||
|
client.gst_category,
|
||||||
|
client.default_notes,
|
||||||
|
client.payment_terms_days,
|
||||||
|
id
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err("Client not found".into());
|
||||||
|
}
|
||||||
id
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ use super::series::{validate_series_format, MAX_NUMBER_LEN};
|
|||||||
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
|
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
|
||||||
use crate::db::format_number;
|
use crate::db::format_number;
|
||||||
use crate::gst::{self, TaxType};
|
use crate::gst::{self, TaxType};
|
||||||
|
use super::payments::{derive_summary, summary_for, today_ist};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings,
|
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings,
|
||||||
};
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
@@ -59,6 +60,8 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
|
|||||||
archived_fingerprint: row.get(35)?,
|
archived_fingerprint: row.get(35)?,
|
||||||
archived_at: row.get(36)?,
|
archived_at: row.get(36)?,
|
||||||
items: Vec::new(),
|
items: Vec::new(),
|
||||||
|
payment: PaymentSummary::default(),
|
||||||
|
total_paise: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +93,8 @@ fn fetch_invoice(conn: &Connection, id: i64) -> rusqlite::Result<Invoice> {
|
|||||||
map_invoice,
|
map_invoice,
|
||||||
)?;
|
)?;
|
||||||
invoice.items = fetch_items(conn, id)?;
|
invoice.items = fetch_items(conn, id)?;
|
||||||
|
invoice.total_paise = gst::rupees_to_paise(invoice.total);
|
||||||
|
invoice.payment = summary_for(conn, id, &invoice.status, invoice.total, &invoice.due_date)?;
|
||||||
Ok(invoice)
|
Ok(invoice)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,15 +343,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)?;
|
||||||
@@ -459,6 +465,12 @@ pub fn issue_invoice(
|
|||||||
|
|
||||||
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
/// Issued invoices are never deleted: cancelling keeps the row and its number.
|
||||||
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
|
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
|
||||||
|
let payments: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", params![id], |r| r.get(0))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if payments > 0 {
|
||||||
|
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
|
||||||
|
}
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
let changed = conn
|
let changed = conn
|
||||||
.execute(
|
.execute(
|
||||||
@@ -490,23 +502,42 @@ pub fn cancel_invoice(state: State<AppState>, id: i64, reason: String) -> Result
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
|
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_invoices_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One query: invoices left-joined to their payment sums. Status derivation happens in Rust
|
||||||
|
/// (`derive_summary`) so list and detail agree.
|
||||||
|
pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, String> {
|
||||||
|
let today = today_ist();
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, number, invoice_date, due_date, client_name, total, status, created_at
|
"SELECT i.id, i.number, i.invoice_date, i.due_date, i.client_name, i.total, i.status,
|
||||||
FROM invoices ORDER BY id DESC",
|
i.created_at, i.po_number, COALESCE(p.paid, 0), COALESCE(p.tds, 0)
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN (SELECT invoice_id, SUM(amount_paise) AS paid, SUM(tds_paise) AS tds
|
||||||
|
FROM payments GROUP BY invoice_id) p ON p.invoice_id = i.id
|
||||||
|
ORDER BY i.id DESC",
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
|
let total: f64 = row.get(5)?;
|
||||||
|
let status: String = row.get(6)?;
|
||||||
|
let due_date: String = row.get(3)?;
|
||||||
|
let total_paise = gst::rupees_to_paise(total);
|
||||||
|
let payment = derive_summary(&status, total_paise, row.get(9)?, row.get(10)?, &due_date, &today);
|
||||||
Ok(InvoiceSummary {
|
Ok(InvoiceSummary {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
number: row.get(1)?,
|
number: row.get(1)?,
|
||||||
invoice_date: row.get(2)?,
|
invoice_date: row.get(2)?,
|
||||||
due_date: row.get(3)?,
|
due_date,
|
||||||
client_name: row.get(4)?,
|
client_name: row.get(4)?,
|
||||||
total: row.get(5)?,
|
total,
|
||||||
status: row.get(6)?,
|
status,
|
||||||
created_at: row.get(7)?,
|
created_at: row.get(7)?,
|
||||||
|
po_number: row.get(8)?,
|
||||||
|
total_paise,
|
||||||
|
payment,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
@@ -515,10 +546,91 @@ pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, Stri
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the History export needs beyond `InvoiceSummary`: the tax split and CA-facing fields, all money as
|
||||||
|
/// integer paise. Joined to the list rows by `id` on the TypeScript side.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InvoiceLedgerRow {
|
||||||
|
pub id: i64,
|
||||||
|
pub client_gstin: String,
|
||||||
|
pub place_of_supply_code: String,
|
||||||
|
pub doc_type: String,
|
||||||
|
/// Distinct non-empty HSN/SAC codes of the lines, in line order.
|
||||||
|
pub hsn_sac: Vec<String>,
|
||||||
|
/// Subtotal less discount.
|
||||||
|
pub taxable_paise: i64,
|
||||||
|
pub cgst_paise: i64,
|
||||||
|
pub sgst_paise: i64,
|
||||||
|
pub igst_paise: i64,
|
||||||
|
pub reverse_charge: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_invoice_ledger_impl(conn: &Connection) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||||
|
let mut hsn_by_invoice: std::collections::HashMap<i64, Vec<String>> = std::collections::HashMap::new();
|
||||||
|
{
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT invoice_id, TRIM(hsn_sac) FROM invoice_items ORDER BY invoice_id, sort_order, id")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let items = stmt
|
||||||
|
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
for item in items {
|
||||||
|
let (id, code) = item.map_err(|e| e.to_string())?;
|
||||||
|
let list = hsn_by_invoice.entry(id).or_default();
|
||||||
|
if !code.is_empty() && !list.contains(&code) {
|
||||||
|
list.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, client_gstin, place_of_supply_state_code, doc_type, subtotal, discount,
|
||||||
|
cgst_amount, sgst_amount, igst_amount, reverse_charge
|
||||||
|
FROM invoices ORDER BY id DESC",
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |r| {
|
||||||
|
let id: i64 = r.get(0)?;
|
||||||
|
Ok(InvoiceLedgerRow {
|
||||||
|
id,
|
||||||
|
client_gstin: r.get(1)?,
|
||||||
|
place_of_supply_code: r.get(2)?,
|
||||||
|
doc_type: r.get(3)?,
|
||||||
|
hsn_sac: Vec::new(),
|
||||||
|
taxable_paise: gst::rupees_to_paise(r.get(4)?) - gst::rupees_to_paise(r.get(5)?),
|
||||||
|
cgst_paise: gst::rupees_to_paise(r.get(6)?),
|
||||||
|
sgst_paise: gst::rupees_to_paise(r.get(7)?),
|
||||||
|
igst_paise: gst::rupees_to_paise(r.get(8)?),
|
||||||
|
reverse_charge: r.get::<_, i64>(9)? != 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut row| {
|
||||||
|
row.hsn_sac = hsn_by_invoice.remove(&row.id).unwrap_or_default();
|
||||||
|
row
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_invoice_ledger(state: State<AppState>) -> Result<Vec<InvoiceLedgerRow>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_invoice_ledger_impl(&conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
|
||||||
|
fetch_invoice(conn, id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
|
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
fetch_invoice(&conn, id).map_err(|e| e.to_string())
|
get_invoice_impl(&conn, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_draft_impl(
|
pub fn save_draft_impl(
|
||||||
@@ -661,6 +773,28 @@ mod tests {
|
|||||||
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
|
conn.query_row("SELECT COUNT(*) FROM invoices", [], |r| r.get(0)).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ledger_rows_carry_the_tax_split_in_paise_and_distinct_hsn() {
|
||||||
|
let mut conn = registered();
|
||||||
|
let first = issue(&mut conn, input(json!({"placeOfSupplyStateCode": "27"}))).unwrap();
|
||||||
|
conn.execute("UPDATE invoice_items SET hsn_sac = ' 9983 ' WHERE invoice_id = ?1", params![first.id]).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO invoice_items (invoice_id, description, amount, sort_order, hsn_sac) VALUES (?1, 'b', 1, 5, '9983'), (?1, 'c', 1, 6, '9984'), (?1, 'd', 1, 7, '')",
|
||||||
|
params![first.id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ledger = list_invoice_ledger_impl(&conn).unwrap();
|
||||||
|
assert_eq!(ledger.len(), 1);
|
||||||
|
let row = &ledger[0];
|
||||||
|
assert_eq!(row.id, first.id);
|
||||||
|
assert_eq!(row.place_of_supply_code, "27");
|
||||||
|
assert_eq!(row.doc_type, "tax_invoice");
|
||||||
|
assert_eq!(row.hsn_sac, vec!["9983".to_string(), "9984".to_string()]);
|
||||||
|
assert_eq!(row.taxable_paise, 731_000);
|
||||||
|
assert_eq!((row.cgst_paise, row.sgst_paise, row.igst_paise), (65_790, 65_790, 0));
|
||||||
|
assert!(!row.reverse_charge);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn issues_numbered_tax_invoices_with_derived_totals() {
|
fn issues_numbered_tax_invoices_with_derived_totals() {
|
||||||
let mut conn = registered();
|
let mut conn = registered();
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
pub mod archive;
|
pub mod archive;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
|
pub mod backup;
|
||||||
pub mod clients;
|
pub mod clients;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod fonts;
|
pub mod fonts;
|
||||||
pub mod invoice;
|
pub mod invoice;
|
||||||
pub mod logo;
|
pub mod logo;
|
||||||
|
pub mod payments;
|
||||||
|
pub mod presets;
|
||||||
pub mod raw;
|
pub mod raw;
|
||||||
pub mod series;
|
pub mod series;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
//! Payments received against issued invoices.
|
||||||
|
//!
|
||||||
|
//! Money is integer paise throughout. Payment status is never stored: it is derived from the
|
||||||
|
//! payment rows, the invoice total and the due date, so deleting a payment reverts it for free.
|
||||||
|
//! The issued invoice row is not touched by any of this.
|
||||||
|
|
||||||
|
use crate::gst::rupees_to_paise;
|
||||||
|
use crate::models::PaymentSummary;
|
||||||
|
use crate::AppState;
|
||||||
|
use chrono::{FixedOffset, NaiveDate, Utc};
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
const MODES: &[&str] = &["bank_transfer", "upi", "cash", "cheque", "other"];
|
||||||
|
/// Upper bound on one payment (paise); keeps sums far from i64 overflow.
|
||||||
|
const MAX_PAISE: i64 = 100_000_000_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Payment {
|
||||||
|
pub id: i64,
|
||||||
|
pub invoice_id: i64,
|
||||||
|
pub paid_on: String,
|
||||||
|
/// Cash received.
|
||||||
|
pub amount_paise: i64,
|
||||||
|
/// Tax deducted at source by the client; settles the invoice like cash does.
|
||||||
|
pub tds_paise: i64,
|
||||||
|
pub mode: String,
|
||||||
|
pub reference: String,
|
||||||
|
pub notes: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PaymentInput {
|
||||||
|
pub invoice_id: i64,
|
||||||
|
pub paid_on: String,
|
||||||
|
pub amount_paise: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tds_paise: i64,
|
||||||
|
pub mode: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reference: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub notes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Today's date in India (IST, UTC+05:30), as YYYY-MM-DD. Due dates are plain calendar dates.
|
||||||
|
pub fn today_ist() -> String {
|
||||||
|
let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset");
|
||||||
|
Utc::now().with_timezone(&ist).date_naive().format("%Y-%m-%d").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the payment summary. Only an issued invoice takes payments; anything else is `none`
|
||||||
|
/// with a zero balance (a cancelled invoice is owed nothing). `settled` is cash plus TDS.
|
||||||
|
/// Overdue means a due date strictly before `today` while a balance remains; it wins over
|
||||||
|
/// `partially_paid`, and the paid amounts still show the partial payment.
|
||||||
|
pub fn derive_summary(
|
||||||
|
status: &str,
|
||||||
|
total_paise: i64,
|
||||||
|
paid_paise: i64,
|
||||||
|
tds_paise: i64,
|
||||||
|
due_date: &str,
|
||||||
|
today: &str,
|
||||||
|
) -> PaymentSummary {
|
||||||
|
if status != "issued" {
|
||||||
|
return PaymentSummary {
|
||||||
|
paid_paise,
|
||||||
|
tds_paise,
|
||||||
|
balance_paise: 0,
|
||||||
|
payment_status: "none".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let settled = paid_paise + tds_paise;
|
||||||
|
let balance = (total_paise - settled).max(0);
|
||||||
|
let overdue = balance > 0
|
||||||
|
&& NaiveDate::parse_from_str(due_date.trim(), "%Y-%m-%d").is_ok()
|
||||||
|
&& due_date.trim() < today;
|
||||||
|
let payment_status = if balance == 0 && total_paise > 0 {
|
||||||
|
"paid"
|
||||||
|
} else if overdue {
|
||||||
|
"overdue"
|
||||||
|
} else if settled > 0 {
|
||||||
|
"partially_paid"
|
||||||
|
} else {
|
||||||
|
"unpaid"
|
||||||
|
};
|
||||||
|
PaymentSummary {
|
||||||
|
paid_paise,
|
||||||
|
tds_paise,
|
||||||
|
balance_paise: balance,
|
||||||
|
payment_status: payment_status.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary for one invoice read from the payments table.
|
||||||
|
pub fn summary_for(
|
||||||
|
conn: &Connection,
|
||||||
|
invoice_id: i64,
|
||||||
|
status: &str,
|
||||||
|
total: f64,
|
||||||
|
due_date: &str,
|
||||||
|
) -> rusqlite::Result<PaymentSummary> {
|
||||||
|
let (paid, tds): (i64, i64) = conn.query_row(
|
||||||
|
"SELECT COALESCE(SUM(amount_paise), 0), COALESCE(SUM(tds_paise), 0)
|
||||||
|
FROM payments WHERE invoice_id = ?1",
|
||||||
|
params![invoice_id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)?;
|
||||||
|
Ok(derive_summary(status, rupees_to_paise(total), paid, tds, due_date, &today_ist()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rupees(paise: i64) -> String {
|
||||||
|
format!("₹{}.{:02}", paise / 100, paise % 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_payment(r: &rusqlite::Row) -> rusqlite::Result<Payment> {
|
||||||
|
Ok(Payment {
|
||||||
|
id: r.get(0)?,
|
||||||
|
invoice_id: r.get(1)?,
|
||||||
|
paid_on: r.get(2)?,
|
||||||
|
amount_paise: r.get(3)?,
|
||||||
|
tds_paise: r.get(4)?,
|
||||||
|
mode: r.get(5)?,
|
||||||
|
reference: r.get(6)?,
|
||||||
|
notes: r.get(7)?,
|
||||||
|
created_at: r.get(8)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLS: &str = "id, invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at";
|
||||||
|
|
||||||
|
pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result<Payment, String> {
|
||||||
|
let db = |e: rusqlite::Error| e.to_string();
|
||||||
|
|
||||||
|
NaiveDate::parse_from_str(input.paid_on.trim(), "%Y-%m-%d")
|
||||||
|
.map_err(|_| "Payment date must be a date like 2026-04-01".to_string())?;
|
||||||
|
if !MODES.contains(&input.mode.as_str()) {
|
||||||
|
return Err(format!("Unknown payment mode \"{}\"", input.mode));
|
||||||
|
}
|
||||||
|
if input.amount_paise < 0 || input.tds_paise < 0 {
|
||||||
|
return Err("Amounts cannot be negative".into());
|
||||||
|
}
|
||||||
|
if input.amount_paise > MAX_PAISE || input.tds_paise > MAX_PAISE {
|
||||||
|
return Err("That amount is too large".into());
|
||||||
|
}
|
||||||
|
if input.amount_paise + input.tds_paise == 0 {
|
||||||
|
return Err("Enter an amount received or TDS deducted".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let tx = conn.transaction().map_err(db)?;
|
||||||
|
let (status, total): (String, f64) = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT status, total FROM invoices WHERE id = ?1",
|
||||||
|
params![input.invoice_id],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(db)?
|
||||||
|
.ok_or_else(|| "Invoice not found".to_string())?;
|
||||||
|
if status != "issued" {
|
||||||
|
return Err(format!("Payments can only be recorded on an issued invoice (this one is {status})"));
|
||||||
|
}
|
||||||
|
let total_paise = rupees_to_paise(total);
|
||||||
|
let settled: i64 = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT COALESCE(SUM(amount_paise + tds_paise), 0) FROM payments WHERE invoice_id = ?1",
|
||||||
|
params![input.invoice_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.map_err(db)?;
|
||||||
|
let balance = total_paise - settled;
|
||||||
|
let incoming = input.amount_paise + input.tds_paise;
|
||||||
|
if incoming > balance {
|
||||||
|
return Err(format!(
|
||||||
|
"This payment of {} (cash plus TDS) is more than the outstanding balance of {}",
|
||||||
|
rupees(incoming),
|
||||||
|
rupees(balance.max(0))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
params![
|
||||||
|
input.invoice_id,
|
||||||
|
input.paid_on.trim(),
|
||||||
|
input.amount_paise,
|
||||||
|
input.tds_paise,
|
||||||
|
input.mode,
|
||||||
|
input.reference.trim(),
|
||||||
|
input.notes.trim(),
|
||||||
|
now
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(db)?;
|
||||||
|
let id = tx.last_insert_rowid();
|
||||||
|
let payment = tx
|
||||||
|
.query_row(&format!("SELECT {COLS} FROM payments WHERE id = ?1"), params![id], map_payment)
|
||||||
|
.map_err(db)?;
|
||||||
|
tx.commit().map_err(db)?;
|
||||||
|
Ok(payment)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_payments_impl(conn: &Connection, invoice_id: i64) -> Result<Vec<Payment>, String> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(&format!(
|
||||||
|
"SELECT {COLS} FROM payments WHERE invoice_id = ?1 ORDER BY paid_on ASC, id ASC"
|
||||||
|
))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![invoice_id], map_payment)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_payment_impl(conn: &Connection, id: i64) -> Result<(), String> {
|
||||||
|
let changed = conn
|
||||||
|
.execute("DELETE FROM payments WHERE id = ?1", params![id])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if changed == 0 {
|
||||||
|
return Err("Payment not found".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn record_payment(state: State<AppState>, input: PaymentInput) -> Result<Payment, String> {
|
||||||
|
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
record_payment_impl(&mut conn, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_payments(state: State<AppState>, invoice_id: i64) -> Result<Vec<Payment>, String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
list_payments_impl(&conn, invoice_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_payment(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||||
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
delete_payment_impl(&conn, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::commands::invoice::{cancel_invoice_impl, issue_invoice_impl};
|
||||||
|
use crate::models::InvoiceInput;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// An unregistered vendor issues without tax, so the invoice total is exactly the line amount.
|
||||||
|
fn issued(conn: &mut Connection, amount: f64, due: &str) -> i64 {
|
||||||
|
let input: InvoiceInput = serde_json::from_value(json!({
|
||||||
|
"invoiceDate": "2026-04-01",
|
||||||
|
"dueDate": due,
|
||||||
|
"clientName": "Client Ltd",
|
||||||
|
"taxType": "none",
|
||||||
|
"taxRate": 0.0,
|
||||||
|
"items": [{"description": "Design", "mode": "fixed", "amount": amount}],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})).unwrap().id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput {
|
||||||
|
PaymentInput {
|
||||||
|
invoice_id,
|
||||||
|
paid_on: "2026-04-10".into(),
|
||||||
|
amount_paise: amount,
|
||||||
|
tds_paise: tds,
|
||||||
|
mode: "bank_transfer".into(),
|
||||||
|
reference: "UTR1".into(),
|
||||||
|
notes: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summary(conn: &Connection, id: i64) -> PaymentSummary {
|
||||||
|
let (status, total, due): (String, f64, String) = conn
|
||||||
|
.query_row("SELECT status, total, due_date FROM invoices WHERE id = ?1", params![id], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
summary_for(conn, id, &status, total, &due).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_then_full_payment_settles() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
assert_eq!(summary(&conn, id).payment_status, "unpaid");
|
||||||
|
assert_eq!(summary(&conn, id).balance_paise, 100_000);
|
||||||
|
|
||||||
|
record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap();
|
||||||
|
let s = summary(&conn, id);
|
||||||
|
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.balance_paise), ("partially_paid", 40_000, 60_000));
|
||||||
|
|
||||||
|
record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap();
|
||||||
|
let s = summary(&conn, id);
|
||||||
|
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("paid", 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tds_counts_towards_settling() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
// 10% TDS: the client pays 900.00 in cash and deducts 100.00.
|
||||||
|
record_payment_impl(&mut conn, pay(id, 90_000, 10_000)).unwrap();
|
||||||
|
let s = summary(&conn, id);
|
||||||
|
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.tds_paise, s.balance_paise), ("paid", 90_000, 10_000, 0));
|
||||||
|
// TDS alone is a valid payment too.
|
||||||
|
let id2 = issued(&mut conn, 500.0, "2999-01-01");
|
||||||
|
record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap();
|
||||||
|
assert_eq!(summary(&conn, id2).payment_status, "partially_paid");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overpayment_is_rejected_including_tds() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err();
|
||||||
|
assert!(err.contains("more than the outstanding balance of ₹1000.00"), "{err}");
|
||||||
|
// Cash plus TDS is what counts.
|
||||||
|
assert!(record_payment_impl(&mut conn, pay(id, 95_000, 6_000)).is_err());
|
||||||
|
record_payment_impl(&mut conn, pay(id, 50_000, 0)).unwrap();
|
||||||
|
let err = record_payment_impl(&mut conn, pay(id, 50_001, 0)).unwrap_err();
|
||||||
|
assert!(err.contains("₹500.00"), "{err}");
|
||||||
|
let n: i64 = conn.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0)).unwrap();
|
||||||
|
assert_eq!(n, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_input_is_rejected() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
assert!(record_payment_impl(&mut conn, pay(id, 0, 0)).is_err());
|
||||||
|
assert!(record_payment_impl(&mut conn, pay(id, -1, 0)).is_err());
|
||||||
|
assert!(record_payment_impl(&mut conn, pay(id, 100, -5)).is_err());
|
||||||
|
let mut bad = pay(id, 100, 0);
|
||||||
|
bad.mode = "barter".into();
|
||||||
|
assert!(record_payment_impl(&mut conn, bad).is_err());
|
||||||
|
let mut bad = pay(id, 100, 0);
|
||||||
|
bad.paid_on = "10/04/2026".into();
|
||||||
|
assert!(record_payment_impl(&mut conn, bad).is_err());
|
||||||
|
assert!(record_payment_impl(&mut conn, pay(9999, 100, 0)).is_err());
|
||||||
|
// The table CHECKs hold even if a caller bypasses the command validation.
|
||||||
|
assert!(conn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, created_at)
|
||||||
|
VALUES (?1, '2026-04-10', 0, 0, 'cash', 'now')",
|
||||||
|
params![id]
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draft_and_cancelled_invoices_take_no_payments() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
cancel_invoice_impl(&mut conn, id, "mistake").unwrap();
|
||||||
|
let err = record_payment_impl(&mut conn, pay(id, 100, 0)).unwrap_err();
|
||||||
|
assert!(err.contains("issued invoice"), "{err}");
|
||||||
|
let s = summary(&conn, id);
|
||||||
|
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0));
|
||||||
|
|
||||||
|
let id2 = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![id2]).unwrap();
|
||||||
|
assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_invoice_with_payments_cannot_be_cancelled() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
let p = record_payment_impl(&mut conn, pay(id, 100, 0)).unwrap();
|
||||||
|
let err = cancel_invoice_impl(&mut conn, id, "").unwrap_err();
|
||||||
|
assert!(err.contains("payments"), "{err}");
|
||||||
|
delete_payment_impl(&conn, p.id).unwrap();
|
||||||
|
cancel_invoice_impl(&mut conn, id, "").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_payment_reverts_the_status() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let id = issued(&mut conn, 1000.0, "2999-01-01");
|
||||||
|
let full = record_payment_impl(&mut conn, pay(id, 100_000, 0)).unwrap();
|
||||||
|
assert_eq!(summary(&conn, id).payment_status, "paid");
|
||||||
|
delete_payment_impl(&conn, full.id).unwrap();
|
||||||
|
let s = summary(&conn, id);
|
||||||
|
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("unpaid", 100_000));
|
||||||
|
assert!(list_payments_impl(&conn, id).unwrap().is_empty());
|
||||||
|
assert!(delete_payment_impl(&conn, full.id).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overdue_is_derived_from_due_date_and_balance() {
|
||||||
|
let d = |due: &str, paid: i64, tds: i64| derive_summary("issued", 100_000, paid, tds, due, "2026-05-10");
|
||||||
|
// Due today is not overdue yet; the day after is.
|
||||||
|
assert_eq!(d("2026-05-10", 0, 0).payment_status, "unpaid");
|
||||||
|
assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue");
|
||||||
|
// Overdue wins over partially paid; the paid amount is still reported.
|
||||||
|
let s = d("2026-05-09", 10_000, 0);
|
||||||
|
assert_eq!((s.payment_status.as_str(), s.paid_paise), ("overdue", 10_000));
|
||||||
|
// A settled invoice is never overdue.
|
||||||
|
assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid");
|
||||||
|
// No usable due date means never overdue.
|
||||||
|
assert_eq!(d("", 0, 0).payment_status, "unpaid");
|
||||||
|
assert_eq!(d("not a date", 0, 0).payment_status, "unpaid");
|
||||||
|
// Cancelled invoices are not owed.
|
||||||
|
assert_eq!(derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn totals_are_integer_paise_without_float_drift() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
// 0.1 + 0.2 style amounts: 3 x 33.33 = 99.99.
|
||||||
|
let id = issued(&mut conn, 99.99, "2999-01-01");
|
||||||
|
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
||||||
|
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
||||||
|
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
|
||||||
|
let s = summary(&conn, id);
|
||||||
|
assert_eq!((s.paid_paise, s.balance_paise, s.payment_status.as_str()), (9999, 0, "paid"));
|
||||||
|
let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap();
|
||||||
|
assert_eq!(inv.total_paise, 9999);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_invoices_joins_the_payment_sums() {
|
||||||
|
let mut conn = crate::db::open_in_memory().unwrap();
|
||||||
|
let a = issued(&mut conn, 1000.0, "2020-01-01");
|
||||||
|
let b = issued(&mut conn, 200.0, "2999-01-01");
|
||||||
|
record_payment_impl(&mut conn, pay(a, 30_000, 5_000)).unwrap();
|
||||||
|
record_payment_impl(&mut conn, pay(a, 10_000, 0)).unwrap();
|
||||||
|
record_payment_impl(&mut conn, pay(b, 20_000, 0)).unwrap();
|
||||||
|
let rows = super::super::invoice::list_invoices_impl(&conn).unwrap();
|
||||||
|
let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap();
|
||||||
|
let ra = get(a);
|
||||||
|
assert_eq!((ra.payment.paid_paise, ra.payment.tds_paise, ra.payment.balance_paise), (40_000, 5_000, 55_000));
|
||||||
|
assert_eq!(ra.payment.payment_status, "overdue");
|
||||||
|
assert_eq!(ra.total_paise, 100_000);
|
||||||
|
assert_eq!(get(b).payment.payment_status, "paid");
|
||||||
|
// The JSON keeps the existing fields and adds the flattened summary.
|
||||||
|
let json = serde_json::to_value(ra).unwrap();
|
||||||
|
assert_eq!(json["balancePaise"], 55_000);
|
||||||
|
assert_eq!(json["paymentStatus"], "overdue");
|
||||||
|
assert_eq!(json["total"], 1000.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+134
-5
@@ -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;
|
pub(crate) const LATEST_VERSION: i64 = 9;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum DbError {
|
pub enum DbError {
|
||||||
@@ -217,8 +217,71 @@ 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
|
||||||
|
);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Version 8: payments received against issued invoices. Amounts are integer paise. `amount_paise` is the cash
|
||||||
|
/// received and `tds_paise` the tax the client deducted at source; both count towards settling the invoice.
|
||||||
|
/// Payment status is derived from these rows and the due date, never stored, and the issued invoice row is untouched.
|
||||||
|
const M8: &str = r#"
|
||||||
|
CREATE TABLE payments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
invoice_id INTEGER NOT NULL REFERENCES invoices(id),
|
||||||
|
paid_on TEXT NOT NULL,
|
||||||
|
amount_paise INTEGER NOT NULL DEFAULT 0 CHECK (amount_paise >= 0),
|
||||||
|
tds_paise INTEGER NOT NULL DEFAULT 0 CHECK (tds_paise >= 0),
|
||||||
|
mode TEXT NOT NULL CHECK (mode IN ('bank_transfer', 'upi', 'cash', 'cheque', 'other')),
|
||||||
|
reference TEXT NOT NULL DEFAULT '',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
CHECK (amount_paise + tds_paise > 0)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_payments_invoice ON payments(invoice_id);
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Version 9: `auto_backup` switches the daily automatic backup (see commands::backup). On by default. It is read and
|
||||||
|
/// written through its own commands, not through `Settings`, so a settings save can never clobber it.
|
||||||
|
const M9: &str = r#"
|
||||||
|
ALTER TABLE app_settings ADD COLUMN auto_backup INTEGER NOT NULL DEFAULT 1;
|
||||||
|
"#;
|
||||||
|
|
||||||
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),
|
||||||
|
M::up(M8),
|
||||||
|
M::up(M9),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
@@ -238,7 +301,14 @@ pub fn open(path: &Path, backup_dir: &Path) -> Result<Connection, DbError> {
|
|||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
/// Write a consistent, compacted snapshot of the open database to `target` (which must not exist yet).
|
||||||
|
/// Safe while other connections read and write: SQLite takes a read transaction for the copy.
|
||||||
|
pub(crate) fn vacuum_into(conn: &Connection, target: &Path) -> rusqlite::Result<()> {
|
||||||
|
let quoted = target.to_string_lossy().replace('\'', "''");
|
||||||
|
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_user_tables(conn: &Connection) -> rusqlite::Result<bool> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
"SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%')",
|
||||||
[],
|
[],
|
||||||
@@ -256,8 +326,7 @@ fn backup_before_migration(
|
|||||||
fs::create_dir_all(backup_dir)?;
|
fs::create_dir_all(backup_dir)?;
|
||||||
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
|
||||||
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
|
let target = backup_dir.join(format!("{BACKUP_PREFIX}{version}-{stamp}.db"));
|
||||||
let quoted = target.to_string_lossy().replace('\'', "''");
|
vacuum_into(conn, &target)?;
|
||||||
conn.execute_batch(&format!("VACUUM INTO '{quoted}'"))?;
|
|
||||||
// A failed prune must not block startup; the backup itself already succeeded.
|
// A failed prune must not block startup; the backup itself already succeeded.
|
||||||
let _ = prune_backups(backup_dir, MAX_BACKUPS);
|
let _ = prune_backups(backup_dir, MAX_BACKUPS);
|
||||||
Ok(target)
|
Ok(target)
|
||||||
@@ -657,6 +726,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), LATEST_VERSION);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
|
show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir));
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
|
// A restore staged by the Data tab is swapped in now, before anything opens the database.
|
||||||
|
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
|
||||||
|
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
|
||||||
|
commands::backup::ApplyOutcome::NothingPending => {}
|
||||||
|
commands::backup::ApplyOutcome::Applied { safety_dir } => {
|
||||||
|
eprintln!("Backup restored; the previous data is in {}", safety_dir.display());
|
||||||
|
}
|
||||||
|
commands::backup::ApplyOutcome::Failed { message } => eprintln!("Backup restore failed: {message}"),
|
||||||
|
}
|
||||||
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -71,6 +80,7 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
|
if let Err(e) = commands::logo::ensure_logo_derived_impl(&conn, &data_dir) {
|
||||||
eprintln!("Could not derive the logo images: {e}");
|
eprintln!("Could not derive the logo images: {e}");
|
||||||
}
|
}
|
||||||
|
commands::backup::spawn_auto_backup(dirs);
|
||||||
Ok(AppState {
|
Ok(AppState {
|
||||||
db: Mutex::new(conn),
|
db: Mutex::new(conn),
|
||||||
data_dir,
|
data_dir,
|
||||||
@@ -80,6 +90,9 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
// After "Restart" in the Data tab the new process waits for the old one (and its single-instance lock) to go.
|
||||||
|
commands::backup::wait_if_relaunched();
|
||||||
|
|
||||||
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
|
// WebKitGTK's DMABUF renderer shows a blank window on several Linux GPU/driver
|
||||||
// combinations. Respect an explicit user setting, otherwise turn it off.
|
// combinations. Respect an explicit user setting, otherwise turn it off.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -121,6 +134,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,
|
||||||
@@ -130,6 +146,16 @@ pub fn run() {
|
|||||||
commands::invoice::list_drafts,
|
commands::invoice::list_drafts,
|
||||||
commands::invoice::get_draft,
|
commands::invoice::get_draft,
|
||||||
commands::invoice::delete_draft,
|
commands::invoice::delete_draft,
|
||||||
|
commands::payments::record_payment,
|
||||||
|
commands::payments::list_payments,
|
||||||
|
commands::payments::delete_payment,
|
||||||
|
commands::backup::create_backup,
|
||||||
|
commands::backup::restore_backup,
|
||||||
|
commands::backup::cancel_pending_restore,
|
||||||
|
commands::backup::get_backup_status,
|
||||||
|
commands::backup::set_auto_backup,
|
||||||
|
commands::backup::restart_app,
|
||||||
|
commands::invoice::list_invoice_ledger,
|
||||||
commands::assets::import_asset,
|
commands::assets::import_asset,
|
||||||
commands::assets::save_asset_bytes,
|
commands::assets::save_asset_bytes,
|
||||||
commands::assets::read_asset_data_uri,
|
commands::assets::read_asset_data_uri,
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -221,6 +265,34 @@ pub struct Invoice {
|
|||||||
pub archived_fingerprint: Option<String>,
|
pub archived_fingerprint: Option<String>,
|
||||||
pub archived_at: Option<String>,
|
pub archived_at: Option<String>,
|
||||||
pub items: Vec<InvoiceItem>,
|
pub items: Vec<InvoiceItem>,
|
||||||
|
/// Derived from the payments table (never stored on the invoice): see commands::payments.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub payment: PaymentSummary,
|
||||||
|
/// `total` in integer paise.
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_paise: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What has been received against an invoice. All amounts are integer paise; `payment_status` is
|
||||||
|
/// unpaid | partially_paid | paid | overdue, or `none` for an invoice that takes no payments.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PaymentSummary {
|
||||||
|
pub paid_paise: i64,
|
||||||
|
pub tds_paise: i64,
|
||||||
|
pub balance_paise: i64,
|
||||||
|
pub payment_status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PaymentSummary {
|
||||||
|
fn default() -> Self {
|
||||||
|
PaymentSummary {
|
||||||
|
paid_paise: 0,
|
||||||
|
tds_paise: 0,
|
||||||
|
balance_paise: 0,
|
||||||
|
payment_status: "none".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -234,6 +306,10 @@ pub struct InvoiceSummary {
|
|||||||
pub total: f64,
|
pub total: f64,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub po_number: String,
|
||||||
|
pub total_paise: i64,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub payment: PaymentSummary,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
+40
-5
@@ -6,14 +6,28 @@ 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 InvoiceDetail from "./views/InvoiceDetail";
|
||||||
|
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);
|
||||||
const [view, setView] = useState<AppView>("new");
|
const [view, setView] = useState<AppView>("new");
|
||||||
|
const [detailId, setDetailId] = useState<number | null>(null);
|
||||||
|
// A draft made by "Duplicate" that the New invoice form should open once it is shown.
|
||||||
|
const [openDraftId, setOpenDraftId] = useState<number | null>(null);
|
||||||
|
|
||||||
const refreshSettings = useCallback(async () => {
|
const refreshSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -81,7 +95,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));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,12 +108,33 @@ export default function App() {
|
|||||||
onSettingsChange={refreshSettings}
|
onSettingsChange={refreshSettings}
|
||||||
active={view === "new"}
|
active={view === "new"}
|
||||||
onActivate={() => setView("new")}
|
onActivate={() => setView("new")}
|
||||||
|
openDraftId={openDraftId}
|
||||||
|
onDraftOpened={() => setOpenDraftId(null)}
|
||||||
/>
|
/>
|
||||||
<div hidden={view !== "invoices"}>
|
<div hidden={view !== "invoices"}>
|
||||||
<InvoiceHistory settings={settings} active={view === "invoices"} />
|
<InvoiceHistory
|
||||||
|
settings={settings}
|
||||||
|
active={view === "invoices"}
|
||||||
|
onOpen={(id) => {
|
||||||
|
setDetailId(id);
|
||||||
|
setView("invoice");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div hidden={view !== "series"}>
|
{view === "invoice" && detailId !== null ? (
|
||||||
<SeriesSettings active={view === "series"} />
|
<InvoiceDetail
|
||||||
|
key={detailId}
|
||||||
|
invoiceId={detailId}
|
||||||
|
settings={settings}
|
||||||
|
onBack={() => setView("invoices")}
|
||||||
|
onDuplicated={(draftId) => {
|
||||||
|
setOpenDraftId(draftId);
|
||||||
|
setView("new");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div hidden={view !== "clients"}>
|
||||||
|
<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,13 @@ 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";
|
/** "invoice" is the detail page of one invoice; it is reached from "invoices" and highlights that tab. */
|
||||||
|
export type AppView = "new" | "invoices" | "invoice" | "clients" | "settings";
|
||||||
|
|
||||||
const NAV: { id: AppView; label: string }[] = [
|
const NAV: { id: Exclude<AppView, "invoice">; 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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
|
|||||||
{NAV.map((item) => (
|
{NAV.map((item) => (
|
||||||
<HeaderMenuItem
|
<HeaderMenuItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
isCurrentPage={view === item.id}
|
isCurrentPage={view === item.id || (view === "invoice" && item.id === "invoices")}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onNavigate(item.id);
|
onNavigate(item.id);
|
||||||
|
|||||||
@@ -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,278 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
|
||||||
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import {
|
||||||
|
backupFileName,
|
||||||
|
ensureZipExtension,
|
||||||
|
formatBytes,
|
||||||
|
formatWhen,
|
||||||
|
type BackupStatus,
|
||||||
|
type PendingRestore,
|
||||||
|
} from "../lib/backup";
|
||||||
|
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||||
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
|
const ZIP_FILTER = [{ name: "Voiced backup", extensions: ["zip"] }];
|
||||||
|
|
||||||
|
type RestoreStep = "confirm" | "staging" | "ready";
|
||||||
|
|
||||||
|
const joinPath = (dir: string, name: string) =>
|
||||||
|
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
|
||||||
|
|
||||||
|
/** Settings > Data: manual backup, restore (staged, applied after a restart) and the automatic daily backup. */
|
||||||
|
export default function DataBackupPanel() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [status, setStatus] = useState<BackupStatus | null>(null);
|
||||||
|
const [backingUp, setBackingUp] = useState(false);
|
||||||
|
const [restorePath, setRestorePath] = useState<string | null>(null);
|
||||||
|
const [step, setStep] = useState<RestoreStep>("confirm");
|
||||||
|
const [staged, setStaged] = useState<PendingRestore | null>(null);
|
||||||
|
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
useReturnFocus(restorePath !== null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setStatus(await api.getBackupStatus());
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not load backup status", String(e));
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const backUpNow = async () => {
|
||||||
|
setBackingUp(true);
|
||||||
|
try {
|
||||||
|
let dir = "";
|
||||||
|
try {
|
||||||
|
dir = await api.getLastExportDir();
|
||||||
|
} catch {
|
||||||
|
// No remembered folder: the dialog opens wherever the system prefers.
|
||||||
|
}
|
||||||
|
const chosen = await save({ defaultPath: joinPath(dir, backupFileName()), filters: ZIP_FILTER });
|
||||||
|
if (!chosen) return;
|
||||||
|
const summary = await api.createBackup(ensureZipExtension(chosen));
|
||||||
|
toast.success("Backup saved", `${summary.fileCount} files, ${formatBytes(summary.totalBytes)}: ${summary.path}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Backup failed", String(e));
|
||||||
|
} finally {
|
||||||
|
setBackingUp(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chooseRestoreFile = async () => {
|
||||||
|
try {
|
||||||
|
const picked = await open({ multiple: false, directory: false, filters: ZIP_FILTER });
|
||||||
|
if (typeof picked !== "string") return;
|
||||||
|
setRestoreError(null);
|
||||||
|
setStaged(null);
|
||||||
|
setStep("confirm");
|
||||||
|
setRestorePath(picked);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not open the file picker", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRestore = () => {
|
||||||
|
if (step === "staging") return;
|
||||||
|
setRestorePath(null);
|
||||||
|
void refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stageRestore = async () => {
|
||||||
|
if (!restorePath) return;
|
||||||
|
setStep("staging");
|
||||||
|
setRestoreError(null);
|
||||||
|
try {
|
||||||
|
setStaged(await api.restoreBackup(restorePath));
|
||||||
|
setStep("ready");
|
||||||
|
} catch (e) {
|
||||||
|
setRestoreError(String(e));
|
||||||
|
setStep("confirm");
|
||||||
|
toast.error("This backup cannot be restored", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restart = async () => {
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
await api.restartApp();
|
||||||
|
} catch (e) {
|
||||||
|
setRestarting(false);
|
||||||
|
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the restore.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelPending = async () => {
|
||||||
|
try {
|
||||||
|
await api.cancelPendingRestore();
|
||||||
|
toast.info("Restore cancelled", "Your data was not changed.");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not cancel the restore", String(e));
|
||||||
|
}
|
||||||
|
setRestorePath(null);
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAuto = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const saved = await api.setAutoBackup(enabled);
|
||||||
|
setStatus((prev) => (prev ? { ...prev, autoBackup: saved } : prev));
|
||||||
|
toast.success(saved ? "Automatic backup on" : "Automatic backup off");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not change automatic backup", String(e));
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFolder = async (path: string) => {
|
||||||
|
try {
|
||||||
|
await api.openFile(path);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not open the folder", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pending = status?.pendingRestore ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="voiced-stack">
|
||||||
|
{pending ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind="warning"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="A restore is waiting for a restart"
|
||||||
|
subtitle={`${pending.backupName} (${pending.fileCount} files, ${formatBytes(pending.totalBytes)}) has been checked. Restart Voiced to switch to it; your current data is kept in a safety copy.`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{pending ? (
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" onClick={() => void restart()} disabled={restarting}>
|
||||||
|
{restarting ? "Restarting…" : "Restart now"}
|
||||||
|
</Button>
|
||||||
|
<Button size="md" kind="tertiary" onClick={() => void cancelPending()} disabled={restarting}>
|
||||||
|
Cancel restore
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Back up</h2>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
Saves one zip with your invoices, clients, payments, settings, logo and signature images, archived PDFs and imported
|
||||||
|
fonts. Keep a copy somewhere other than this computer.
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" onClick={() => void backUpNow()} disabled={backingUp}>
|
||||||
|
{backingUp ? "Backing up…" : "Back up now"}
|
||||||
|
</Button>
|
||||||
|
{backingUp ? <InlineLoading description="Writing the backup" /> : null}
|
||||||
|
</div>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Restore from backup</h2>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
Replaces everything in Voiced with the contents of a backup file. Your current data is moved into a safety copy first,
|
||||||
|
never deleted.
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" kind="danger--tertiary" onClick={() => void chooseRestoreFile()} disabled={restorePath !== null || !!pending}>
|
||||||
|
Restore from backup…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{status?.lastRestore ? (
|
||||||
|
<p className="voiced-muted" role="status">
|
||||||
|
{status.lastRestore.ok
|
||||||
|
? `Last restore: ${status.lastRestore.backupName || "a backup"}, ${formatWhen(status.lastRestore.at)}.`
|
||||||
|
: `The last restore attempt (${formatWhen(status.lastRestore.at)}) did not go through: ${status.lastRestore.message}`}{" "}
|
||||||
|
{status.lastRestore.safetyDir ? (
|
||||||
|
<Button kind="ghost" size="sm" onClick={() => void api.revealInFolder(status.lastRestore!.safetyDir!).catch((e) => toast.error("Could not open the folder", String(e)))}>
|
||||||
|
Show safety copy
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Tile>
|
||||||
|
<h2 className="voiced-section-title">Automatic backup</h2>
|
||||||
|
<Toggle
|
||||||
|
id="autoBackup"
|
||||||
|
labelText="Back up automatically every day"
|
||||||
|
labelA="Off"
|
||||||
|
labelB="On"
|
||||||
|
toggled={status?.autoBackup ?? true}
|
||||||
|
disabled={!status}
|
||||||
|
onToggle={(checked: boolean) => void toggleAuto(checked)}
|
||||||
|
/>
|
||||||
|
<p className="voiced-muted">
|
||||||
|
While Voiced is open it writes one backup a day into its data folder and keeps the newest 14. This changes right away; you
|
||||||
|
do not need to click Save settings.
|
||||||
|
</p>
|
||||||
|
<p role="status">
|
||||||
|
{status === null
|
||||||
|
? "Loading…"
|
||||||
|
: status.lastAutoBackup
|
||||||
|
? `Last automatic backup: ${formatWhen(status.lastAutoBackup)} (${status.autoBackupCount} kept).`
|
||||||
|
: "No automatic backup yet."}
|
||||||
|
</p>
|
||||||
|
<div className="voiced-actions">
|
||||||
|
<Button size="md" kind="tertiary" disabled={!status} onClick={() => status && void openFolder(status.autoDir)}>
|
||||||
|
Open backup folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tile>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={restorePath !== null}
|
||||||
|
danger={step === "confirm"}
|
||||||
|
modalHeading={step === "ready" ? "Restart to finish restoring" : "Restore from this backup?"}
|
||||||
|
primaryButtonText={
|
||||||
|
step === "confirm" ? "Check and restore" : step === "staging" ? "Checking the backup…" : restarting ? "Restarting…" : "Restart now"
|
||||||
|
}
|
||||||
|
secondaryButtonText={step === "ready" ? "Later" : "Cancel"}
|
||||||
|
primaryButtonDisabled={step === "staging" || restarting}
|
||||||
|
onRequestClose={closeRestore}
|
||||||
|
onRequestSubmit={() => void (step === "confirm" ? stageRestore() : step === "ready" ? restart() : undefined)}
|
||||||
|
onSecondarySubmit={closeRestore}
|
||||||
|
>
|
||||||
|
<div className="voiced-stack">
|
||||||
|
{restoreError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Cannot restore" subtitle={restoreError} /> : null}
|
||||||
|
{step === "ready" && staged ? (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
<strong>{staged.backupName}</strong> passed every check: {staged.fileCount} files, {formatBytes(staged.totalBytes)}, made{" "}
|
||||||
|
{formatWhen(staged.backupCreatedAt)}.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Voiced must restart to switch to it. Until then nothing has changed. Choose Later to keep working and restart from this tab
|
||||||
|
when you are ready (anything you add before restarting will not be in the restored data, but stays in the safety copy).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
File: <strong>{restorePath}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Everything now in Voiced (invoices, clients, payments, settings, images, archived PDFs and fonts) will be replaced by the
|
||||||
|
contents of this backup.
|
||||||
|
</p>
|
||||||
|
<ul className="voiced-list">
|
||||||
|
<li>The file is checked first; a damaged or newer-version backup is refused and nothing changes.</li>
|
||||||
|
<li>Your current data is moved to a safety copy in Voiced’s backups folder before the swap. It is never deleted.</li>
|
||||||
|
<li>Voiced restarts to complete the restore.</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,127 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { InlineNotification, Modal, Select, SelectItem, TextInput } from "@carbon/react";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { todayIst } from "../lib/fiscal";
|
||||||
|
import { formatPaise, paiseToInput, parseRupeesToPaise } from "../lib/money";
|
||||||
|
import { PAYMENT_MODES, type Payment, type PaymentMode } from "../lib/payments";
|
||||||
|
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
invoiceId: number;
|
||||||
|
invoiceNumber: string;
|
||||||
|
/** What is still owed, in paise. */
|
||||||
|
balancePaise: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onRecorded: (payment: Payment) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Record payment": cash received plus TDS deducted, validated against the balance before it is sent. */
|
||||||
|
export default function RecordPaymentModal({ open, invoiceId, invoiceNumber, balancePaise, onClose, onRecorded }: Props) {
|
||||||
|
const [paidOn, setPaidOn] = useState(todayIst());
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [tds, setTds] = useState("");
|
||||||
|
const [mode, setMode] = useState<PaymentMode>("bank_transfer");
|
||||||
|
const [reference, setReference] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
useReturnFocus(open);
|
||||||
|
|
||||||
|
// Each opening starts from the balance, so the common case (paid in full) is one click.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setPaidOn(todayIst());
|
||||||
|
setAmount(paiseToInput(balancePaise));
|
||||||
|
setTds("");
|
||||||
|
setMode("bank_transfer");
|
||||||
|
setReference("");
|
||||||
|
setNotes("");
|
||||||
|
setError(null);
|
||||||
|
setBusy(false);
|
||||||
|
}, [open, balancePaise]);
|
||||||
|
|
||||||
|
const amountPaise = parseRupeesToPaise(amount);
|
||||||
|
const tdsPaise = parseRupeesToPaise(tds);
|
||||||
|
const amountInvalid = amountPaise === null;
|
||||||
|
const tdsInvalid = tdsPaise === null;
|
||||||
|
const total = (amountPaise ?? 0) + (tdsPaise ?? 0);
|
||||||
|
const overBalance = !amountInvalid && !tdsInvalid && total > balancePaise;
|
||||||
|
const empty = !amountInvalid && !tdsInvalid && total === 0;
|
||||||
|
const dateInvalid = !/^\d{4}-\d{2}-\d{2}$/.test(paidOn);
|
||||||
|
const canSubmit = !busy && !amountInvalid && !tdsInvalid && !overBalance && !empty && !dateInvalid;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!canSubmit || amountPaise === null || tdsPaise === null) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payment = await api.recordPayment({ invoiceId, paidOn, amountPaise, tdsPaise, mode, reference, notes });
|
||||||
|
onRecorded(payment);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
modalHeading={`Record payment for ${invoiceNumber}`}
|
||||||
|
primaryButtonText={busy ? "Saving…" : "Record payment"}
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
primaryButtonDisabled={!canSubmit}
|
||||||
|
onRequestClose={onClose}
|
||||||
|
onRequestSubmit={() => void submit()}
|
||||||
|
>
|
||||||
|
<div className="voiced-stack">
|
||||||
|
<p>
|
||||||
|
Balance due: <strong>{formatPaise(balancePaise)}</strong>
|
||||||
|
</p>
|
||||||
|
{error ? <InlineNotification kind="error" lowContrast hideCloseButton title="Could not record the payment" subtitle={error} /> : null}
|
||||||
|
<TextInput
|
||||||
|
id="payDate"
|
||||||
|
type="date"
|
||||||
|
labelText="Date received"
|
||||||
|
value={paidOn}
|
||||||
|
invalid={dateInvalid}
|
||||||
|
invalidText="Enter a date"
|
||||||
|
onChange={(e) => setPaidOn(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
id="payAmount"
|
||||||
|
inputMode="decimal"
|
||||||
|
labelText="Amount received (₹)"
|
||||||
|
helperText="Cash that reached your account"
|
||||||
|
value={amount}
|
||||||
|
invalid={amountInvalid || overBalance || empty}
|
||||||
|
invalidText={
|
||||||
|
amountInvalid
|
||||||
|
? "Enter an amount like 1250.50"
|
||||||
|
: overBalance
|
||||||
|
? `Amount plus TDS is more than the balance of ${formatPaise(balancePaise)}`
|
||||||
|
: "Enter an amount received or TDS deducted"
|
||||||
|
}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
id="payTds"
|
||||||
|
inputMode="decimal"
|
||||||
|
labelText="TDS deducted by client (₹)"
|
||||||
|
helperText="Counts towards settling the invoice; leave empty if none"
|
||||||
|
value={tds}
|
||||||
|
invalid={tdsInvalid}
|
||||||
|
invalidText="Enter an amount like 125.00"
|
||||||
|
onChange={(e) => setTds(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select id="payMode" labelText="Mode" value={mode} onChange={(e) => setMode(e.target.value as PaymentMode)}>
|
||||||
|
{PAYMENT_MODES.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id} text={m.label} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<TextInput id="payRef" labelText="UTR / cheque number (optional)" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||||
|
<TextInput id="payNotes" labelText="Notes (optional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives focus back to whatever had it when a modal opened, once the modal closes. Carbon's Modal only
|
||||||
|
* does this with a launcher ref, which does not suit modals opened from table rows or menus.
|
||||||
|
*/
|
||||||
|
export function useReturnFocus(open: boolean): void {
|
||||||
|
const origin = useRef<HTMLElement | null>(null);
|
||||||
|
const wasOpen = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && !wasOpen.current) {
|
||||||
|
origin.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
} else if (!open && wasOpen.current) {
|
||||||
|
const el = origin.current;
|
||||||
|
origin.current = null;
|
||||||
|
// After the modal has unmounted its focus trap.
|
||||||
|
if (el && el.isConnected) setTimeout(() => el.focus(), 0);
|
||||||
|
}
|
||||||
|
wasOpen.current = open;
|
||||||
|
}, [open]);
|
||||||
|
}
|
||||||
@@ -7,9 +7,13 @@ 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";
|
||||||
|
import type { Payment, PaymentInput } from "./payments";
|
||||||
|
import type { BackupStatus, BackupSummary, PendingRestore } from "./backup";
|
||||||
|
import type { LedgerRow } from "./historyExport";
|
||||||
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
|
||||||
|
|
||||||
export interface ArchiveStatus {
|
export interface ArchiveStatus {
|
||||||
@@ -36,14 +40,23 @@ 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 }),
|
||||||
cancelInvoice: (id: number, reason: string) =>
|
cancelInvoice: (id: number, reason: string) =>
|
||||||
invoke<Invoice>("cancel_invoice", { id, reason }),
|
invoke<Invoice>("cancel_invoice", { id, reason }),
|
||||||
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
|
||||||
|
listInvoiceLedger: () => invoke<LedgerRow[]>("list_invoice_ledger"),
|
||||||
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
|
||||||
|
|
||||||
|
recordPayment: (input: PaymentInput) => invoke<Payment>("record_payment", { input }),
|
||||||
|
listPayments: (invoiceId: number) => invoke<Payment[]>("list_payments", { invoiceId }),
|
||||||
|
deletePayment: (id: number) => invoke<void>("delete_payment", { id }),
|
||||||
|
|
||||||
saveDraft: (id: number | null, payload: unknown) =>
|
saveDraft: (id: number | null, payload: unknown) =>
|
||||||
invoke<number>("save_draft", { id, payload }),
|
invoke<number>("save_draft", { id, payload }),
|
||||||
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
|
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
|
||||||
@@ -92,4 +105,12 @@ export const api = {
|
|||||||
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
getLastExportDir: () => invoke<string>("get_last_export_dir"),
|
||||||
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
revealInFolder: (path: string) => invoke<void>("reveal_in_folder", { path }),
|
||||||
openFile: (path: string) => invoke<void>("open_file", { path }),
|
openFile: (path: string) => invoke<void>("open_file", { path }),
|
||||||
|
|
||||||
|
// Backup and restore. A restore is staged now and applied by the next start (see commands/backup.rs).
|
||||||
|
createBackup: (dest: string) => invoke<BackupSummary>("create_backup", { dest }),
|
||||||
|
restoreBackup: (path: string) => invoke<PendingRestore>("restore_backup", { path }),
|
||||||
|
cancelPendingRestore: () => invoke<void>("cancel_pending_restore"),
|
||||||
|
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
|
||||||
|
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
|
||||||
|
restartApp: () => invoke<void>("restart_app"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { backupFileName, ensureZipExtension, formatBytes, formatWhen } from "./backup";
|
||||||
|
|
||||||
|
describe("backup helpers", () => {
|
||||||
|
it("names backups by local date and time", () => {
|
||||||
|
expect(backupFileName(new Date(2026, 2, 4, 9, 5))).toBe("voiced-backup-20260304-0905.zip");
|
||||||
|
});
|
||||||
|
it("adds the zip extension only when missing", () => {
|
||||||
|
expect(ensureZipExtension("/a/b")).toBe("/a/b.zip");
|
||||||
|
expect(ensureZipExtension("/a/b.ZIP")).toBe("/a/b.ZIP");
|
||||||
|
expect(ensureZipExtension("/a/b.zip")).toBe("/a/b.zip");
|
||||||
|
});
|
||||||
|
it("formats sizes", () => {
|
||||||
|
expect(formatBytes(512)).toBe("512 B");
|
||||||
|
expect(formatBytes(1536)).toBe("1.5 KB");
|
||||||
|
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
|
||||||
|
expect(formatBytes(300 * 1024 * 1024)).toBe("300 MB");
|
||||||
|
});
|
||||||
|
it("falls back to the raw text for an unparseable time", () => {
|
||||||
|
expect(formatWhen("not a date")).toBe("not a date");
|
||||||
|
expect(formatWhen("2026-03-04T10:00:00Z")).toMatch(/2026/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/** Shapes returned by the backup commands (src-tauri/src/commands/backup.rs). */
|
||||||
|
export interface BackupSummary {
|
||||||
|
path: string;
|
||||||
|
createdAt: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
fileCount: number;
|
||||||
|
totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingRestore {
|
||||||
|
backupName: string;
|
||||||
|
backupCreatedAt: string;
|
||||||
|
appVersion: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
fileCount: number;
|
||||||
|
totalBytes: number;
|
||||||
|
stagedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LastRestore {
|
||||||
|
ok: boolean;
|
||||||
|
at: string;
|
||||||
|
backupName: string;
|
||||||
|
message: string;
|
||||||
|
safetyDir: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupStatus {
|
||||||
|
autoBackup: boolean;
|
||||||
|
autoDir: string;
|
||||||
|
lastAutoBackup: string | null;
|
||||||
|
autoBackupCount: number;
|
||||||
|
pendingRestore: PendingRestore | null;
|
||||||
|
lastRestore: LastRestore | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
/** `voiced-backup-20260304-1530.zip` in local time. */
|
||||||
|
export function backupFileName(now: Date = new Date()): string {
|
||||||
|
const day = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
||||||
|
return `voiced-backup-${day}-${pad(now.getHours())}${pad(now.getMinutes())}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The save dialog does not always add the extension (notably on Linux). */
|
||||||
|
export function ensureZipExtension(path: string): string {
|
||||||
|
return /\.zip$/i.test(path) ? path : `${path}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1536 -> "1.5 KB". Sizes of backup contents are display-only, so plain division is fine. */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let value = bytes / 1024;
|
||||||
|
let i = 0;
|
||||||
|
while (value >= 1024 && i < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "4 Mar 2026, 3:30 pm" for an ISO timestamp; the raw text if it does not parse. */
|
||||||
|
export function formatWhen(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
|
return d.toLocaleString("en-GB", { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
@@ -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,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { invoiceToFormState } from "./duplicate";
|
||||||
|
import { fromDraftPayload, toDraftPayload } from "./draft";
|
||||||
|
import type { Invoice } from "./types";
|
||||||
|
|
||||||
|
const invoice = {
|
||||||
|
id: 7,
|
||||||
|
number: "INV/2025-007",
|
||||||
|
invoiceDate: "2025-04-01",
|
||||||
|
dueDate: "2025-05-01",
|
||||||
|
clientId: 3,
|
||||||
|
clientName: "Acme",
|
||||||
|
clientAddress: "1 Road",
|
||||||
|
clientGstin: "",
|
||||||
|
poNumber: "PO-1",
|
||||||
|
placeOfSupplyStateCode: "29",
|
||||||
|
discount: 50,
|
||||||
|
taxRate: 18,
|
||||||
|
reverseCharge: false,
|
||||||
|
bankAccountId: 2,
|
||||||
|
signaturePath: "assets/sig.png",
|
||||||
|
notes: "Thanks",
|
||||||
|
items: [
|
||||||
|
{ id: 11, description: "Design", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 1000, sortOrder: 4, hsnSac: "998" },
|
||||||
|
],
|
||||||
|
} as unknown as Invoice;
|
||||||
|
|
||||||
|
describe("invoiceToFormState", () => {
|
||||||
|
it("copies the content with a fresh date, due date and no item ids", () => {
|
||||||
|
const s = invoiceToFormState(invoice, { paymentTermsDays: 15 }, "2026-02-20");
|
||||||
|
expect(s.invoiceDate).toBe("2026-02-20");
|
||||||
|
expect(s.dueDate).toBe("2026-03-07");
|
||||||
|
expect(s.clientName).toBe("Acme");
|
||||||
|
expect(s.placeOfSupply).toBe("29");
|
||||||
|
expect(s.bankId).toBe(2);
|
||||||
|
expect(s.saveClient).toBe(false);
|
||||||
|
expect(s.items).toHaveLength(1);
|
||||||
|
expect(s.items[0]).not.toHaveProperty("id");
|
||||||
|
expect(s.items[0].sortOrder).toBe(0);
|
||||||
|
expect(s.items[0].hsnSac).toBe("998");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a round trip through the draft payload", () => {
|
||||||
|
const s = invoiceToFormState(invoice, { paymentTermsDays: 30 }, "2026-02-20");
|
||||||
|
expect(fromDraftPayload(toDraftPayload(s), s)).toEqual(s);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { addDays, todayIso } from "./format";
|
||||||
|
import type { InvoiceFormState } from "./draft";
|
||||||
|
import type { Invoice, Settings } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The New invoice form state for "Duplicate": every content field of the issued invoice, with today's date,
|
||||||
|
* a fresh due date, and no link to the original (no id, no number). The result goes through the normal draft
|
||||||
|
* mechanism (`toDraftPayload` + `saveDraft`), so the original stays immutable.
|
||||||
|
*/
|
||||||
|
export function invoiceToFormState(invoice: Invoice, settings: Pick<Settings, "paymentTermsDays">, today = todayIso()): InvoiceFormState {
|
||||||
|
return {
|
||||||
|
invoiceDate: today,
|
||||||
|
dueDate: addDays(today, settings.paymentTermsDays),
|
||||||
|
clientId: invoice.clientId,
|
||||||
|
clientName: invoice.clientName,
|
||||||
|
clientAddress: invoice.clientAddress,
|
||||||
|
clientGstin: invoice.clientGstin,
|
||||||
|
poNumber: invoice.poNumber,
|
||||||
|
placeOfSupply: invoice.placeOfSupplyStateCode,
|
||||||
|
saveClient: false,
|
||||||
|
items: invoice.items.map((item, index) => {
|
||||||
|
const { id: _id, ...rest } = item;
|
||||||
|
return { ...rest, sortOrder: index };
|
||||||
|
}),
|
||||||
|
discount: invoice.discount,
|
||||||
|
taxRate: invoice.taxRate,
|
||||||
|
reverseCharge: invoice.reverseCharge,
|
||||||
|
bankId: invoice.bankAccountId,
|
||||||
|
signaturePath: invoice.signaturePath,
|
||||||
|
notes: invoice.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { currentFyStartYear, fyLabel, fyRange, fyStartYear, monthOf, todayIst, FY_MONTHS } from "./fiscal";
|
||||||
|
|
||||||
|
describe("financial year", () => {
|
||||||
|
it("starts on 1 April", () => {
|
||||||
|
expect(fyStartYear("2026-03-31")).toBe(2025);
|
||||||
|
expect(fyStartYear("2026-04-01")).toBe(2026);
|
||||||
|
expect(fyStartYear("2026-12-31")).toBe(2026);
|
||||||
|
expect(fyStartYear("2027-01-01")).toBe(2026);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects things that are not ISO dates", () => {
|
||||||
|
expect(fyStartYear("")).toBeNull();
|
||||||
|
expect(fyStartYear("01/04/2026")).toBeNull();
|
||||||
|
expect(fyStartYear("2026-13-01")).toBeNull();
|
||||||
|
expect(monthOf("2026-00-10")).toBeNull();
|
||||||
|
expect(monthOf("2026-07-10")).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels and ranges", () => {
|
||||||
|
expect(fyLabel(2025)).toBe("FY 2025-26");
|
||||||
|
expect(fyLabel(2099)).toBe("FY 2099-00");
|
||||||
|
expect(fyRange(2025)).toEqual({ from: "2025-04-01", to: "2026-03-31" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists months April to March", () => {
|
||||||
|
expect(FY_MONTHS.map((m) => m.month)).toEqual([4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2, 3]);
|
||||||
|
expect(FY_MONTHS[0].label).toBe("April");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses IST for now, not the machine time zone", () => {
|
||||||
|
// 20:00 UTC on 31 March is already 01:30 on 1 April in India: a new financial year.
|
||||||
|
const now = new Date("2026-03-31T20:00:00Z");
|
||||||
|
expect(todayIst(now)).toBe("2026-04-01");
|
||||||
|
expect(currentFyStartYear(now)).toBe(2026);
|
||||||
|
// 18:29 UTC is still 23:59 on 31 March in India.
|
||||||
|
expect(currentFyStartYear(new Date("2026-03-31T18:29:00Z"))).toBe(2025);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/** Indian financial year (April to March) helpers. Invoice dates are plain YYYY-MM-DD calendar dates, so they
|
||||||
|
* are read from the string; only "now" needs a time zone, and that is always IST (UTC+05:30). */
|
||||||
|
|
||||||
|
const ISO = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||||
|
const IST_OFFSET_MS = (5 * 60 + 30) * 60_000;
|
||||||
|
|
||||||
|
/** Months in financial-year order, for the month filter. */
|
||||||
|
export const FY_MONTHS: { month: number; label: string }[] = [4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2, 3].map((month) => ({
|
||||||
|
month,
|
||||||
|
label: new Date(2000, month - 1, 1).toLocaleDateString("en-GB", { month: "long" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Month 1-12 of an ISO date, or null when it is not one. */
|
||||||
|
export function monthOf(iso: string): number | null {
|
||||||
|
const m = ISO.exec(iso);
|
||||||
|
if (!m) return null;
|
||||||
|
const month = Number(m[2]);
|
||||||
|
return month >= 1 && month <= 12 ? month : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The calendar year in which the financial year containing `iso` starts (2026-03-31 -> 2025, 2026-04-01 -> 2026). */
|
||||||
|
export function fyStartYear(iso: string): number | null {
|
||||||
|
const m = ISO.exec(iso);
|
||||||
|
const month = monthOf(iso);
|
||||||
|
if (!m || month === null) return null;
|
||||||
|
const year = Number(m[1]);
|
||||||
|
return month >= 4 ? year : year - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2025 -> "FY 2025-26". */
|
||||||
|
export function fyLabel(startYear: number): string {
|
||||||
|
return `FY ${startYear}-${String((startYear + 1) % 100).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First and last day of a financial year. */
|
||||||
|
export function fyRange(startYear: number): { from: string; to: string } {
|
||||||
|
return { from: `${startYear}-04-01`, to: `${startYear + 1}-03-31` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's date in IST as YYYY-MM-DD. */
|
||||||
|
export function todayIst(now: Date = new Date()): string {
|
||||||
|
return new Date(now.getTime() + IST_OFFSET_MS).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The financial year that `now` falls in, in IST. */
|
||||||
|
export function currentFyStartYear(now: Date = new Date()): number {
|
||||||
|
return fyStartYear(todayIst(now)) as number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildHistoryRows,
|
||||||
|
csvField,
|
||||||
|
exportHistory,
|
||||||
|
guardFormula,
|
||||||
|
HISTORY_COLUMNS,
|
||||||
|
historyExportName,
|
||||||
|
paiseToDecimal,
|
||||||
|
toCsv,
|
||||||
|
toJson,
|
||||||
|
type HistoryExportDeps,
|
||||||
|
type LedgerRow,
|
||||||
|
} from "./historyExport";
|
||||||
|
import { NO_FILTERS } from "./historyFilter";
|
||||||
|
import type { InvoiceSummary } from "./types";
|
||||||
|
|
||||||
|
const summary = (over: Partial<InvoiceSummary> = {}): InvoiceSummary => ({
|
||||||
|
id: 1,
|
||||||
|
number: "INV/2025-001",
|
||||||
|
invoiceDate: "2025-06-10",
|
||||||
|
dueDate: "2025-07-10",
|
||||||
|
clientName: "Acme, Inc.",
|
||||||
|
total: 0,
|
||||||
|
status: "issued",
|
||||||
|
createdAt: "",
|
||||||
|
poNumber: "PO-1",
|
||||||
|
totalPaise: 1_180_050,
|
||||||
|
paidPaise: 1_000_000,
|
||||||
|
tdsPaise: 50_000,
|
||||||
|
balancePaise: 130_050,
|
||||||
|
paymentStatus: "partially_paid",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ledger = (over: Partial<LedgerRow> = {}): LedgerRow => ({
|
||||||
|
id: 1,
|
||||||
|
clientGstin: "29ABCDE1234F1Z5",
|
||||||
|
placeOfSupplyCode: "29",
|
||||||
|
docType: "tax_invoice",
|
||||||
|
hsnSac: ["9983", "9984", "9983"],
|
||||||
|
taxablePaise: 1_000_000,
|
||||||
|
cgstPaise: 90_025,
|
||||||
|
sgstPaise: 90_025,
|
||||||
|
igstPaise: 0,
|
||||||
|
reverseCharge: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnIndex = (key: string) => HISTORY_COLUMNS.findIndex((c) => c.key === key);
|
||||||
|
|
||||||
|
describe("paiseToDecimal", () => {
|
||||||
|
it("formats from integer paise without float math", () => {
|
||||||
|
expect(paiseToDecimal(0)).toBe("0.00");
|
||||||
|
expect(paiseToDecimal(5)).toBe("0.05");
|
||||||
|
expect(paiseToDecimal(100)).toBe("1.00");
|
||||||
|
expect(paiseToDecimal(123_450)).toBe("1234.50");
|
||||||
|
expect(paiseToDecimal(-5)).toBe("-0.05");
|
||||||
|
expect(paiseToDecimal(-123_456)).toBe("-1234.56");
|
||||||
|
// 1.005 * 100 style float traps cannot happen: 100_100_000_000_005 paise.
|
||||||
|
expect(paiseToDecimal(100_100_000_000_005)).toBe("1001000000000.05");
|
||||||
|
});
|
||||||
|
it("rejects non-integers", () => {
|
||||||
|
expect(() => paiseToDecimal(1.5)).toThrow();
|
||||||
|
expect(() => paiseToDecimal(Number.NaN)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rows", () => {
|
||||||
|
it("builds the CA-friendly columns", () => {
|
||||||
|
const [row] = buildHistoryRows([summary()], [ledger()]);
|
||||||
|
const get = (key: string) => row[columnIndex(key)];
|
||||||
|
expect(HISTORY_COLUMNS.map((c) => c.header)).toEqual([
|
||||||
|
"Invoice No", "Date", "Due Date", "Client", "Client GSTIN", "Place of Supply Code", "Place of Supply",
|
||||||
|
"Document Type", "HSN/SAC", "Taxable Value", "CGST", "SGST", "IGST", "Total Tax", "Invoice Total",
|
||||||
|
"Received", "TDS", "Balance", "Status", "Payment Status", "PO Number", "Cancelled",
|
||||||
|
]);
|
||||||
|
expect(get("invoiceNo")).toBe("INV/2025-001");
|
||||||
|
expect(get("placeOfSupplyCode")).toBe("29");
|
||||||
|
expect(get("placeOfSupply")).toBe("Karnataka");
|
||||||
|
expect(get("documentType")).toBe("Tax Invoice");
|
||||||
|
expect(get("hsnSac")).toBe("9983; 9984");
|
||||||
|
expect(get("taxableValue")).toBe("10000.00");
|
||||||
|
expect(get("cgst")).toBe("900.25");
|
||||||
|
expect(get("igst")).toBe("0.00");
|
||||||
|
expect(get("totalTax")).toBe("1800.50");
|
||||||
|
expect(get("invoiceTotal")).toBe("11800.50");
|
||||||
|
expect(get("received")).toBe("10000.00");
|
||||||
|
expect(get("tds")).toBe("500.00");
|
||||||
|
expect(get("balance")).toBe("1300.50");
|
||||||
|
expect(get("status")).toBe("Issued");
|
||||||
|
expect(get("paymentStatus")).toBe("Partly paid");
|
||||||
|
expect(get("cancelled")).toBe("No");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags cancelled invoices and leaves ledger fields blank when the row is missing", () => {
|
||||||
|
const [row] = buildHistoryRows([summary({ status: "cancelled", paymentStatus: "none" })], []);
|
||||||
|
expect(row[columnIndex("cancelled")]).toBe("Yes");
|
||||||
|
expect(row[columnIndex("status")]).toBe("Cancelled");
|
||||||
|
expect(row[columnIndex("paymentStatus")]).toBe("");
|
||||||
|
expect(row[columnIndex("taxableValue")]).toBe("");
|
||||||
|
expect(row[columnIndex("invoiceTotal")]).toBe("11800.50");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSV", () => {
|
||||||
|
it("quotes per RFC 4180", () => {
|
||||||
|
expect(csvField("plain")).toBe("plain");
|
||||||
|
expect(csvField("a,b")).toBe('"a,b"');
|
||||||
|
expect(csvField('say "hi"')).toBe('"say ""hi"""');
|
||||||
|
expect(csvField("two\nlines")).toBe('"two\nlines"');
|
||||||
|
expect(csvField("cr\rhere")).toBe('"cr\rhere"');
|
||||||
|
expect(csvField(" padded")).toBe('" padded"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards text cells against formula injection but leaves amounts numeric", () => {
|
||||||
|
for (const bad of ["=1+1", "+SUM(A1)", "-2+3", "@cmd", "\tTAB", "\rCR"]) {
|
||||||
|
expect(guardFormula(bad)).toBe(`'${bad}`);
|
||||||
|
}
|
||||||
|
expect(guardFormula("INV/2025-001")).toBe("INV/2025-001");
|
||||||
|
const cells = buildHistoryRows(
|
||||||
|
[summary({ clientName: "=HYPERLINK(\"http://x\")", poNumber: "-5", balancePaise: -2_500, paidPaise: 0 })],
|
||||||
|
[ledger()],
|
||||||
|
);
|
||||||
|
const csv = toCsv(cells);
|
||||||
|
const dataLine = csv.split("\r\n")[1];
|
||||||
|
expect(dataLine).toContain(`"'=HYPERLINK(""http://x"")"`);
|
||||||
|
expect(dataLine).toContain(",'-5,"); // PO number text is guarded
|
||||||
|
expect(dataLine).toContain(",-25.00,"); // the numeric balance is not
|
||||||
|
expect(dataLine).not.toContain("'-25.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has a BOM, CRLF line ends and a trailing newline", () => {
|
||||||
|
const csv = toCsv(buildHistoryRows([summary(), summary({ id: 2, number: "INV/2025-002" })], [ledger(), ledger({ id: 2 })]));
|
||||||
|
expect(csv.startsWith("Invoice No,Date,")).toBe(true);
|
||||||
|
expect(csv.endsWith("\r\n")).toBe(true);
|
||||||
|
expect(csv.split("\r\n")).toHaveLength(4); // header, two rows, empty after the last CRLF
|
||||||
|
expect(csv.replace(/\r\n/g, "").includes("\n")).toBe(false);
|
||||||
|
expect(new TextEncoder().encode(csv).slice(0, 3)).toEqual(new Uint8Array([0xef, 0xbb, 0xbf]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty set is a header-only file", () => {
|
||||||
|
const csv = toCsv(buildHistoryRows([], []));
|
||||||
|
expect(csv.split("\r\n")).toEqual([expect.stringMatching(/^Invoice No,.*Cancelled$/), ""]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JSON", () => {
|
||||||
|
it("wraps the rows with schema, time and filters", () => {
|
||||||
|
const filters = { ...NO_FILTERS, fy: 2025 as const, search: "acme" };
|
||||||
|
const parsed = JSON.parse(toJson(buildHistoryRows([summary()], [ledger()]), filters, "2026-01-02T03:04:05.000Z"));
|
||||||
|
expect(parsed.schema).toBe("voiced.history.v1");
|
||||||
|
expect(parsed.exported_at).toBe("2026-01-02T03:04:05.000Z");
|
||||||
|
expect(parsed.filters).toEqual(filters);
|
||||||
|
expect(parsed.count).toBe(1);
|
||||||
|
expect(parsed.rows[0]).toMatchObject({ invoiceNo: "INV/2025-001", taxableValue: "10000.00", totalTax: "1800.50", cancelled: "No" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an empty set has no rows", () => {
|
||||||
|
const parsed = JSON.parse(toJson([], NO_FILTERS, "t"));
|
||||||
|
expect(parsed.rows).toEqual([]);
|
||||||
|
expect(parsed.count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("historyExportName", () => {
|
||||||
|
it("uses the financial year when one is picked", () => {
|
||||||
|
expect(historyExportName({ ...NO_FILTERS, fy: 2025 }, [], "csv")).toBe("voiced-history-FY2025-26.csv");
|
||||||
|
expect(historyExportName({ ...NO_FILTERS, fy: 2025, month: 4 }, [], "json")).toBe("voiced-history-FY2025-26-m04.json");
|
||||||
|
});
|
||||||
|
it("otherwise the date range of the rows", () => {
|
||||||
|
const rows = [summary({ invoiceDate: "2025-06-10" }), summary({ invoiceDate: "2025-04-02" })];
|
||||||
|
expect(historyExportName(NO_FILTERS, rows, "csv")).toBe("voiced-history-2025-04-02_to_2025-06-10.csv");
|
||||||
|
expect(historyExportName(NO_FILTERS, [rows[0]], "csv")).toBe("voiced-history-2025-06-10.csv");
|
||||||
|
expect(historyExportName(NO_FILTERS, [], "csv")).toBe("voiced-history-all.csv");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exportHistory", () => {
|
||||||
|
const make = (chosen: string | null) => {
|
||||||
|
const written: { path: string; text: string }[] = [];
|
||||||
|
const saves: { defaultPath: string; format: string }[] = [];
|
||||||
|
const deps: HistoryExportDeps = {
|
||||||
|
listLedger: async () => [ledger()],
|
||||||
|
getLastExportDir: async () => "/home/a/Exports",
|
||||||
|
save: async (o) => {
|
||||||
|
saves.push(o);
|
||||||
|
return chosen;
|
||||||
|
},
|
||||||
|
writeExportFile: async (path, bytes) => {
|
||||||
|
written.push({ path, text: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes) });
|
||||||
|
return path;
|
||||||
|
},
|
||||||
|
now: () => new Date("2026-01-02T03:04:05Z"),
|
||||||
|
};
|
||||||
|
return { deps, written, saves };
|
||||||
|
};
|
||||||
|
|
||||||
|
it("writes exactly the rows given, through the save dialog", async () => {
|
||||||
|
const { deps, written, saves } = make("/home/a/Exports/out.csv");
|
||||||
|
const out = await exportHistory(deps, [summary()], { ...NO_FILTERS, fy: 2025 }, "csv");
|
||||||
|
expect(out).toEqual({ path: "/home/a/Exports/out.csv", count: 1 });
|
||||||
|
expect(saves[0]).toEqual({ defaultPath: "/home/a/Exports/voiced-history-FY2025-26.csv", format: "csv" });
|
||||||
|
expect(written).toHaveLength(1);
|
||||||
|
expect(written[0].text.startsWith("Invoice No")).toBe(true);
|
||||||
|
expect(written[0].text.split("\r\n")).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes nothing when the dialog is cancelled, and a header-only file for an empty set", async () => {
|
||||||
|
const cancelled = make(null);
|
||||||
|
expect(await exportHistory(cancelled.deps, [summary()], NO_FILTERS, "json")).toBeNull();
|
||||||
|
expect(cancelled.written).toEqual([]);
|
||||||
|
const empty = make("/x/e.json");
|
||||||
|
expect(await exportHistory(empty.deps, [], NO_FILTERS, "json")).toEqual({ path: "/x/e.json", count: 0 });
|
||||||
|
expect(JSON.parse(empty.written[0].text).rows).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { fyLabel } from "./fiscal";
|
||||||
|
import type { HistoryFilters } from "./historyFilter";
|
||||||
|
import { INDIAN_STATES, type InvoiceSummary } from "./types";
|
||||||
|
|
||||||
|
/** Per-invoice fields the History list does not carry (from the `list_invoice_ledger` command). Money is integer paise. */
|
||||||
|
export interface LedgerRow {
|
||||||
|
id: number;
|
||||||
|
clientGstin: string;
|
||||||
|
placeOfSupplyCode: string;
|
||||||
|
docType: string;
|
||||||
|
hsnSac: string[];
|
||||||
|
taxablePaise: number;
|
||||||
|
cgstPaise: number;
|
||||||
|
sgstPaise: number;
|
||||||
|
igstPaise: number;
|
||||||
|
reverseCharge: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HISTORY_SCHEMA = "voiced.history.v1";
|
||||||
|
|
||||||
|
/** 123450 -> "1234.50", -5 -> "-0.05". Integer arithmetic only; throws on anything that is not a safe integer. */
|
||||||
|
export function paiseToDecimal(paise: number): string {
|
||||||
|
if (!Number.isSafeInteger(paise)) throw new Error(`Not an amount in paise: ${paise}`);
|
||||||
|
const abs = Math.abs(paise);
|
||||||
|
const whole = Math.floor(abs / 100);
|
||||||
|
const frac = abs % 100;
|
||||||
|
return `${paise < 0 ? "-" : ""}${whole}.${frac < 10 ? "0" : ""}${frac}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text cells are guarded against spreadsheet formula injection; amount cells are known numbers and stay numeric. */
|
||||||
|
type Kind = "text" | "amount";
|
||||||
|
|
||||||
|
interface Column {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
kind: Kind;
|
||||||
|
value: (s: InvoiceSummary, l: LedgerRow | undefined) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_TYPES: Record<string, string> = { tax_invoice: "Tax Invoice", invoice: "Invoice" };
|
||||||
|
const PAYMENT_STATUS: Record<string, string> = {
|
||||||
|
unpaid: "Unpaid",
|
||||||
|
partially_paid: "Partly paid",
|
||||||
|
paid: "Paid",
|
||||||
|
overdue: "Overdue",
|
||||||
|
none: "",
|
||||||
|
};
|
||||||
|
const STATUS: Record<string, string> = { issued: "Issued", cancelled: "Cancelled", draft: "Draft" };
|
||||||
|
|
||||||
|
/** An amount that comes from the ledger row; blank (not a made-up zero) if the row is missing. */
|
||||||
|
const ledgerAmount = (pick: (l: LedgerRow) => number) => (_s: InvoiceSummary, l: LedgerRow | undefined) =>
|
||||||
|
l ? paiseToDecimal(pick(l)) : "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column order and meaning for both formats. Cancelled invoices keep their figures (the document exists and
|
||||||
|
* keeps its number); filter on the Cancelled column to leave them out of a sum.
|
||||||
|
*/
|
||||||
|
export const HISTORY_COLUMNS: readonly Column[] = [
|
||||||
|
{ key: "invoiceNo", header: "Invoice No", kind: "text", value: (s) => s.number },
|
||||||
|
{ key: "date", header: "Date", kind: "text", value: (s) => s.invoiceDate },
|
||||||
|
{ key: "dueDate", header: "Due Date", kind: "text", value: (s) => s.dueDate },
|
||||||
|
{ key: "client", header: "Client", kind: "text", value: (s) => s.clientName },
|
||||||
|
{ key: "clientGstin", header: "Client GSTIN", kind: "text", value: (_s, l) => l?.clientGstin ?? "" },
|
||||||
|
{ key: "placeOfSupplyCode", header: "Place of Supply Code", kind: "text", value: (_s, l) => l?.placeOfSupplyCode ?? "" },
|
||||||
|
{
|
||||||
|
key: "placeOfSupply",
|
||||||
|
header: "Place of Supply",
|
||||||
|
kind: "text",
|
||||||
|
value: (_s, l) => INDIAN_STATES.find((st) => st.code === l?.placeOfSupplyCode)?.name ?? "",
|
||||||
|
},
|
||||||
|
{ key: "documentType", header: "Document Type", kind: "text", value: (_s, l) => (l ? (DOC_TYPES[l.docType] ?? l.docType) : "") },
|
||||||
|
{ key: "hsnSac", header: "HSN/SAC", kind: "text", value: (_s, l) => (l ? [...new Set(l.hsnSac)].join("; ") : "") },
|
||||||
|
{ key: "taxableValue", header: "Taxable Value", kind: "amount", value: ledgerAmount((l) => l.taxablePaise) },
|
||||||
|
{ key: "cgst", header: "CGST", kind: "amount", value: ledgerAmount((l) => l.cgstPaise) },
|
||||||
|
{ key: "sgst", header: "SGST", kind: "amount", value: ledgerAmount((l) => l.sgstPaise) },
|
||||||
|
{ key: "igst", header: "IGST", kind: "amount", value: ledgerAmount((l) => l.igstPaise) },
|
||||||
|
{ key: "totalTax", header: "Total Tax", kind: "amount", value: ledgerAmount((l) => l.cgstPaise + l.sgstPaise + l.igstPaise) },
|
||||||
|
{ key: "invoiceTotal", header: "Invoice Total", kind: "amount", value: (s) => paiseToDecimal(s.totalPaise) },
|
||||||
|
{ key: "received", header: "Received", kind: "amount", value: (s) => paiseToDecimal(s.paidPaise) },
|
||||||
|
{ key: "tds", header: "TDS", kind: "amount", value: (s) => paiseToDecimal(s.tdsPaise) },
|
||||||
|
{ key: "balance", header: "Balance", kind: "amount", value: (s) => paiseToDecimal(s.balancePaise) },
|
||||||
|
{ key: "status", header: "Status", kind: "text", value: (s) => STATUS[s.status] ?? s.status },
|
||||||
|
{ key: "paymentStatus", header: "Payment Status", kind: "text", value: (s) => PAYMENT_STATUS[s.paymentStatus] ?? s.paymentStatus },
|
||||||
|
{ key: "poNumber", header: "PO Number", kind: "text", value: (s) => s.poNumber },
|
||||||
|
{ key: "cancelled", header: "Cancelled", kind: "text", value: (s) => (s.status === "cancelled" ? "Yes" : "No") },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The cells of every exported row, in column order. `ledger` is looked up by invoice id. */
|
||||||
|
export function buildHistoryRows(summaries: InvoiceSummary[], ledger: LedgerRow[]): string[][] {
|
||||||
|
const byId = new Map(ledger.map((l) => [l.id, l]));
|
||||||
|
return summaries.map((s) => HISTORY_COLUMNS.map((c) => c.value(s, byId.get(s.id))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A text cell that a spreadsheet would read as a formula gets a leading apostrophe. */
|
||||||
|
export function guardFormula(text: string): string {
|
||||||
|
return /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 4180 field: quoted when it holds a quote, comma or line break (or edge spaces), inner quotes doubled. */
|
||||||
|
export function csvField(text: string): string {
|
||||||
|
return /[",\r\n]|^\s|\s$/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTF-8 BOM (so Excel reads it as UTF-8), CRLF line ends, header row always present. */
|
||||||
|
export function toCsv(cells: string[][]): string {
|
||||||
|
const lines = [HISTORY_COLUMNS.map((c) => csvField(c.header))];
|
||||||
|
for (const row of cells) {
|
||||||
|
lines.push(
|
||||||
|
row.map((value, i) => {
|
||||||
|
const column = HISTORY_COLUMNS[i];
|
||||||
|
return csvField(column.kind === "text" ? guardFormula(value) : value);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${lines.map((l) => l.join(",")).join("\r\n")}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `{ schema, exported_at, filters, count, rows }`. Rows are objects keyed like the CSV columns; amounts are the same
|
||||||
|
* 2-decimal rupee strings as in the CSV (no binary floats anywhere).
|
||||||
|
*/
|
||||||
|
export function toJson(cells: string[][], filters: HistoryFilters, exportedAt: string): string {
|
||||||
|
const rows = cells.map((row) => Object.fromEntries(HISTORY_COLUMNS.map((c, i) => [c.key, row[i]])));
|
||||||
|
return `${JSON.stringify({ schema: HISTORY_SCHEMA, exported_at: exportedAt, filters, count: rows.length, rows }, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = (s: string) => s.replace(/[^A-Za-z0-9._-]+/g, "-");
|
||||||
|
|
||||||
|
/** `voiced-history-FY2025-26.csv`, or the date range of the rows when no year is picked, or `all` when empty. */
|
||||||
|
export function historyExportName(
|
||||||
|
filters: HistoryFilters,
|
||||||
|
summaries: InvoiceSummary[],
|
||||||
|
ext: "csv" | "json",
|
||||||
|
): string {
|
||||||
|
let part: string;
|
||||||
|
if (filters.fy !== "all") {
|
||||||
|
part = fyLabel(filters.fy).replace(/^FY /, "FY");
|
||||||
|
if (filters.month !== "all") part += `-m${String(filters.month).padStart(2, "0")}`;
|
||||||
|
} else {
|
||||||
|
const dates = summaries.map((s) => s.invoiceDate).filter(Boolean).sort();
|
||||||
|
if (dates.length === 0) part = "all";
|
||||||
|
else part = dates[0] === dates[dates.length - 1] ? dates[0] : `${dates[0]}_to_${dates[dates.length - 1]}`;
|
||||||
|
}
|
||||||
|
return `voiced-history-${safeName(part)}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HistoryFormat = "csv" | "json";
|
||||||
|
|
||||||
|
export interface HistoryExportDeps {
|
||||||
|
listLedger(): Promise<LedgerRow[]>;
|
||||||
|
getLastExportDir(): Promise<string>;
|
||||||
|
save(opts: { defaultPath: string; format: HistoryFormat }): Promise<string | null>;
|
||||||
|
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
|
||||||
|
now(): Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPath(dir: string, name: string): string {
|
||||||
|
if (!dir) return name;
|
||||||
|
if (/[\\/]$/.test(dir)) return `${dir}${name}`;
|
||||||
|
return `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exports exactly `summaries` (the filtered, sorted rows) through the save dialog. Returns the path, or null if cancelled. */
|
||||||
|
export async function exportHistory(
|
||||||
|
deps: HistoryExportDeps,
|
||||||
|
summaries: InvoiceSummary[],
|
||||||
|
filters: HistoryFilters,
|
||||||
|
format: HistoryFormat,
|
||||||
|
): Promise<{ path: string; count: number } | null> {
|
||||||
|
const ledger = await deps.listLedger();
|
||||||
|
const cells = buildHistoryRows(summaries, ledger);
|
||||||
|
const text = format === "csv" ? toCsv(cells) : toJson(cells, filters, deps.now().toISOString());
|
||||||
|
let dir = "";
|
||||||
|
try {
|
||||||
|
dir = await deps.getLastExportDir();
|
||||||
|
} catch {
|
||||||
|
// No remembered folder: the dialog opens wherever the system prefers.
|
||||||
|
}
|
||||||
|
const chosen = await deps.save({ defaultPath: joinPath(dir, historyExportName(filters, summaries, format)), format });
|
||||||
|
if (!chosen) return null;
|
||||||
|
const path = (await deps.writeExportFile(chosen, new TextEncoder().encode(text))) || chosen;
|
||||||
|
return { path, count: cells.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { clientOptions, filterInvoices, fyOptions, fySummary, NO_FILTERS, sortInvoices, totalsOf } from "./historyFilter";
|
||||||
|
import type { InvoiceSummary } from "./types";
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
function row(over: Partial<InvoiceSummary>): InvoiceSummary {
|
||||||
|
const base: InvoiceSummary = {
|
||||||
|
id: nextId++,
|
||||||
|
number: "INV/2025-001",
|
||||||
|
invoiceDate: "2025-06-10",
|
||||||
|
dueDate: "2025-07-10",
|
||||||
|
clientName: "Acme",
|
||||||
|
total: 0,
|
||||||
|
status: "issued",
|
||||||
|
createdAt: "",
|
||||||
|
poNumber: "",
|
||||||
|
totalPaise: 100_000,
|
||||||
|
paidPaise: 0,
|
||||||
|
tdsPaise: 0,
|
||||||
|
balancePaise: 100_000,
|
||||||
|
paymentStatus: "unpaid",
|
||||||
|
};
|
||||||
|
return { ...base, ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: InvoiceSummary[] = [
|
||||||
|
row({ number: "INV/2025-001", invoiceDate: "2025-04-01", clientName: "Acme", poNumber: "PO-77", paymentStatus: "paid", paidPaise: 90_000, tdsPaise: 10_000, balancePaise: 0 }),
|
||||||
|
row({ number: "INV/2025-002", invoiceDate: "2025-05-20", clientName: "Beta Labs", totalPaise: 250_050, balancePaise: 250_050, paymentStatus: "overdue" }),
|
||||||
|
row({ number: "INV/2025-010", invoiceDate: "2026-03-31", clientName: "Acme", paidPaise: 30_000, balancePaise: 70_000, paymentStatus: "partially_paid" }),
|
||||||
|
row({ number: "INV/2026-001", invoiceDate: "2026-04-02", clientName: "Gamma", status: "cancelled", balancePaise: 0, paymentStatus: "none" }),
|
||||||
|
row({ number: "INV/2026-002", invoiceDate: "2026-04-05", clientName: "Acme", paymentStatus: "unpaid" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const f = (over: object) => filterInvoices(rows, { ...NO_FILTERS, ...over }).map((r) => r.number);
|
||||||
|
|
||||||
|
describe("filterInvoices", () => {
|
||||||
|
it("returns everything with no filters", () => expect(f({})).toHaveLength(5));
|
||||||
|
|
||||||
|
it("searches number, client and PO case-insensitively", () => {
|
||||||
|
expect(f({ search: "beta" })).toEqual(["INV/2025-002"]);
|
||||||
|
expect(f({ search: "po-77" })).toEqual(["INV/2025-001"]);
|
||||||
|
expect(f({ search: " 2026-00 " })).toEqual(["INV/2026-001", "INV/2026-002"]);
|
||||||
|
expect(f({ search: "nothing" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by status and payment status", () => {
|
||||||
|
expect(f({ status: "cancelled" })).toEqual(["INV/2026-001"]);
|
||||||
|
expect(f({ status: "issued" })).toHaveLength(4);
|
||||||
|
expect(f({ status: "paid" })).toEqual(["INV/2025-001"]);
|
||||||
|
expect(f({ status: "overdue" })).toEqual(["INV/2025-002"]);
|
||||||
|
expect(f({ status: "partially_paid" })).toEqual(["INV/2025-010"]);
|
||||||
|
// An overdue invoice with nothing received is also unpaid.
|
||||||
|
expect(f({ status: "unpaid" })).toEqual(["INV/2025-002", "INV/2026-002"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by financial year, month and client", () => {
|
||||||
|
expect(f({ fy: 2025 })).toEqual(["INV/2025-001", "INV/2025-002", "INV/2025-010"]);
|
||||||
|
expect(f({ fy: 2026 })).toEqual(["INV/2026-001", "INV/2026-002"]);
|
||||||
|
expect(f({ month: 4 })).toEqual(["INV/2025-001", "INV/2026-001", "INV/2026-002"]);
|
||||||
|
expect(f({ fy: 2026, month: 4 })).toEqual(["INV/2026-001", "INV/2026-002"]);
|
||||||
|
expect(f({ client: "Acme", fy: 2025 })).toEqual(["INV/2025-001", "INV/2025-010"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sortInvoices", () => {
|
||||||
|
const nums = (sort: Parameters<typeof sortInvoices>[1]) => sortInvoices(rows, sort).map((r) => r.number);
|
||||||
|
|
||||||
|
it("sorts numbers naturally, so 010 follows 002", () => {
|
||||||
|
expect(nums({ key: "number", dir: "asc" }).slice(0, 3)).toEqual(["INV/2025-001", "INV/2025-002", "INV/2025-010"]);
|
||||||
|
expect(nums({ key: "number", dir: "desc" })[0]).toBe("INV/2026-002");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts by date, total and balance", () => {
|
||||||
|
expect(nums({ key: "date", dir: "desc" })[0]).toBe("INV/2026-002");
|
||||||
|
expect(nums({ key: "total", dir: "desc" })[0]).toBe("INV/2025-002");
|
||||||
|
expect(nums({ key: "balance", dir: "asc" }).slice(0, 2).sort()).toEqual(["INV/2025-001", "INV/2026-001"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts by status with overdue first and cancelled last, and does not mutate", () => {
|
||||||
|
const before = rows.map((r) => r.id);
|
||||||
|
const s = sortInvoices(rows, { key: "status", dir: "asc" });
|
||||||
|
expect(s[0].paymentStatus).toBe("overdue");
|
||||||
|
expect(s[s.length - 1].status).toBe("cancelled");
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks ties by newest id", () => {
|
||||||
|
const tied = [row({ clientName: "Same" }), row({ clientName: "Same" })];
|
||||||
|
expect(sortInvoices(tied, { key: "client", dir: "asc" })[0].id).toBe(tied[1].id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("totals", () => {
|
||||||
|
it("adds paise exactly and ignores cancelled invoices", () => {
|
||||||
|
const t = totalsOf(rows);
|
||||||
|
expect(t.count).toBe(5);
|
||||||
|
expect(t.invoicedPaise).toBe(100_000 + 250_050 + 100_000 + 100_000);
|
||||||
|
expect(t.receivedPaise).toBe(120_000);
|
||||||
|
expect(t.tdsPaise).toBe(10_000);
|
||||||
|
expect(t.outstandingPaise).toBe(250_050 + 70_000 + 100_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps cents exact where floats would drift", () => {
|
||||||
|
const many = Array.from({ length: 1000 }, () => row({ totalPaise: 10, balancePaise: 10 }));
|
||||||
|
expect(totalsOf(many).invoicedPaise).toBe(10_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarises one financial year including overdue", () => {
|
||||||
|
const s = fySummary(rows, 2025);
|
||||||
|
expect(s.count).toBe(3);
|
||||||
|
expect(s.overdueCount).toBe(1);
|
||||||
|
expect(s.overduePaise).toBe(250_050);
|
||||||
|
expect(fySummary(rows, 2030).invoicedPaise).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("options", () => {
|
||||||
|
it("lists financial years newest first and always the current one", () => {
|
||||||
|
expect(fyOptions(rows, 2025)).toEqual([2026, 2025]);
|
||||||
|
expect(fyOptions([], 2027)).toEqual([2027]);
|
||||||
|
});
|
||||||
|
it("lists distinct clients", () => expect(clientOptions(rows)).toEqual(["Acme", "Beta Labs", "Gamma"]));
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { fyStartYear, monthOf } from "./fiscal";
|
||||||
|
import type { InvoiceSummary } from "./types";
|
||||||
|
|
||||||
|
export type StatusFilter = "all" | "issued" | "cancelled" | "unpaid" | "partially_paid" | "paid" | "overdue";
|
||||||
|
|
||||||
|
export const STATUS_FILTERS: { id: StatusFilter; label: string }[] = [
|
||||||
|
{ id: "all", label: "All statuses" },
|
||||||
|
{ id: "issued", label: "Issued" },
|
||||||
|
{ id: "unpaid", label: "Unpaid" },
|
||||||
|
{ id: "partially_paid", label: "Partly paid" },
|
||||||
|
{ id: "paid", label: "Paid" },
|
||||||
|
{ id: "overdue", label: "Overdue" },
|
||||||
|
{ id: "cancelled", label: "Cancelled" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface HistoryFilters {
|
||||||
|
search: string;
|
||||||
|
status: StatusFilter;
|
||||||
|
/** Financial-year start year, or "all". */
|
||||||
|
fy: number | "all";
|
||||||
|
/** Calendar month 1-12, or "all". */
|
||||||
|
month: number | "all";
|
||||||
|
client: string | "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NO_FILTERS: HistoryFilters = { search: "", status: "all", fy: "all", month: "all", client: "all" };
|
||||||
|
|
||||||
|
export type SortKey = "number" | "date" | "client" | "total" | "balance" | "status";
|
||||||
|
export interface SortSpec {
|
||||||
|
key: SortKey;
|
||||||
|
dir: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All amounts are integer paise. Cancelled invoices count for nothing. */
|
||||||
|
export interface Totals {
|
||||||
|
count: number;
|
||||||
|
invoicedPaise: number;
|
||||||
|
receivedPaise: number;
|
||||||
|
tdsPaise: number;
|
||||||
|
outstandingPaise: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settled = (r: InvoiceSummary) => r.paidPaise + r.tdsPaise;
|
||||||
|
|
||||||
|
function matchesStatus(r: InvoiceSummary, f: StatusFilter): boolean {
|
||||||
|
switch (f) {
|
||||||
|
case "all":
|
||||||
|
return true;
|
||||||
|
case "issued":
|
||||||
|
return r.status === "issued";
|
||||||
|
case "cancelled":
|
||||||
|
return r.status === "cancelled";
|
||||||
|
case "unpaid":
|
||||||
|
return r.status === "issued" && settled(r) === 0;
|
||||||
|
case "partially_paid":
|
||||||
|
return r.status === "issued" && settled(r) > 0 && r.balancePaise > 0;
|
||||||
|
case "paid":
|
||||||
|
return r.status === "issued" && r.paymentStatus === "paid";
|
||||||
|
case "overdue":
|
||||||
|
return r.status === "issued" && r.paymentStatus === "overdue";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows that satisfy every filter. The search is a case-insensitive substring of number, client or PO number. */
|
||||||
|
export function filterInvoices(rows: InvoiceSummary[], f: HistoryFilters): InvoiceSummary[] {
|
||||||
|
const q = f.search.trim().toLowerCase();
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (q && !`${r.number}\n${r.clientName}\n${r.poNumber}`.toLowerCase().includes(q)) return false;
|
||||||
|
if (!matchesStatus(r, f.status)) return false;
|
||||||
|
if (f.fy !== "all" && fyStartYear(r.invoiceDate) !== f.fy) return false;
|
||||||
|
if (f.month !== "all" && monthOf(r.invoiceDate) !== f.month) return false;
|
||||||
|
if (f.client !== "all" && r.clientName !== f.client) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_RANK: Record<string, number> = { overdue: 0, unpaid: 1, partially_paid: 2, paid: 3, none: 4 };
|
||||||
|
const statusRank = (r: InvoiceSummary) => (r.status === "cancelled" ? 5 : (STATUS_RANK[r.paymentStatus] ?? 4));
|
||||||
|
const numeric = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
|
||||||
|
|
||||||
|
function compare(a: InvoiceSummary, b: InvoiceSummary, key: SortKey): number {
|
||||||
|
switch (key) {
|
||||||
|
case "number":
|
||||||
|
return numeric.compare(a.number, b.number);
|
||||||
|
case "date":
|
||||||
|
return a.invoiceDate < b.invoiceDate ? -1 : a.invoiceDate > b.invoiceDate ? 1 : 0;
|
||||||
|
case "client":
|
||||||
|
return numeric.compare(a.clientName, b.clientName);
|
||||||
|
case "total":
|
||||||
|
return a.totalPaise - b.totalPaise;
|
||||||
|
case "balance":
|
||||||
|
return a.balancePaise - b.balancePaise;
|
||||||
|
case "status":
|
||||||
|
return statusRank(a) - statusRank(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new, stably sorted array; ties fall back to the newest invoice first so the order never jitters. */
|
||||||
|
export function sortInvoices(rows: InvoiceSummary[], sort: SortSpec): InvoiceSummary[] {
|
||||||
|
const sign = sort.dir === "asc" ? 1 : -1;
|
||||||
|
return [...rows].sort((a, b) => sign * compare(a, b, sort.key) || b.id - a.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalsOf(rows: InvoiceSummary[]): Totals {
|
||||||
|
const t: Totals = { count: rows.length, invoicedPaise: 0, receivedPaise: 0, tdsPaise: 0, outstandingPaise: 0 };
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.status === "cancelled") continue;
|
||||||
|
t.invoicedPaise += r.totalPaise;
|
||||||
|
t.receivedPaise += r.paidPaise;
|
||||||
|
t.tdsPaise += r.tdsPaise;
|
||||||
|
t.outstandingPaise += r.balancePaise;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FySummary extends Totals {
|
||||||
|
overdueCount: number;
|
||||||
|
overduePaise: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tiles above the table: everything dated in one financial year, regardless of the other filters. */
|
||||||
|
export function fySummary(rows: InvoiceSummary[], fy: number): FySummary {
|
||||||
|
const inYear = rows.filter((r) => fyStartYear(r.invoiceDate) === fy);
|
||||||
|
const overdue = inYear.filter((r) => r.status === "issued" && r.paymentStatus === "overdue");
|
||||||
|
return {
|
||||||
|
...totalsOf(inYear),
|
||||||
|
overdueCount: overdue.length,
|
||||||
|
overduePaise: overdue.reduce((sum, r) => sum + r.balancePaise, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Financial years that have invoices, newest first, always including `current`. */
|
||||||
|
export function fyOptions(rows: InvoiceSummary[], current: number): number[] {
|
||||||
|
const years = new Set<number>([current]);
|
||||||
|
for (const r of rows) {
|
||||||
|
const y = fyStartYear(r.invoiceDate);
|
||||||
|
if (y !== null) years.add(y);
|
||||||
|
}
|
||||||
|
return [...years].sort((a, b) => b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct client names, alphabetical, blanks left out. */
|
||||||
|
export function clientOptions(rows: InvoiceSummary[]): string[] {
|
||||||
|
return [...new Set(rows.map((r) => r.clientName.trim()).filter(Boolean))].sort((a, b) => numeric.compare(a, b));
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatPaise, paiseToInput, parseRupeesToPaise } from "./money";
|
||||||
|
|
||||||
|
describe("money", () => {
|
||||||
|
it("parses typed amounts to exact paise", () => {
|
||||||
|
expect(parseRupeesToPaise("1250.5")).toBe(125050);
|
||||||
|
expect(parseRupeesToPaise("1,25,000")).toBe(12500000);
|
||||||
|
expect(parseRupeesToPaise("₹ 99.99")).toBe(9999);
|
||||||
|
expect(parseRupeesToPaise("0.29")).toBe(29);
|
||||||
|
expect(parseRupeesToPaise("19.9")).toBe(1990);
|
||||||
|
expect(parseRupeesToPaise("5.")).toBe(500);
|
||||||
|
expect(parseRupeesToPaise("")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-amounts", () => {
|
||||||
|
for (const bad of ["abc", "-5", "1.234", "1e3", "1.2.3", ".5"]) expect(parseRupeesToPaise(bad)).toBeNull();
|
||||||
|
expect(parseRupeesToPaise("99999999999999999999")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats", () => {
|
||||||
|
expect(paiseToInput(123450)).toBe("1234.50");
|
||||||
|
expect(paiseToInput(5)).toBe("0.05");
|
||||||
|
expect(formatPaise(12500000)).toBe("₹1,25,000.00");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { formatAmount } from "./format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses what a person types into an amount field ("1,25,000", "1250.5", "₹ 99.99") into integer paise
|
||||||
|
* without going through floating point. Returns null for anything that is not a plain amount with at
|
||||||
|
* most two decimals; an empty string is 0.
|
||||||
|
*/
|
||||||
|
export function parseRupeesToPaise(text: string): number | null {
|
||||||
|
const cleaned = text.replace(/[₹,\s]/g, "");
|
||||||
|
if (cleaned === "") return 0;
|
||||||
|
const m = /^(\d+)(?:\.(\d{0,2}))?$/.exec(cleaned);
|
||||||
|
if (!m) return null;
|
||||||
|
const whole = Number(m[1]);
|
||||||
|
const paise = whole * 100 + Number((m[2] ?? "").padEnd(2, "0"));
|
||||||
|
return Number.isSafeInteger(paise) ? paise : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 123450 -> "1234.50", for filling an amount field. */
|
||||||
|
export function paiseToInput(paise: number): string {
|
||||||
|
const p = Math.max(0, Math.round(paise));
|
||||||
|
return `${Math.floor(p / 100)}.${String(p % 100).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 123450 -> "₹1,234.50" */
|
||||||
|
export function formatPaise(paise: number): string {
|
||||||
|
return `₹${formatAmount(paise / 100)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { InvoiceStatus, PaymentStatus } from "./types";
|
||||||
|
|
||||||
|
export type PaymentMode = "bank_transfer" | "upi" | "cash" | "cheque" | "other";
|
||||||
|
|
||||||
|
export const PAYMENT_MODES: { id: PaymentMode; label: string }[] = [
|
||||||
|
{ id: "bank_transfer", label: "Bank transfer" },
|
||||||
|
{ id: "upi", label: "UPI" },
|
||||||
|
{ id: "cash", label: "Cash" },
|
||||||
|
{ id: "cheque", label: "Cheque" },
|
||||||
|
{ id: "other", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const paymentModeLabel = (mode: string): string => PAYMENT_MODES.find((m) => m.id === mode)?.label ?? mode;
|
||||||
|
|
||||||
|
/** A payment as stored: amounts are integer paise. `amountPaise` is cash; `tdsPaise` is tax the client deducted. */
|
||||||
|
export interface Payment {
|
||||||
|
id: number;
|
||||||
|
invoiceId: number;
|
||||||
|
paidOn: string;
|
||||||
|
amountPaise: number;
|
||||||
|
tdsPaise: number;
|
||||||
|
mode: PaymentMode;
|
||||||
|
reference: string;
|
||||||
|
notes: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentInput = Omit<Payment, "id" | "createdAt">;
|
||||||
|
|
||||||
|
type TagType = "red" | "green" | "blue" | "gray" | "warm-gray" | "cool-gray" | "purple";
|
||||||
|
|
||||||
|
/** What to show in the status column and badges: the cancelled state wins over payment state. */
|
||||||
|
export function statusBadge(status: InvoiceStatus, payment: PaymentStatus | undefined): { label: string; type: TagType } {
|
||||||
|
if (status === "cancelled") return { label: "Cancelled", type: "gray" };
|
||||||
|
if (status === "draft") return { label: "Draft", type: "warm-gray" };
|
||||||
|
switch (payment) {
|
||||||
|
case "paid":
|
||||||
|
return { label: "Paid", type: "green" };
|
||||||
|
case "partially_paid":
|
||||||
|
return { label: "Partly paid", type: "blue" };
|
||||||
|
case "overdue":
|
||||||
|
return { label: "Overdue", type: "red" };
|
||||||
|
default:
|
||||||
|
return { label: "Unpaid", type: "cool-gray" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -107,6 +171,9 @@ export interface InvoiceInput {
|
|||||||
|
|
||||||
export type InvoiceStatus = "draft" | "issued" | "cancelled";
|
export type InvoiceStatus = "draft" | "issued" | "cancelled";
|
||||||
|
|
||||||
|
/** Derived by the backend from the payments and the due date; "none" for invoices that take no payments. */
|
||||||
|
export type PaymentStatus = "none" | "unpaid" | "partially_paid" | "paid" | "overdue";
|
||||||
|
|
||||||
/** An invoice as stored: all amounts are derived on the Rust side. */
|
/** An invoice as stored: all amounts are derived on the Rust side. */
|
||||||
export interface Invoice {
|
export interface Invoice {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -150,6 +217,12 @@ export interface Invoice {
|
|||||||
archivedFingerprint?: string | null;
|
archivedFingerprint?: string | null;
|
||||||
archivedAt?: string | null;
|
archivedAt?: string | null;
|
||||||
items: InvoiceItem[];
|
items: InvoiceItem[];
|
||||||
|
/** Payment summary added by the backend (integer paise). Optional so PDF fixtures built by hand need not carry it. */
|
||||||
|
totalPaise?: number;
|
||||||
|
paidPaise?: number;
|
||||||
|
tdsPaise?: number;
|
||||||
|
balancePaise?: number;
|
||||||
|
paymentStatus?: PaymentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DraftSummary {
|
export interface DraftSummary {
|
||||||
@@ -168,6 +241,12 @@ export interface InvoiceSummary {
|
|||||||
total: number;
|
total: number;
|
||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
poNumber: string;
|
||||||
|
totalPaise: number;
|
||||||
|
paidPaise: number;
|
||||||
|
tdsPaise: number;
|
||||||
|
balancePaise: number;
|
||||||
|
paymentStatus: PaymentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_ITEM: InvoiceItem = {
|
export const EMPTY_ITEM: InvoiceItem = {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -523,3 +540,161 @@ body {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Settings: Data tab ---------- */
|
||||||
|
.voiced-section-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-list {
|
||||||
|
margin-left: 1.25rem;
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Invoice history and detail ---------- */
|
||||||
|
.voiced-num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-history__fy {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-history__tiles,
|
||||||
|
.voiced-detail__tiles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-history__amount,
|
||||||
|
.voiced-detail__amount {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-history__tile--overdue {
|
||||||
|
border-left: 4px solid var(--cds-support-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-history__filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(14rem, 2fr) repeat(4, minmax(9rem, 1fr)) auto;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-history__export {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-history__row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-history__totals {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 2rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--cds-layer-accent);
|
||||||
|
border-top: 1px solid var(--cds-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.voiced-detail__grid,
|
||||||
|
.voiced-history__filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__parties {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__field {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__pre {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__totals {
|
||||||
|
margin: 1rem 0 0.5rem auto;
|
||||||
|
max-width: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__totals > div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__grand {
|
||||||
|
border-top: 1px solid var(--cds-border-strong);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__preview {
|
||||||
|
position: sticky;
|
||||||
|
top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__unarchived {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--cds-layer-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voiced-detail__unarchived p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
+132
-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";
|
||||||
@@ -35,8 +39,12 @@ import {
|
|||||||
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
import { emailError, ifscError, panError, phoneError, vendorGstinError } from "../lib/validators";
|
||||||
import { ImagePicker } from "../components/ImagePicker";
|
import { ImagePicker } from "../components/ImagePicker";
|
||||||
import { LogoBranding } from "../components/LogoBranding";
|
import { LogoBranding } from "../components/LogoBranding";
|
||||||
|
import DataBackupPanel from "../components/DataBackupPanel";
|
||||||
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 +69,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 +106,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 +125,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 +132,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 +152,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 +289,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 +301,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 +429,95 @@ 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>
|
||||||
|
<DataBackupPanel />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
InlineLoading,
|
||||||
|
InlineNotification,
|
||||||
|
Modal,
|
||||||
|
OverflowMenu,
|
||||||
|
OverflowMenuItem,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
Tag,
|
||||||
|
TextInput,
|
||||||
|
Tile,
|
||||||
|
} from "@carbon/react";
|
||||||
|
import { ArrowLeft, TrashCan } from "@carbon/icons-react";
|
||||||
|
import { api, type ArchiveStatus } from "../lib/api";
|
||||||
|
import { formatAmount, formatDate } from "../lib/format";
|
||||||
|
import { formatPaise } from "../lib/money";
|
||||||
|
import { paymentModeLabel, statusBadge, type Payment } from "../lib/payments";
|
||||||
|
import { createExportDeps } from "../lib/exportDeps";
|
||||||
|
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
|
||||||
|
import { toDraftPayload } from "../lib/draft";
|
||||||
|
import { invoiceToFormState } from "../lib/duplicate";
|
||||||
|
import type { ExportMode } from "../lib/filename";
|
||||||
|
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
||||||
|
import { useReturnFocus } from "../hooks/useReturnFocus";
|
||||||
|
import ExportFeedback from "../components/ExportFeedback";
|
||||||
|
import PdfPreview from "../components/PdfPreview";
|
||||||
|
import RecordPaymentModal from "../components/RecordPaymentModal";
|
||||||
|
import { useToast } from "../components/ToastProvider";
|
||||||
|
import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
invoiceId: number;
|
||||||
|
settings: Settings;
|
||||||
|
onBack: () => void;
|
||||||
|
/** A draft was created from this invoice; the shell opens it in the New invoice form. */
|
||||||
|
onDuplicated: (draftId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Frozen {
|
||||||
|
vendorName?: string;
|
||||||
|
vendorAddress?: string;
|
||||||
|
vendorGstin?: string;
|
||||||
|
vendorPan?: string;
|
||||||
|
vendorEmail?: string;
|
||||||
|
vendorPhone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSnapshot(text: string): Frozen {
|
||||||
|
try {
|
||||||
|
return text ? (JSON.parse(text) as Frozen) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateName = (code: string) => {
|
||||||
|
const s = INDIAN_STATES.find((x) => x.code === code);
|
||||||
|
return s ? `${code} - ${s.name}` : code;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="voiced-detail__field">
|
||||||
|
<dt className="voiced-muted">{label}</dt>
|
||||||
|
<dd>{children || "—"}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicated }: Props) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [invoice, setInvoice] = useState<Invoice | null>(null);
|
||||||
|
const [payments, setPayments] = useState<Payment[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [archive, setArchive] = useState<ArchiveStatus | null>(null);
|
||||||
|
const [pdf, setPdf] = useState<Uint8Array | null>(null);
|
||||||
|
const [pdfError, setPdfError] = useState<string | null>(null);
|
||||||
|
const [archiving, setArchiving] = useState(false);
|
||||||
|
const [recordOpen, setRecordOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Payment | null>(null);
|
||||||
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [duplicating, setDuplicating] = useState(false);
|
||||||
|
useReturnFocus(deleteTarget !== null);
|
||||||
|
useReturnFocus(cancelOpen);
|
||||||
|
|
||||||
|
// Guards against a slow load for a previous id landing after the id changed.
|
||||||
|
const generation = useRef(0);
|
||||||
|
|
||||||
|
const loadPdf = useCallback(async (id: number, gen: number) => {
|
||||||
|
setPdfError(null);
|
||||||
|
try {
|
||||||
|
const status = await api.archiveStatus(id);
|
||||||
|
if (gen !== generation.current) return;
|
||||||
|
setArchive(status);
|
||||||
|
if (status.archived) {
|
||||||
|
const bytes = await api.readArchive(id);
|
||||||
|
if (gen === generation.current) setPdf(bytes);
|
||||||
|
} else {
|
||||||
|
setPdf(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (gen === generation.current) setPdfError(String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = useCallback(async (id: number, gen: number) => {
|
||||||
|
try {
|
||||||
|
const [inv, pays] = await Promise.all([api.getInvoice(id), api.listPayments(id)]);
|
||||||
|
if (gen !== generation.current) return;
|
||||||
|
setInvoice(inv);
|
||||||
|
setPayments(pays);
|
||||||
|
setLoadError(null);
|
||||||
|
} catch (e) {
|
||||||
|
if (gen === generation.current) setLoadError(String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const gen = ++generation.current;
|
||||||
|
setInvoice(null);
|
||||||
|
setPdf(null);
|
||||||
|
setArchive(null);
|
||||||
|
void loadData(invoiceId, gen);
|
||||||
|
void loadPdf(invoiceId, gen);
|
||||||
|
}, [invoiceId, loadData, loadPdf]);
|
||||||
|
|
||||||
|
const refresh = () => loadData(invoiceId, generation.current);
|
||||||
|
|
||||||
|
const exp = useInvoiceExport({
|
||||||
|
settings,
|
||||||
|
bank: null,
|
||||||
|
// An export that had to archive the original first: show it in the preview.
|
||||||
|
onInvoiceUpdated: () => void loadPdf(invoiceId, generation.current),
|
||||||
|
});
|
||||||
|
|
||||||
|
const busy = exp.busy || archiving || duplicating;
|
||||||
|
|
||||||
|
const onExport = (mode: ExportMode, source: ExportSource) => {
|
||||||
|
if (!invoice) return;
|
||||||
|
void exp.start(invoice, mode, source).catch((e) => toast.error("Export failed", String(e)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onArchive = async () => {
|
||||||
|
if (!invoice) return;
|
||||||
|
setArchiving(true);
|
||||||
|
try {
|
||||||
|
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
|
||||||
|
if (outcome.archived) {
|
||||||
|
toast.success(`Invoice ${invoice.number} is archived`);
|
||||||
|
await Promise.all([loadPdf(invoiceId, generation.current), refresh()]);
|
||||||
|
} else {
|
||||||
|
toast.error("Could not archive the PDF", outcome.error ?? "The PDF could not be archived.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not archive the PDF", String(e));
|
||||||
|
} finally {
|
||||||
|
setArchiving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDuplicate = async () => {
|
||||||
|
if (!invoice) return;
|
||||||
|
setDuplicating(true);
|
||||||
|
try {
|
||||||
|
const id = await api.saveDraft(null, toDraftPayload(invoiceToFormState(invoice, settings)));
|
||||||
|
toast.success("Draft created from this invoice", "Review it, then issue it as a new invoice.");
|
||||||
|
onDuplicated(id);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not duplicate the invoice", String(e));
|
||||||
|
} finally {
|
||||||
|
setDuplicating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancelInvoice = async () => {
|
||||||
|
if (!invoice) return;
|
||||||
|
try {
|
||||||
|
await api.cancelInvoice(invoice.id, reason);
|
||||||
|
toast.success(`Invoice ${invoice.number} cancelled`);
|
||||||
|
setCancelOpen(false);
|
||||||
|
setReason("");
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setCancelOpen(false);
|
||||||
|
toast.error("Could not cancel the invoice", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeletePayment = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
const target = deleteTarget;
|
||||||
|
setDeleteTarget(null);
|
||||||
|
try {
|
||||||
|
await api.deletePayment(target.id);
|
||||||
|
toast.success("Payment deleted", `${formatPaise(target.amountPaise + target.tdsPaise)} on ${formatDate(target.paidOn)}`);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Could not delete the payment", String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div className="voiced-page">
|
||||||
|
<Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={onBack}>
|
||||||
|
Back to invoices
|
||||||
|
</Button>
|
||||||
|
<InlineNotification kind="error" lowContrast hideCloseButton title="Could not load the invoice" subtitle={loadError} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!invoice) {
|
||||||
|
return (
|
||||||
|
<div className="voiced-page">
|
||||||
|
<InlineLoading description="Loading invoice" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = statusBadge(invoice.status, invoice.paymentStatus);
|
||||||
|
const vendor = parseSnapshot(invoice.vendorSnapshot);
|
||||||
|
const total = invoice.totalPaise ?? Math.round(invoice.total * 100);
|
||||||
|
const balance = invoice.balancePaise ?? 0;
|
||||||
|
const canPay = invoice.status === "issued" && balance > 0;
|
||||||
|
const half = invoice.taxRate / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voiced-page voiced-detail">
|
||||||
|
<Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={onBack}>
|
||||||
|
Back to invoices
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="voiced-detail__head">
|
||||||
|
<h1 className="voiced-page__title">{invoice.number}</h1>
|
||||||
|
<Tag type={badge.type}>{badge.label}</Tag>
|
||||||
|
{invoice.status === "issued" && invoice.paymentStatus === "overdue" && (invoice.paidPaise ?? 0) + (invoice.tdsPaise ?? 0) > 0 ? (
|
||||||
|
<Tag type="blue" size="sm">
|
||||||
|
Partly paid
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
{archive && !archive.archived ? (
|
||||||
|
<Tag type="warm-gray" size="sm">
|
||||||
|
Not archived
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="voiced-page__subtitle">
|
||||||
|
{invoice.clientName || "No client name"} · issued {formatDate(invoice.invoiceDate)}
|
||||||
|
{invoice.dueDate ? ` · due ${formatDate(invoice.dueDate)}` : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{invoice.status === "cancelled" ? (
|
||||||
|
<InlineNotification
|
||||||
|
kind="warning"
|
||||||
|
lowContrast
|
||||||
|
hideCloseButton
|
||||||
|
title="This invoice is cancelled"
|
||||||
|
subtitle={invoice.cancelReason ? `Reason: ${invoice.cancelReason}` : "Its number is not reused."}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="voiced-detail__actions" role="toolbar" aria-label="Invoice actions">
|
||||||
|
{canPay ? (
|
||||||
|
<Button size="md" onClick={() => setRecordOpen(true)} disabled={busy}>
|
||||||
|
Record payment
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button kind="tertiary" size="md" onClick={() => onExport("searchable", "original")} disabled={busy}>
|
||||||
|
Export PDF
|
||||||
|
</Button>
|
||||||
|
<Button kind="tertiary" size="md" onClick={() => onExport("flattened", "original")} disabled={busy}>
|
||||||
|
Export flattened
|
||||||
|
</Button>
|
||||||
|
<Button kind="tertiary" size="md" onClick={() => void onDuplicate()} disabled={busy}>
|
||||||
|
{duplicating ? "Duplicating…" : "Duplicate"}
|
||||||
|
</Button>
|
||||||
|
<OverflowMenu aria-label="More actions" iconDescription="More actions" flipped size="md" disabled={busy}>
|
||||||
|
<OverflowMenuItem itemText="Re-render with current template" onClick={() => onExport("searchable", "rerender")} />
|
||||||
|
{invoice.status === "issued" ? (
|
||||||
|
<OverflowMenuItem
|
||||||
|
itemText={payments.length > 0 ? "Cancel invoice (delete payments first)" : "Cancel invoice"}
|
||||||
|
hasDivider
|
||||||
|
isDelete
|
||||||
|
disabled={payments.length > 0}
|
||||||
|
onClick={() => {
|
||||||
|
setReason("");
|
||||||
|
setCancelOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</OverflowMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExportFeedback exp={exp} />
|
||||||
|
|
||||||
|
<div className="voiced-detail__grid">
|
||||||
|
<div className="voiced-detail__main voiced-stack">
|
||||||
|
<div className="voiced-detail__tiles">
|
||||||
|
<Tile>
|
||||||
|
<p className="voiced-muted">Total</p>
|
||||||
|
<p className="voiced-detail__amount">{formatPaise(total)}</p>
|
||||||
|
</Tile>
|
||||||
|
<Tile>
|
||||||
|
<p className="voiced-muted">Received</p>
|
||||||
|
<p className="voiced-detail__amount">{formatPaise(invoice.paidPaise ?? 0)}</p>
|
||||||
|
</Tile>
|
||||||
|
<Tile>
|
||||||
|
<p className="voiced-muted">TDS deducted</p>
|
||||||
|
<p className="voiced-detail__amount">{formatPaise(invoice.tdsPaise ?? 0)}</p>
|
||||||
|
</Tile>
|
||||||
|
<Tile>
|
||||||
|
<p className="voiced-muted">Balance</p>
|
||||||
|
<p className="voiced-detail__amount">{formatPaise(balance)}</p>
|
||||||
|
</Tile>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section aria-labelledby="detail-parties">
|
||||||
|
<h2 id="detail-parties" className="voiced-detail__h2">
|
||||||
|
Parties
|
||||||
|
</h2>
|
||||||
|
<div className="voiced-detail__parties">
|
||||||
|
<dl>
|
||||||
|
<Field label="Billed to">{invoice.clientName}</Field>
|
||||||
|
<Field label="Address">
|
||||||
|
<span className="voiced-detail__pre">{invoice.clientAddress}</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="GSTIN">{invoice.clientGstin}</Field>
|
||||||
|
<Field label="PO number">{invoice.poNumber}</Field>
|
||||||
|
<Field label="Place of supply">{invoice.placeOfSupplyStateCode ? stateName(invoice.placeOfSupplyStateCode) : ""}</Field>
|
||||||
|
</dl>
|
||||||
|
<dl>
|
||||||
|
<Field label="Issued by">{vendor.vendorName}</Field>
|
||||||
|
<Field label="Address">
|
||||||
|
<span className="voiced-detail__pre">{vendor.vendorAddress}</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="GSTIN">{vendor.vendorGstin}</Field>
|
||||||
|
<Field label="PAN">{vendor.vendorPan}</Field>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="detail-items">
|
||||||
|
<h2 id="detail-items" className="voiced-detail__h2">
|
||||||
|
Items
|
||||||
|
</h2>
|
||||||
|
<TableContainer>
|
||||||
|
<Table aria-label="Invoice items" size="sm">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeader>Description</TableHeader>
|
||||||
|
<TableHeader>HSN/SAC</TableHeader>
|
||||||
|
<TableHeader className="voiced-num">Amount</TableHeader>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{invoice.items.map((item, i) => (
|
||||||
|
<TableRow key={item.id ?? i}>
|
||||||
|
<TableCell>
|
||||||
|
<span className="voiced-detail__pre">{item.description}</span>
|
||||||
|
{item.mode === "rate" ? (
|
||||||
|
<div className="voiced-muted">
|
||||||
|
{item.quantity} {item.unit} × ₹{formatAmount(item.rate)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.hsnSac || "—"}</TableCell>
|
||||||
|
<TableCell className="voiced-num">₹{formatAmount(item.amount)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<dl className="voiced-detail__totals">
|
||||||
|
<div>
|
||||||
|
<dt>Subtotal</dt>
|
||||||
|
<dd>₹{formatAmount(invoice.subtotal)}</dd>
|
||||||
|
</div>
|
||||||
|
{invoice.discount > 0 ? (
|
||||||
|
<div>
|
||||||
|
<dt>Discount</dt>
|
||||||
|
<dd>−₹{formatAmount(invoice.discount)}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{invoice.taxType === "cgst_sgst" ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<dt>CGST @ {half}%</dt>
|
||||||
|
<dd>₹{formatAmount(invoice.cgstAmount)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>SGST @ {half}%</dt>
|
||||||
|
<dd>₹{formatAmount(invoice.sgstAmount)}</dd>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{invoice.taxType === "igst" ? (
|
||||||
|
<div>
|
||||||
|
<dt>IGST @ {invoice.taxRate}%</dt>
|
||||||
|
<dd>₹{formatAmount(invoice.igstAmount)}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="voiced-detail__grand">
|
||||||
|
<dt>Total</dt>
|
||||||
|
<dd>₹{formatAmount(invoice.total)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{invoice.amountInWords ? <p className="voiced-muted">{invoice.amountInWords}</p> : null}
|
||||||
|
{invoice.notes ? <p className="voiced-detail__pre voiced-muted">{invoice.notes}</p> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="detail-payments">
|
||||||
|
<h2 id="detail-payments" className="voiced-detail__h2">
|
||||||
|
Payments
|
||||||
|
</h2>
|
||||||
|
{payments.length === 0 ? (
|
||||||
|
<p className="voiced-muted">No payments recorded.</p>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table aria-label="Payments" size="sm">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeader>Date</TableHeader>
|
||||||
|
<TableHeader>Mode</TableHeader>
|
||||||
|
<TableHeader>Reference</TableHeader>
|
||||||
|
<TableHeader className="voiced-num">Received</TableHeader>
|
||||||
|
<TableHeader className="voiced-num">TDS</TableHeader>
|
||||||
|
<TableHeader>
|
||||||
|
<span className="cds--assistive-text">Actions</span>
|
||||||
|
</TableHeader>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{payments.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell>{formatDate(p.paidOn)}</TableCell>
|
||||||
|
<TableCell>{paymentModeLabel(p.mode)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{p.reference || "—"}
|
||||||
|
{p.notes ? <div className="voiced-muted">{p.notes}</div> : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="voiced-num">{formatPaise(p.amountPaise)}</TableCell>
|
||||||
|
<TableCell className="voiced-num">{formatPaise(p.tdsPaise)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
kind="ghost"
|
||||||
|
size="sm"
|
||||||
|
hasIconOnly
|
||||||
|
renderIcon={TrashCan}
|
||||||
|
iconDescription={`Delete payment of ${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`}
|
||||||
|
tooltipPosition="left"
|
||||||
|
onClick={() => setDeleteTarget(p)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="voiced-detail__preview" aria-label="Archived PDF">
|
||||||
|
<h2 className="voiced-detail__h2">PDF as issued</h2>
|
||||||
|
{archive && !archive.archived ? (
|
||||||
|
<div className="voiced-detail__unarchived">
|
||||||
|
<p>Issued – not archived. The original PDF was never saved, so there is nothing to preview yet.</p>
|
||||||
|
<Button kind="primary" size="sm" onClick={() => void onArchive()} disabled={busy}>
|
||||||
|
{archiving ? "Working…" : "Render & archive"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PdfPreview bytes={pdf} pages={0} issues={[]} rendering={archive === null && !pdfError} mode="worker" error={pdfError} />
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecordPaymentModal
|
||||||
|
open={recordOpen}
|
||||||
|
invoiceId={invoice.id}
|
||||||
|
invoiceNumber={invoice.number}
|
||||||
|
balancePaise={balance}
|
||||||
|
onClose={() => setRecordOpen(false)}
|
||||||
|
onRecorded={(p) => {
|
||||||
|
setRecordOpen(false);
|
||||||
|
toast.success("Payment recorded", `${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`);
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
danger
|
||||||
|
modalHeading="Delete this payment?"
|
||||||
|
primaryButtonText="Delete payment"
|
||||||
|
secondaryButtonText="Keep payment"
|
||||||
|
onRequestClose={() => setDeleteTarget(null)}
|
||||||
|
onRequestSubmit={() => void onDeletePayment()}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{deleteTarget
|
||||||
|
? `${formatPaise(deleteTarget.amountPaise + deleteTarget.tdsPaise)} (cash ${formatPaise(deleteTarget.amountPaise)}, TDS ${formatPaise(deleteTarget.tdsPaise)}) received on ${formatDate(deleteTarget.paidOn)}. The invoice balance goes back up by that amount.`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={cancelOpen}
|
||||||
|
danger
|
||||||
|
modalHeading={`Cancel invoice ${invoice.number}?`}
|
||||||
|
primaryButtonText="Cancel invoice"
|
||||||
|
secondaryButtonText="Keep invoice"
|
||||||
|
onRequestClose={() => setCancelOpen(false)}
|
||||||
|
onRequestSubmit={() => void onCancelInvoice()}
|
||||||
|
>
|
||||||
|
<p style={{ marginBottom: "1rem" }}>The invoice stays in the list as cancelled and its number is not reused.</p>
|
||||||
|
<TextInput id="detailCancelReason" labelText="Reason (optional)" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+297
-167
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
InlineLoading,
|
InlineLoading,
|
||||||
InlineNotification,
|
Link,
|
||||||
Modal,
|
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
OverflowMenuItem,
|
OverflowMenuItem,
|
||||||
|
Pagination,
|
||||||
|
Search,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
@@ -14,126 +17,148 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
Tag,
|
Tag,
|
||||||
TextInput,
|
Tile,
|
||||||
} from "@carbon/react";
|
} from "@carbon/react";
|
||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { formatAmount, formatDate } from "../lib/format";
|
import { exportHistory, type HistoryFormat } from "../lib/historyExport";
|
||||||
import { createExportDeps } from "../lib/exportDeps";
|
import { formatDate } from "../lib/format";
|
||||||
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
|
import { formatPaise } from "../lib/money";
|
||||||
|
import { statusBadge } from "../lib/payments";
|
||||||
|
import { currentFyStartYear, FY_MONTHS, fyLabel } from "../lib/fiscal";
|
||||||
|
import {
|
||||||
|
clientOptions,
|
||||||
|
filterInvoices,
|
||||||
|
fyOptions,
|
||||||
|
fySummary,
|
||||||
|
NO_FILTERS,
|
||||||
|
sortInvoices,
|
||||||
|
STATUS_FILTERS,
|
||||||
|
totalsOf,
|
||||||
|
type HistoryFilters,
|
||||||
|
type SortKey,
|
||||||
|
type SortSpec,
|
||||||
|
type StatusFilter,
|
||||||
|
} from "../lib/historyFilter";
|
||||||
import type { ExportMode } from "../lib/filename";
|
import type { ExportMode } from "../lib/filename";
|
||||||
|
import type { ExportSource } from "../lib/exportFlow";
|
||||||
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
||||||
import ExportFeedback from "../components/ExportFeedback";
|
import ExportFeedback from "../components/ExportFeedback";
|
||||||
|
import { useToast } from "../components/ToastProvider";
|
||||||
import type { InvoiceSummary, Settings } from "../lib/types";
|
import type { InvoiceSummary, Settings } from "../lib/types";
|
||||||
|
|
||||||
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
|
interface Props {
|
||||||
|
settings: Settings;
|
||||||
|
active: boolean;
|
||||||
|
/** Opens the detail page of an invoice. */
|
||||||
|
onOpen: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZES = [25, 50, 100];
|
||||||
|
|
||||||
|
const COLUMNS: { key: SortKey; label: string; numeric?: boolean }[] = [
|
||||||
|
{ key: "number", label: "Number" },
|
||||||
|
{ key: "date", label: "Date" },
|
||||||
|
{ key: "client", label: "Client" },
|
||||||
|
{ key: "total", label: "Total", numeric: true },
|
||||||
|
{ key: "balance", label: "Balance", numeric: true },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function InvoiceHistory({ settings, active, onOpen }: Props) {
|
||||||
|
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 [filters, setFilters] = useState<HistoryFilters>(NO_FILTERS);
|
||||||
const [reason, setReason] = useState("");
|
const [sort, setSort] = useState<SortSpec>({ key: "date", dir: "desc" });
|
||||||
// Whether each invoice has an archived PDF; unknown (absent) until its status was read.
|
const [page, setPage] = useState(1);
|
||||||
const [archived, setArchived] = useState<Record<number, boolean>>({});
|
const [pageSize, setPageSize] = useState(PAGE_SIZES[0]);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const exp = useInvoiceExport({ settings, bank: null });
|
||||||
const exp = useInvoiceExport({
|
const [exportingHistory, setExportingHistory] = useState(false);
|
||||||
settings,
|
|
||||||
bank: null,
|
|
||||||
onInvoiceUpdated: (inv) => setArchived((a) => ({ ...a, [inv.id]: Boolean(inv.archivedPdfSha256) })),
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// The view stays mounted, so refetch every time it is shown again.
|
// The view stays mounted, so refetch every time it is shown again (a payment may have been recorded).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) void load();
|
if (active) void load();
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
// Read each invoice's archive status in the background; it is a local call per row.
|
const patch = (change: Partial<HistoryFilters>) => {
|
||||||
useEffect(() => {
|
setFilters((f) => ({ ...f, ...change }));
|
||||||
let stop = false;
|
setPage(1);
|
||||||
void (async () => {
|
};
|
||||||
for (const row of rows) {
|
|
||||||
if (stop) return;
|
const currentFy = currentFyStartYear();
|
||||||
try {
|
const tileFy = filters.fy === "all" ? currentFy : filters.fy;
|
||||||
const status = await api.archiveStatus(row.id);
|
const filtered = useMemo(() => filterInvoices(rows, filters), [rows, filters]);
|
||||||
if (!stop) setArchived((a) => ({ ...a, [row.id]: status.archived }));
|
const sorted = useMemo(() => sortInvoices(filtered, sort), [filtered, sort]);
|
||||||
} catch {
|
const totals = useMemo(() => totalsOf(filtered), [filtered]);
|
||||||
// Unknown stays unknown: the row just shows no tag.
|
const summary = useMemo(() => fySummary(rows, tileFy), [rows, tileFy]);
|
||||||
}
|
const years = useMemo(() => fyOptions(rows, currentFy), [rows, currentFy]);
|
||||||
}
|
const clients = useMemo(() => clientOptions(rows), [rows]);
|
||||||
})();
|
const filtersActive = JSON.stringify(filters) !== JSON.stringify(NO_FILTERS);
|
||||||
return () => {
|
|
||||||
stop = true;
|
const lastPage = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||||
};
|
const shown = sorted.slice((Math.min(page, lastPage) - 1) * pageSize, Math.min(page, lastPage) * pageSize);
|
||||||
}, [rows]);
|
|
||||||
|
const onSort = (key: SortKey) =>
|
||||||
|
setSort((s) => (s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: key === "date" || key === "total" || key === "balance" ? "desc" : "asc" }));
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onArchive = async (id: number) => {
|
/** Exactly the rows the table is filtered to (every page, in the current sort order). */
|
||||||
setBusyId(id);
|
const onExportHistory = async (format: HistoryFormat) => {
|
||||||
setError(null);
|
setExportingHistory(true);
|
||||||
setNotice(null);
|
|
||||||
try {
|
try {
|
||||||
const invoice = await api.getInvoice(id);
|
const done = await exportHistory(
|
||||||
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
|
{
|
||||||
if (outcome.archived) {
|
listLedger: api.listInvoiceLedger,
|
||||||
setArchived((a) => ({ ...a, [id]: true }));
|
getLastExportDir: api.getLastExportDir,
|
||||||
setNotice(`Invoice ${invoice.number} is archived.`);
|
save: ({ defaultPath, format: fmt }) =>
|
||||||
} else {
|
save({
|
||||||
setError(`Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
|
defaultPath,
|
||||||
}
|
filters: [fmt === "csv" ? { name: "CSV (Excel)", extensions: ["csv"] } : { name: "JSON", extensions: ["json"] }],
|
||||||
|
}),
|
||||||
|
writeExportFile: api.writeExportFile,
|
||||||
|
now: () => new Date(),
|
||||||
|
},
|
||||||
|
sorted,
|
||||||
|
filters,
|
||||||
|
format,
|
||||||
|
);
|
||||||
|
if (done) toast.success(`Exported ${done.count} invoices`, done.path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
toast.error("Could not export the invoices", String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setExportingHistory(false);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCancel = async () => {
|
|
||||||
if (!cancelTarget) return;
|
|
||||||
try {
|
|
||||||
await api.cancelInvoice(cancelTarget.id, reason);
|
|
||||||
setCancelTarget(null);
|
|
||||||
setReason("");
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
setCancelTarget(null);
|
|
||||||
setError(String(e));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="voiced-page">
|
<div className="voiced-page">
|
||||||
<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. Open one to see its PDF, record payments or export it.</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 ? (
|
||||||
@@ -141,98 +166,203 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
|
|||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="voiced-empty">No invoices yet. Create one from “New invoice”.</div>
|
<div className="voiced-empty">No invoices yet. Create one from “New invoice”.</div>
|
||||||
) : (
|
) : (
|
||||||
<TableContainer>
|
<>
|
||||||
<Table aria-label="Invoices">
|
<section aria-label={`Summary for ${fyLabel(tileFy)}`}>
|
||||||
<TableHead>
|
<h2 className="voiced-history__fy">{fyLabel(tileFy)}</h2>
|
||||||
<TableRow>
|
<div className="voiced-history__tiles">
|
||||||
<TableHeader>Number</TableHeader>
|
<Tile>
|
||||||
<TableHeader>Date</TableHeader>
|
<p className="voiced-muted">Invoiced</p>
|
||||||
<TableHeader>Client</TableHeader>
|
<p className="voiced-history__amount">{formatPaise(summary.invoicedPaise)}</p>
|
||||||
<TableHeader>Total</TableHeader>
|
<p className="voiced-muted">{summary.count} invoices</p>
|
||||||
<TableHeader>Status</TableHeader>
|
</Tile>
|
||||||
<TableHeader>Actions</TableHeader>
|
<Tile>
|
||||||
</TableRow>
|
<p className="voiced-muted">Received</p>
|
||||||
</TableHead>
|
<p className="voiced-history__amount">{formatPaise(summary.receivedPaise)}</p>
|
||||||
<TableBody>
|
</Tile>
|
||||||
{rows.map((row) => (
|
<Tile>
|
||||||
<TableRow key={row.id}>
|
<p className="voiced-muted">TDS deducted</p>
|
||||||
<TableCell>{row.number}</TableCell>
|
<p className="voiced-history__amount">{formatPaise(summary.tdsPaise)}</p>
|
||||||
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
|
</Tile>
|
||||||
<TableCell>{row.clientName || "—"}</TableCell>
|
<Tile>
|
||||||
<TableCell>₹{formatAmount(row.total)}</TableCell>
|
<p className="voiced-muted">Outstanding</p>
|
||||||
<TableCell>
|
<p className="voiced-history__amount">{formatPaise(summary.outstandingPaise)}</p>
|
||||||
<div className="voiced-header-action">
|
</Tile>
|
||||||
{row.status === "cancelled" ? (
|
<Tile className={summary.overdueCount > 0 ? "voiced-history__tile--overdue" : undefined}>
|
||||||
<Tag type="red" size="sm">
|
<p className="voiced-muted">Overdue</p>
|
||||||
Cancelled
|
<p className="voiced-history__amount">{formatPaise(summary.overduePaise)}</p>
|
||||||
</Tag>
|
<p className="voiced-muted">{summary.overdueCount} invoices</p>
|
||||||
) : (
|
</Tile>
|
||||||
row.status
|
</div>
|
||||||
)}
|
</section>
|
||||||
{archived[row.id] === false ? (
|
|
||||||
<Tag type="warm-gray" size="sm">
|
|
||||||
Not archived
|
|
||||||
</Tag>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="voiced-header-action">
|
|
||||||
{archived[row.id] === false ? (
|
|
||||||
<Button kind="ghost" size="sm" onClick={() => void onArchive(row.id)} disabled={busyId !== null || exp.busy}>
|
|
||||||
{busyId === row.id ? "Working…" : "Render & archive"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<OverflowMenu
|
|
||||||
aria-label={`Actions for invoice ${row.number}`}
|
|
||||||
iconDescription="Actions"
|
|
||||||
flipped
|
|
||||||
size="sm"
|
|
||||||
disabled={busyId !== null || exp.busy}
|
|
||||||
>
|
|
||||||
<OverflowMenuItem itemText="Export PDF (original as issued)" onClick={() => void onExport(row.id, "searchable", "original")} />
|
|
||||||
<OverflowMenuItem itemText="Export flattened PDF" onClick={() => void onExport(row.id, "flattened", "original")} />
|
|
||||||
<OverflowMenuItem itemText="Re-render with current template" onClick={() => void onExport(row.id, "searchable", "rerender")} />
|
|
||||||
{row.status === "issued" ? (
|
|
||||||
<OverflowMenuItem
|
|
||||||
itemText="Cancel invoice"
|
|
||||||
hasDivider
|
|
||||||
isDelete
|
|
||||||
onClick={() => {
|
|
||||||
setReason("");
|
|
||||||
setCancelTarget(row);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</OverflowMenu>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal
|
<div className="voiced-history__filters" role="search">
|
||||||
open={cancelTarget !== null}
|
<Search
|
||||||
danger
|
id="historySearch"
|
||||||
modalHeading={`Cancel invoice ${cancelTarget?.number ?? ""}?`}
|
size="md"
|
||||||
primaryButtonText="Cancel invoice"
|
labelText="Search invoices"
|
||||||
secondaryButtonText="Keep invoice"
|
placeholder="Number, client or PO number"
|
||||||
onRequestClose={() => setCancelTarget(null)}
|
value={filters.search}
|
||||||
onRequestSubmit={onCancel}
|
onChange={(e) => patch({ search: e.target.value })}
|
||||||
>
|
onClear={() => patch({ search: "" })}
|
||||||
<p style={{ marginBottom: "1rem" }}>
|
/>
|
||||||
The invoice stays in the list as cancelled and its number is not reused.
|
<Select id="historyStatus" size="md" labelText="Status" value={filters.status} onChange={(e) => patch({ status: e.target.value as StatusFilter })}>
|
||||||
</p>
|
{STATUS_FILTERS.map((s) => (
|
||||||
<TextInput
|
<SelectItem key={s.id} value={s.id} text={s.label} />
|
||||||
id="cancelReason"
|
))}
|
||||||
labelText="Reason (optional)"
|
</Select>
|
||||||
value={reason}
|
<Select
|
||||||
onChange={(e) => setReason(e.target.value)}
|
id="historyFy"
|
||||||
/>
|
size="md"
|
||||||
</Modal>
|
labelText="Financial year"
|
||||||
|
value={String(filters.fy)}
|
||||||
|
onChange={(e) => patch({ fy: e.target.value === "all" ? "all" : Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
<SelectItem value="all" text="All years" />
|
||||||
|
{years.map((y) => (
|
||||||
|
<SelectItem key={y} value={String(y)} text={fyLabel(y)} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
id="historyMonth"
|
||||||
|
size="md"
|
||||||
|
labelText="Month"
|
||||||
|
value={String(filters.month)}
|
||||||
|
onChange={(e) => patch({ month: e.target.value === "all" ? "all" : Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
<SelectItem value="all" text="All months" />
|
||||||
|
{FY_MONTHS.map((m) => (
|
||||||
|
<SelectItem key={m.month} value={String(m.month)} text={m.label} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select id="historyClient" size="md" labelText="Client" value={filters.client} onChange={(e) => patch({ client: e.target.value })}>
|
||||||
|
<SelectItem value="all" text="All clients" />
|
||||||
|
{clients.map((c) => (
|
||||||
|
<SelectItem key={c} value={c} text={c} />
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{filtersActive ? (
|
||||||
|
<Button kind="ghost" size="md" onClick={() => patch({ ...NO_FILTERS })}>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="voiced-history__export">
|
||||||
|
<Button kind="tertiary" size="md" disabled={sorted.length === 0 || exportingHistory} onClick={() => void onExportHistory("csv")}>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
<Button kind="ghost" size="md" disabled={sorted.length === 0 || exportingHistory} onClick={() => void onExportHistory("json")}>
|
||||||
|
Export JSON
|
||||||
|
</Button>
|
||||||
|
<span className="voiced-muted">
|
||||||
|
{filtersActive ? `The ${sorted.length} filtered invoices` : `All ${sorted.length} invoices`}, for your accountant
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sorted.length === 0 ? (
|
||||||
|
<div className="voiced-empty">No invoices match these filters.</div>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table aria-label="Invoices">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{COLUMNS.map((c) => (
|
||||||
|
<TableHeader
|
||||||
|
key={c.key}
|
||||||
|
isSortable
|
||||||
|
isSortHeader={sort.key === c.key}
|
||||||
|
sortDirection={sort.key === c.key ? (sort.dir === "asc" ? "ASC" : "DESC") : "NONE"}
|
||||||
|
onClick={() => onSort(c.key)}
|
||||||
|
className={c.numeric ? "voiced-num" : undefined}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</TableHeader>
|
||||||
|
))}
|
||||||
|
<TableHeader>
|
||||||
|
<span className="cds--assistive-text">Actions</span>
|
||||||
|
</TableHeader>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{shown.map((row) => {
|
||||||
|
const badge = statusBadge(row.status, row.paymentStatus);
|
||||||
|
return (
|
||||||
|
<TableRow key={row.id} className="voiced-history__row" onClick={() => onOpen(row.id)}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href="#"
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpen(row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.number}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
|
||||||
|
<TableCell>{row.clientName || "—"}</TableCell>
|
||||||
|
<TableCell className="voiced-num">{formatPaise(row.totalPaise)}</TableCell>
|
||||||
|
<TableCell className="voiced-num">{row.status === "cancelled" ? "—" : formatPaise(row.balancePaise)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tag type={badge.type} size="sm">
|
||||||
|
{badge.label}
|
||||||
|
</Tag>
|
||||||
|
{row.status === "issued" && row.paymentStatus === "overdue" && row.paidPaise + row.tdsPaise > 0 ? (
|
||||||
|
<Tag type="blue" size="sm">
|
||||||
|
Partly paid
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||||
|
<OverflowMenu
|
||||||
|
aria-label={`Actions for invoice ${row.number}`}
|
||||||
|
iconDescription="Actions"
|
||||||
|
flipped
|
||||||
|
size="sm"
|
||||||
|
disabled={busyId !== null || exp.busy}
|
||||||
|
>
|
||||||
|
<OverflowMenuItem itemText="Open" onClick={() => onOpen(row.id)} />
|
||||||
|
<OverflowMenuItem itemText="Export PDF (original as issued)" onClick={() => void onExport(row.id, "searchable", "original")} />
|
||||||
|
<OverflowMenuItem itemText="Export flattened PDF" onClick={() => void onExport(row.id, "flattened", "original")} />
|
||||||
|
</OverflowMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<div className="voiced-history__totals" role="status" aria-label="Totals for the filtered invoices">
|
||||||
|
<span className="voiced-muted">
|
||||||
|
{filtersActive ? "Filtered" : "All"}: {totals.count} invoices
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Invoiced <strong>{formatPaise(totals.invoicedPaise)}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Received <strong>{formatPaise(totals.receivedPaise)}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
TDS <strong>{formatPaise(totals.tdsPaise)}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Outstanding <strong>{formatPaise(totals.outstandingPaise)}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Pagination
|
||||||
|
page={Math.min(page, lastPage)}
|
||||||
|
pageSize={pageSize}
|
||||||
|
pageSizes={PAGE_SIZES}
|
||||||
|
totalItems={sorted.length}
|
||||||
|
onChange={({ page: p, pageSize: s }: { page: number; pageSize: number }) => {
|
||||||
|
setPage(s !== pageSize ? 1 : p);
|
||||||
|
setPageSize(s);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-19
@@ -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"));
|
||||||
@@ -79,6 +84,9 @@ interface NewInvoiceProps {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
/** Ask the shell to show this view (used by Ctrl+N from another tab). */
|
/** Ask the shell to show this view (used by Ctrl+N from another tab). */
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
|
/** A draft to open as soon as the form is shown (set by "Duplicate" on the invoice page). */
|
||||||
|
openDraftId?: number | null;
|
||||||
|
onDraftOpened?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTOSAVE_MS = 1000;
|
const AUTOSAVE_MS = 1000;
|
||||||
@@ -89,7 +97,8 @@ const formatStamp = (iso: string) => {
|
|||||||
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
|
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NewInvoice({ settings, onSettingsChange, active, onActivate }: NewInvoiceProps) {
|
export default function NewInvoice({ settings, onSettingsChange, active, onActivate, openDraftId = null, onDraftOpened }: 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 +127,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 +200,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 +208,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 +352,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 +415,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,15 +561,23 @@ 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));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openDraftId === null || !active) return;
|
||||||
|
onDraftOpened?.();
|
||||||
|
void resumeDraft(openDraftId);
|
||||||
|
// resumeDraft closes over fresh state each render; only a new request should trigger it.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [openDraftId, active]);
|
||||||
|
|
||||||
const removeDraft = async (id: number) => {
|
const removeDraft = async (id: number) => {
|
||||||
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 +638,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 +723,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 +799,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 +892,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 +1195,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(`INV/${new Date().getFullYear()}-`);
|
const [prefix, setPrefix] = useState(`INV/${new Date().getFullYear()}-`);
|
||||||
const [padding, setPadding] = useState(3);
|
const [padding, setPadding] = useState(3);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const seeded = useRef(false);
|
const seeded = useRef(false);
|
||||||
|
|
||||||
@@ -44,9 +50,8 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
|
|||||||
setPadding(current.padding);
|
setPadding(current.padding);
|
||||||
seeded.current = true;
|
seeded.current = true;
|
||||||
}
|
}
|
||||||
setError(null);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
toast.error("Could not load series", String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -65,47 +70,34 @@ export default function SeriesSettings({ active: visible }: { active: boolean })
|
|||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
|
||||||
setNotice(null);
|
|
||||||
try {
|
try {
|
||||||
const created = await api.startNewSeries(prefix, padding);
|
const created = await api.startNewSeries(prefix, padding);
|
||||||
setNotice(`New series started. Next invoice: ${created.nextInvoiceNumber}`);
|
toast.success("New series started", `Next invoice: ${created.nextInvoiceNumber}`);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
toast.error("Could not start a new series", String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pageClass = embedded ? undefined : "voiced-page";
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="voiced-page">
|
<div className={pageClass}>
|
||||||
<InlineLoading description="Loading series" />
|
<InlineLoading description="Loading series" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="voiced-page">
|
<div className={pageClass}>
|
||||||
<h1 className="voiced-page__title">Invoice series</h1>
|
{embedded ? null : <h1 className="voiced-page__title">Invoice series</h1>}
|
||||||
<p className="voiced-page__subtitle">
|
<p className={embedded ? "voiced-muted" : "voiced-page__subtitle"}>
|
||||||
The active series drives the automatic invoice numbers. Start a new series to reset the counter.
|
The active series drives the automatic invoice numbers. Start a new series to reset the counter.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<InlineNotification kind="error" lowContrast title="Something went wrong" subtitle={error} />
|
|
||||||
) : null}
|
|
||||||
{notice ? (
|
|
||||||
<InlineNotification
|
|
||||||
kind="success"
|
|
||||||
lowContrast
|
|
||||||
title="Done"
|
|
||||||
subtitle={notice}
|
|
||||||
onCloseButtonClick={() => setNotice(null)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Grid narrow style={{ marginBottom: "2rem" }}>
|
<Grid narrow style={{ marginBottom: "2rem" }}>
|
||||||
<Column sm={4} md={4} lg={6}>
|
<Column sm={4} md={4} lg={6}>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
Reference in New Issue
Block a user