feat: add project budgets and rounding override columns

This commit is contained in:
Your Name
2026-02-18 02:02:13 +02:00
parent 26f1b19dde
commit 85c20247f5
4 changed files with 179 additions and 7 deletions

View File

@@ -25,6 +25,9 @@ pub struct Project {
pub hourly_rate: f64, pub hourly_rate: f64,
pub color: String, pub color: String,
pub archived: bool, pub archived: bool,
pub budget_hours: Option<f64>,
pub budget_amount: Option<f64>,
pub rounding_override: Option<i32>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -115,7 +118,9 @@ pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub fn get_projects(state: State<AppState>) -> Result<Vec<Project>, String> { pub fn get_projects(state: State<AppState>) -> Result<Vec<Project>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?; let conn = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = conn.prepare("SELECT id, 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| { let projects = stmt.query_map([], |row| {
Ok(Project { Ok(Project {
id: Some(row.get(0)?), id: Some(row.get(0)?),
@@ -124,6 +129,9 @@ pub fn get_projects(state: State<AppState>) -> Result<Vec<Project>, String> {
hourly_rate: row.get(3)?, hourly_rate: row.get(3)?,
color: row.get(4)?, color: row.get(4)?,
archived: row.get::<_, i32>(5)? != 0, 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())?; }).map_err(|e| e.to_string())?;
projects.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string()) projects.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
@@ -133,8 +141,8 @@ pub fn get_projects(state: State<AppState>) -> Result<Vec<Project>, String> {
pub fn create_project(state: State<AppState>, project: Project) -> Result<i64, String> { pub fn create_project(state: State<AppState>, project: Project) -> Result<i64, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?; let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute( conn.execute(
"INSERT INTO projects (client_id, name, hourly_rate, color, archived) VALUES (?1, ?2, ?3, ?4, ?5)", "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], 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())?; ).map_err(|e| e.to_string())?;
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
} }
@@ -143,8 +151,8 @@ pub fn create_project(state: State<AppState>, project: Project) -> Result<i64, S
pub fn update_project(state: State<AppState>, project: Project) -> Result<(), String> { pub fn update_project(state: State<AppState>, project: Project) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?; let conn = state.db.lock().map_err(|e| e.to_string())?;
conn.execute( conn.execute(
"UPDATE projects SET client_id = ?1, name = ?2, hourly_rate = ?3, color = ?4, archived = ?5 WHERE id = ?6", "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.id], 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())?; ).map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
@@ -416,7 +424,7 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
}; };
let projects = { 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<serde_json::Value> = stmt.query_map([], |row| { let rows: Vec<serde_json::Value> = stmt.query_map([], |row| {
Ok(serde_json::json!({ Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?, "id": row.get::<_, i64>(0)?,
@@ -424,7 +432,10 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
"name": row.get::<_, String>(2)?, "name": row.get::<_, String>(2)?,
"hourly_rate": row.get::<_, f64>(3)?, "hourly_rate": row.get::<_, f64>(3)?,
"color": row.get::<_, String>(4)?, "color": row.get::<_, String>(4)?,
"archived": row.get::<_, i32>(5)? != 0 "archived": row.get::<_, i32>(5)? != 0,
"budget_hours": row.get::<_, Option<f64>>(6)?,
"budget_amount": row.get::<_, Option<f64>>(7)?,
"rounding_override": row.get::<_, Option<i32>>(8)?
})) }))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?; }).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
rows rows
@@ -637,3 +648,32 @@ pub fn set_entry_tags(state: State<AppState>, entry_id: i64, tag_ids: Vec<i64>)
} }
Ok(()) Ok(())
} }
#[tauri::command]
pub fn get_project_budget_status(state: State<AppState>, project_id: i64) -> Result<serde_json::Value, String> {
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<f64>, Option<f64>, 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 })
}))
}

View File

@@ -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( conn.execute(
"CREATE TABLE IF NOT EXISTS tasks ( "CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -72,6 +72,7 @@ pub fn run() {
commands::delete_tag, commands::delete_tag,
commands::get_entry_tags, commands::get_entry_tags,
commands::set_entry_tags, commands::set_entry_tags,
commands::get_project_budget_status,
]) ])
.setup(|app| { .setup(|app| {
#[cfg(desktop)] #[cfg(desktop)]

113
src/stores/projects.ts Normal file
View File

@@ -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<Project[]>([])
const loading = ref(false)
async function fetchProjects() {
loading.value = true
try {
projects.value = await invoke<Project[]>('get_projects')
} catch (error) {
console.error('Failed to fetch projects:', error)
} finally {
loading.value = false
}
}
async function createProject(project: Project): Promise<number | null> {
try {
const id = await invoke<number>('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<boolean> {
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<boolean> {
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<Task[]> {
try {
return await invoke<Task[]>('get_tasks', { projectId })
} catch (error) {
console.error('Failed to fetch tasks:', error)
return []
}
}
async function createTask(task: Task): Promise<number | null> {
try {
return await invoke<number>('create_task', { task })
} catch (error) {
console.error('Failed to create task:', error)
return null
}
}
async function deleteTask(id: number): Promise<boolean> {
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
}
})