From 5ab96769ac2e3285b4148800ad34258f3ec50dac Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 17 Feb 2026 22:52:51 +0200 Subject: [PATCH] feat: add client billing fields to database and Rust backend --- src-tauri/src/commands.rs | 162 ++++++++++++++++++++++++++++++++++---- src-tauri/src/database.rs | 22 +++++- 2 files changed, 169 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f04cdff..b569408 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -9,6 +9,11 @@ pub struct Client { pub name: String, pub email: Option, pub address: Option, + pub company: Option, + pub phone: Option, + pub tax_id: Option, + pub payment_terms: Option, + pub notes: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -59,13 +64,20 @@ pub struct Invoice { #[tauri::command] pub fn get_clients(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; - let mut stmt = conn.prepare("SELECT id, name, email, address FROM clients ORDER BY name").map_err(|e| e.to_string())?; + let mut stmt = conn.prepare( + "SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes FROM clients ORDER BY name" + ).map_err(|e| e.to_string())?; let clients = stmt.query_map([], |row| { Ok(Client { id: Some(row.get(0)?), name: row.get(1)?, email: row.get(2)?, address: row.get(3)?, + company: row.get(4)?, + phone: row.get(5)?, + tax_id: row.get(6)?, + payment_terms: row.get(7)?, + notes: row.get(8)?, }) }).map_err(|e| e.to_string())?; clients.collect::, _>>().map_err(|e| e.to_string()) @@ -75,8 +87,8 @@ pub fn get_clients(state: State) -> Result, String> { pub fn create_client(state: State, client: Client) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( - "INSERT INTO clients (name, email, address) VALUES (?1, ?2, ?3)", - params![client.name, client.email, client.address], + "INSERT INTO clients (name, email, address, company, phone, tax_id, payment_terms, notes) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } @@ -85,8 +97,8 @@ pub fn create_client(state: State, client: Client) -> Result, client: Client) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( - "UPDATE clients SET name = ?1, email = ?2, address = ?3 WHERE id = ?4", - params![client.name, client.email, client.address, client.id], + "UPDATE clients SET name = ?1, email = ?2, address = ?3, company = ?4, phone = ?5, tax_id = ?6, payment_terms = ?7, notes = ?8 WHERE id = ?9", + params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes, client.id], ).map_err(|e| e.to_string())?; Ok(()) } @@ -187,7 +199,7 @@ pub fn get_time_entries(state: State, start_date: Option, end_ WHERE date(start_time) >= date(?1) AND date(start_time) <= date(?2) ORDER BY start_time DESC" ).map_err(|e| e.to_string())?; - stmt.query_map(params![start, end], |row| { + let rows = stmt.query_map(params![start, end], |row| { Ok(TimeEntry { id: Some(row.get(0)?), project_id: row.get(1)?, @@ -197,14 +209,15 @@ pub fn get_time_entries(state: State, start_date: Option, end_ end_time: row.get(5)?, duration: row.get(6)?, }) - }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())? + }).map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string())? } _ => { let mut stmt = conn.prepare( "SELECT id, project_id, task_id, description, start_time, end_time, duration FROM time_entries ORDER BY start_time DESC LIMIT 100" ).map_err(|e| e.to_string())?; - stmt.query_map([], |row| { + let rows = stmt.query_map([], |row| { Ok(TimeEntry { id: Some(row.get(0)?), project_id: row.get(1)?, @@ -214,7 +227,8 @@ pub fn get_time_entries(state: State, start_date: Option, end_ end_time: row.get(5)?, duration: row.get(6)?, }) - }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())? + }).map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string())? } }; Ok(query) @@ -262,9 +276,9 @@ pub fn get_reports(state: State, start_date: String, end_date: String) |row| row.get(0), ).map_err(|e| e.to_string())?; - // By project + // By project - include project_id so frontend can look up name/color/rate let mut stmt = conn.prepare( - "SELECT p.name, p.color, SUM(t.duration) as total_duration + "SELECT p.id, p.name, p.color, SUM(t.duration) as total_duration FROM time_entries t JOIN projects p ON t.project_id = p.id WHERE date(t.start_time) >= date(?1) AND date(t.start_time) <= date(?2) @@ -274,9 +288,10 @@ pub fn get_reports(state: State, start_date: String, end_date: String) let by_project: Vec = stmt.query_map(params![start_date, end_date], |row| { Ok(serde_json::json!({ - "name": row.get::<_, String>(0)?, - "color": row.get::<_, String>(1)?, - "duration": row.get::<_, i64>(2)? + "project_id": row.get::<_, i64>(0)?, + "name": row.get::<_, String>(1)?, + "color": row.get::<_, String>(2)?, + "total_seconds": row.get::<_, i64>(3)? })) }).map_err(|e| e.to_string())? .collect::, _>>().map_err(|e| e.to_string())?; @@ -327,6 +342,27 @@ pub fn get_invoices(state: State) -> Result, String> { invoices.collect::, _>>().map_err(|e| e.to_string()) } +#[tauri::command] +pub fn update_invoice(state: State, invoice: Invoice) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute( + "UPDATE invoices SET client_id = ?1, invoice_number = ?2, date = ?3, due_date = ?4, + subtotal = ?5, tax_rate = ?6, tax_amount = ?7, discount = ?8, total = ?9, notes = ?10, status = ?11 + WHERE id = ?12", + params![invoice.client_id, invoice.invoice_number, invoice.date, invoice.due_date, + invoice.subtotal, invoice.tax_rate, invoice.tax_amount, invoice.discount, + invoice.total, invoice.notes, invoice.status, invoice.id], + ).map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub fn delete_invoice(state: State, id: i64) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM invoices WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; + Ok(()) +} + // Settings commands #[tauri::command] pub fn get_settings(state: State) -> Result, String> { @@ -353,3 +389,101 @@ pub fn update_settings(state: State, key: String, value: String) -> Re ).map_err(|e| e.to_string())?; Ok(()) } + +// Export all data as JSON +#[tauri::command] +pub fn export_data(state: State) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + + let clients = { + let mut stmt = conn.prepare("SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes FROM clients").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "name": row.get::<_, String>(1)?, + "email": row.get::<_, Option>(2)?, + "address": row.get::<_, Option>(3)?, + "company": row.get::<_, Option>(4)?, + "phone": row.get::<_, Option>(5)?, + "tax_id": row.get::<_, Option>(6)?, + "payment_terms": row.get::<_, Option>(7)?, + "notes": row.get::<_, Option>(8)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let projects = { + let mut stmt = conn.prepare("SELECT id, client_id, name, hourly_rate, color, archived FROM projects").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "client_id": row.get::<_, Option>(1)?, + "name": row.get::<_, String>(2)?, + "hourly_rate": row.get::<_, f64>(3)?, + "color": row.get::<_, String>(4)?, + "archived": row.get::<_, i32>(5)? != 0 + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let time_entries = { + let mut stmt = conn.prepare("SELECT id, project_id, task_id, description, start_time, end_time, duration FROM time_entries").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "project_id": row.get::<_, i64>(1)?, + "task_id": row.get::<_, Option>(2)?, + "description": row.get::<_, Option>(3)?, + "start_time": row.get::<_, String>(4)?, + "end_time": row.get::<_, Option>(5)?, + "duration": row.get::<_, i64>(6)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let invoices = { + let mut stmt = conn.prepare("SELECT id, client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status FROM invoices").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "client_id": row.get::<_, i64>(1)?, + "invoice_number": row.get::<_, String>(2)?, + "date": row.get::<_, String>(3)?, + "due_date": row.get::<_, Option>(4)?, + "subtotal": row.get::<_, f64>(5)?, + "tax_rate": row.get::<_, f64>(6)?, + "tax_amount": row.get::<_, f64>(7)?, + "discount": row.get::<_, f64>(8)?, + "total": row.get::<_, f64>(9)?, + "notes": row.get::<_, Option>(10)?, + "status": row.get::<_, String>(11)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + Ok(serde_json::json!({ + "clients": clients, + "projects": projects, + "time_entries": time_entries, + "invoices": invoices + })) +} + +// Clear all data +#[tauri::command] +pub fn clear_all_data(state: State) -> Result<(), String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + conn.execute_batch( + "DELETE FROM invoice_items; + DELETE FROM invoices; + DELETE FROM time_entries; + DELETE FROM tasks; + DELETE FROM projects; + DELETE FROM clients;" + ).map_err(|e| e.to_string())?; + Ok(()) +} diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 4675e22..1f44815 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -12,6 +12,26 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // Migrate clients table — add new columns (safe to re-run) + let migration_columns = [ + "ALTER TABLE clients ADD COLUMN company TEXT", + "ALTER TABLE clients ADD COLUMN phone TEXT", + "ALTER TABLE clients ADD COLUMN tax_id TEXT", + "ALTER TABLE clients ADD COLUMN payment_terms TEXT", + "ALTER TABLE clients ADD COLUMN notes TEXT", + ]; + for sql in &migration_columns { + match conn.execute(sql, []) { + Ok(_) => {} + Err(e) => { + let msg = e.to_string(); + if !msg.contains("duplicate column") { + return Err(e); + } + } + } + } + conn.execute( "CREATE TABLE IF NOT EXISTS projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -98,7 +118,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { // Insert default settings conn.execute( - "INSERT OR IGNORE INTO settings (key, value) VALUES ('default_hourly_rate', '50')", + "INSERT OR IGNORE INTO settings (key, value) VALUES ('hourly_rate', '50')", [], )?; conn.execute(