use crate::AppState; use crate::os_detection; use rusqlite::params; use serde::{Deserialize, Serialize}; use tauri::{Manager, State}; #[derive(Debug, Serialize, Deserialize)] pub struct Client { pub id: Option, 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, pub currency: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct Project { pub id: Option, pub client_id: Option, pub name: String, pub hourly_rate: f64, pub color: String, pub archived: bool, pub budget_hours: Option, pub budget_amount: Option, pub rounding_override: Option, pub notes: Option, pub currency: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct Task { pub id: Option, pub project_id: i64, pub name: String, pub estimated_hours: Option, pub hourly_rate: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct TimeEntry { pub id: Option, pub project_id: i64, pub task_id: Option, pub description: Option, pub start_time: String, pub end_time: Option, pub duration: i64, pub billable: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct Invoice { pub id: Option, pub client_id: i64, pub invoice_number: String, pub date: String, pub due_date: Option, pub subtotal: f64, pub tax_rate: f64, pub tax_amount: f64, pub discount: f64, pub total: f64, pub notes: Option, pub status: String, pub template_id: Option, } // Client commands #[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, company, phone, tax_id, payment_terms, notes, currency 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)?, currency: row.get(9)?, }) }).map_err(|e| e.to_string())?; clients.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] 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, company, phone, tax_id, payment_terms, notes, currency) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes, client.currency], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn update_client(state: State, 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, company = ?4, phone = ?5, tax_id = ?6, payment_terms = ?7, notes = ?8, currency = ?9 WHERE id = ?10", params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes, client.currency, client.id], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn get_client_dependents(state: State, client_id: i64) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let project_count: i64 = conn.query_row( "SELECT COUNT(*) FROM projects WHERE client_id = ?1", params![client_id], |row| row.get(0) ).map_err(|e| e.to_string())?; let invoice_count: i64 = conn.query_row( "SELECT COUNT(*) FROM invoices WHERE client_id = ?1", params![client_id], |row| row.get(0) ).map_err(|e| e.to_string())?; let expense_count: i64 = conn.query_row( "SELECT COUNT(*) FROM expenses WHERE client_id = ?1", params![client_id], |row| row.get(0) ).map_err(|e| e.to_string())?; Ok(serde_json::json!({ "projects": project_count, "invoices": invoice_count, "expenses": expense_count })) } #[tauri::command] pub fn delete_client(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute_batch("BEGIN TRANSACTION").map_err(|e| e.to_string())?; let result = (|| -> Result<(), rusqlite::Error> { let project_ids: Vec = { let mut stmt = conn.prepare("SELECT id FROM projects WHERE client_id = ?1")?; let rows = stmt.query_map(params![id], |row| row.get(0))?; rows.filter_map(|r| r.ok()).collect() }; for pid in &project_ids { conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![pid])?; conn.execute("DELETE FROM invoice_items WHERE time_entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![pid])?; conn.execute("DELETE FROM entry_tags WHERE entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![pid])?; conn.execute("DELETE FROM time_entries WHERE project_id = ?1", params![pid])?; conn.execute("DELETE FROM tracked_apps WHERE project_id = ?1", params![pid])?; conn.execute("DELETE FROM favorites WHERE project_id = ?1", params![pid])?; conn.execute("DELETE FROM recurring_entries WHERE project_id = ?1", params![pid])?; conn.execute("DELETE FROM entry_templates WHERE project_id = ?1", params![pid])?; conn.execute("DELETE FROM timesheet_rows WHERE project_id = ?1", params![pid])?; conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![pid])?; } conn.execute("DELETE FROM expenses WHERE client_id = ?1", params![id])?; conn.execute("DELETE FROM invoice_payments WHERE invoice_id IN (SELECT id FROM invoices WHERE client_id = ?1)", params![id])?; conn.execute("DELETE FROM invoice_items WHERE invoice_id IN (SELECT id FROM invoices WHERE client_id = ?1)", params![id])?; conn.execute("DELETE FROM invoices WHERE client_id = ?1", params![id])?; conn.execute("DELETE FROM recurring_invoices WHERE client_id = ?1", params![id])?; conn.execute("DELETE FROM projects WHERE client_id = ?1", params![id])?; conn.execute("DELETE FROM clients WHERE id = ?1", params![id])?; Ok(()) })(); match result { Ok(()) => { conn.execute_batch("COMMIT").map_err(|e| e.to_string())?; Ok(()) } Err(e) => { conn.execute_batch("ROLLBACK").ok(); Err(e.to_string()) } } } // Project commands #[tauri::command] pub fn get_projects(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency FROM projects ORDER BY name" ).map_err(|e| e.to_string())?; let projects = stmt.query_map([], |row| { Ok(Project { id: Some(row.get(0)?), client_id: row.get(1)?, name: row.get(2)?, hourly_rate: row.get(3)?, color: row.get(4)?, archived: row.get::<_, i32>(5)? != 0, budget_hours: row.get(6)?, budget_amount: row.get(7)?, rounding_override: row.get(8)?, notes: row.get(9)?, currency: row.get(10)?, }) }).map_err(|e| e.to_string())?; projects.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn create_project(state: State, project: Project) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO projects (client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.budget_hours, project.budget_amount, project.rounding_override, project.notes, project.currency], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn update_project(state: State, project: Project) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "UPDATE projects SET client_id = ?1, name = ?2, hourly_rate = ?3, color = ?4, archived = ?5, budget_hours = ?6, budget_amount = ?7, rounding_override = ?8, notes = ?9, currency = ?10 WHERE id = ?11", params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.budget_hours, project.budget_amount, project.rounding_override, project.notes, project.currency, project.id], ).map_err(|e| e.to_string())?; Ok(()) } #[derive(serde::Serialize)] pub struct DependentCounts { pub time_entries: i64, pub favorites: i64, pub expenses: i64, pub recurring_entries: i64, pub timeline_events: i64, pub tasks: i64, pub tracked_apps: i64, } #[tauri::command] pub fn get_project_dependents(state: State, project_id: i64) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let count = |table: &str, col: &str| -> i64 { conn.query_row( &format!("SELECT COUNT(*) FROM {} WHERE {} = ?1", table, col), params![project_id], |row| row.get(0), ).unwrap_or(0) }; Ok(DependentCounts { time_entries: count("time_entries", "project_id"), favorites: count("favorites", "project_id"), expenses: count("expenses", "project_id"), recurring_entries: count("recurring_entries", "project_id"), timeline_events: count("timeline_events", "project_id"), tasks: count("tasks", "project_id"), tracked_apps: count("tracked_apps", "project_id"), }) } #[tauri::command] pub fn delete_project(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute_batch("BEGIN TRANSACTION").map_err(|e| e.to_string())?; let result = (|| -> Result<(), rusqlite::Error> { conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM invoice_items WHERE time_entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![id])?; conn.execute( "DELETE FROM entry_tags WHERE entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![id], )?; conn.execute("DELETE FROM time_entries WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM favorites WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM expenses WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM recurring_entries WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM tracked_apps WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM entry_templates WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM timesheet_rows WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![id])?; conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?; Ok(()) })(); match result { Ok(()) => { conn.execute_batch("COMMIT").map_err(|e| e.to_string())?; Ok(()) } Err(e) => { conn.execute_batch("ROLLBACK").ok(); Err(e.to_string()) } } } // Task commands #[tauri::command] pub fn get_tasks(state: State, project_id: i64) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours, hourly_rate FROM tasks WHERE project_id = ?1 ORDER BY name").map_err(|e| e.to_string())?; let tasks = stmt.query_map(params![project_id], |row| { Ok(Task { id: Some(row.get(0)?), project_id: row.get(1)?, name: row.get(2)?, estimated_hours: row.get(3)?, hourly_rate: row.get(4)?, }) }).map_err(|e| e.to_string())?; tasks.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn create_task(state: State, task: Task) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO tasks (project_id, name, estimated_hours, hourly_rate) VALUES (?1, ?2, ?3, ?4)", params![task.project_id, task.name, task.estimated_hours, task.hourly_rate], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn delete_task(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM entry_templates WHERE task_id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute("DELETE FROM timesheet_rows WHERE task_id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute("DELETE FROM recurring_entries WHERE task_id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute("DELETE FROM tasks WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn update_task(state: State, task: Task) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "UPDATE tasks SET name = ?1, estimated_hours = ?2, hourly_rate = ?3 WHERE id = ?4", params![task.name, task.estimated_hours, task.hourly_rate, task.id], ).map_err(|e| e.to_string())?; Ok(()) } // Time entry commands #[tauri::command] pub fn get_time_entries(state: State, start_date: Option, end_date: Option) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let query = match (start_date, end_date) { (Some(start), Some(end)) => { let mut stmt = conn.prepare( "SELECT id, project_id, task_id, description, start_time, end_time, duration, billable FROM time_entries WHERE date(start_time) >= date(?1) AND date(start_time) <= date(?2) ORDER BY start_time DESC" ).map_err(|e| e.to_string())?; let rows = stmt.query_map(params![start, end], |row| { Ok(TimeEntry { id: Some(row.get(0)?), project_id: row.get(1)?, task_id: row.get(2)?, description: row.get(3)?, start_time: row.get(4)?, end_time: row.get(5)?, duration: row.get(6)?, billable: row.get(7)?, }) }).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, billable FROM time_entries ORDER BY start_time DESC LIMIT 100" ).map_err(|e| e.to_string())?; let rows = stmt.query_map([], |row| { Ok(TimeEntry { id: Some(row.get(0)?), project_id: row.get(1)?, task_id: row.get(2)?, description: row.get(3)?, start_time: row.get(4)?, end_time: row.get(5)?, duration: row.get(6)?, billable: row.get(7)?, }) }).map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string())? } }; Ok(query) } #[tauri::command] pub fn create_time_entry(state: State, entry: TimeEntry) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let week_start: String = conn.query_row( "SELECT date(?1, 'weekday 0', '-6 days')", params![entry.start_time], |row| row.get(0), ).map_err(|e| e.to_string())?; let locked: bool = conn.query_row( "SELECT COUNT(*) FROM timesheet_locks WHERE week_start = ?1", params![week_start], |row| row.get::<_, i64>(0), ).map_err(|e| e.to_string())? > 0; if locked { return Err("Cannot modify entries in a locked week".to_string()); } conn.execute( "INSERT INTO time_entries (project_id, task_id, description, start_time, end_time, duration, billable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![entry.project_id, entry.task_id, entry.description, entry.start_time, entry.end_time, entry.duration, entry.billable], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn update_time_entry(state: State, entry: TimeEntry) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let week_start: String = conn.query_row( "SELECT date(?1, 'weekday 0', '-6 days')", params![entry.start_time], |row| row.get(0), ).map_err(|e| e.to_string())?; let locked: bool = conn.query_row( "SELECT COUNT(*) FROM timesheet_locks WHERE week_start = ?1", params![week_start], |row| row.get::<_, i64>(0), ).map_err(|e| e.to_string())? > 0; if locked { return Err("Cannot modify entries in a locked week".to_string()); } conn.execute( "UPDATE time_entries SET project_id = ?1, task_id = ?2, description = ?3, start_time = ?4, end_time = ?5, duration = ?6, billable = ?7 WHERE id = ?8", params![entry.project_id, entry.task_id, entry.description, entry.start_time, entry.end_time, entry.duration, entry.billable, entry.id], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn delete_time_entry(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let entry_start: String = conn.query_row( "SELECT start_time FROM time_entries WHERE id = ?1", params![id], |row| row.get(0), ).map_err(|e| e.to_string())?; let week_start: String = conn.query_row( "SELECT date(?1, 'weekday 0', '-6 days')", params![entry_start], |row| row.get(0), ).map_err(|e| e.to_string())?; let locked: bool = conn.query_row( "SELECT COUNT(*) FROM timesheet_locks WHERE week_start = ?1", params![week_start], |row| row.get::<_, i64>(0), ).map_err(|e| e.to_string())? > 0; if locked { return Err("Cannot modify entries in a locked week".to_string()); } conn.execute("DELETE FROM invoice_items WHERE time_entry_id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute("DELETE FROM entry_tags WHERE entry_id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute("DELETE FROM time_entries WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; Ok(()) } // Reports #[tauri::command] pub fn get_reports(state: State, start_date: String, end_date: String) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; // Total hours let total: i64 = conn.query_row( "SELECT COALESCE(SUM(duration), 0) FROM time_entries WHERE date(start_time) >= date(?1) AND date(start_time) <= date(?2)", params![start_date, end_date], |row| row.get(0), ).map_err(|e| e.to_string())?; // By project - include project_id so frontend can look up name/color/rate let mut stmt = conn.prepare( "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) GROUP BY p.id ORDER BY total_duration DESC" ).map_err(|e| e.to_string())?; let by_project: Vec = stmt.query_map(params![start_date, end_date], |row| { Ok(serde_json::json!({ "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())?; Ok(serde_json::json!({ "totalSeconds": total, "byProject": by_project })) } // Invoice commands #[tauri::command] pub fn create_invoice(state: State, invoice: Invoice) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO invoices (client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?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.template_id], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn get_invoices(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id FROM invoices ORDER BY date DESC" ).map_err(|e| e.to_string())?; let invoices = stmt.query_map([], |row| { Ok(Invoice { id: Some(row.get(0)?), client_id: row.get(1)?, invoice_number: row.get(2)?, date: row.get(3)?, due_date: row.get(4)?, subtotal: row.get(5)?, tax_rate: row.get(6)?, tax_amount: row.get(7)?, discount: row.get(8)?, total: row.get(9)?, notes: row.get(10)?, status: row.get(11)?, template_id: row.get(12)?, }) }).map_err(|e| e.to_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, template_id = ?12 WHERE id = ?13", 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.template_id, 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 invoice_payments WHERE invoice_id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute("DELETE FROM invoice_items WHERE invoice_id = ?1", params![id]).map_err(|e| e.to_string())?; conn.execute("DELETE FROM invoices WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn update_invoice_template(state: State, id: i64, template_id: String) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "UPDATE invoices SET template_id = ?1 WHERE id = ?2", params![template_id, id], ).map_err(|e| e.to_string())?; Ok(()) } // Invoice items #[derive(Debug, Serialize, Deserialize)] pub struct InvoiceItem { pub id: Option, pub invoice_id: i64, pub description: String, pub quantity: f64, pub rate: f64, pub amount: f64, pub time_entry_id: Option, } #[tauri::command] pub fn get_invoice_items(state: State, invoice_id: i64) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, invoice_id, description, quantity, rate, amount, time_entry_id FROM invoice_items WHERE invoice_id = ?1 ORDER BY id" ).map_err(|e| e.to_string())?; let items = stmt.query_map(params![invoice_id], |row| { Ok(InvoiceItem { id: Some(row.get(0)?), invoice_id: row.get(1)?, description: row.get(2)?, quantity: row.get(3)?, rate: row.get(4)?, amount: row.get(5)?, time_entry_id: row.get(6)?, }) }).map_err(|e| e.to_string())?; items.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn create_invoice_item(state: State, item: InvoiceItem) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount, time_entry_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![item.invoice_id, item.description, item.quantity, item.rate, item.amount, item.time_entry_id], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn delete_invoice_items(state: State, invoice_id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM invoice_items WHERE invoice_id = ?1", params![invoice_id]).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn save_invoice_items_batch( state: State, invoice_id: i64, items: Vec, ) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("BEGIN TRANSACTION", []).map_err(|e| e.to_string())?; // Delete existing items first if let Err(e) = conn.execute("DELETE FROM invoice_items WHERE invoice_id = ?1", params![invoice_id]) { let _ = conn.execute("ROLLBACK", []); return Err(e.to_string()); } let mut ids = Vec::new(); for item in &items { let description = item.get("description").and_then(|v| v.as_str()).unwrap_or(""); let quantity = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(0.0); let unit_price = item.get("unit_price").and_then(|v| v.as_f64()).unwrap_or(0.0); let amount = quantity * unit_price; let time_entry_id = item.get("time_entry_id").and_then(|v| v.as_i64()); match conn.execute( "INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount, time_entry_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![invoice_id, description, quantity, unit_price, amount, time_entry_id], ) { Ok(_) => ids.push(conn.last_insert_rowid()), Err(e) => { let _ = conn.execute("ROLLBACK", []); return Err(format!("Failed to save item: {}", e)); } } } conn.execute("COMMIT", []).map_err(|e| e.to_string())?; Ok(ids) } // Settings commands #[tauri::command] pub fn get_settings(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare("SELECT key, value FROM settings").map_err(|e| e.to_string())?; let settings = stmt.query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }).map_err(|e| e.to_string())?; let mut result = std::collections::HashMap::new(); for setting in settings { let (key, value) = setting.map_err(|e| e.to_string())?; result.insert(key, value); } Ok(result) } #[tauri::command] pub fn update_settings(state: State, key: String, value: String) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", params![key, value], ).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, currency 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)?, "currency": row.get::<_, Option>(9)? })) }).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, budget_hours, budget_amount, rounding_override, notes, currency 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, "budget_hours": row.get::<_, Option>(6)?, "budget_amount": row.get::<_, Option>(7)?, "rounding_override": row.get::<_, Option>(8)?, "notes": row.get::<_, Option>(9)?, "currency": row.get::<_, Option>(10)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let tasks = { let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours, hourly_rate FROM tasks").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)?, "name": row.get::<_, String>(2)?, "estimated_hours": row.get::<_, Option>(3)?, "hourly_rate": row.get::<_, Option>(4)? })) }).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, billable 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)?, "billable": row.get::<_, Option>(7)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let tags = { let mut stmt = conn.prepare("SELECT id, name, color FROM tags").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)?, "color": row.get::<_, Option>(2)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let entry_tags = { let mut stmt = conn.prepare("SELECT entry_id, tag_id FROM entry_tags").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { Ok(serde_json::json!({ "entry_id": row.get::<_, i64>(0)?, "tag_id": row.get::<_, i64>(1)? })) }).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, template_id 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)?, "template_id": row.get::<_, Option>(12)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let invoice_items = { let mut stmt = conn.prepare("SELECT id, invoice_id, description, quantity, rate, amount, time_entry_id FROM invoice_items").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "invoice_id": row.get::<_, i64>(1)?, "description": row.get::<_, String>(2)?, "quantity": row.get::<_, f64>(3)?, "rate": row.get::<_, f64>(4)?, "amount": row.get::<_, f64>(5)?, "time_entry_id": row.get::<_, Option>(6)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let tracked_apps = { let mut stmt = conn.prepare("SELECT id, project_id, exe_name, exe_path, display_name FROM tracked_apps").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)?, "exe_name": row.get::<_, String>(2)?, "exe_path": row.get::<_, Option>(3)?, "display_name": row.get::<_, Option>(4)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let favorites = { let mut stmt = conn.prepare("SELECT id, project_id, task_id, description, sort_order FROM favorites").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)?, "sort_order": row.get::<_, i32>(4)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let recurring_entries = { let mut stmt = conn.prepare("SELECT id, project_id, task_id, description, duration, recurrence_rule, time_of_day, mode, enabled, last_triggered FROM recurring_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)?, "duration": row.get::<_, i64>(4)?, "recurrence_rule": row.get::<_, String>(5)?, "time_of_day": row.get::<_, Option>(6)?, "mode": row.get::<_, Option>(7)?, "enabled": row.get::<_, i32>(8)?, "last_triggered": row.get::<_, Option>(9)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let expenses = { let mut stmt = conn.prepare("SELECT id, project_id, client_id, category, description, amount, date, receipt_path, invoiced FROM expenses").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)?, "client_id": row.get::<_, Option>(2)?, "category": row.get::<_, Option>(3)?, "description": row.get::<_, Option>(4)?, "amount": row.get::<_, f64>(5)?, "date": row.get::<_, String>(6)?, "receipt_path": row.get::<_, Option>(7)?, "invoiced": row.get::<_, i32>(8)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let timeline_events = { let mut stmt = conn.prepare("SELECT id, project_id, exe_name, exe_path, window_title, started_at, ended_at, duration FROM timeline_events").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)?, "exe_name": row.get::<_, Option>(2)?, "exe_path": row.get::<_, Option>(3)?, "window_title": row.get::<_, Option>(4)?, "started_at": row.get::<_, String>(5)?, "ended_at": row.get::<_, Option>(6)?, "duration": row.get::<_, i64>(7)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let calendar_sources = { let mut stmt = conn.prepare("SELECT id, name, type, url, last_synced, sync_interval, enabled FROM calendar_sources").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)?, "source_type": row.get::<_, String>(2)?, "url": row.get::<_, Option>(3)?, "last_synced": row.get::<_, Option>(4)?, "sync_interval": row.get::<_, i32>(5)?, "enabled": row.get::<_, i32>(6)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let calendar_events = { let mut stmt = conn.prepare("SELECT id, source_id, uid, summary, start_time, end_time, duration, location FROM calendar_events").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "source_id": row.get::<_, i64>(1)?, "uid": row.get::<_, Option>(2)?, "summary": row.get::<_, Option>(3)?, "start_time": row.get::<_, Option>(4)?, "end_time": row.get::<_, Option>(5)?, "duration": row.get::<_, i64>(6)?, "location": row.get::<_, Option>(7)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let timesheet_locks = { let mut stmt = conn.prepare("SELECT id, week_start, status, locked_at FROM timesheet_locks").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "week_start": row.get::<_, String>(1)?, "status": row.get::<_, Option>(2)?, "locked_at": row.get::<_, Option>(3)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let entry_templates = { let mut stmt = conn.prepare("SELECT id, name, project_id, task_id, description, duration, billable FROM entry_templates").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)?, "project_id": row.get::<_, i64>(2)?, "task_id": row.get::<_, Option>(3)?, "description": row.get::<_, Option>(4)?, "duration": row.get::<_, i64>(5)?, "billable": row.get::<_, i32>(6)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let timesheet_rows = { let mut stmt = conn.prepare("SELECT id, week_start, project_id, task_id, sort_order FROM timesheet_rows").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "week_start": row.get::<_, String>(1)?, "project_id": row.get::<_, i64>(2)?, "task_id": row.get::<_, Option>(3)?, "sort_order": row.get::<_, i32>(4)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let invoice_payments = { let mut stmt = conn.prepare("SELECT id, invoice_id, amount, date, method, notes, created_at FROM invoice_payments").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "invoice_id": row.get::<_, i64>(1)?, "amount": row.get::<_, f64>(2)?, "date": row.get::<_, String>(3)?, "method": row.get::<_, Option>(4)?, "notes": row.get::<_, Option>(5)?, "created_at": row.get::<_, Option>(6)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let recurring_invoices = { let mut stmt = conn.prepare("SELECT id, client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled, created_at FROM recurring_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)?, "template_id": row.get::<_, Option>(2)?, "line_items_json": row.get::<_, String>(3)?, "tax_rate": row.get::<_, f64>(4)?, "discount": row.get::<_, f64>(5)?, "notes": row.get::<_, Option>(6)?, "recurrence_rule": row.get::<_, String>(7)?, "next_due_date": row.get::<_, String>(8)?, "enabled": row.get::<_, i32>(9)?, "created_at": row.get::<_, Option>(10)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; let settings = { let mut stmt = conn.prepare("SELECT key, value FROM settings").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { Ok(serde_json::json!({ "key": row.get::<_, String>(0)?, "value": row.get::<_, Option>(1)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows }; Ok(serde_json::json!({ "clients": clients, "projects": projects, "tasks": tasks, "time_entries": time_entries, "tags": tags, "entry_tags": entry_tags, "invoices": invoices, "invoice_items": invoice_items, "invoice_payments": invoice_payments, "recurring_invoices": recurring_invoices, "tracked_apps": tracked_apps, "favorites": favorites, "recurring_entries": recurring_entries, "expenses": expenses, "timeline_events": timeline_events, "calendar_sources": calendar_sources, "calendar_events": calendar_events, "timesheet_locks": timesheet_locks, "entry_templates": entry_templates, "timesheet_rows": timesheet_rows, "settings": settings })) } // 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 entry_tags; DELETE FROM invoice_payments; DELETE FROM invoice_items; DELETE FROM recurring_invoices; DELETE FROM invoices; DELETE FROM favorites; DELETE FROM recurring_entries; DELETE FROM entry_templates; DELETE FROM timesheet_rows; DELETE FROM timesheet_locks; DELETE FROM timeline_events; DELETE FROM expenses; DELETE FROM tracked_apps; DELETE FROM time_entries; DELETE FROM tasks; DELETE FROM projects; DELETE FROM clients; DELETE FROM tags; DELETE FROM calendar_events; DELETE FROM calendar_sources;" ).map_err(|e| e.to_string())?; Ok(()) } // Tracked Apps struct #[derive(Debug, Serialize, Deserialize)] pub struct TrackedApp { pub id: Option, pub project_id: i64, pub exe_name: String, pub exe_path: Option, pub display_name: Option, } // OS Detection commands #[tauri::command] pub fn get_idle_seconds() -> Result { Ok(os_detection::get_system_idle_seconds()) } #[tauri::command] pub fn get_visible_windows() -> Result, String> { Ok(os_detection::enumerate_visible_windows()) } #[tauri::command] pub fn get_running_processes() -> Result, String> { Ok(os_detection::enumerate_running_processes()) } // Tracked Apps CRUD commands #[tauri::command] pub fn get_tracked_apps(state: State, project_id: i64) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, project_id, exe_name, exe_path, display_name FROM tracked_apps WHERE project_id = ?1 ORDER BY display_name" ).map_err(|e| e.to_string())?; let apps = stmt.query_map(params![project_id], |row| { Ok(TrackedApp { id: Some(row.get(0)?), project_id: row.get(1)?, exe_name: row.get(2)?, exe_path: row.get(3)?, display_name: row.get(4)?, }) }).map_err(|e| e.to_string())?; apps.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn add_tracked_app(state: State, app: TrackedApp) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO tracked_apps (project_id, exe_name, exe_path, display_name) VALUES (?1, ?2, ?3, ?4)", params![app.project_id, app.exe_name, app.exe_path, app.display_name], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn remove_tracked_app(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM tracked_apps WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; Ok(()) } // Tag structs and commands #[derive(Debug, Serialize, Deserialize)] pub struct Tag { pub id: Option, pub name: String, pub color: String, } #[tauri::command] pub fn get_tags(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare("SELECT id, name, color FROM tags ORDER BY name") .map_err(|e| e.to_string())?; let tags = stmt.query_map([], |row| { Ok(Tag { id: Some(row.get(0)?), name: row.get(1)?, color: row.get(2)?, }) }).map_err(|e| e.to_string())?; tags.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn create_tag(state: State, tag: Tag) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO tags (name, color) VALUES (?1, ?2)", params![tag.name, tag.color], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn update_tag(state: State, tag: Tag) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "UPDATE tags SET name = ?1, color = ?2 WHERE id = ?3", params![tag.name, tag.color, tag.id], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn delete_tag(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM entry_tags WHERE tag_id = ?1", params![id]) .map_err(|e| e.to_string())?; conn.execute("DELETE FROM tags WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn get_entry_tags(state: State, entry_id: i64) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT t.id, t.name, t.color FROM tags t JOIN entry_tags et ON t.id = et.tag_id WHERE et.entry_id = ?1 ORDER BY t.name" ).map_err(|e| e.to_string())?; let tags = stmt.query_map(params![entry_id], |row| { Ok(Tag { id: Some(row.get(0)?), name: row.get(1)?, color: row.get(2)?, }) }).map_err(|e| e.to_string())?; tags.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn set_entry_tags(state: State, entry_id: i64, tag_ids: Vec) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM entry_tags WHERE entry_id = ?1", params![entry_id]) .map_err(|e| e.to_string())?; for tag_id in tag_ids { conn.execute( "INSERT INTO entry_tags (entry_id, tag_id) VALUES (?1, ?2)", params![entry_id, tag_id], ).map_err(|e| e.to_string())?; } Ok(()) } #[tauri::command] pub fn get_project_budget_status(state: State, project_id: i64, today: String) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let total_seconds: i64 = conn.query_row( "SELECT COALESCE(SUM(duration), 0) FROM time_entries WHERE project_id = ?1", params![project_id], |row| row.get(0), ).map_err(|e| e.to_string())?; let project_row: (Option, Option, f64) = conn.query_row( "SELECT budget_hours, budget_amount, hourly_rate FROM projects WHERE id = ?1", params![project_id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ).map_err(|e| e.to_string())?; let hours_used = total_seconds as f64 / 3600.0; let amount_used = hours_used * project_row.2; // Daily average based on last 14 days let fourteen_days_ago: String = conn.query_row( "SELECT date(?1, '-14 days')", params![today], |row| row.get(0), ).map_err(|e| e.to_string())?; let recent_seconds: i64 = conn.query_row( "SELECT COALESCE(SUM(duration), 0) FROM time_entries WHERE project_id = ?1 AND date(start_time) >= date(?2)", params![project_id, fourteen_days_ago], |row| row.get(0), ).map_err(|e| e.to_string())?; let daily_avg_hours = (recent_seconds as f64 / 3600.0) / 14.0; let hours_remaining = project_row.0.map(|b| (b - hours_used).max(0.0)); let est_completion_days = hours_remaining.map(|r| { if daily_avg_hours > 0.0 { (r / daily_avg_hours).ceil() } else { -1.0 } }); let pace = hours_remaining.map(|remaining| { if remaining <= 0.0 { "complete" } else if daily_avg_hours <= 0.0 { "behind" } else { let expected_days = remaining / daily_avg_hours; if expected_days <= 7.0 { "ahead" } else if expected_days <= 30.0 { "on_track" } else { "behind" } } }); Ok(serde_json::json!({ "hours_used": hours_used, "amount_used": amount_used, "budget_hours": project_row.0, "budget_amount": project_row.1, "percent_hours": project_row.0.map(|b| if b > 0.0 { (hours_used / b) * 100.0 } else { 0.0 }), "percent_amount": project_row.1.map(|b| if b > 0.0 { (amount_used / b) * 100.0 } else { 0.0 }), "daily_average_hours": daily_avg_hours, "estimated_completion_days": est_completion_days, "hours_remaining": hours_remaining, "pace": pace })) } // Favorite structs and commands #[derive(Debug, Serialize, Deserialize)] pub struct Favorite { pub id: Option, pub project_id: i64, pub task_id: Option, pub description: Option, pub sort_order: i32, } #[tauri::command] pub fn get_favorites(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, project_id, task_id, description, sort_order FROM favorites ORDER BY sort_order" ).map_err(|e| e.to_string())?; let favs = stmt.query_map([], |row| { Ok(Favorite { id: Some(row.get(0)?), project_id: row.get(1)?, task_id: row.get(2)?, description: row.get(3)?, sort_order: row.get(4)?, }) }).map_err(|e| e.to_string())?; favs.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn create_favorite(state: State, fav: Favorite) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO favorites (project_id, task_id, description, sort_order) VALUES (?1, ?2, ?3, ?4)", params![fav.project_id, fav.task_id, fav.description, fav.sort_order], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn delete_favorite(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM favorites WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn reorder_favorites(state: State, ids: Vec) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; for (i, id) in ids.iter().enumerate() { conn.execute( "UPDATE favorites SET sort_order = ?1 WHERE id = ?2", params![i as i32, id], ).map_err(|e| e.to_string())?; } Ok(()) } // Recurring entry struct and commands #[derive(Debug, Serialize, Deserialize)] pub struct RecurringEntry { pub id: Option, pub project_id: i64, pub task_id: Option, pub description: Option, pub duration: i64, pub recurrence_rule: String, pub time_of_day: String, pub mode: String, pub enabled: Option, pub last_triggered: Option, } #[tauri::command] pub fn get_recurring_entries(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, project_id, task_id, description, duration, recurrence_rule, time_of_day, mode, enabled, last_triggered FROM recurring_entries ORDER BY created_at" ).map_err(|e| e.to_string())?; let entries = stmt.query_map([], |row| { Ok(RecurringEntry { id: Some(row.get(0)?), project_id: row.get(1)?, task_id: row.get(2)?, description: row.get(3)?, duration: row.get(4)?, recurrence_rule: row.get(5)?, time_of_day: row.get(6)?, mode: row.get(7)?, enabled: row.get(8)?, last_triggered: row.get(9)?, }) }).map_err(|e| e.to_string())?; entries.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn create_recurring_entry(state: State, entry: RecurringEntry) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO recurring_entries (project_id, task_id, description, duration, recurrence_rule, time_of_day, mode, enabled) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![entry.project_id, entry.task_id, entry.description, entry.duration, entry.recurrence_rule, entry.time_of_day, entry.mode, entry.enabled.unwrap_or(1)], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn update_recurring_entry(state: State, entry: RecurringEntry) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "UPDATE recurring_entries SET project_id = ?1, task_id = ?2, description = ?3, duration = ?4, recurrence_rule = ?5, time_of_day = ?6, mode = ?7, enabled = ?8 WHERE id = ?9", params![entry.project_id, entry.task_id, entry.description, entry.duration, entry.recurrence_rule, entry.time_of_day, entry.mode, entry.enabled, entry.id], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn delete_recurring_entry(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM recurring_entries WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn update_recurring_last_triggered(state: State, id: i64, last_triggered: String) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "UPDATE recurring_entries SET last_triggered = ?1 WHERE id = ?2", params![last_triggered, id], ).map_err(|e| e.to_string())?; Ok(()) } // Expense struct and commands #[derive(Debug, Serialize, Deserialize)] pub struct Expense { pub id: Option, pub project_id: i64, pub client_id: Option, pub category: String, pub description: Option, pub amount: f64, pub date: String, pub receipt_path: Option, pub invoiced: Option, } #[tauri::command] pub fn get_expenses(state: State, project_id: Option, start_date: Option, end_date: Option) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let map_row = |row: &rusqlite::Row| -> rusqlite::Result { Ok(Expense { id: Some(row.get(0)?), project_id: row.get(1)?, client_id: row.get(2)?, category: row.get(3)?, description: row.get(4)?, amount: row.get(5)?, date: row.get(6)?, receipt_path: row.get(7)?, invoiced: row.get(8)?, }) }; let results: Vec = match (project_id, &start_date, &end_date) { (Some(pid), Some(start), Some(end)) => { let mut stmt = conn.prepare( "SELECT id, project_id, client_id, category, description, amount, date, receipt_path, invoiced FROM expenses WHERE project_id = ?1 AND date >= ?2 AND date <= ?3 ORDER BY date DESC" ).map_err(|e| e.to_string())?; let rows = stmt.query_map(params![pid, start, end], map_row) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; rows }, (Some(pid), _, _) => { let mut stmt = conn.prepare( "SELECT id, project_id, client_id, category, description, amount, date, receipt_path, invoiced FROM expenses WHERE project_id = ?1 ORDER BY date DESC" ).map_err(|e| e.to_string())?; let rows = stmt.query_map(params![pid], map_row) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; rows }, (_, Some(start), Some(end)) => { let mut stmt = conn.prepare( "SELECT id, project_id, client_id, category, description, amount, date, receipt_path, invoiced FROM expenses WHERE date >= ?1 AND date <= ?2 ORDER BY date DESC" ).map_err(|e| e.to_string())?; let rows = stmt.query_map(params![start, end], map_row) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; rows }, _ => { let mut stmt = conn.prepare( "SELECT id, project_id, client_id, category, description, amount, date, receipt_path, invoiced FROM expenses ORDER BY date DESC" ).map_err(|e| e.to_string())?; let rows = stmt.query_map([], map_row) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; rows }, }; Ok(results) } #[tauri::command] pub fn create_expense(state: State, expense: Expense) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO expenses (project_id, client_id, category, description, amount, date, receipt_path, invoiced) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![expense.project_id, expense.client_id, expense.category, expense.description, expense.amount, expense.date, expense.receipt_path, expense.invoiced.unwrap_or(0)], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn update_expense(state: State, expense: Expense) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "UPDATE expenses SET project_id = ?1, client_id = ?2, category = ?3, description = ?4, amount = ?5, date = ?6, receipt_path = ?7, invoiced = ?8 WHERE id = ?9", params![expense.project_id, expense.client_id, expense.category, expense.description, expense.amount, expense.date, expense.receipt_path, expense.invoiced, expense.id], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn delete_expense(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM expenses WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn get_uninvoiced_expenses(state: State, project_id: Option, client_id: Option) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let map_row = |row: &rusqlite::Row| -> rusqlite::Result { Ok(Expense { id: Some(row.get(0)?), project_id: row.get(1)?, client_id: row.get(2)?, category: row.get(3)?, description: row.get(4)?, amount: row.get(5)?, date: row.get(6)?, receipt_path: row.get(7)?, invoiced: row.get(8)?, }) }; let results: Vec = match (project_id, client_id) { (Some(pid), _) => { let mut stmt = conn.prepare( "SELECT id, project_id, client_id, category, description, amount, date, receipt_path, invoiced FROM expenses WHERE invoiced = 0 AND project_id = ?1 ORDER BY date DESC" ).map_err(|e| e.to_string())?; let rows = stmt.query_map(params![pid], map_row) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; rows }, (_, Some(cid)) => { let mut stmt = conn.prepare( "SELECT e.id, e.project_id, e.client_id, e.category, e.description, e.amount, e.date, e.receipt_path, e.invoiced FROM expenses e JOIN projects p ON e.project_id = p.id WHERE e.invoiced = 0 AND p.client_id = ?1 ORDER BY e.date DESC" ).map_err(|e| e.to_string())?; let rows = stmt.query_map(params![cid], map_row) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; rows }, _ => { let mut stmt = conn.prepare( "SELECT id, project_id, client_id, category, description, amount, date, receipt_path, invoiced FROM expenses WHERE invoiced = 0 ORDER BY date DESC" ).map_err(|e| e.to_string())?; let rows = stmt.query_map([], map_row) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; rows }, }; Ok(results) } #[tauri::command] pub fn mark_expenses_invoiced(state: State, ids: Vec) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; for id in ids { conn.execute("UPDATE expenses SET invoiced = 1 WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; } Ok(()) } // Timeline event struct and commands #[derive(Debug, Serialize, Deserialize)] pub struct TimelineEvent { pub id: Option, pub project_id: i64, pub exe_name: Option, pub exe_path: Option, pub window_title: Option, pub started_at: String, pub ended_at: Option, pub duration: Option, } #[tauri::command] pub fn get_timeline_events(state: State, project_id: i64, date: String) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, project_id, exe_name, exe_path, window_title, started_at, ended_at, duration FROM timeline_events WHERE project_id = ?1 AND date(started_at) = ?2 ORDER BY started_at ASC" ).map_err(|e| e.to_string())?; let events = stmt.query_map(params![project_id, date], |row| { Ok(TimelineEvent { id: Some(row.get(0)?), project_id: row.get(1)?, exe_name: row.get(2)?, exe_path: row.get(3)?, window_title: row.get(4)?, started_at: row.get(5)?, ended_at: row.get(6)?, duration: row.get(7)?, }) }).map_err(|e| e.to_string())?; events.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn create_timeline_event(state: State, event: TimelineEvent) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO timeline_events (project_id, exe_name, exe_path, window_title, started_at, ended_at, duration) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![event.project_id, event.exe_name, event.exe_path, event.window_title, event.started_at, event.ended_at, event.duration.unwrap_or(0)], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn update_timeline_event_ended(state: State, id: i64, ended_at: String, duration: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "UPDATE timeline_events SET ended_at = ?1, duration = ?2 WHERE id = ?3", params![ended_at, duration, id], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn delete_timeline_events(state: State, project_id: i64, date: String) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "DELETE FROM timeline_events WHERE project_id = ?1 AND date(started_at) = ?2", params![project_id, date], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn clear_all_timeline_data(state: State) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM timeline_events", []).map_err(|e| e.to_string())?; Ok(()) } // Goals command #[tauri::command] pub fn get_goal_progress(state: State, today: String) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; // Today's total seconds let today_seconds: i64 = conn.query_row( "SELECT COALESCE(SUM(duration), 0) FROM time_entries WHERE date(start_time) = date(?1)", params![today], |row| row.get(0), ).map_err(|e| e.to_string())?; // This week's total seconds (Monday to Sunday, ISO week) let week_seconds: i64 = conn.query_row( "SELECT COALESCE(SUM(duration), 0) FROM time_entries WHERE strftime('%Y-%W', start_time) = strftime('%Y-%W', ?1)", params![today], |row| row.get(0), ).map_err(|e| e.to_string())?; // Streak: count consecutive days with entries going backwards from today let mut streak_days: i64 = 0; let mut check_date = today.clone(); loop { let has_entry: i64 = conn.query_row( "SELECT COUNT(*) FROM time_entries WHERE date(start_time) = date(?1)", params![check_date], |row| row.get(0), ).map_err(|e| e.to_string())?; if has_entry > 0 { streak_days += 1; // Go to previous day check_date = conn.query_row( "SELECT date(?1, '-1 day')", params![check_date], |row| row.get::<_, String>(0), ).map_err(|e| e.to_string())?; } else { break; } } Ok(serde_json::json!({ "today_seconds": today_seconds, "week_seconds": week_seconds, "streak_days": streak_days })) } // Profitability report command - includes expenses for net profit #[tauri::command] pub fn get_profitability_report(state: State, start_date: String, end_date: String) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT p.id, p.name, p.color, p.hourly_rate, p.budget_hours, p.budget_amount, c.name as client_name, COALESCE(SUM(t.duration), 0) as total_seconds FROM projects p LEFT JOIN clients c ON p.client_id = c.id LEFT JOIN time_entries t ON t.project_id = p.id AND date(t.start_time) >= date(?1) AND date(t.start_time) <= date(?2) GROUP BY p.id ORDER BY total_seconds DESC" ).map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map(params![start_date, end_date], |row| { let project_id: i64 = row.get(0)?; let total_seconds: i64 = row.get(7)?; let hourly_rate: f64 = row.get(3)?; let hours = total_seconds as f64 / 3600.0; let revenue = hours * hourly_rate; let budget_hours: Option = row.get(4)?; let budget_amount: Option = row.get(5)?; Ok(serde_json::json!({ "project_id": project_id, "project_name": row.get::<_, String>(1)?, "color": row.get::<_, String>(2)?, "hourly_rate": hourly_rate, "client_name": row.get::<_, Option>(6)?, "total_seconds": total_seconds, "total_hours": hours, "revenue": revenue, "budget_hours": budget_hours, "budget_amount": budget_amount, "budget_used_pct": budget_hours.map(|b| if b > 0.0 { (hours / b) * 100.0 } else { 0.0 }), "percent_amount": budget_amount.map(|b| if b > 0.0 { (revenue / b) * 100.0 } else { 0.0 }) })) }).map_err(|e| e.to_string())? .collect::, _>>().map_err(|e| e.to_string())?; // Add expense totals per project for the date range let mut result: Vec = Vec::new(); for mut row in rows { let pid = row["project_id"].as_i64().unwrap_or(0); let expense_total: f64 = conn.query_row( "SELECT COALESCE(SUM(amount), 0) FROM expenses WHERE project_id = ?1 AND date >= ?2 AND date <= ?3", params![pid, start_date, end_date], |r| r.get(0), ).unwrap_or(0.0); let revenue = row["revenue"].as_f64().unwrap_or(0.0); row.as_object_mut().unwrap().insert("expenses".into(), serde_json::json!(expense_total)); row.as_object_mut().unwrap().insert("net_profit".into(), serde_json::json!(revenue - expense_total)); result.push(row); } Ok(result) } // Timesheet data command #[tauri::command] pub fn get_timesheet_data(state: State, week_start: String) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; // Get the 7 days of the week let mut days: Vec = Vec::new(); for i in 0..7 { let day: String = conn.query_row( &format!("SELECT date(?1, '+{} days')", i), params![week_start], |row| row.get(0), ).map_err(|e| e.to_string())?; days.push(day); } let mut stmt = conn.prepare( "SELECT p.id, p.name, p.color, t2.id as task_id, t2.name as task_name, date(te.start_time) as entry_date, COALESCE(SUM(te.duration), 0) as total_seconds FROM time_entries te JOIN projects p ON te.project_id = p.id LEFT JOIN tasks t2 ON te.task_id = t2.id WHERE date(te.start_time) >= date(?1) AND date(te.start_time) < date(?1, '+7 days') GROUP BY p.id, t2.id, date(te.start_time) ORDER BY p.name, t2.name" ).map_err(|e| e.to_string())?; let raw_rows: Vec<(i64, String, String, Option, Option, String, i64)> = stmt .query_map(params![week_start], |row| { Ok(( row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, )) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; // Group by project+task let mut grouped: std::collections::HashMap<(i64, Option), serde_json::Value> = std::collections::HashMap::new(); for (project_id, project_name, color, task_id, task_name, entry_date, total_seconds) in &raw_rows { let key = (*project_id, *task_id); let entry = grouped.entry(key).or_insert_with(|| { serde_json::json!({ "project_id": project_id, "project_name": project_name, "color": color, "task_id": task_id, "task_name": task_name, "days": vec![0i64; 7] }) }); // Find which day index this entry_date corresponds to if let Some(day_idx) = days.iter().position(|d| d == entry_date) { if let Some(arr) = entry.get_mut("days").and_then(|d| d.as_array_mut()) { arr[day_idx] = serde_json::json!(total_seconds); } } } Ok(grouped.into_values().collect()) } // Import entries command (CSV-style: individual entries) #[tauri::command] pub fn import_entries(state: State, entries: Vec) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut count: i64 = 0; for entry in entries { let project_name = entry.get("project").and_then(|v| v.as_str()).unwrap_or("Imported"); let description = entry.get("description").and_then(|v| v.as_str()).unwrap_or(""); let start_time = entry.get("start_time").and_then(|v| v.as_str()).unwrap_or(""); let end_time = entry.get("end_time").and_then(|v| v.as_str()); let duration = entry.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); let billable = entry.get("billable").and_then(|v| v.as_i64()).unwrap_or(1); if start_time.is_empty() { continue; } // Find or create project let project_id: i64 = match conn.query_row( "SELECT id FROM projects WHERE name = ?1", params![project_name], |row| row.get(0), ) { Ok(id) => id, Err(_) => { conn.execute( "INSERT INTO projects (name, hourly_rate, color, archived) VALUES (?1, 0, '#F59E0B', 0)", params![project_name], ).map_err(|e| e.to_string())?; conn.last_insert_rowid() } }; conn.execute( "INSERT INTO time_entries (project_id, description, start_time, end_time, duration, billable) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![project_id, description, start_time, end_time, duration, billable], ).map_err(|e| e.to_string())?; count += 1; } Ok(count) } // Import full JSON data (clients, projects, tasks, entries, tags) #[tauri::command] pub fn import_json_data(state: State, data: serde_json::Value) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut counts = serde_json::json!({ "clients": 0, "projects": 0, "tasks": 0, "entries": 0, "tags": 0 }); // Import clients if let Some(clients) = data.get("clients").and_then(|v| v.as_array()) { for client in clients { let name = client.get("name").and_then(|v| v.as_str()).unwrap_or(""); if name.is_empty() { continue; } let email = client.get("email").and_then(|v| v.as_str()); let address = client.get("address").and_then(|v| v.as_str()); let company = client.get("company").and_then(|v| v.as_str()); let phone = client.get("phone").and_then(|v| v.as_str()); let tax_id = client.get("tax_id").and_then(|v| v.as_str()); let payment_terms = client.get("payment_terms").and_then(|v| v.as_str()); let notes = client.get("notes").and_then(|v| v.as_str()); let currency = client.get("currency").and_then(|v| v.as_str()); conn.execute( "INSERT OR IGNORE INTO clients (name, email, address, company, phone, tax_id, payment_terms, notes, currency) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![name, email, address, company, phone, tax_id, payment_terms, notes, currency], ).map_err(|e| e.to_string())?; counts["clients"] = serde_json::json!(counts["clients"].as_i64().unwrap_or(0) + 1); } } // Import projects if let Some(projects) = data.get("projects").and_then(|v| v.as_array()) { for project in projects { let name = project.get("name").and_then(|v| v.as_str()).unwrap_or(""); if name.is_empty() { continue; } let client_id = project.get("client_id").and_then(|v| v.as_i64()); let hourly_rate = project.get("hourly_rate").and_then(|v| v.as_f64()).unwrap_or(0.0); let color = project.get("color").and_then(|v| v.as_str()).unwrap_or("#F59E0B"); let archived = if project.get("archived").and_then(|v| v.as_bool()).unwrap_or(false) { 1 } else { 0 }; let budget_hours = project.get("budget_hours").and_then(|v| v.as_f64()); let budget_amount = project.get("budget_amount").and_then(|v| v.as_f64()); let rounding_override = project.get("rounding_override").and_then(|v| v.as_i64()).map(|v| v as i32); let notes = project.get("notes").and_then(|v| v.as_str()); let currency = project.get("currency").and_then(|v| v.as_str()); conn.execute( "INSERT OR IGNORE INTO projects (client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency], ).map_err(|e| e.to_string())?; counts["projects"] = serde_json::json!(counts["projects"].as_i64().unwrap_or(0) + 1); } } // Import tags if let Some(tags) = data.get("tags").and_then(|v| v.as_array()) { for tag in tags { let name = tag.get("name").and_then(|v| v.as_str()).unwrap_or(""); if name.is_empty() { continue; } let color = tag.get("color").and_then(|v| v.as_str()).unwrap_or("#6B7280"); conn.execute( "INSERT OR IGNORE INTO tags (name, color) VALUES (?1, ?2)", params![name, color], ).map_err(|e| e.to_string())?; counts["tags"] = serde_json::json!(counts["tags"].as_i64().unwrap_or(0) + 1); } } // Import tasks if let Some(tasks) = data.get("tasks").and_then(|v| v.as_array()) { for task in tasks { let name = task.get("name").and_then(|v| v.as_str()).unwrap_or(""); if name.is_empty() { continue; } let project_id = task.get("project_id").and_then(|v| v.as_i64()); if let Some(pid) = project_id { let estimated_hours = task.get("estimated_hours").and_then(|v| v.as_f64()); let hourly_rate = task.get("hourly_rate").and_then(|v| v.as_f64()); conn.execute( "INSERT OR IGNORE INTO tasks (project_id, name, estimated_hours, hourly_rate) VALUES (?1, ?2, ?3, ?4)", params![pid, name, estimated_hours, hourly_rate], ).map_err(|e| e.to_string())?; counts["tasks"] = serde_json::json!(counts["tasks"].as_i64().unwrap_or(0) + 1); } } } // Import time entries if let Some(entries) = data.get("time_entries").and_then(|v| v.as_array()) { for entry in entries { let start_time = entry.get("start_time").and_then(|v| v.as_str()).unwrap_or(""); if start_time.is_empty() { continue; } let project_id = entry.get("project_id").and_then(|v| v.as_i64()); if let Some(pid) = project_id { let task_id = entry.get("task_id").and_then(|v| v.as_i64()); let description = entry.get("description").and_then(|v| v.as_str()); let end_time = entry.get("end_time").and_then(|v| v.as_str()); let duration = entry.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); let billable = entry.get("billable").and_then(|v| v.as_i64()).unwrap_or(1); conn.execute( "INSERT INTO time_entries (project_id, task_id, description, start_time, end_time, duration, billable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![pid, task_id, description, start_time, end_time, duration, billable], ).map_err(|e| e.to_string())?; counts["entries"] = serde_json::json!(counts["entries"].as_i64().unwrap_or(0) + 1); } } } // Import entry_tags if let Some(et_list) = data.get("entry_tags").and_then(|v| v.as_array()) { for et in et_list { let entry_id = et.get("entry_id").and_then(|v| v.as_i64()); let tag_id = et.get("tag_id").and_then(|v| v.as_i64()); if let (Some(eid), Some(tid)) = (entry_id, tag_id) { conn.execute( "INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?1, ?2)", params![eid, tid], ).ok(); } } } // Import invoices if let Some(invoices) = data.get("invoices").and_then(|v| v.as_array()) { for inv in invoices { let client_id = inv.get("client_id").and_then(|v| v.as_i64()).unwrap_or(0); let invoice_number = inv.get("invoice_number").and_then(|v| v.as_str()).unwrap_or(""); if invoice_number.is_empty() { continue; } let date = inv.get("date").and_then(|v| v.as_str()).unwrap_or(""); let due_date = inv.get("due_date").and_then(|v| v.as_str()); let subtotal = inv.get("subtotal").and_then(|v| v.as_f64()).unwrap_or(0.0); let tax_rate = inv.get("tax_rate").and_then(|v| v.as_f64()).unwrap_or(0.0); let tax_amount = inv.get("tax_amount").and_then(|v| v.as_f64()).unwrap_or(0.0); let discount = inv.get("discount").and_then(|v| v.as_f64()).unwrap_or(0.0); let total = inv.get("total").and_then(|v| v.as_f64()).unwrap_or(0.0); let notes = inv.get("notes").and_then(|v| v.as_str()); let status = inv.get("status").and_then(|v| v.as_str()).unwrap_or("draft"); let template_id = inv.get("template_id").and_then(|v| v.as_str()); conn.execute( "INSERT INTO invoices (client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id], ).ok(); } } // Import invoice_items if let Some(items) = data.get("invoice_items").and_then(|v| v.as_array()) { for item in items { let invoice_id = item.get("invoice_id").and_then(|v| v.as_i64()).unwrap_or(0); let description = item.get("description").and_then(|v| v.as_str()).unwrap_or(""); let quantity = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(1.0); let rate = item.get("rate").and_then(|v| v.as_f64()).unwrap_or(0.0); let amount = item.get("amount").and_then(|v| v.as_f64()).unwrap_or(0.0); let time_entry_id = item.get("time_entry_id").and_then(|v| v.as_i64()); conn.execute( "INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount, time_entry_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![invoice_id, description, quantity, rate, amount, time_entry_id], ).ok(); } } // Import tracked_apps if let Some(apps) = data.get("tracked_apps").and_then(|v| v.as_array()) { for app in apps { let project_id = app.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); let exe_name = app.get("exe_name").and_then(|v| v.as_str()).unwrap_or(""); if exe_name.is_empty() { continue; } let exe_path = app.get("exe_path").and_then(|v| v.as_str()); let display_name = app.get("display_name").and_then(|v| v.as_str()); conn.execute( "INSERT INTO tracked_apps (project_id, exe_name, exe_path, display_name) VALUES (?1, ?2, ?3, ?4)", params![project_id, exe_name, exe_path, display_name], ).ok(); } } // Import favorites if let Some(favs) = data.get("favorites").and_then(|v| v.as_array()) { for fav in favs { let project_id = fav.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); let task_id = fav.get("task_id").and_then(|v| v.as_i64()); let description = fav.get("description").and_then(|v| v.as_str()); let sort_order = fav.get("sort_order").and_then(|v| v.as_i64()).unwrap_or(0) as i32; conn.execute( "INSERT INTO favorites (project_id, task_id, description, sort_order) VALUES (?1, ?2, ?3, ?4)", params![project_id, task_id, description, sort_order], ).ok(); } } // Import recurring_entries if let Some(recs) = data.get("recurring_entries").and_then(|v| v.as_array()) { for rec in recs { let project_id = rec.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); let task_id = rec.get("task_id").and_then(|v| v.as_i64()); let description = rec.get("description").and_then(|v| v.as_str()); let duration = rec.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); let recurrence_rule = rec.get("recurrence_rule").and_then(|v| v.as_str()).unwrap_or(""); if recurrence_rule.is_empty() { continue; } let time_of_day = rec.get("time_of_day").and_then(|v| v.as_str()); let mode = rec.get("mode").and_then(|v| v.as_str()); let enabled = rec.get("enabled").and_then(|v| v.as_i64()).unwrap_or(1) as i32; let last_triggered = rec.get("last_triggered").and_then(|v| v.as_str()); conn.execute( "INSERT INTO recurring_entries (project_id, task_id, description, duration, recurrence_rule, time_of_day, mode, enabled, last_triggered) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![project_id, task_id, description, duration, recurrence_rule, time_of_day, mode, enabled, last_triggered], ).ok(); } } // Import expenses if let Some(exps) = data.get("expenses").and_then(|v| v.as_array()) { for exp in exps { let project_id = exp.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); let client_id = exp.get("client_id").and_then(|v| v.as_i64()); let category = exp.get("category").and_then(|v| v.as_str()); let description = exp.get("description").and_then(|v| v.as_str()); let amount = exp.get("amount").and_then(|v| v.as_f64()).unwrap_or(0.0); let date = exp.get("date").and_then(|v| v.as_str()).unwrap_or(""); if date.is_empty() { continue; } let receipt_path = exp.get("receipt_path").and_then(|v| v.as_str()); let invoiced = exp.get("invoiced").and_then(|v| v.as_i64()).unwrap_or(0) as i32; conn.execute( "INSERT INTO expenses (project_id, client_id, category, description, amount, date, receipt_path, invoiced) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![project_id, client_id, category, description, amount, date, receipt_path, invoiced], ).ok(); } } // Import timeline_events if let Some(events) = data.get("timeline_events").and_then(|v| v.as_array()) { for evt in events { let project_id = evt.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); let exe_name = evt.get("exe_name").and_then(|v| v.as_str()); let exe_path = evt.get("exe_path").and_then(|v| v.as_str()); let window_title = evt.get("window_title").and_then(|v| v.as_str()); let started_at = evt.get("started_at").and_then(|v| v.as_str()).unwrap_or(""); if started_at.is_empty() { continue; } let ended_at = evt.get("ended_at").and_then(|v| v.as_str()); let duration = evt.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); conn.execute( "INSERT INTO timeline_events (project_id, exe_name, exe_path, window_title, started_at, ended_at, duration) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![project_id, exe_name, exe_path, window_title, started_at, ended_at, duration], ).ok(); } } // Import calendar_sources if let Some(sources) = data.get("calendar_sources").and_then(|v| v.as_array()) { for src in sources { let name = src.get("name").and_then(|v| v.as_str()).unwrap_or(""); if name.is_empty() { continue; } let source_type = src.get("source_type").and_then(|v| v.as_str()).unwrap_or(""); let url = src.get("url").and_then(|v| v.as_str()); let last_synced = src.get("last_synced").and_then(|v| v.as_str()); let sync_interval = src.get("sync_interval").and_then(|v| v.as_i64()).unwrap_or(3600) as i32; let enabled = src.get("enabled").and_then(|v| v.as_i64()).unwrap_or(1) as i32; conn.execute( "INSERT INTO calendar_sources (name, type, url, last_synced, sync_interval, enabled) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![name, source_type, url, last_synced, sync_interval, enabled], ).ok(); } } // Import calendar_events if let Some(cal_events) = data.get("calendar_events").and_then(|v| v.as_array()) { for evt in cal_events { let source_id = evt.get("source_id").and_then(|v| v.as_i64()).unwrap_or(0); let uid = evt.get("uid").and_then(|v| v.as_str()); let summary = evt.get("summary").and_then(|v| v.as_str()); let start_time = evt.get("start_time").and_then(|v| v.as_str()); let end_time = evt.get("end_time").and_then(|v| v.as_str()); let duration = evt.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); let location = evt.get("location").and_then(|v| v.as_str()); conn.execute( "INSERT INTO calendar_events (source_id, uid, summary, start_time, end_time, duration, location) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![source_id, uid, summary, start_time, end_time, duration, location], ).ok(); } } // Import invoice_payments if let Some(payments) = data.get("invoice_payments").and_then(|v| v.as_array()) { for pay in payments { let invoice_id = pay.get("invoice_id").and_then(|v| v.as_i64()).unwrap_or(0); let amount = pay.get("amount").and_then(|v| v.as_f64()).unwrap_or(0.0); let date = pay.get("date").and_then(|v| v.as_str()).unwrap_or(""); if date.is_empty() { continue; } let method = pay.get("method").and_then(|v| v.as_str()); let notes = pay.get("notes").and_then(|v| v.as_str()); let created_at = pay.get("created_at").and_then(|v| v.as_str()); conn.execute( "INSERT INTO invoice_payments (invoice_id, amount, date, method, notes, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![invoice_id, amount, date, method, notes, created_at], ).ok(); } } // Import recurring_invoices if let Some(rec_invs) = data.get("recurring_invoices").and_then(|v| v.as_array()) { for ri in rec_invs { let client_id = ri.get("client_id").and_then(|v| v.as_i64()).unwrap_or(0); let template_id = ri.get("template_id").and_then(|v| v.as_str()); let line_items_json = ri.get("line_items_json").and_then(|v| v.as_str()).unwrap_or("[]"); let tax_rate = ri.get("tax_rate").and_then(|v| v.as_f64()).unwrap_or(0.0); let discount = ri.get("discount").and_then(|v| v.as_f64()).unwrap_or(0.0); let notes = ri.get("notes").and_then(|v| v.as_str()); let recurrence_rule = ri.get("recurrence_rule").and_then(|v| v.as_str()).unwrap_or(""); if recurrence_rule.is_empty() { continue; } let next_due_date = ri.get("next_due_date").and_then(|v| v.as_str()).unwrap_or(""); if next_due_date.is_empty() { continue; } let enabled = ri.get("enabled").and_then(|v| v.as_i64()).unwrap_or(1) as i32; let created_at = ri.get("created_at").and_then(|v| v.as_str()); conn.execute( "INSERT INTO recurring_invoices (client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled, created_at], ).ok(); } } // Import settings if let Some(settings_list) = data.get("settings").and_then(|v| v.as_array()) { for setting in settings_list { let key = setting.get("key").and_then(|v| v.as_str()).unwrap_or(""); if key.is_empty() { continue; } let value = setting.get("value").and_then(|v| v.as_str()); conn.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", params![key, value], ).ok(); } } // Import entry_templates if let Some(templates) = data.get("entry_templates").and_then(|v| v.as_array()) { for tmpl in templates { let name = tmpl.get("name").and_then(|v| v.as_str()).unwrap_or(""); if name.is_empty() { continue; } let project_id = tmpl.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); let task_id = tmpl.get("task_id").and_then(|v| v.as_i64()); let description = tmpl.get("description").and_then(|v| v.as_str()); let duration = tmpl.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); let billable = tmpl.get("billable").and_then(|v| v.as_i64()).unwrap_or(1) as i32; conn.execute( "INSERT INTO entry_templates (name, project_id, task_id, description, duration, billable) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![name, project_id, task_id, description, duration, billable], ).ok(); } } // Import timesheet_rows if let Some(rows) = data.get("timesheet_rows").and_then(|v| v.as_array()) { for row in rows { let week_start = row.get("week_start").and_then(|v| v.as_str()).unwrap_or(""); if week_start.is_empty() { continue; } let project_id = row.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); let task_id = row.get("task_id").and_then(|v| v.as_i64()); let sort_order = row.get("sort_order").and_then(|v| v.as_i64()).unwrap_or(0) as i32; conn.execute( "INSERT INTO timesheet_rows (week_start, project_id, task_id, sort_order) VALUES (?1, ?2, ?3, ?4)", params![week_start, project_id, task_id, sort_order], ).ok(); } } // Import timesheet_locks if let Some(locks) = data.get("timesheet_locks").and_then(|v| v.as_array()) { for lock in locks { let week_start = lock.get("week_start").and_then(|v| v.as_str()).unwrap_or(""); if week_start.is_empty() { continue; } let status = lock.get("status").and_then(|v| v.as_str()); let locked_at = lock.get("locked_at").and_then(|v| v.as_str()); conn.execute( "INSERT OR IGNORE INTO timesheet_locks (week_start, status, locked_at) VALUES (?1, ?2, ?3)", params![week_start, status, locked_at], ).ok(); } } Ok(counts) } // File save command (bypasses fs plugin scope for user-selected paths) #[tauri::command] pub fn save_binary_file(path: String, data: Vec) -> Result<(), String> { std::fs::write(&path, &data).map_err(|e| e.to_string()) } // Mini timer window commands #[tauri::command] pub fn open_mini_timer(app: tauri::AppHandle) -> Result<(), String> { if let Some(win) = app.get_webview_window("mini-timer") { win.show().map_err(|e| e.to_string())?; win.set_focus().ok(); } Ok(()) } #[tauri::command] pub fn close_mini_timer(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("mini-timer") { window.hide().map_err(|e| e.to_string())?; } if let Some(main) = app.get_webview_window("main") { main.show().ok(); main.set_focus().ok(); } Ok(()) } // Invoice template types and commands #[derive(Debug, Serialize, Deserialize, Clone)] pub struct InvoiceTemplateColors { pub primary: String, pub secondary: String, pub background: String, #[serde(rename = "headerBg")] pub header_bg: String, #[serde(rename = "headerText")] pub header_text: String, #[serde(rename = "bodyText")] pub body_text: String, #[serde(rename = "tableHeaderBg")] pub table_header_bg: String, #[serde(rename = "tableHeaderText")] pub table_header_text: String, #[serde(rename = "tableRowAlt")] pub table_row_alt: String, #[serde(rename = "tableBorder")] pub table_border: String, #[serde(rename = "totalHighlight")] pub total_highlight: String, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct InvoiceTemplate { pub id: String, pub name: String, pub layout: String, pub category: String, pub description: String, pub colors: InvoiceTemplateColors, } #[tauri::command] pub fn get_invoice_templates(state: State) -> Result, String> { let templates_dir = state.data_dir.join("templates"); let mut templates: Vec = Vec::new(); if let Ok(entries) = std::fs::read_dir(&templates_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("json") { if let Ok(content) = std::fs::read_to_string(&path) { match serde_json::from_str::(&content) { Ok(template) => templates.push(template), Err(e) => eprintln!("Failed to parse template {:?}: {}", path, e), } } } } } // Sort: essentials first, then creative, warm, premium; alphabetical within category let cat_order = |c: &str| -> u8 { match c { "essential" => 0, "creative" => 1, "warm" => 2, "premium" => 3, _ => 4 } }; templates.sort_by(|a, b| { cat_order(&a.category).cmp(&cat_order(&b.category)).then(a.name.cmp(&b.name)) }); Ok(templates) } #[tauri::command] pub fn auto_backup(state: State, backup_dir: String) -> Result { let data = export_data(state)?; let today = chrono::Local::now().format("%Y-%m-%d").to_string(); let filename = format!("zeroclock-backup-{}.json", today); let path = std::path::Path::new(&backup_dir).join(&filename); let json = serde_json::to_string_pretty(&data).map_err(|e| e.to_string())?; std::fs::write(&path, json).map_err(|e| e.to_string())?; Ok(path.to_string_lossy().to_string()) } #[tauri::command] pub fn list_backup_files(backup_dir: String) -> Result, String> { let dir = std::path::Path::new(&backup_dir); if !dir.exists() { return Ok(Vec::new()); } let mut files: Vec = std::fs::read_dir(dir) .map_err(|e| e.to_string())? .flatten() .filter(|e| { e.path().extension().and_then(|ext| ext.to_str()) == Some("json") && e.file_name().to_string_lossy().starts_with("zeroclock-backup-") }) .filter_map(|e| { let meta = e.metadata().ok()?; let modified = meta.modified().ok()?; Some(serde_json::json!({ "path": e.path().to_string_lossy().to_string(), "name": e.file_name().to_string_lossy().to_string(), "size": meta.len(), "modified": modified.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(), })) }) .collect(); files.sort_by(|a, b| { b.get("modified").and_then(|v| v.as_u64()) .cmp(&a.get("modified").and_then(|v| v.as_u64())) }); Ok(files) } #[tauri::command] pub fn delete_backup_file(path: String) -> Result<(), String> { std::fs::remove_file(&path).map_err(|e| e.to_string()) } // Get recent unique descriptions for autocomplete #[tauri::command] pub fn get_recent_descriptions(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT description, COUNT(*) as cnt FROM time_entries WHERE description IS NOT NULL AND description != '' GROUP BY description ORDER BY cnt DESC LIMIT 50" ).map_err(|e| e.to_string())?; let rows = stmt.query_map([], |row| { row.get::<_, String>(0) }).map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } // Check for overlapping time entries #[tauri::command] pub fn check_entry_overlap( state: State, start_time: String, end_time: String, exclude_id: Option, ) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let query = if let Some(eid) = exclude_id { let mut stmt = conn.prepare( "SELECT te.id, te.description, te.start_time, te.end_time, p.name as project_name FROM time_entries te JOIN projects p ON te.project_id = p.id WHERE te.end_time IS NOT NULL AND te.id != ?3 AND te.start_time < ?2 AND te.end_time > ?1 ORDER BY te.start_time" ).map_err(|e| e.to_string())?; let rows = stmt.query_map(params![start_time, end_time, eid], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "description": row.get::<_, Option>(1)?, "start_time": row.get::<_, String>(2)?, "end_time": row.get::<_, Option>(3)?, "project_name": row.get::<_, String>(4)? })) }).map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string())? } else { let mut stmt = conn.prepare( "SELECT te.id, te.description, te.start_time, te.end_time, p.name as project_name FROM time_entries te JOIN projects p ON te.project_id = p.id WHERE te.end_time IS NOT NULL AND te.start_time < ?2 AND te.end_time > ?1 ORDER BY te.start_time" ).map_err(|e| e.to_string())?; let rows = stmt.query_map(params![start_time, end_time], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "description": row.get::<_, Option>(1)?, "start_time": row.get::<_, String>(2)?, "end_time": row.get::<_, Option>(3)?, "project_name": row.get::<_, String>(4)? })) }).map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string())? }; Ok(query) } // Get actual hours by task for a project (estimates vs actuals) #[tauri::command] pub fn get_task_actuals(state: State, project_id: i64) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT t.id, t.name, t.estimated_hours, t.hourly_rate, COALESCE(SUM(te.duration), 0) as actual_seconds FROM tasks t LEFT JOIN time_entries te ON te.task_id = t.id WHERE t.project_id = ?1 GROUP BY t.id ORDER BY t.name" ).map_err(|e| e.to_string())?; let rows = stmt.query_map(params![project_id], |row| { let estimated: Option = row.get(2)?; let actual_seconds: i64 = row.get(4)?; let actual_hours = actual_seconds as f64 / 3600.0; let variance = estimated.map(|est| actual_hours - est); let progress = estimated.map(|est| if est > 0.0 { (actual_hours / est) * 100.0 } else { 0.0 }); Ok(serde_json::json!({ "task_id": row.get::<_, i64>(0)?, "task_name": row.get::<_, String>(1)?, "estimated_hours": estimated, "hourly_rate": row.get::<_, Option>(3)?, "actual_seconds": actual_seconds, "actual_hours": actual_hours, "variance": variance, "progress": progress })) }).map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } // Invoice payment struct and commands #[derive(Debug, Serialize, Deserialize)] pub struct InvoicePayment { pub id: Option, pub invoice_id: i64, pub amount: f64, pub date: String, pub method: Option, pub notes: Option, } #[tauri::command] pub fn get_invoice_payments(state: State, invoice_id: i64) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, invoice_id, amount, date, method, notes FROM invoice_payments WHERE invoice_id = ?1 ORDER BY date" ).map_err(|e| e.to_string())?; let payments = stmt.query_map(params![invoice_id], |row| { Ok(InvoicePayment { id: Some(row.get(0)?), invoice_id: row.get(1)?, amount: row.get(2)?, date: row.get(3)?, method: row.get(4)?, notes: row.get(5)?, }) }).map_err(|e| e.to_string())?; payments.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn add_invoice_payment(state: State, payment: InvoicePayment) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO invoice_payments (invoice_id, amount, date, method, notes) VALUES (?1, ?2, ?3, ?4, ?5)", params![payment.invoice_id, payment.amount, payment.date, payment.method, payment.notes], ).map_err(|e| e.to_string())?; // Update invoice status based on total paid let total_paid: f64 = conn.query_row( "SELECT COALESCE(SUM(amount), 0) FROM invoice_payments WHERE invoice_id = ?1", params![payment.invoice_id], |row| row.get(0), ).map_err(|e| e.to_string())?; let invoice_total: f64 = conn.query_row( "SELECT total FROM invoices WHERE id = ?1", params![payment.invoice_id], |row| row.get(0), ).map_err(|e| e.to_string())?; let new_status = if total_paid >= invoice_total { "paid" } else { "partial" }; conn.execute( "UPDATE invoices SET status = ?1 WHERE id = ?2", params![new_status, payment.invoice_id], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn delete_invoice_payment(state: State, id: i64, invoice_id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM invoice_payments WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; // Recalculate invoice status let total_paid: f64 = conn.query_row( "SELECT COALESCE(SUM(amount), 0) FROM invoice_payments WHERE invoice_id = ?1", params![invoice_id], |row| row.get(0), ).map_err(|e| e.to_string())?; let invoice_total: f64 = conn.query_row( "SELECT total FROM invoices WHERE id = ?1", params![invoice_id], |row| row.get(0), ).map_err(|e| e.to_string())?; let new_status = if total_paid >= invoice_total { "paid" } else if total_paid > 0.0 { "partial" } else { "sent" }; conn.execute( "UPDATE invoices SET status = ?1 WHERE id = ?2", params![new_status, invoice_id], ).map_err(|e| e.to_string())?; Ok(()) } // Recurring invoice struct and commands #[derive(Debug, Serialize, Deserialize)] pub struct RecurringInvoice { pub id: Option, pub client_id: i64, pub template_id: Option, pub line_items_json: String, pub tax_rate: f64, pub discount: f64, pub notes: Option, pub recurrence_rule: String, pub next_due_date: String, pub enabled: Option, } #[tauri::command] pub fn get_recurring_invoices(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled FROM recurring_invoices ORDER BY next_due_date" ).map_err(|e| e.to_string())?; let rows = stmt.query_map([], |row| { Ok(RecurringInvoice { id: Some(row.get(0)?), client_id: row.get(1)?, template_id: row.get(2)?, line_items_json: row.get(3)?, tax_rate: row.get(4)?, discount: row.get(5)?, notes: row.get(6)?, recurrence_rule: row.get(7)?, next_due_date: row.get(8)?, enabled: row.get(9)?, }) }).map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn create_recurring_invoice(state: State, invoice: RecurringInvoice) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO recurring_invoices (client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![invoice.client_id, invoice.template_id, invoice.line_items_json, invoice.tax_rate, invoice.discount, invoice.notes, invoice.recurrence_rule, invoice.next_due_date, invoice.enabled.unwrap_or(1)], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn update_recurring_invoice(state: State, invoice: RecurringInvoice) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "UPDATE recurring_invoices SET client_id = ?1, template_id = ?2, line_items_json = ?3, tax_rate = ?4, discount = ?5, notes = ?6, recurrence_rule = ?7, next_due_date = ?8, enabled = ?9 WHERE id = ?10", params![invoice.client_id, invoice.template_id, invoice.line_items_json, invoice.tax_rate, invoice.discount, invoice.notes, invoice.recurrence_rule, invoice.next_due_date, invoice.enabled, invoice.id], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn delete_recurring_invoice(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM recurring_invoices WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; Ok(()) } // Check recurring invoices and auto-create drafts when due #[tauri::command] pub fn check_recurring_invoices(state: State, today: String) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date FROM recurring_invoices WHERE enabled = 1 AND date(next_due_date) <= date(?1)" ).map_err(|e| e.to_string())?; let due: Vec<(i64, i64, Option, String, f64, f64, Option, String, String)> = stmt .query_map(params![today], |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?, row.get(8)?)) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; let mut created_ids: Vec = Vec::new(); for (ri_id, client_id, template_id, line_items_json, tax_rate, discount, notes, rule, next_due) in &due { // Generate invoice number let count: i64 = conn.query_row( "SELECT COUNT(*) FROM invoices", [], |row| row.get(0) ).map_err(|e| e.to_string())?; let inv_number = format!("INV-{:04}", count + 1); // Parse line items to calculate totals let items: Vec = serde_json::from_str(line_items_json).unwrap_or_default(); let subtotal: f64 = items.iter().map(|item| { let qty = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(0.0); let rate = item.get("unit_price").and_then(|v| v.as_f64()).unwrap_or(0.0); qty * rate }).sum(); let tax_amount = subtotal * tax_rate / 100.0; let total = subtotal + tax_amount - discount; conn.execute( "INSERT INTO invoices (client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'draft', ?11)", params![client_id, inv_number, next_due, next_due, subtotal, tax_rate, tax_amount, discount, total, notes, template_id], ).map_err(|e| e.to_string())?; let invoice_id = conn.last_insert_rowid(); // Insert line items for item in &items { let desc = item.get("description").and_then(|v| v.as_str()).unwrap_or(""); let qty = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(0.0); let rate = item.get("unit_price").and_then(|v| v.as_f64()).unwrap_or(0.0); let amount = qty * rate; conn.execute( "INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount) VALUES (?1, ?2, ?3, ?4, ?5)", params![invoice_id, desc, qty, rate, amount], ).map_err(|e| e.to_string())?; } created_ids.push(invoice_id); // Advance next_due_date based on recurrence rule let next: String = match rule.as_str() { "weekly" => conn.query_row( "SELECT date(?1, '+7 days')", params![next_due], |row| row.get(0) ).map_err(|e| e.to_string())?, "biweekly" => conn.query_row( "SELECT date(?1, '+14 days')", params![next_due], |row| row.get(0) ).map_err(|e| e.to_string())?, "quarterly" => conn.query_row( "SELECT date(?1, '+3 months')", params![next_due], |row| row.get(0) ).map_err(|e| e.to_string())?, "yearly" => conn.query_row( "SELECT date(?1, '+1 year')", params![next_due], |row| row.get(0) ).map_err(|e| e.to_string())?, _ => conn.query_row( "SELECT date(?1, '+1 month')", params![next_due], |row| row.get(0) ).map_err(|e| e.to_string())?, }; conn.execute( "UPDATE recurring_invoices SET next_due_date = ?1 WHERE id = ?2", params![next, ri_id], ).map_err(|e| e.to_string())?; } Ok(created_ids) } pub fn seed_default_templates(data_dir: &std::path::Path) { let templates_dir = data_dir.join("templates"); std::fs::create_dir_all(&templates_dir).ok(); // Only seed if directory is empty (no .json files) let has_templates = std::fs::read_dir(&templates_dir) .map(|entries| entries.flatten().any(|e| { e.path().extension().and_then(|ext| ext.to_str()) == Some("json") })) .unwrap_or(false); if has_templates { return; } let defaults = get_default_templates(); for template in &defaults { let filename = format!("{}.json", template.id); let path = templates_dir.join(&filename); if let Ok(json) = serde_json::to_string_pretty(template) { std::fs::write(&path, json).ok(); } } } // Calendar integration structs and commands #[derive(Debug, Serialize, Deserialize)] pub struct CalendarSource { pub id: Option, pub name: String, pub source_type: String, pub url: Option, pub last_synced: Option, pub sync_interval: Option, pub enabled: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct CalendarEvent { pub id: Option, pub source_id: i64, pub uid: Option, pub summary: Option, pub start_time: Option, pub end_time: Option, pub duration: Option, pub location: Option, pub synced_at: Option, } struct ParsedCalendarEvent { uid: Option, summary: Option, start_time: Option, end_time: Option, location: Option, description: Option, duration: i64, } fn parse_ics_datetime(dt: &str) -> Option { if dt.is_empty() { return None; } let dt = dt.trim(); if dt.len() >= 15 { // 20260115T090000Z -> 2026-01-15T09:00:00Z Some(format!( "{}-{}-{}T{}:{}:{}{}", &dt[0..4], &dt[4..6], &dt[6..8], &dt[9..11], &dt[11..13], &dt[13..15], if dt.ends_with('Z') { "Z" } else { "" } )) } else if dt.len() >= 8 { // 20260115 -> 2026-01-15 Some(format!("{}-{}-{}", &dt[0..4], &dt[4..6], &dt[6..8])) } else { None } } fn unfold_ics_lines(content: &str) -> String { let mut result = String::new(); for line in content.lines() { let line = line.trim_end_matches('\r'); if line.starts_with(' ') || line.starts_with('\t') { result.push_str(line.trim_start()); } else { if !result.is_empty() { result.push('\n'); } result.push_str(line); } } result } fn parse_ics_duration(dur: &str) -> Option { let dur = dur.strip_prefix("PT")?; let mut seconds: i64 = 0; let mut num_buf = String::new(); for ch in dur.chars() { if ch.is_ascii_digit() { num_buf.push(ch); } else { let n: i64 = num_buf.parse().ok()?; num_buf.clear(); match ch { 'H' => seconds += n * 3600, 'M' => seconds += n * 60, 'S' => seconds += n, _ => {} } } } Some(seconds) } fn calc_ics_duration_from_times(start: &str, end: &str) -> i64 { let parse_ts = |s: &str| -> Option { let s = s.trim(); if s.len() >= 15 { let year: i64 = s[0..4].parse().ok()?; let month: i64 = s[4..6].parse().ok()?; let day: i64 = s[6..8].parse().ok()?; let hour: i64 = s[9..11].parse().ok()?; let min: i64 = s[11..13].parse().ok()?; let sec: i64 = s[13..15].parse().ok()?; // Approximate seconds since epoch (good enough for duration calc) Some(((year * 365 + month * 30 + day) * 86400) + hour * 3600 + min * 60 + sec) } else { None } }; match (parse_ts(start), parse_ts(end)) { (Some(s), Some(e)) if e > s => e - s, _ => 0, } } fn parse_ics_content(content: &str) -> Vec { let unfolded = unfold_ics_lines(content); let mut events = Vec::new(); let mut in_event = false; let mut uid = String::new(); let mut summary = String::new(); let mut dtstart = String::new(); let mut dtend = String::new(); let mut location = String::new(); let mut description = String::new(); let mut duration_str = String::new(); for line in unfolded.lines() { if line == "BEGIN:VEVENT" { in_event = true; uid.clear(); summary.clear(); dtstart.clear(); dtend.clear(); location.clear(); description.clear(); duration_str.clear(); } else if line == "END:VEVENT" { if in_event { let duration = if !duration_str.is_empty() { parse_ics_duration(&duration_str).unwrap_or(0) } else if !dtstart.is_empty() && !dtend.is_empty() { calc_ics_duration_from_times(&dtstart, &dtend) } else { 0 }; events.push(ParsedCalendarEvent { uid: if uid.is_empty() { None } else { Some(uid.clone()) }, summary: if summary.is_empty() { None } else { Some(summary.clone()) }, start_time: parse_ics_datetime(&dtstart), end_time: parse_ics_datetime(&dtend), location: if location.is_empty() { None } else { Some(location.clone()) }, description: if description.is_empty() { None } else { Some(description.clone()) }, duration, }); } in_event = false; } else if in_event { if let Some(val) = line.strip_prefix("UID:") { uid = val.to_string(); } else if let Some(val) = line.strip_prefix("SUMMARY:") { summary = val.to_string(); } else if line.starts_with("DTSTART") { if let Some(idx) = line.find(':') { dtstart = line[idx + 1..].to_string(); } } else if line.starts_with("DTEND") { if let Some(idx) = line.find(':') { dtend = line[idx + 1..].to_string(); } } else if let Some(val) = line.strip_prefix("LOCATION:") { location = val.to_string(); } else if let Some(val) = line.strip_prefix("DESCRIPTION:") { description = val.replace("\\n", "\n").replace("\\,", ","); } else if line.starts_with("DURATION") { if let Some(idx) = line.find(':') { duration_str = line[idx + 1..].to_string(); } } } } events } #[tauri::command] pub fn get_calendar_sources(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT id, name, type, url, last_synced, sync_interval, enabled FROM calendar_sources ORDER BY name", ) .map_err(|e| e.to_string())?; let sources = stmt .query_map([], |row| { Ok(CalendarSource { id: Some(row.get(0)?), name: row.get(1)?, source_type: row.get(2)?, url: row.get(3)?, last_synced: row.get(4)?, sync_interval: row.get(5)?, enabled: row.get(6)?, }) }) .map_err(|e| e.to_string())?; sources .collect::, _>>() .map_err(|e| e.to_string()) } #[tauri::command] pub fn create_calendar_source( state: State, source: CalendarSource, ) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT INTO calendar_sources (name, type, url, sync_interval, enabled) VALUES (?1, ?2, ?3, ?4, ?5)", params![ source.name, source.source_type, source.url, source.sync_interval.unwrap_or(30), source.enabled.unwrap_or(1) ], ) .map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn update_calendar_source( state: State, source: CalendarSource, ) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "UPDATE calendar_sources SET name = ?1, type = ?2, url = ?3, sync_interval = ?4, enabled = ?5 WHERE id = ?6", params![ source.name, source.source_type, source.url, source.sync_interval, source.enabled, source.id ], ) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn delete_calendar_source(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM calendar_events WHERE source_id = ?1", params![id]) .map_err(|e| e.to_string())?; conn.execute("DELETE FROM calendar_sources WHERE id = ?1", params![id]) .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn import_ics_file( state: State, source_id: i64, content: String, ) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let events = parse_ics_content(&content); let mut count: i64 = 0; let now = chrono::Local::now().to_rfc3339(); for event in &events { // Upsert by source_id + uid when uid is present if let Some(ref uid) = event.uid { // Delete existing event with same source_id + uid, then insert conn.execute( "DELETE FROM calendar_events WHERE source_id = ?1 AND uid = ?2", params![source_id, uid], ) .map_err(|e| e.to_string())?; } conn.execute( "INSERT INTO calendar_events (source_id, uid, summary, start_time, end_time, duration, location, description, synced_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ source_id, event.uid, event.summary, event.start_time, event.end_time, event.duration, event.location, event.description, now ], ) .map_err(|e| e.to_string())?; count += 1; } // Update last_synced on the source conn.execute( "UPDATE calendar_sources SET last_synced = ?1 WHERE id = ?2", params![now, source_id], ) .map_err(|e| e.to_string())?; Ok(count) } #[tauri::command] pub fn get_calendar_events( state: State, start_date: String, end_date: String, ) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT ce.id, ce.source_id, ce.uid, ce.summary, ce.start_time, ce.end_time, ce.duration, ce.location, ce.synced_at FROM calendar_events ce JOIN calendar_sources cs ON ce.source_id = cs.id WHERE cs.enabled = 1 AND ce.start_time >= ?1 AND ce.start_time <= ?2 ORDER BY ce.start_time", ) .map_err(|e| e.to_string())?; let events = stmt .query_map(params![start_date, end_date], |row| { Ok(CalendarEvent { id: Some(row.get(0)?), source_id: row.get(1)?, uid: row.get(2)?, summary: row.get(3)?, start_time: row.get(4)?, end_time: row.get(5)?, duration: row.get(6)?, location: row.get(7)?, synced_at: row.get(8)?, }) }) .map_err(|e| e.to_string())?; events .collect::, _>>() .map_err(|e| e.to_string()) } #[derive(Debug, Serialize, Deserialize)] pub struct TimesheetLock { pub id: Option, pub week_start: String, pub status: Option, pub locked_at: Option, } #[tauri::command] pub fn lock_timesheet_week(state: State, week_start: String) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "INSERT OR IGNORE INTO timesheet_locks (week_start, status) VALUES (?1, 'locked')", params![week_start], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn unlock_timesheet_week(state: State, week_start: String) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "DELETE FROM timesheet_locks WHERE week_start = ?1", params![week_start], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn get_timesheet_locks(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, week_start, status, locked_at FROM timesheet_locks ORDER BY week_start DESC" ).map_err(|e| e.to_string())?; let rows = stmt.query_map([], |row| { Ok(TimesheetLock { id: Some(row.get(0)?), week_start: row.get(1)?, status: row.get(2)?, locked_at: row.get(3)?, }) }).map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn is_week_locked(state: State, week_start: String) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let count: i64 = conn.query_row( "SELECT COUNT(*) FROM timesheet_locks WHERE week_start = ?1", params![week_start], |row| row.get(0), ).map_err(|e| e.to_string())?; Ok(count > 0) } #[tauri::command] pub fn update_invoice_status(state: State, id: i64, status: String) -> Result<(), String> { let valid = ["draft", "sent", "paid", "overdue"]; if !valid.contains(&status.as_str()) { return Err(format!("Invalid status: {}. Must be one of: {:?}", status, valid)); } let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute( "UPDATE invoices SET status = ?1 WHERE id = ?2", params![status, id], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn check_overdue_invoices(state: State, today: String) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let count = conn.execute( "UPDATE invoices SET status = 'overdue' WHERE status = 'sent' AND due_date < ?1 AND due_date IS NOT NULL", params![today], ).map_err(|e| e.to_string())?; Ok(count as i64) } #[derive(serde::Serialize)] pub struct PaginatedEntries { pub entries: Vec, pub total: i64, pub has_more: bool, } #[tauri::command] pub fn get_time_entries_paginated( state: State, start_date: Option, end_date: Option, limit: Option, offset: Option, ) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let lim = limit.unwrap_or(50); let off = offset.unwrap_or(0); let (where_clause, has_dates) = match (&start_date, &end_date) { (Some(_), Some(_)) => ( "WHERE date(start_time) >= date(?1) AND date(start_time) <= date(?2)", true, ), _ => ("", false), }; let count_sql = format!("SELECT COUNT(*) FROM time_entries {}", where_clause); let total: i64 = if has_dates { conn.query_row(&count_sql, params![start_date.as_ref().unwrap(), end_date.as_ref().unwrap()], |r| r.get(0)) } else { conn.query_row(&count_sql, [], |r| r.get(0)) }.map_err(|e| e.to_string())?; let query_sql = if has_dates { format!( "SELECT id, project_id, task_id, description, start_time, end_time, duration, billable FROM time_entries {} ORDER BY start_time DESC LIMIT ?3 OFFSET ?4", where_clause, ) } else { "SELECT id, project_id, task_id, description, start_time, end_time, duration, billable FROM time_entries ORDER BY start_time DESC LIMIT ?1 OFFSET ?2".to_string() }; let entries = if has_dates { let mut stmt = conn.prepare(&query_sql).map_err(|e| e.to_string())?; let result = stmt.query_map(params![start_date.as_ref().unwrap(), end_date.as_ref().unwrap(), lim, off], |row| { Ok(TimeEntry { id: row.get(0)?, project_id: row.get(1)?, task_id: row.get(2)?, description: row.get(3)?, start_time: row.get(4)?, end_time: row.get(5)?, duration: row.get(6)?, billable: row.get(7)?, }) }).map_err(|e| e.to_string())?.filter_map(|r| r.ok()).collect::>(); result } else { let mut stmt = conn.prepare(&query_sql).map_err(|e| e.to_string())?; let result = stmt.query_map(params![lim, off], |row| { Ok(TimeEntry { id: row.get(0)?, project_id: row.get(1)?, task_id: row.get(2)?, description: row.get(3)?, start_time: row.get(4)?, end_time: row.get(5)?, duration: row.get(6)?, billable: row.get(7)?, }) }).map_err(|e| e.to_string())?.filter_map(|r| r.ok()).collect::>(); result }; Ok(PaginatedEntries { has_more: (off + lim) < total, entries, total, }) } #[tauri::command] pub fn search_entries(state: State, query: String, limit: Option) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let limit = limit.unwrap_or(10); let pattern = format!("%{}%", query); let mut stmt = conn.prepare( "SELECT te.id, te.project_id, te.description, te.start_time, te.duration, p.name as project_name, p.color as project_color FROM time_entries te LEFT JOIN projects p ON te.project_id = p.id WHERE te.description LIKE ?1 ORDER BY te.start_time DESC LIMIT ?2" ).map_err(|e| e.to_string())?; let rows = stmt.query_map(params![pattern, limit], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "project_id": row.get::<_, i64>(1)?, "description": row.get::<_, Option>(2)?, "start_time": row.get::<_, String>(3)?, "duration": row.get::<_, i64>(4)?, "project_name": row.get::<_, Option>(5)?, "project_color": row.get::<_, Option>(6)?, })) }).map_err(|e| e.to_string())?; rows.collect::, _>>().map_err(|e| e.to_string()) } #[tauri::command] pub fn bulk_delete_entries(state: State, ids: Vec) -> Result<(), String> { if ids.is_empty() { return Ok(()); } let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute_batch("BEGIN TRANSACTION").map_err(|e| e.to_string())?; let result = (|| -> Result<(), rusqlite::Error> { for id in &ids { conn.execute("DELETE FROM invoice_items WHERE time_entry_id = ?1", params![id])?; conn.execute("DELETE FROM entry_tags WHERE entry_id = ?1", params![id])?; conn.execute("DELETE FROM time_entries WHERE id = ?1", params![id])?; } Ok(()) })(); match result { Ok(()) => { conn.execute_batch("COMMIT").map_err(|e| e.to_string())?; Ok(()) } Err(e) => { conn.execute_batch("ROLLBACK").ok(); Err(e.to_string()) } } } #[tauri::command] pub fn bulk_update_entries_project(state: State, ids: Vec, project_id: i64) -> Result<(), String> { if ids.is_empty() { return Ok(()); } let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute_batch("BEGIN TRANSACTION").map_err(|e| e.to_string())?; let result = (|| -> Result<(), rusqlite::Error> { for id in &ids { conn.execute("UPDATE time_entries SET project_id = ?1 WHERE id = ?2", params![project_id, id])?; } Ok(()) })(); match result { Ok(()) => { conn.execute_batch("COMMIT").map_err(|e| e.to_string())?; Ok(()) } Err(e) => { conn.execute_batch("ROLLBACK").ok(); Err(e.to_string()) } } } #[tauri::command] pub fn bulk_update_entries_billable(state: State, ids: Vec, billable: i32) -> Result<(), String> { if ids.is_empty() { return Ok(()); } let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute_batch("BEGIN TRANSACTION").map_err(|e| e.to_string())?; let result = (|| -> Result<(), rusqlite::Error> { for id in &ids { conn.execute("UPDATE time_entries SET billable = ?1 WHERE id = ?2", params![billable, id])?; } Ok(()) })(); match result { Ok(()) => { conn.execute_batch("COMMIT").map_err(|e| e.to_string())?; Ok(()) } Err(e) => { conn.execute_batch("ROLLBACK").ok(); Err(e.to_string()) } } } #[tauri::command] pub fn upsert_timesheet_entry( state: State, project_id: i64, task_id: Option, date: String, duration_seconds: i64, ) -> Result { if duration_seconds < 0 || duration_seconds > 86400 { return Err("Duration must be between 0 and 86400 seconds".to_string()); } let conn = state.db.lock().map_err(|e| e.to_string())?; let existing: Option = conn.query_row( "SELECT id FROM time_entries WHERE project_id = ?1 AND (task_id = ?2 OR (task_id IS NULL AND ?2 IS NULL)) AND date(start_time) = date(?3) ORDER BY id ASC LIMIT 1", params![project_id, task_id, date], |row| row.get(0), ).ok(); if let Some(id) = existing { let end_time = format!("{}T{}", date, format_seconds_as_time(duration_seconds)); conn.execute( "UPDATE time_entries SET duration = ?1, end_time = ?2 WHERE id = ?3", params![duration_seconds, end_time, id], ).map_err(|e| e.to_string())?; Ok(id) } else { let start_time = format!("{}T09:00:00", date); let end_secs = 9 * 3600 + duration_seconds; let end_h = end_secs / 3600; let end_m = (end_secs % 3600) / 60; let end_s = end_secs % 60; let end_time = format!("{}T{:02}:{:02}:{:02}", date, end_h, end_m, end_s); conn.execute( "INSERT INTO time_entries (project_id, task_id, description, start_time, end_time, duration, billable) VALUES (?1, ?2, NULL, ?3, ?4, ?5, 1)", params![project_id, task_id, start_time, end_time, duration_seconds], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } } // Entry template commands #[tauri::command] pub fn get_entry_templates(state: State) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, name, project_id, task_id, description, duration, billable, created_at FROM entry_templates ORDER BY name" ).map_err(|e| e.to_string())?; let rows = stmt.query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "name": row.get::<_, String>(1)?, "project_id": row.get::<_, i64>(2)?, "task_id": row.get::<_, Option>(3)?, "description": row.get::<_, Option>(4)?, "duration": row.get::<_, i64>(5)?, "billable": row.get::<_, i64>(6)?, "created_at": row.get::<_, String>(7)?, })) }).map_err(|e| e.to_string())?; Ok(rows.filter_map(|r| r.ok()).collect()) } #[tauri::command] pub fn create_entry_template(state: State, template: serde_json::Value) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; let name = template.get("name").and_then(|v| v.as_str()).unwrap_or("Untitled"); let project_id = template.get("project_id").and_then(|v| v.as_i64()).ok_or("project_id required")?; let task_id = template.get("task_id").and_then(|v| v.as_i64()); let description = template.get("description").and_then(|v| v.as_str()); let duration = template.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); let billable = template.get("billable").and_then(|v| v.as_i64()).unwrap_or(1); conn.execute( "INSERT INTO entry_templates (name, project_id, task_id, description, duration, billable) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![name, project_id, task_id, description, duration, billable], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn delete_entry_template(state: State, id: i64) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("DELETE FROM entry_templates WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn update_entry_template(state: State, template: serde_json::Value) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let id = template.get("id").and_then(|v| v.as_i64()).ok_or("id required")?; let name = template.get("name").and_then(|v| v.as_str()).unwrap_or("Untitled"); let project_id = template.get("project_id").and_then(|v| v.as_i64()).ok_or("project_id required")?; let task_id = template.get("task_id").and_then(|v| v.as_i64()); let description = template.get("description").and_then(|v| v.as_str()); let duration = template.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); let billable = template.get("billable").and_then(|v| v.as_i64()).unwrap_or(1); conn.execute( "UPDATE entry_templates SET name=?1, project_id=?2, task_id=?3, description=?4, duration=?5, billable=?6 WHERE id=?7", params![name, project_id, task_id, description, duration, billable, id], ).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn get_timesheet_rows(state: State, week_start: String) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT id, week_start, project_id, task_id, sort_order FROM timesheet_rows WHERE week_start = ?1 ORDER BY sort_order" ).map_err(|e| e.to_string())?; let rows = stmt.query_map(params![week_start], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "week_start": row.get::<_, String>(1)?, "project_id": row.get::<_, i64>(2)?, "task_id": row.get::<_, Option>(3)?, "sort_order": row.get::<_, i64>(4)?, })) }).map_err(|e| e.to_string())?; Ok(rows.filter_map(|r| r.ok()).collect()) } #[tauri::command] pub fn save_timesheet_rows(state: State, week_start: String, rows: Vec) -> Result<(), String> { let conn = state.db.lock().map_err(|e| e.to_string())?; conn.execute("BEGIN TRANSACTION", []).map_err(|e| e.to_string())?; if let Err(e) = conn.execute("DELETE FROM timesheet_rows WHERE week_start = ?1", params![week_start]) { let _ = conn.execute("ROLLBACK", []); return Err(e.to_string()); } for (i, row) in rows.iter().enumerate() { let project_id = row.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); let task_id = row.get("task_id").and_then(|v| v.as_i64()); if let Err(e) = conn.execute( "INSERT INTO timesheet_rows (week_start, project_id, task_id, sort_order) VALUES (?1, ?2, ?3, ?4)", params![week_start, project_id, task_id, i as i64], ) { let _ = conn.execute("ROLLBACK", []); return Err(e.to_string()); } } conn.execute("COMMIT", []).map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn get_previous_week_structure(state: State, current_week_start: String) -> Result, String> { let conn = state.db.lock().map_err(|e| e.to_string())?; let current = chrono::NaiveDate::parse_from_str(¤t_week_start, "%Y-%m-%d") .map_err(|e| e.to_string())?; let prev = current - chrono::Duration::days(7); let prev_str = prev.format("%Y-%m-%d").to_string(); let mut stmt = conn.prepare( "SELECT id, week_start, project_id, task_id, sort_order FROM timesheet_rows WHERE week_start = ?1 ORDER BY sort_order" ).map_err(|e| e.to_string())?; let rows = stmt.query_map(params![prev_str], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, "week_start": row.get::<_, String>(1)?, "project_id": row.get::<_, i64>(2)?, "task_id": row.get::<_, Option>(3)?, "sort_order": row.get::<_, i64>(4)?, })) }).map_err(|e| e.to_string())?; Ok(rows.filter_map(|r| r.ok()).collect()) } fn format_seconds_as_time(secs: i64) -> String { let h = secs / 3600; let m = (secs % 3600) / 60; let s = secs % 60; format!("{:02}:{:02}:{:02}", h, m, s) } fn get_default_templates() -> Vec { vec![ InvoiceTemplate { id: "clean".into(), name: "Clean".into(), layout: "clean".into(), category: "essential".into(), description: "Swiss minimalism with a single blue accent".into(), colors: InvoiceTemplateColors { primary: "#1e293b".into(), secondary: "#3b82f6".into(), background: "#ffffff".into(), header_bg: "#ffffff".into(), header_text: "#1e293b".into(), body_text: "#374151".into(), table_header_bg: "#f8fafc".into(), table_header_text: "#374151".into(), table_row_alt: "#f8fafc".into(), table_border: "#e5e7eb".into(), total_highlight: "#3b82f6".into(), }, }, InvoiceTemplate { id: "professional".into(), name: "Professional".into(), layout: "professional".into(), category: "essential".into(), description: "Navy header band with corporate polish".into(), colors: InvoiceTemplateColors { primary: "#1e3a5f".into(), secondary: "#2563eb".into(), background: "#ffffff".into(), header_bg: "#1e3a5f".into(), header_text: "#ffffff".into(), body_text: "#374151".into(), table_header_bg: "#1e3a5f".into(), table_header_text: "#ffffff".into(), table_row_alt: "#f3f4f6".into(), table_border: "#d1d5db".into(), total_highlight: "#1e3a5f".into(), }, }, InvoiceTemplate { id: "bold".into(), name: "Bold".into(), layout: "bold".into(), category: "essential".into(), description: "Large indigo block with oversized typography".into(), colors: InvoiceTemplateColors { primary: "#4f46e5".into(), secondary: "#a5b4fc".into(), background: "#ffffff".into(), header_bg: "#4f46e5".into(), header_text: "#ffffff".into(), body_text: "#1f2937".into(), table_header_bg: "#4f46e5".into(), table_header_text: "#ffffff".into(), table_row_alt: "#f5f3ff".into(), table_border: "#e0e7ff".into(), total_highlight: "#4f46e5".into(), }, }, InvoiceTemplate { id: "minimal".into(), name: "Minimal".into(), layout: "minimal".into(), category: "essential".into(), description: "Pure monochrome centered layout".into(), colors: InvoiceTemplateColors { primary: "#18181b".into(), secondary: "#18181b".into(), background: "#ffffff".into(), header_bg: "#ffffff".into(), header_text: "#18181b".into(), body_text: "#3f3f46".into(), table_header_bg: "#ffffff".into(), table_header_text: "#18181b".into(), table_row_alt: "#ffffff".into(), table_border: "#e4e4e7".into(), total_highlight: "#18181b".into(), }, }, InvoiceTemplate { id: "classic".into(), name: "Classic".into(), layout: "classic".into(), category: "essential".into(), description: "Traditional layout with burgundy accents".into(), colors: InvoiceTemplateColors { primary: "#7f1d1d".into(), secondary: "#991b1b".into(), background: "#ffffff".into(), header_bg: "#7f1d1d".into(), header_text: "#ffffff".into(), body_text: "#374151".into(), table_header_bg: "#7f1d1d".into(), table_header_text: "#ffffff".into(), table_row_alt: "#f5f5f4".into(), table_border: "#d6d3d1".into(), total_highlight: "#7f1d1d".into(), }, }, InvoiceTemplate { id: "modern".into(), name: "Modern".into(), layout: "modern".into(), category: "creative".into(), description: "Asymmetric header with teal accents".into(), colors: InvoiceTemplateColors { primary: "#0d9488".into(), secondary: "#14b8a6".into(), background: "#ffffff".into(), header_bg: "#ffffff".into(), header_text: "#0f172a".into(), body_text: "#334155".into(), table_header_bg: "#ffffff".into(), table_header_text: "#0d9488".into(), table_row_alt: "#f0fdfa".into(), table_border: "#99f6e4".into(), total_highlight: "#0d9488".into(), }, }, InvoiceTemplate { id: "elegant".into(), name: "Elegant".into(), layout: "elegant".into(), category: "creative".into(), description: "Gold double-rule accents on centered layout".into(), colors: InvoiceTemplateColors { primary: "#a16207".into(), secondary: "#ca8a04".into(), background: "#ffffff".into(), header_bg: "#ffffff".into(), header_text: "#422006".into(), body_text: "#57534e".into(), table_header_bg: "#ffffff".into(), table_header_text: "#422006".into(), table_row_alt: "#fefce8".into(), table_border: "#a16207".into(), total_highlight: "#a16207".into(), }, }, InvoiceTemplate { id: "creative".into(), name: "Creative".into(), layout: "creative".into(), category: "creative".into(), description: "Purple sidebar with card-style rows".into(), colors: InvoiceTemplateColors { primary: "#7c3aed".into(), secondary: "#a78bfa".into(), background: "#ffffff".into(), header_bg: "#ffffff".into(), header_text: "#1f2937".into(), body_text: "#374151".into(), table_header_bg: "#faf5ff".into(), table_header_text: "#7c3aed".into(), table_row_alt: "#faf5ff".into(), table_border: "#e9d5ff".into(), total_highlight: "#7c3aed".into(), }, }, InvoiceTemplate { id: "compact".into(), name: "Compact".into(), layout: "compact".into(), category: "creative".into(), description: "Data-dense layout with tight spacing".into(), colors: InvoiceTemplateColors { primary: "#475569".into(), secondary: "#64748b".into(), background: "#ffffff".into(), header_bg: "#ffffff".into(), header_text: "#0f172a".into(), body_text: "#334155".into(), table_header_bg: "#f1f5f9".into(), table_header_text: "#334155".into(), table_row_alt: "#f8fafc".into(), table_border: "#e2e8f0".into(), total_highlight: "#475569".into(), }, }, InvoiceTemplate { id: "dark".into(), name: "Dark".into(), layout: "dark".into(), category: "warm".into(), description: "Near-black background with cyan highlights".into(), colors: InvoiceTemplateColors { primary: "#06b6d4".into(), secondary: "#22d3ee".into(), background: "#0f172a".into(), header_bg: "#020617".into(), header_text: "#e2e8f0".into(), body_text: "#cbd5e1".into(), table_header_bg: "#020617".into(), table_header_text: "#06b6d4".into(), table_row_alt: "#1e293b".into(), table_border: "#334155".into(), total_highlight: "#06b6d4".into(), }, }, InvoiceTemplate { id: "vibrant".into(), name: "Vibrant".into(), layout: "vibrant".into(), category: "warm".into(), description: "Coral-to-orange gradient header band".into(), colors: InvoiceTemplateColors { primary: "#ea580c".into(), secondary: "#f97316".into(), background: "#ffffff".into(), header_bg: "#ea580c".into(), header_text: "#ffffff".into(), body_text: "#1f2937".into(), table_header_bg: "#fff7ed".into(), table_header_text: "#9a3412".into(), table_row_alt: "#fff7ed".into(), table_border: "#fed7aa".into(), total_highlight: "#ea580c".into(), }, }, InvoiceTemplate { id: "corporate".into(), name: "Corporate".into(), layout: "corporate".into(), category: "warm".into(), description: "Deep blue header with info bar below".into(), colors: InvoiceTemplateColors { primary: "#1e40af".into(), secondary: "#3b82f6".into(), background: "#ffffff".into(), header_bg: "#1e40af".into(), header_text: "#ffffff".into(), body_text: "#1f2937".into(), table_header_bg: "#1e40af".into(), table_header_text: "#ffffff".into(), table_row_alt: "#eff6ff".into(), table_border: "#bfdbfe".into(), total_highlight: "#1e40af".into(), }, }, InvoiceTemplate { id: "fresh".into(), name: "Fresh".into(), layout: "fresh".into(), category: "premium".into(), description: "Oversized watermark invoice number".into(), colors: InvoiceTemplateColors { primary: "#0284c7".into(), secondary: "#38bdf8".into(), background: "#ffffff".into(), header_bg: "#ffffff".into(), header_text: "#0c4a6e".into(), body_text: "#334155".into(), table_header_bg: "#0284c7".into(), table_header_text: "#ffffff".into(), table_row_alt: "#f0f9ff".into(), table_border: "#bae6fd".into(), total_highlight: "#0284c7".into(), }, }, InvoiceTemplate { id: "natural".into(), name: "Natural".into(), layout: "natural".into(), category: "premium".into(), description: "Warm beige background with terracotta accents".into(), colors: InvoiceTemplateColors { primary: "#c2703e".into(), secondary: "#d97706".into(), background: "#fdf6ec".into(), header_bg: "#fdf6ec".into(), header_text: "#78350f".into(), body_text: "#57534e".into(), table_header_bg: "#c2703e".into(), table_header_text: "#ffffff".into(), table_row_alt: "#fef3c7".into(), table_border: "#d6d3d1".into(), total_highlight: "#c2703e".into(), }, }, InvoiceTemplate { id: "statement".into(), name: "Statement".into(), layout: "statement".into(), category: "premium".into(), description: "Total-forward design with hero amount".into(), colors: InvoiceTemplateColors { primary: "#18181b".into(), secondary: "#be123c".into(), background: "#ffffff".into(), header_bg: "#ffffff".into(), header_text: "#18181b".into(), body_text: "#3f3f46".into(), table_header_bg: "#ffffff".into(), table_header_text: "#18181b".into(), table_row_alt: "#ffffff".into(), table_border: "#e4e4e7".into(), total_highlight: "#be123c".into(), }, }, ] }