diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f7ad4b6..dab89b7 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2361,6 +2361,52 @@ pub fn upsert_timesheet_entry( } } +// 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(()) +} + fn format_seconds_as_time(secs: i64) -> String { let h = secs / 3600; let m = (secs % 3600) / 60; diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 53b119f..2a1451f 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -51,6 +51,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { "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", + "ALTER TABLE projects ADD COLUMN timeline_override TEXT DEFAULT NULL", ]; for sql in &project_migrations { match conn.execute(sql, []) { @@ -75,6 +76,22 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // Migrate tasks table - add estimated_hours column (safe to re-run) + let task_migrations = [ + "ALTER TABLE tasks ADD COLUMN estimated_hours REAL DEFAULT NULL", + ]; + for sql in &task_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 time_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -91,6 +108,22 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + // Migrate time_entries table + let time_entry_migrations = [ + "ALTER TABLE time_entries ADD COLUMN billable INTEGER DEFAULT 1", + ]; + for sql in &time_entry_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 invoices ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -187,6 +220,112 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { [], )?; + conn.execute( + "CREATE TABLE IF NOT EXISTS recurring_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + task_id INTEGER, + description TEXT, + duration INTEGER DEFAULT 0, + recurrence_rule TEXT NOT NULL, + time_of_day TEXT DEFAULT '09:00', + mode TEXT DEFAULT 'prompt', + enabled INTEGER DEFAULT 1, + last_triggered TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (task_id) REFERENCES tasks(id) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + client_id INTEGER, + category TEXT DEFAULT 'other', + description TEXT, + amount REAL DEFAULT 0, + date TEXT NOT NULL, + receipt_path TEXT, + invoiced INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (client_id) REFERENCES clients(id) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS timeline_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + exe_name TEXT, + exe_path TEXT, + window_title TEXT, + started_at TEXT NOT NULL, + ended_at TEXT, + duration INTEGER DEFAULT 0, + FOREIGN KEY (project_id) REFERENCES projects(id) + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS calendar_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL, + url TEXT, + last_synced TEXT, + sync_interval INTEGER DEFAULT 30, + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS calendar_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + uid TEXT, + summary TEXT, + start_time TEXT, + end_time TEXT, + duration INTEGER DEFAULT 0, + location TEXT, + synced_at TEXT, + FOREIGN KEY (source_id) REFERENCES calendar_sources(id) ON DELETE CASCADE + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS timesheet_locks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + week_start TEXT NOT NULL UNIQUE, + status TEXT DEFAULT 'locked', + locked_at TEXT DEFAULT CURRENT_TIMESTAMP + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS entry_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + project_id INTEGER NOT NULL REFERENCES projects(id), + task_id INTEGER REFERENCES tasks(id), + description TEXT, + duration INTEGER NOT NULL DEFAULT 0, + billable INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )", + [], + )?; + conn.execute( "CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, @@ -231,6 +370,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('shortcut_toggle_timer', 'CmdOrCtrl+Shift+T')", [])?; conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('shortcut_show_app', 'CmdOrCtrl+Shift+Z')", [])?; conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('mini_timer_opacity', '90')", [])?; + conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('timeline_recording', 'off')", [])?; Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index de96642..c13e724 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -132,6 +132,9 @@ pub fn run() { commands::bulk_update_entries_project, commands::bulk_update_entries_billable, commands::upsert_timesheet_entry, + commands::get_entry_templates, + commands::create_entry_template, + commands::delete_entry_template, ]) .setup(|app| { #[cfg(desktop)]