From 37751eb0c81e38c358e15535882f01b182fef475 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 20 Feb 2026 14:54:37 +0200 Subject: [PATCH] feat: batch invoice items save with transaction --- src-tauri/src/commands.rs | 1144 +++++++++++++++++++++++++++++++++++-- src-tauri/src/lib.rs | 40 +- 2 files changed, 1143 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fe191c1..5b94b09 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -35,6 +35,7 @@ pub struct Task { pub id: Option, pub project_id: i64, pub name: String, + pub estimated_hours: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -46,6 +47,7 @@ pub struct TimeEntry { pub start_time: String, pub end_time: Option, pub duration: i64, + pub billable: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -158,24 +160,83 @@ pub fn update_project(state: State, project: Project) -> Result<(), St 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("DELETE FROM tracked_apps WHERE project_id = ?1", params![id]).map_err(|e| e.to_string())?; - conn.execute("DELETE FROM projects WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; - Ok(()) + + 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 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 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 FROM tasks WHERE project_id = ?1 ORDER BY name").map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours 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)?, }) }).map_err(|e| e.to_string())?; tasks.collect::, _>>().map_err(|e| e.to_string()) @@ -185,8 +246,8 @@ pub fn get_tasks(state: State, project_id: i64) -> Result, S 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) VALUES (?1, ?2)", - params![task.project_id, task.name], + "INSERT INTO tasks (project_id, name, estimated_hours) VALUES (?1, ?2, ?3)", + params![task.project_id, task.name, task.estimated_hours], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } @@ -198,6 +259,16 @@ pub fn delete_task(state: State, id: i64) -> Result<(), 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 WHERE id = ?3", + params![task.name, task.estimated_hours, 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> { @@ -205,7 +276,7 @@ pub fn get_time_entries(state: State, start_date: Option, end_ 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 + "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" @@ -219,13 +290,14 @@ pub fn get_time_entries(state: State, start_date: Option, end_ 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 + "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| { @@ -237,6 +309,7 @@ pub fn get_time_entries(state: State, start_date: Option, end_ 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())? @@ -248,10 +321,23 @@ pub fn get_time_entries(state: State, start_date: Option, end_ #[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) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![entry.project_id, entry.task_id, entry.description, entry.start_time, entry.end_time, entry.duration], + "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()) } @@ -259,10 +345,23 @@ pub fn create_time_entry(state: State, entry: TimeEntry) -> Result, 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 WHERE id = ?7", - params![entry.project_id, entry.task_id, entry.description, entry.start_time, entry.end_time, entry.duration, entry.id], + 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(()) } @@ -270,6 +369,24 @@ pub fn update_time_entry(state: State, entry: TimeEntry) -> Result<(), #[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 time_entries WHERE id = ?1", params![id]).map_err(|e| e.to_string())?; Ok(()) } @@ -437,6 +554,45 @@ pub fn delete_invoice_items(state: State, invoice_id: i64) -> Result<( 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> { @@ -506,7 +662,7 @@ pub fn export_data(state: State) -> Result }; let time_entries = { - let mut stmt = conn.prepare("SELECT id, project_id, task_id, description, start_time, end_time, duration FROM time_entries").map_err(|e| e.to_string())?; + let 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)?, @@ -515,7 +671,8 @@ pub fn export_data(state: State) -> Result "description": row.get::<_, Option>(3)?, "start_time": row.get::<_, String>(4)?, "end_time": row.get::<_, Option>(5)?, - "duration": row.get::<_, i64>(6)? + "duration": row.get::<_, i64>(6)?, + "billable": row.get::<_, Option>(7)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows @@ -714,7 +871,7 @@ pub fn set_entry_tags(state: State, entry_id: i64, tag_ids: Vec) } #[tauri::command] -pub fn get_project_budget_status(state: State, project_id: i64) -> Result { +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( @@ -732,13 +889,47 @@ pub fn get_project_budget_status(state: State, project_id: i64) -> Res 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 }) + "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 })) } @@ -800,6 +991,343 @@ pub fn reorder_favorites(state: State, ids: Vec) -> Result<(), St 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 { @@ -975,6 +1503,7 @@ pub fn import_entries(state: State, entries: Vec) - 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; @@ -997,8 +1526,8 @@ pub fn import_entries(state: State, entries: Vec) - }; conn.execute( - "INSERT INTO time_entries (project_id, description, start_time, end_time, duration) VALUES (?1, ?2, ?3, ?4, ?5)", - params![project_id, description, start_time, end_time, duration], + "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; } @@ -1104,10 +1633,11 @@ pub fn import_json_data(state: State, data: serde_json::Value) -> Resu 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, description, start_time, end_time, duration) VALUES (?1, ?2, ?3, ?4, ?5)", - params![pid, description, start_time, end_time, duration], + "INSERT INTO time_entries (project_id, description, start_time, end_time, duration, billable) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![pid, 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); } @@ -1126,33 +1656,18 @@ pub fn save_binary_file(path: String, data: Vec) -> Result<(), String> { // Mini timer window commands #[tauri::command] pub fn open_mini_timer(app: tauri::AppHandle) -> Result<(), String> { - use tauri::WebviewUrl; - - if app.get_webview_window("mini-timer").is_some() { - return Ok(()); + if let Some(win) = app.get_webview_window("mini-timer") { + win.show().map_err(|e| e.to_string())?; + win.set_focus().ok(); } - - // Load root URL — App.vue detects the "mini-timer" window label - // and renders MiniTimer directly, bypassing the router. - tauri::WebviewWindowBuilder::new(&app, "mini-timer", WebviewUrl::App(Default::default())) - .title("Timer") - .inner_size(300.0, 64.0) - .always_on_top(true) - .decorations(false) - .resizable(false) - .skip_taskbar(true) - .build() - .map_err(|e| e.to_string())?; - Ok(()) } #[tauri::command] pub fn close_mini_timer(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("mini-timer") { - window.close().map_err(|e| e.to_string())?; + window.hide().map_err(|e| e.to_string())?; } - // Show and focus the main window if let Some(main) = app.get_webview_window("main") { main.show().ok(); main.set_focus().ok(); @@ -1249,6 +1764,555 @@ pub fn seed_default_templates(data_dir: &std::path::Path) { } } +// 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, +} + +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 parse_ics_content(content: &str) -> Vec { + 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(); + + for line in content.lines() { + let line = line.trim_end_matches('\r'); + if line == "BEGIN:VEVENT" { + in_event = true; + uid.clear(); + summary.clear(); + dtstart.clear(); + dtend.clear(); + location.clear(); + } else if line == "END:VEVENT" { + if in_event { + 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()) + }, + }); + } + 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(); + } + } + } + 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, synced_at) + VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7)", + params![ + source_id, + event.uid, + event.summary, + event.start_time, + event.end_time, + event.location, + 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 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 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()) + } +} + +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 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c3932d7..dc6be9d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,7 +31,9 @@ pub fn run() { commands::seed_default_templates(&data_dir); tauri::Builder::default() - .plugin(tauri_plugin_window_state::Builder::new().build()) + .plugin(tauri_plugin_window_state::Builder::new() + .with_denylist(&["mini-timer"]) + .build()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) @@ -47,9 +49,11 @@ pub fn run() { commands::create_project, commands::update_project, commands::delete_project, + commands::get_project_dependents, commands::get_tasks, commands::create_task, commands::delete_task, + commands::update_task, commands::get_time_entries, commands::create_time_entry, commands::update_time_entry, @@ -63,6 +67,7 @@ pub fn run() { commands::get_invoice_items, commands::create_invoice_item, commands::delete_invoice_items, + commands::save_invoice_items_batch, commands::get_settings, commands::update_settings, commands::export_data, @@ -93,6 +98,39 @@ pub fn run() { commands::open_mini_timer, commands::close_mini_timer, commands::get_invoice_templates, + commands::get_recurring_entries, + commands::create_recurring_entry, + commands::update_recurring_entry, + commands::delete_recurring_entry, + commands::update_recurring_last_triggered, + commands::get_expenses, + commands::create_expense, + commands::update_expense, + commands::delete_expense, + commands::get_uninvoiced_expenses, + commands::mark_expenses_invoiced, + commands::get_timeline_events, + commands::create_timeline_event, + commands::update_timeline_event_ended, + commands::delete_timeline_events, + commands::clear_all_timeline_data, + commands::get_calendar_sources, + commands::create_calendar_source, + commands::update_calendar_source, + commands::delete_calendar_source, + commands::import_ics_file, + commands::get_calendar_events, + commands::lock_timesheet_week, + commands::unlock_timesheet_week, + commands::get_timesheet_locks, + commands::is_week_locked, + commands::update_invoice_status, + commands::check_overdue_invoices, + commands::get_time_entries_paginated, + commands::bulk_delete_entries, + commands::bulk_update_entries_project, + commands::bulk_update_entries_billable, + commands::upsert_timesheet_entry, ]) .setup(|app| { #[cfg(desktop)]