feat: add project budgets and rounding override columns
This commit is contained in:
@@ -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 })
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
113
src/stores/projects.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user