feat: add client billing fields to database and Rust backend

This commit is contained in:
Your Name
2026-02-17 22:52:51 +02:00
parent 29a0510192
commit 5ab96769ac
2 changed files with 169 additions and 15 deletions

View File

@@ -9,6 +9,11 @@ pub struct Client {
pub name: String,
pub email: Option<String>,
pub address: Option<String>,
pub company: Option<String>,
pub phone: Option<String>,
pub tax_id: Option<String>,
pub payment_terms: Option<String>,
pub notes: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -59,13 +64,20 @@ pub struct Invoice {
#[tauri::command]
pub fn get_clients(state: State<AppState>) -> Result<Vec<Client>, 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
@@ -75,8 +87,8 @@ pub fn get_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
pub fn create_client(state: State<AppState>, client: Client) -> Result<i64, String> {
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<AppState>, client: Client) -> Result<i64, Stri
pub fn update_client(state: State<AppState>, 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<AppState>, start_date: Option<String>, 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<AppState>, start_date: Option<String>, end_
end_time: row.get(5)?,
duration: row.get(6)?,
})
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().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<AppState>, start_date: Option<String>, end_
end_time: row.get(5)?,
duration: row.get(6)?,
})
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
}).map_err(|e| e.to_string())?;
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
}
};
Ok(query)
@@ -262,9 +276,9 @@ pub fn get_reports(state: State<AppState>, 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<AppState>, start_date: String, end_date: String)
let by_project: Vec<serde_json::Value> = 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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
@@ -327,6 +342,27 @@ pub fn get_invoices(state: State<AppState>) -> Result<Vec<Invoice>, String> {
invoices.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn update_invoice(state: State<AppState>, 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<AppState>, 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<AppState>) -> Result<std::collections::HashMap<String, String>, String> {
@@ -353,3 +389,101 @@ pub fn update_settings(state: State<AppState>, 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<AppState>) -> Result<serde_json::Value, String> {
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<serde_json::Value> = stmt.query_map([], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"name": row.get::<_, String>(1)?,
"email": row.get::<_, Option<String>>(2)?,
"address": row.get::<_, Option<String>>(3)?,
"company": row.get::<_, Option<String>>(4)?,
"phone": row.get::<_, Option<String>>(5)?,
"tax_id": row.get::<_, Option<String>>(6)?,
"payment_terms": row.get::<_, Option<String>>(7)?,
"notes": row.get::<_, Option<String>>(8)?
}))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().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<serde_json::Value> = stmt.query_map([], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"client_id": row.get::<_, Option<i64>>(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::<Result<Vec<_>, _>>().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<serde_json::Value> = stmt.query_map([], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"project_id": row.get::<_, i64>(1)?,
"task_id": row.get::<_, Option<i64>>(2)?,
"description": row.get::<_, Option<String>>(3)?,
"start_time": row.get::<_, String>(4)?,
"end_time": row.get::<_, Option<String>>(5)?,
"duration": row.get::<_, i64>(6)?
}))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().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<serde_json::Value> = 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<String>>(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<String>>(10)?,
"status": row.get::<_, String>(11)?
}))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().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<AppState>) -> 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(())
}

View File

@@ -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(