feat: add client billing fields to database and Rust backend
This commit is contained in:
@@ -9,6 +9,11 @@ pub struct Client {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub email: Option<String>,
|
pub email: Option<String>,
|
||||||
pub address: 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)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -59,13 +64,20 @@ pub struct Invoice {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
pub fn get_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_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| {
|
let clients = stmt.query_map([], |row| {
|
||||||
Ok(Client {
|
Ok(Client {
|
||||||
id: Some(row.get(0)?),
|
id: Some(row.get(0)?),
|
||||||
name: row.get(1)?,
|
name: row.get(1)?,
|
||||||
email: row.get(2)?,
|
email: row.get(2)?,
|
||||||
address: row.get(3)?,
|
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())?;
|
}).map_err(|e| e.to_string())?;
|
||||||
clients.collect::<Result<Vec<_>, _>>().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> {
|
pub fn create_client(state: State<AppState>, client: Client) -> Result<i64, String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO clients (name, email, address) VALUES (?1, ?2, ?3)",
|
"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],
|
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())?;
|
).map_err(|e| e.to_string())?;
|
||||||
Ok(conn.last_insert_rowid())
|
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> {
|
pub fn update_client(state: State<AppState>, client: Client) -> Result<(), String> {
|
||||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE clients SET name = ?1, email = ?2, address = ?3 WHERE id = ?4",
|
"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.id],
|
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())?;
|
).map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
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)
|
WHERE date(start_time) >= date(?1) AND date(start_time) <= date(?2)
|
||||||
ORDER BY start_time DESC"
|
ORDER BY start_time DESC"
|
||||||
).map_err(|e| e.to_string())?;
|
).map_err(|e| e.to_string())?;
|
||||||
stmt.query_map(params![start, end], |row| {
|
let rows = stmt.query_map(params![start, end], |row| {
|
||||||
Ok(TimeEntry {
|
Ok(TimeEntry {
|
||||||
id: Some(row.get(0)?),
|
id: Some(row.get(0)?),
|
||||||
project_id: row.get(1)?,
|
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)?,
|
end_time: row.get(5)?,
|
||||||
duration: row.get(6)?,
|
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(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, project_id, task_id, description, start_time, end_time, duration
|
"SELECT id, project_id, task_id, description, start_time, end_time, duration
|
||||||
FROM time_entries ORDER BY start_time DESC LIMIT 100"
|
FROM time_entries ORDER BY start_time DESC LIMIT 100"
|
||||||
).map_err(|e| e.to_string())?;
|
).map_err(|e| e.to_string())?;
|
||||||
stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok(TimeEntry {
|
Ok(TimeEntry {
|
||||||
id: Some(row.get(0)?),
|
id: Some(row.get(0)?),
|
||||||
project_id: row.get(1)?,
|
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)?,
|
end_time: row.get(5)?,
|
||||||
duration: row.get(6)?,
|
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)
|
Ok(query)
|
||||||
@@ -262,9 +276,9 @@ pub fn get_reports(state: State<AppState>, start_date: String, end_date: String)
|
|||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
).map_err(|e| e.to_string())?;
|
).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(
|
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
|
FROM time_entries t
|
||||||
JOIN projects p ON t.project_id = p.id
|
JOIN projects p ON t.project_id = p.id
|
||||||
WHERE date(t.start_time) >= date(?1) AND date(t.start_time) <= date(?2)
|
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| {
|
let by_project: Vec<serde_json::Value> = stmt.query_map(params![start_date, end_date], |row| {
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"name": row.get::<_, String>(0)?,
|
"project_id": row.get::<_, i64>(0)?,
|
||||||
"color": row.get::<_, String>(1)?,
|
"name": row.get::<_, String>(1)?,
|
||||||
"duration": row.get::<_, i64>(2)?
|
"color": row.get::<_, String>(2)?,
|
||||||
|
"total_seconds": row.get::<_, i64>(3)?
|
||||||
}))
|
}))
|
||||||
}).map_err(|e| e.to_string())?
|
}).map_err(|e| e.to_string())?
|
||||||
.collect::<Result<Vec<_>, _>>().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())
|
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
|
// Settings commands
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_settings(state: State<AppState>) -> Result<std::collections::HashMap<String, String>, String> {
|
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())?;
|
).map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS projects (
|
"CREATE TABLE IF NOT EXISTS projects (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -98,7 +118,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
|||||||
|
|
||||||
// Insert default settings
|
// Insert default settings
|
||||||
conn.execute(
|
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(
|
conn.execute(
|
||||||
|
|||||||
Reference in New Issue
Block a user