From 85c20247f598d60d3669aa2c662660a6e04093a4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Feb 2026 02:02:13 +0200 Subject: [PATCH] feat: add project budgets and rounding override columns --- src-tauri/src/commands.rs | 54 +++++++++++++++--- src-tauri/src/database.rs | 18 ++++++ src-tauri/src/lib.rs | 1 + src/stores/projects.ts | 113 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 src/stores/projects.ts diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1a3614b..d4bd9c9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -25,6 +25,9 @@ pub struct Project { pub hourly_rate: f64, pub color: String, pub archived: bool, + pub budget_hours: Option, + pub budget_amount: Option, + pub rounding_override: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -115,7 +118,9 @@ pub fn delete_client(state: State, id: i64) -> Result<(), String> { #[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 FROM projects ORDER BY name").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 FROM projects ORDER BY name" + ).map_err(|e| e.to_string())?; let projects = stmt.query_map([], |row| { Ok(Project { id: Some(row.get(0)?), @@ -124,6 +129,9 @@ pub fn get_projects(state: State) -> Result, String> { 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)?, }) }).map_err(|e| e.to_string())?; projects.collect::, _>>().map_err(|e| e.to_string()) @@ -133,8 +141,8 @@ pub fn get_projects(state: State) -> Result, String> { 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) VALUES (?1, ?2, ?3, ?4, ?5)", - params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32], + "INSERT INTO projects (client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.budget_hours, project.budget_amount, project.rounding_override], ).map_err(|e| e.to_string())?; Ok(conn.last_insert_rowid()) } @@ -143,8 +151,8 @@ pub fn create_project(state: State, project: Project) -> Result, 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 WHERE id = ?6", - params![project.client_id, project.name, project.hourly_rate, project.color, project.archived as i32, project.id], + "UPDATE projects SET client_id = ?1, name = ?2, hourly_rate = ?3, color = ?4, archived = ?5, budget_hours = ?6, budget_amount = ?7, rounding_override = ?8 WHERE id = ?9", + 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.id], ).map_err(|e| e.to_string())?; Ok(()) } @@ -416,7 +424,7 @@ pub fn export_data(state: State) -> Result }; let projects = { - let mut stmt = conn.prepare("SELECT id, client_id, name, hourly_rate, color, archived FROM projects").map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT id, client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override FROM projects").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, @@ -424,7 +432,10 @@ pub fn export_data(state: State) -> Result "name": row.get::<_, String>(2)?, "hourly_rate": row.get::<_, f64>(3)?, "color": row.get::<_, String>(4)?, - "archived": row.get::<_, i32>(5)? != 0 + "archived": row.get::<_, i32>(5)? != 0, + "budget_hours": row.get::<_, Option>(6)?, + "budget_amount": row.get::<_, Option>(7)?, + "rounding_override": row.get::<_, Option>(8)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows @@ -637,3 +648,32 @@ pub fn set_entry_tags(state: State, entry_id: i64, tag_ids: Vec) } Ok(()) } + +#[tauri::command] +pub fn get_project_budget_status(state: State, project_id: i64) -> 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; + + 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 }) + })) +} diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index f2ec011..3249972 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -46,6 +46,24 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // Migrate projects table — add budget columns (safe to re-run) + let project_migrations = [ + "ALTER TABLE projects ADD COLUMN budget_hours REAL DEFAULT NULL", + "ALTER TABLE projects ADD COLUMN budget_amount REAL DEFAULT NULL", + "ALTER TABLE projects ADD COLUMN rounding_override INTEGER DEFAULT NULL", + ]; + for sql in &project_migrations { + match conn.execute(sql, []) { + Ok(_) => {} + Err(e) => { + let msg = e.to_string(); + if !msg.contains("duplicate column") { + return Err(e); + } + } + } + } + conn.execute( "CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fb31e3a..b2358a0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -72,6 +72,7 @@ pub fn run() { commands::delete_tag, commands::get_entry_tags, commands::set_entry_tags, + commands::get_project_budget_status, ]) .setup(|app| { #[cfg(desktop)] diff --git a/src/stores/projects.ts b/src/stores/projects.ts new file mode 100644 index 0000000..4b37ede --- /dev/null +++ b/src/stores/projects.ts @@ -0,0 +1,113 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { invoke } from '@tauri-apps/api/core' + +export interface Project { + id?: number + client_id?: number + name: string + hourly_rate: number + color: string + archived: boolean + budget_hours?: number | null + budget_amount?: number | null + rounding_override?: number | null +} + +export interface Task { + id?: number + project_id: number + name: string +} + +export const useProjectsStore = defineStore('projects', () => { + const projects = ref([]) + const loading = ref(false) + + async function fetchProjects() { + loading.value = true + try { + projects.value = await invoke('get_projects') + } catch (error) { + console.error('Failed to fetch projects:', error) + } finally { + loading.value = false + } + } + + async function createProject(project: Project): Promise { + try { + const id = await invoke('create_project', { project }) + projects.value.push({ ...project, id: Number(id) }) + return Number(id) + } catch (error) { + console.error('Failed to create project:', error) + return null + } + } + + async function updateProject(project: Project): Promise { + try { + await invoke('update_project', { project }) + const index = projects.value.findIndex(p => p.id === project.id) + if (index !== -1) { + projects.value[index] = project + } + return true + } catch (error) { + console.error('Failed to update project:', error) + return false + } + } + + async function deleteProject(id: number): Promise { + try { + await invoke('delete_project', { id }) + projects.value = projects.value.filter(p => p.id !== id) + return true + } catch (error) { + console.error('Failed to delete project:', error) + return false + } + } + + async function fetchTasks(projectId: number): Promise { + try { + return await invoke('get_tasks', { projectId }) + } catch (error) { + console.error('Failed to fetch tasks:', error) + return [] + } + } + + async function createTask(task: Task): Promise { + try { + return await invoke('create_task', { task }) + } catch (error) { + console.error('Failed to create task:', error) + return null + } + } + + async function deleteTask(id: number): Promise { + try { + await invoke('delete_task', { id }) + return true + } catch (error) { + console.error('Failed to delete task:', error) + return false + } + } + + return { + projects, + loading, + fetchProjects, + createProject, + updateProject, + deleteProject, + fetchTasks, + createTask, + deleteTask + } +})