The app uses createWebHashHistory but the mini timer window was
opened with WebviewUrl::App("/mini-timer") which sets the URL path,
not the hash fragment. Vue Router never matched the route, so the
Dashboard rendered in a 300x64 window (appearing blank). Now loads
root URL and sets window.location.hash via eval. Also shows/focuses
the main window when closing the mini timer.
1409 lines
58 KiB
Rust
1409 lines
58 KiB
Rust
use crate::AppState;
|
|
use crate::os_detection;
|
|
use rusqlite::params;
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::{Manager, State};
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct Client {
|
|
pub id: Option<i64>,
|
|
pub name: String,
|
|
pub email: Option<String>,
|
|
pub address: Option<String>,
|
|
pub company: Option<String>,
|
|
pub phone: Option<String>,
|
|
pub tax_id: Option<String>,
|
|
pub payment_terms: Option<String>,
|
|
pub notes: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct Project {
|
|
pub id: Option<i64>,
|
|
pub client_id: Option<i64>,
|
|
pub name: String,
|
|
pub hourly_rate: f64,
|
|
pub color: String,
|
|
pub archived: bool,
|
|
pub budget_hours: Option<f64>,
|
|
pub budget_amount: Option<f64>,
|
|
pub rounding_override: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct Task {
|
|
pub id: Option<i64>,
|
|
pub project_id: i64,
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct TimeEntry {
|
|
pub id: Option<i64>,
|
|
pub project_id: i64,
|
|
pub task_id: Option<i64>,
|
|
pub description: Option<String>,
|
|
pub start_time: String,
|
|
pub end_time: Option<String>,
|
|
pub duration: i64,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct Invoice {
|
|
pub id: Option<i64>,
|
|
pub client_id: i64,
|
|
pub invoice_number: String,
|
|
pub date: String,
|
|
pub due_date: Option<String>,
|
|
pub subtotal: f64,
|
|
pub tax_rate: f64,
|
|
pub tax_amount: f64,
|
|
pub discount: f64,
|
|
pub total: f64,
|
|
pub notes: Option<String>,
|
|
pub status: String,
|
|
pub template_id: Option<String>,
|
|
}
|
|
|
|
// Client commands
|
|
#[tauri::command]
|
|
pub fn get_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes FROM clients ORDER BY name"
|
|
).map_err(|e| e.to_string())?;
|
|
let clients = stmt.query_map([], |row| {
|
|
Ok(Client {
|
|
id: Some(row.get(0)?),
|
|
name: row.get(1)?,
|
|
email: row.get(2)?,
|
|
address: row.get(3)?,
|
|
company: row.get(4)?,
|
|
phone: row.get(5)?,
|
|
tax_id: row.get(6)?,
|
|
payment_terms: row.get(7)?,
|
|
notes: row.get(8)?,
|
|
})
|
|
}).map_err(|e| e.to_string())?;
|
|
clients.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_client(state: State<AppState>, client: Client) -> Result<i64, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"INSERT INTO clients (name, email, address, company, phone, tax_id, payment_terms, notes) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_client(state: State<AppState>, client: Client) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"UPDATE clients SET name = ?1, email = ?2, address = ?3, company = ?4, phone = ?5, tax_id = ?6, payment_terms = ?7, notes = ?8 WHERE id = ?9",
|
|
params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes, client.id],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM clients WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
// Project commands
|
|
#[tauri::command]
|
|
pub fn get_projects(state: State<AppState>) -> Result<Vec<Project>, 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, 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)?),
|
|
client_id: row.get(1)?,
|
|
name: row.get(2)?,
|
|
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::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_project(state: State<AppState>, project: Project) -> Result<i64, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"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())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_project(state: State<AppState>, 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, 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(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_project(state: State<AppState>, 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(())
|
|
}
|
|
|
|
// Task commands
|
|
#[tauri::command]
|
|
pub fn get_tasks(state: State<AppState>, project_id: i64) -> Result<Vec<Task>, 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 tasks = stmt.query_map(params![project_id], |row| {
|
|
Ok(Task {
|
|
id: Some(row.get(0)?),
|
|
project_id: row.get(1)?,
|
|
name: row.get(2)?,
|
|
})
|
|
}).map_err(|e| e.to_string())?;
|
|
tasks.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_task(state: State<AppState>, task: Task) -> Result<i64, String> {
|
|
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],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_task(state: State<AppState>, id: i64) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM tasks WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
// Time entry commands
|
|
#[tauri::command]
|
|
pub fn get_time_entries(state: State<AppState>, start_date: Option<String>, end_date: Option<String>) -> Result<Vec<TimeEntry>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
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
|
|
FROM time_entries
|
|
WHERE date(start_time) >= date(?1) AND date(start_time) <= date(?2)
|
|
ORDER BY start_time DESC"
|
|
).map_err(|e| e.to_string())?;
|
|
let rows = stmt.query_map(params![start, end], |row| {
|
|
Ok(TimeEntry {
|
|
id: Some(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)?,
|
|
})
|
|
}).map_err(|e| e.to_string())?;
|
|
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
|
|
}
|
|
_ => {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, project_id, task_id, description, start_time, end_time, duration
|
|
FROM time_entries ORDER BY start_time DESC LIMIT 100"
|
|
).map_err(|e| e.to_string())?;
|
|
let rows = stmt.query_map([], |row| {
|
|
Ok(TimeEntry {
|
|
id: Some(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)?,
|
|
})
|
|
}).map_err(|e| e.to_string())?;
|
|
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
|
|
}
|
|
};
|
|
Ok(query)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_time_entry(state: State<AppState>, entry: TimeEntry) -> Result<i64, String> {
|
|
let conn = state.db.lock().map_err(|e| e.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],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_time_entry(state: State<AppState>, entry: TimeEntry) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.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],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_time_entry(state: State<AppState>, id: i64) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM time_entries WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
// Reports
|
|
#[tauri::command]
|
|
pub fn get_reports(state: State<AppState>, start_date: String, end_date: String) -> Result<serde_json::Value, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
|
|
// Total hours
|
|
let total: i64 = conn.query_row(
|
|
"SELECT COALESCE(SUM(duration), 0) FROM time_entries
|
|
WHERE date(start_time) >= date(?1) AND date(start_time) <= date(?2)",
|
|
params![start_date, end_date],
|
|
|row| row.get(0),
|
|
).map_err(|e| e.to_string())?;
|
|
|
|
// By project - include project_id so frontend can look up name/color/rate
|
|
let mut stmt = conn.prepare(
|
|
"SELECT p.id, p.name, p.color, SUM(t.duration) as total_duration
|
|
FROM time_entries t
|
|
JOIN projects p ON t.project_id = p.id
|
|
WHERE date(t.start_time) >= date(?1) AND date(t.start_time) <= date(?2)
|
|
GROUP BY p.id
|
|
ORDER BY total_duration DESC"
|
|
).map_err(|e| e.to_string())?;
|
|
|
|
let by_project: Vec<serde_json::Value> = stmt.query_map(params![start_date, end_date], |row| {
|
|
Ok(serde_json::json!({
|
|
"project_id": row.get::<_, i64>(0)?,
|
|
"name": row.get::<_, String>(1)?,
|
|
"color": row.get::<_, String>(2)?,
|
|
"total_seconds": row.get::<_, i64>(3)?
|
|
}))
|
|
}).map_err(|e| e.to_string())?
|
|
.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
|
|
|
|
Ok(serde_json::json!({
|
|
"totalSeconds": total,
|
|
"byProject": by_project
|
|
}))
|
|
}
|
|
|
|
// Invoice commands
|
|
#[tauri::command]
|
|
pub fn create_invoice(state: State<AppState>, invoice: Invoice) -> Result<i64, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"INSERT INTO invoices (client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
|
params![invoice.client_id, invoice.invoice_number, invoice.date, invoice.due_date,
|
|
invoice.subtotal, invoice.tax_rate, invoice.tax_amount, invoice.discount,
|
|
invoice.total, invoice.notes, invoice.status, invoice.template_id],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_invoices(state: State<AppState>) -> Result<Vec<Invoice>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id
|
|
FROM invoices ORDER BY date DESC"
|
|
).map_err(|e| e.to_string())?;
|
|
let invoices = stmt.query_map([], |row| {
|
|
Ok(Invoice {
|
|
id: Some(row.get(0)?),
|
|
client_id: row.get(1)?,
|
|
invoice_number: row.get(2)?,
|
|
date: row.get(3)?,
|
|
due_date: row.get(4)?,
|
|
subtotal: row.get(5)?,
|
|
tax_rate: row.get(6)?,
|
|
tax_amount: row.get(7)?,
|
|
discount: row.get(8)?,
|
|
total: row.get(9)?,
|
|
notes: row.get(10)?,
|
|
status: row.get(11)?,
|
|
template_id: row.get(12)?,
|
|
})
|
|
}).map_err(|e| e.to_string())?;
|
|
invoices.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_invoice(state: State<AppState>, invoice: Invoice) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"UPDATE invoices SET client_id = ?1, invoice_number = ?2, date = ?3, due_date = ?4,
|
|
subtotal = ?5, tax_rate = ?6, tax_amount = ?7, discount = ?8, total = ?9, notes = ?10, status = ?11, template_id = ?12
|
|
WHERE id = ?13",
|
|
params![invoice.client_id, invoice.invoice_number, invoice.date, invoice.due_date,
|
|
invoice.subtotal, invoice.tax_rate, invoice.tax_amount, invoice.discount,
|
|
invoice.total, invoice.notes, invoice.status, invoice.template_id, invoice.id],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_invoice(state: State<AppState>, id: i64) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM invoice_items WHERE invoice_id = ?1", params![id]).map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM invoices WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_invoice_template(state: State<AppState>, id: i64, template_id: String) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"UPDATE invoices SET template_id = ?1 WHERE id = ?2",
|
|
params![template_id, id],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
// Invoice items
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct InvoiceItem {
|
|
pub id: Option<i64>,
|
|
pub invoice_id: i64,
|
|
pub description: String,
|
|
pub quantity: f64,
|
|
pub rate: f64,
|
|
pub amount: f64,
|
|
pub time_entry_id: Option<i64>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_invoice_items(state: State<AppState>, invoice_id: i64) -> Result<Vec<InvoiceItem>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, invoice_id, description, quantity, rate, amount, time_entry_id
|
|
FROM invoice_items WHERE invoice_id = ?1 ORDER BY id"
|
|
).map_err(|e| e.to_string())?;
|
|
let items = stmt.query_map(params![invoice_id], |row| {
|
|
Ok(InvoiceItem {
|
|
id: Some(row.get(0)?),
|
|
invoice_id: row.get(1)?,
|
|
description: row.get(2)?,
|
|
quantity: row.get(3)?,
|
|
rate: row.get(4)?,
|
|
amount: row.get(5)?,
|
|
time_entry_id: row.get(6)?,
|
|
})
|
|
}).map_err(|e| e.to_string())?;
|
|
items.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_invoice_item(state: State<AppState>, item: InvoiceItem) -> Result<i64, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount, time_entry_id)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
params![item.invoice_id, item.description, item.quantity, item.rate, item.amount, item.time_entry_id],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_invoice_items(state: State<AppState>, invoice_id: i64) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM invoice_items WHERE invoice_id = ?1", params![invoice_id]).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
// Settings commands
|
|
#[tauri::command]
|
|
pub fn get_settings(state: State<AppState>) -> Result<std::collections::HashMap<String, String>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = conn.prepare("SELECT key, value FROM settings").map_err(|e| e.to_string())?;
|
|
let settings = stmt.query_map([], |row| {
|
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
|
}).map_err(|e| e.to_string())?;
|
|
|
|
let mut result = std::collections::HashMap::new();
|
|
for setting in settings {
|
|
let (key, value) = setting.map_err(|e| e.to_string())?;
|
|
result.insert(key, value);
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_settings(state: State<AppState>, key: String, value: String) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
params![key, value],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
// Export all data as JSON
|
|
#[tauri::command]
|
|
pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
|
|
let clients = {
|
|
let mut stmt = conn.prepare("SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes FROM clients").map_err(|e| e.to_string())?;
|
|
let rows: Vec<serde_json::Value> = stmt.query_map([], |row| {
|
|
Ok(serde_json::json!({
|
|
"id": row.get::<_, i64>(0)?,
|
|
"name": row.get::<_, String>(1)?,
|
|
"email": row.get::<_, Option<String>>(2)?,
|
|
"address": row.get::<_, Option<String>>(3)?,
|
|
"company": row.get::<_, Option<String>>(4)?,
|
|
"phone": row.get::<_, Option<String>>(5)?,
|
|
"tax_id": row.get::<_, Option<String>>(6)?,
|
|
"payment_terms": row.get::<_, Option<String>>(7)?,
|
|
"notes": row.get::<_, Option<String>>(8)?
|
|
}))
|
|
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
|
|
rows
|
|
};
|
|
|
|
let projects = {
|
|
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| {
|
|
Ok(serde_json::json!({
|
|
"id": row.get::<_, i64>(0)?,
|
|
"client_id": row.get::<_, Option<i64>>(1)?,
|
|
"name": row.get::<_, String>(2)?,
|
|
"hourly_rate": row.get::<_, f64>(3)?,
|
|
"color": row.get::<_, String>(4)?,
|
|
"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())?;
|
|
rows
|
|
};
|
|
|
|
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 rows: Vec<serde_json::Value> = stmt.query_map([], |row| {
|
|
Ok(serde_json::json!({
|
|
"id": row.get::<_, i64>(0)?,
|
|
"project_id": row.get::<_, i64>(1)?,
|
|
"task_id": row.get::<_, Option<i64>>(2)?,
|
|
"description": row.get::<_, Option<String>>(3)?,
|
|
"start_time": row.get::<_, String>(4)?,
|
|
"end_time": row.get::<_, Option<String>>(5)?,
|
|
"duration": row.get::<_, i64>(6)?
|
|
}))
|
|
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
|
|
rows
|
|
};
|
|
|
|
let invoices = {
|
|
let mut stmt = conn.prepare("SELECT id, client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status FROM invoices").map_err(|e| e.to_string())?;
|
|
let rows: Vec<serde_json::Value> = stmt.query_map([], |row| {
|
|
Ok(serde_json::json!({
|
|
"id": row.get::<_, i64>(0)?,
|
|
"client_id": row.get::<_, i64>(1)?,
|
|
"invoice_number": row.get::<_, String>(2)?,
|
|
"date": row.get::<_, String>(3)?,
|
|
"due_date": row.get::<_, Option<String>>(4)?,
|
|
"subtotal": row.get::<_, f64>(5)?,
|
|
"tax_rate": row.get::<_, f64>(6)?,
|
|
"tax_amount": row.get::<_, f64>(7)?,
|
|
"discount": row.get::<_, f64>(8)?,
|
|
"total": row.get::<_, f64>(9)?,
|
|
"notes": row.get::<_, Option<String>>(10)?,
|
|
"status": row.get::<_, String>(11)?
|
|
}))
|
|
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
|
|
rows
|
|
};
|
|
|
|
Ok(serde_json::json!({
|
|
"clients": clients,
|
|
"projects": projects,
|
|
"time_entries": time_entries,
|
|
"invoices": invoices
|
|
}))
|
|
}
|
|
|
|
// Clear all data
|
|
#[tauri::command]
|
|
pub fn clear_all_data(state: State<AppState>) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute_batch(
|
|
"DELETE FROM tracked_apps;
|
|
DELETE FROM invoice_items;
|
|
DELETE FROM invoices;
|
|
DELETE FROM time_entries;
|
|
DELETE FROM tasks;
|
|
DELETE FROM projects;
|
|
DELETE FROM clients;"
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
// Tracked Apps struct
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct TrackedApp {
|
|
pub id: Option<i64>,
|
|
pub project_id: i64,
|
|
pub exe_name: String,
|
|
pub exe_path: Option<String>,
|
|
pub display_name: Option<String>,
|
|
}
|
|
|
|
// OS Detection commands
|
|
#[tauri::command]
|
|
pub fn get_idle_seconds() -> Result<u64, String> {
|
|
Ok(os_detection::get_system_idle_seconds())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_visible_windows() -> Result<Vec<os_detection::WindowInfo>, String> {
|
|
Ok(os_detection::enumerate_visible_windows())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_running_processes() -> Result<Vec<os_detection::WindowInfo>, String> {
|
|
Ok(os_detection::enumerate_running_processes())
|
|
}
|
|
|
|
// Tracked Apps CRUD commands
|
|
#[tauri::command]
|
|
pub fn get_tracked_apps(state: State<AppState>, project_id: i64) -> Result<Vec<TrackedApp>, 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, display_name FROM tracked_apps WHERE project_id = ?1 ORDER BY display_name"
|
|
).map_err(|e| e.to_string())?;
|
|
let apps = stmt.query_map(params![project_id], |row| {
|
|
Ok(TrackedApp {
|
|
id: Some(row.get(0)?),
|
|
project_id: row.get(1)?,
|
|
exe_name: row.get(2)?,
|
|
exe_path: row.get(3)?,
|
|
display_name: row.get(4)?,
|
|
})
|
|
}).map_err(|e| e.to_string())?;
|
|
apps.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn add_tracked_app(state: State<AppState>, app: TrackedApp) -> Result<i64, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"INSERT INTO tracked_apps (project_id, exe_name, exe_path, display_name) VALUES (?1, ?2, ?3, ?4)",
|
|
params![app.project_id, app.exe_name, app.exe_path, app.display_name],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn remove_tracked_app(state: State<AppState>, id: i64) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM tracked_apps WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
// Tag structs and commands
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct Tag {
|
|
pub id: Option<i64>,
|
|
pub name: String,
|
|
pub color: String,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_tags(state: State<AppState>) -> Result<Vec<Tag>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = conn.prepare("SELECT id, name, color FROM tags ORDER BY name")
|
|
.map_err(|e| e.to_string())?;
|
|
let tags = stmt.query_map([], |row| {
|
|
Ok(Tag {
|
|
id: Some(row.get(0)?),
|
|
name: row.get(1)?,
|
|
color: row.get(2)?,
|
|
})
|
|
}).map_err(|e| e.to_string())?;
|
|
tags.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_tag(state: State<AppState>, tag: Tag) -> Result<i64, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"INSERT INTO tags (name, color) VALUES (?1, ?2)",
|
|
params![tag.name, tag.color],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn update_tag(state: State<AppState>, tag: Tag) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"UPDATE tags SET name = ?1, color = ?2 WHERE id = ?3",
|
|
params![tag.name, tag.color, tag.id],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_tag(state: State<AppState>, id: i64) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM entry_tags WHERE tag_id = ?1", params![id])
|
|
.map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM tags WHERE id = ?1", params![id])
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_entry_tags(state: State<AppState>, entry_id: i64) -> Result<Vec<Tag>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = conn.prepare(
|
|
"SELECT t.id, t.name, t.color FROM tags t
|
|
JOIN entry_tags et ON t.id = et.tag_id
|
|
WHERE et.entry_id = ?1 ORDER BY t.name"
|
|
).map_err(|e| e.to_string())?;
|
|
let tags = stmt.query_map(params![entry_id], |row| {
|
|
Ok(Tag {
|
|
id: Some(row.get(0)?),
|
|
name: row.get(1)?,
|
|
color: row.get(2)?,
|
|
})
|
|
}).map_err(|e| e.to_string())?;
|
|
tags.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_entry_tags(state: State<AppState>, entry_id: i64, tag_ids: Vec<i64>) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM entry_tags WHERE entry_id = ?1", params![entry_id])
|
|
.map_err(|e| e.to_string())?;
|
|
for tag_id in tag_ids {
|
|
conn.execute(
|
|
"INSERT INTO entry_tags (entry_id, tag_id) VALUES (?1, ?2)",
|
|
params![entry_id, tag_id],
|
|
).map_err(|e| e.to_string())?;
|
|
}
|
|
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 })
|
|
}))
|
|
}
|
|
|
|
// Favorite structs and commands
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct Favorite {
|
|
pub id: Option<i64>,
|
|
pub project_id: i64,
|
|
pub task_id: Option<i64>,
|
|
pub description: Option<String>,
|
|
pub sort_order: i32,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_favorites(state: State<AppState>) -> Result<Vec<Favorite>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, project_id, task_id, description, sort_order FROM favorites ORDER BY sort_order"
|
|
).map_err(|e| e.to_string())?;
|
|
let favs = stmt.query_map([], |row| {
|
|
Ok(Favorite {
|
|
id: Some(row.get(0)?),
|
|
project_id: row.get(1)?,
|
|
task_id: row.get(2)?,
|
|
description: row.get(3)?,
|
|
sort_order: row.get(4)?,
|
|
})
|
|
}).map_err(|e| e.to_string())?;
|
|
favs.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_favorite(state: State<AppState>, fav: Favorite) -> Result<i64, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute(
|
|
"INSERT INTO favorites (project_id, task_id, description, sort_order) VALUES (?1, ?2, ?3, ?4)",
|
|
params![fav.project_id, fav.task_id, fav.description, fav.sort_order],
|
|
).map_err(|e| e.to_string())?;
|
|
Ok(conn.last_insert_rowid())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_favorite(state: State<AppState>, id: i64) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
conn.execute("DELETE FROM favorites WHERE id = ?1", params![id])
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn reorder_favorites(state: State<AppState>, ids: Vec<i64>) -> Result<(), String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
for (i, id) in ids.iter().enumerate() {
|
|
conn.execute(
|
|
"UPDATE favorites SET sort_order = ?1 WHERE id = ?2",
|
|
params![i as i32, id],
|
|
).map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// Goals command
|
|
#[tauri::command]
|
|
pub fn get_goal_progress(state: State<AppState>, today: String) -> Result<serde_json::Value, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
|
|
// Today's total seconds
|
|
let today_seconds: i64 = conn.query_row(
|
|
"SELECT COALESCE(SUM(duration), 0) FROM time_entries WHERE date(start_time) = date(?1)",
|
|
params![today],
|
|
|row| row.get(0),
|
|
).map_err(|e| e.to_string())?;
|
|
|
|
// This week's total seconds (Monday to Sunday, ISO week)
|
|
let week_seconds: i64 = conn.query_row(
|
|
"SELECT COALESCE(SUM(duration), 0) FROM time_entries
|
|
WHERE strftime('%Y-%W', start_time) = strftime('%Y-%W', ?1)",
|
|
params![today],
|
|
|row| row.get(0),
|
|
).map_err(|e| e.to_string())?;
|
|
|
|
// Streak: count consecutive days with entries going backwards from today
|
|
let mut streak_days: i64 = 0;
|
|
let mut check_date = today.clone();
|
|
loop {
|
|
let has_entry: i64 = conn.query_row(
|
|
"SELECT COUNT(*) FROM time_entries WHERE date(start_time) = date(?1)",
|
|
params![check_date],
|
|
|row| row.get(0),
|
|
).map_err(|e| e.to_string())?;
|
|
if has_entry > 0 {
|
|
streak_days += 1;
|
|
// Go to previous day
|
|
check_date = conn.query_row(
|
|
"SELECT date(?1, '-1 day')",
|
|
params![check_date],
|
|
|row| row.get::<_, String>(0),
|
|
).map_err(|e| e.to_string())?;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(serde_json::json!({
|
|
"today_seconds": today_seconds,
|
|
"week_seconds": week_seconds,
|
|
"streak_days": streak_days
|
|
}))
|
|
}
|
|
|
|
// Profitability report command
|
|
#[tauri::command]
|
|
pub fn get_profitability_report(state: State<AppState>, start_date: String, end_date: String) -> Result<Vec<serde_json::Value>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
|
|
let mut stmt = conn.prepare(
|
|
"SELECT p.id, p.name, p.color, p.hourly_rate, p.budget_hours, p.budget_amount,
|
|
c.name as client_name,
|
|
COALESCE(SUM(t.duration), 0) as total_seconds
|
|
FROM projects p
|
|
LEFT JOIN clients c ON p.client_id = c.id
|
|
LEFT JOIN time_entries t ON t.project_id = p.id
|
|
AND date(t.start_time) >= date(?1)
|
|
AND date(t.start_time) <= date(?2)
|
|
GROUP BY p.id
|
|
ORDER BY total_seconds DESC"
|
|
).map_err(|e| e.to_string())?;
|
|
|
|
let rows = stmt.query_map(params![start_date, end_date], |row| {
|
|
let total_seconds: i64 = row.get(7)?;
|
|
let hourly_rate: f64 = row.get(3)?;
|
|
let hours = total_seconds as f64 / 3600.0;
|
|
let revenue = hours * hourly_rate;
|
|
let budget_hours: Option<f64> = row.get(4)?;
|
|
let budget_amount: Option<f64> = row.get(5)?;
|
|
|
|
Ok(serde_json::json!({
|
|
"project_id": row.get::<_, i64>(0)?,
|
|
"project_name": row.get::<_, String>(1)?,
|
|
"color": row.get::<_, String>(2)?,
|
|
"hourly_rate": hourly_rate,
|
|
"client_name": row.get::<_, Option<String>>(6)?,
|
|
"total_seconds": total_seconds,
|
|
"hours": hours,
|
|
"revenue": revenue,
|
|
"budget_hours": budget_hours,
|
|
"budget_amount": budget_amount,
|
|
"percent_hours": budget_hours.map(|b| if b > 0.0 { (hours / b) * 100.0 } else { 0.0 }),
|
|
"percent_amount": budget_amount.map(|b| if b > 0.0 { (revenue / b) * 100.0 } else { 0.0 })
|
|
}))
|
|
}).map_err(|e| e.to_string())?;
|
|
|
|
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
|
}
|
|
|
|
// Timesheet data command
|
|
#[tauri::command]
|
|
pub fn get_timesheet_data(state: State<AppState>, week_start: String) -> Result<Vec<serde_json::Value>, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
|
|
// Get the 7 days of the week
|
|
let mut days: Vec<String> = Vec::new();
|
|
for i in 0..7 {
|
|
let day: String = conn.query_row(
|
|
&format!("SELECT date(?1, '+{} days')", i),
|
|
params![week_start],
|
|
|row| row.get(0),
|
|
).map_err(|e| e.to_string())?;
|
|
days.push(day);
|
|
}
|
|
|
|
let mut stmt = conn.prepare(
|
|
"SELECT p.id, p.name, p.color, t2.id as task_id, t2.name as task_name,
|
|
date(te.start_time) as entry_date, COALESCE(SUM(te.duration), 0) as total_seconds
|
|
FROM time_entries te
|
|
JOIN projects p ON te.project_id = p.id
|
|
LEFT JOIN tasks t2 ON te.task_id = t2.id
|
|
WHERE date(te.start_time) >= date(?1)
|
|
AND date(te.start_time) < date(?1, '+7 days')
|
|
GROUP BY p.id, t2.id, date(te.start_time)
|
|
ORDER BY p.name, t2.name"
|
|
).map_err(|e| e.to_string())?;
|
|
|
|
let raw_rows: Vec<(i64, String, String, Option<i64>, Option<String>, String, i64)> = stmt
|
|
.query_map(params![week_start], |row| {
|
|
Ok((
|
|
row.get(0)?,
|
|
row.get(1)?,
|
|
row.get(2)?,
|
|
row.get(3)?,
|
|
row.get(4)?,
|
|
row.get(5)?,
|
|
row.get(6)?,
|
|
))
|
|
})
|
|
.map_err(|e| e.to_string())?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
// Group by project+task
|
|
let mut grouped: std::collections::HashMap<(i64, Option<i64>), serde_json::Value> = std::collections::HashMap::new();
|
|
for (project_id, project_name, color, task_id, task_name, entry_date, total_seconds) in &raw_rows {
|
|
let key = (*project_id, *task_id);
|
|
let entry = grouped.entry(key).or_insert_with(|| {
|
|
serde_json::json!({
|
|
"project_id": project_id,
|
|
"project_name": project_name,
|
|
"color": color,
|
|
"task_id": task_id,
|
|
"task_name": task_name,
|
|
"days": vec![0i64; 7]
|
|
})
|
|
});
|
|
// Find which day index this entry_date corresponds to
|
|
if let Some(day_idx) = days.iter().position(|d| d == entry_date) {
|
|
if let Some(arr) = entry.get_mut("days").and_then(|d| d.as_array_mut()) {
|
|
arr[day_idx] = serde_json::json!(total_seconds);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(grouped.into_values().collect())
|
|
}
|
|
|
|
// Import entries command (CSV-style: individual entries)
|
|
#[tauri::command]
|
|
pub fn import_entries(state: State<AppState>, entries: Vec<serde_json::Value>) -> Result<i64, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
let mut count: i64 = 0;
|
|
|
|
for entry in entries {
|
|
let project_name = entry.get("project").and_then(|v| v.as_str()).unwrap_or("Imported");
|
|
let description = entry.get("description").and_then(|v| v.as_str()).unwrap_or("");
|
|
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);
|
|
|
|
if start_time.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Find or create project
|
|
let project_id: i64 = match conn.query_row(
|
|
"SELECT id FROM projects WHERE name = ?1",
|
|
params![project_name],
|
|
|row| row.get(0),
|
|
) {
|
|
Ok(id) => id,
|
|
Err(_) => {
|
|
conn.execute(
|
|
"INSERT INTO projects (name, hourly_rate, color, archived) VALUES (?1, 0, '#F59E0B', 0)",
|
|
params![project_name],
|
|
).map_err(|e| e.to_string())?;
|
|
conn.last_insert_rowid()
|
|
}
|
|
};
|
|
|
|
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],
|
|
).map_err(|e| e.to_string())?;
|
|
count += 1;
|
|
}
|
|
|
|
Ok(count)
|
|
}
|
|
|
|
// Import full JSON data (clients, projects, tasks, entries, tags)
|
|
#[tauri::command]
|
|
pub fn import_json_data(state: State<AppState>, data: serde_json::Value) -> Result<serde_json::Value, String> {
|
|
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
|
let mut counts = serde_json::json!({
|
|
"clients": 0,
|
|
"projects": 0,
|
|
"tasks": 0,
|
|
"entries": 0,
|
|
"tags": 0
|
|
});
|
|
|
|
// Import clients
|
|
if let Some(clients) = data.get("clients").and_then(|v| v.as_array()) {
|
|
for client in clients {
|
|
let name = client.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
if name.is_empty() { continue; }
|
|
let email = client.get("email").and_then(|v| v.as_str());
|
|
let address = client.get("address").and_then(|v| v.as_str());
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO clients (name, email, address) VALUES (?1, ?2, ?3)",
|
|
params![name, email, address],
|
|
).map_err(|e| e.to_string())?;
|
|
counts["clients"] = serde_json::json!(counts["clients"].as_i64().unwrap_or(0) + 1);
|
|
}
|
|
}
|
|
|
|
// Import projects
|
|
if let Some(projects) = data.get("projects").and_then(|v| v.as_array()) {
|
|
for project in projects {
|
|
let name = project.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
if name.is_empty() { continue; }
|
|
let hourly_rate = project.get("hourly_rate").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
|
let color = project.get("color").and_then(|v| v.as_str()).unwrap_or("#F59E0B");
|
|
|
|
// Find client_id if client_name is provided
|
|
let client_id: Option<i64> = project.get("client_name")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|client_name| {
|
|
conn.query_row(
|
|
"SELECT id FROM clients WHERE name = ?1",
|
|
params![client_name],
|
|
|row| row.get(0),
|
|
).ok()
|
|
});
|
|
|
|
// Check if project already exists
|
|
let exists: bool = conn.query_row(
|
|
"SELECT COUNT(*) FROM projects WHERE name = ?1",
|
|
params![name],
|
|
|row| row.get::<_, i64>(0),
|
|
).map_err(|e| e.to_string())? > 0;
|
|
|
|
if !exists {
|
|
conn.execute(
|
|
"INSERT INTO projects (client_id, name, hourly_rate, color, archived) VALUES (?1, ?2, ?3, ?4, 0)",
|
|
params![client_id, name, hourly_rate, color],
|
|
).map_err(|e| e.to_string())?;
|
|
counts["projects"] = serde_json::json!(counts["projects"].as_i64().unwrap_or(0) + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Import tags
|
|
if let Some(tags) = data.get("tags").and_then(|v| v.as_array()) {
|
|
for tag in tags {
|
|
let name = tag.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
if name.is_empty() { continue; }
|
|
let color = tag.get("color").and_then(|v| v.as_str()).unwrap_or("#6B7280");
|
|
conn.execute(
|
|
"INSERT OR IGNORE INTO tags (name, color) VALUES (?1, ?2)",
|
|
params![name, color],
|
|
).map_err(|e| e.to_string())?;
|
|
counts["tags"] = serde_json::json!(counts["tags"].as_i64().unwrap_or(0) + 1);
|
|
}
|
|
}
|
|
|
|
// Import time entries
|
|
if let Some(entries) = data.get("time_entries").and_then(|v| v.as_array()) {
|
|
for entry in entries {
|
|
let project_name = entry.get("project_name").and_then(|v| v.as_str()).unwrap_or("");
|
|
let start_time = entry.get("start_time").and_then(|v| v.as_str()).unwrap_or("");
|
|
if start_time.is_empty() { continue; }
|
|
|
|
let project_id: Option<i64> = if !project_name.is_empty() {
|
|
conn.query_row(
|
|
"SELECT id FROM projects WHERE name = ?1",
|
|
params![project_name],
|
|
|row| row.get(0),
|
|
).ok()
|
|
} else {
|
|
entry.get("project_id").and_then(|v| v.as_i64())
|
|
};
|
|
|
|
if let Some(pid) = project_id {
|
|
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);
|
|
|
|
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],
|
|
).map_err(|e| e.to_string())?;
|
|
counts["entries"] = serde_json::json!(counts["entries"].as_i64().unwrap_or(0) + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(counts)
|
|
}
|
|
|
|
// File save command (bypasses fs plugin scope for user-selected paths)
|
|
#[tauri::command]
|
|
pub fn save_binary_file(path: String, data: Vec<u8>) -> Result<(), String> {
|
|
std::fs::write(&path, &data).map_err(|e| e.to_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(());
|
|
}
|
|
|
|
// Load root URL — the app uses hash-based routing (createWebHashHistory),
|
|
// so we set the hash fragment via eval after the window is created.
|
|
let win = 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())?;
|
|
|
|
// Navigate Vue Router to the mini-timer route via hash
|
|
win.eval("window.location.hash = '/mini-timer'").ok();
|
|
|
|
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())?;
|
|
}
|
|
// Show and focus the main window
|
|
if let Some(main) = app.get_webview_window("main") {
|
|
main.show().ok();
|
|
main.set_focus().ok();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// Invoice template types and commands
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct InvoiceTemplateColors {
|
|
pub primary: String,
|
|
pub secondary: String,
|
|
pub background: String,
|
|
#[serde(rename = "headerBg")]
|
|
pub header_bg: String,
|
|
#[serde(rename = "headerText")]
|
|
pub header_text: String,
|
|
#[serde(rename = "bodyText")]
|
|
pub body_text: String,
|
|
#[serde(rename = "tableHeaderBg")]
|
|
pub table_header_bg: String,
|
|
#[serde(rename = "tableHeaderText")]
|
|
pub table_header_text: String,
|
|
#[serde(rename = "tableRowAlt")]
|
|
pub table_row_alt: String,
|
|
#[serde(rename = "tableBorder")]
|
|
pub table_border: String,
|
|
#[serde(rename = "totalHighlight")]
|
|
pub total_highlight: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct InvoiceTemplate {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub layout: String,
|
|
pub category: String,
|
|
pub description: String,
|
|
pub colors: InvoiceTemplateColors,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_invoice_templates(state: State<AppState>) -> Result<Vec<InvoiceTemplate>, String> {
|
|
let templates_dir = state.data_dir.join("templates");
|
|
let mut templates: Vec<InvoiceTemplate> = Vec::new();
|
|
|
|
if let Ok(entries) = std::fs::read_dir(&templates_dir) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|e| e.to_str()) == Some("json") {
|
|
if let Ok(content) = std::fs::read_to_string(&path) {
|
|
match serde_json::from_str::<InvoiceTemplate>(&content) {
|
|
Ok(template) => templates.push(template),
|
|
Err(e) => eprintln!("Failed to parse template {:?}: {}", path, e),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort: essentials first, then creative, warm, premium; alphabetical within category
|
|
let cat_order = |c: &str| -> u8 {
|
|
match c { "essential" => 0, "creative" => 1, "warm" => 2, "premium" => 3, _ => 4 }
|
|
};
|
|
templates.sort_by(|a, b| {
|
|
cat_order(&a.category).cmp(&cat_order(&b.category)).then(a.name.cmp(&b.name))
|
|
});
|
|
|
|
Ok(templates)
|
|
}
|
|
|
|
pub fn seed_default_templates(data_dir: &std::path::Path) {
|
|
let templates_dir = data_dir.join("templates");
|
|
std::fs::create_dir_all(&templates_dir).ok();
|
|
|
|
// Only seed if directory is empty (no .json files)
|
|
let has_templates = std::fs::read_dir(&templates_dir)
|
|
.map(|entries| entries.flatten().any(|e| {
|
|
e.path().extension().and_then(|ext| ext.to_str()) == Some("json")
|
|
}))
|
|
.unwrap_or(false);
|
|
|
|
if has_templates {
|
|
return;
|
|
}
|
|
|
|
let defaults = get_default_templates();
|
|
for template in &defaults {
|
|
let filename = format!("{}.json", template.id);
|
|
let path = templates_dir.join(&filename);
|
|
if let Ok(json) = serde_json::to_string_pretty(template) {
|
|
std::fs::write(&path, json).ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_default_templates() -> Vec<InvoiceTemplate> {
|
|
vec![
|
|
InvoiceTemplate {
|
|
id: "clean".into(), name: "Clean".into(), layout: "clean".into(),
|
|
category: "essential".into(), description: "Swiss minimalism with a single blue accent".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#1e293b".into(), secondary: "#3b82f6".into(), background: "#ffffff".into(),
|
|
header_bg: "#ffffff".into(), header_text: "#1e293b".into(), body_text: "#374151".into(),
|
|
table_header_bg: "#f8fafc".into(), table_header_text: "#374151".into(),
|
|
table_row_alt: "#f8fafc".into(), table_border: "#e5e7eb".into(), total_highlight: "#3b82f6".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "professional".into(), name: "Professional".into(), layout: "professional".into(),
|
|
category: "essential".into(), description: "Navy header band with corporate polish".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#1e3a5f".into(), secondary: "#2563eb".into(), background: "#ffffff".into(),
|
|
header_bg: "#1e3a5f".into(), header_text: "#ffffff".into(), body_text: "#374151".into(),
|
|
table_header_bg: "#1e3a5f".into(), table_header_text: "#ffffff".into(),
|
|
table_row_alt: "#f3f4f6".into(), table_border: "#d1d5db".into(), total_highlight: "#1e3a5f".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "bold".into(), name: "Bold".into(), layout: "bold".into(),
|
|
category: "essential".into(), description: "Large indigo block with oversized typography".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#4f46e5".into(), secondary: "#a5b4fc".into(), background: "#ffffff".into(),
|
|
header_bg: "#4f46e5".into(), header_text: "#ffffff".into(), body_text: "#1f2937".into(),
|
|
table_header_bg: "#4f46e5".into(), table_header_text: "#ffffff".into(),
|
|
table_row_alt: "#f5f3ff".into(), table_border: "#e0e7ff".into(), total_highlight: "#4f46e5".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "minimal".into(), name: "Minimal".into(), layout: "minimal".into(),
|
|
category: "essential".into(), description: "Pure monochrome centered layout".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#18181b".into(), secondary: "#18181b".into(), background: "#ffffff".into(),
|
|
header_bg: "#ffffff".into(), header_text: "#18181b".into(), body_text: "#3f3f46".into(),
|
|
table_header_bg: "#ffffff".into(), table_header_text: "#18181b".into(),
|
|
table_row_alt: "#ffffff".into(), table_border: "#e4e4e7".into(), total_highlight: "#18181b".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "classic".into(), name: "Classic".into(), layout: "classic".into(),
|
|
category: "essential".into(), description: "Traditional layout with burgundy accents".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#7f1d1d".into(), secondary: "#991b1b".into(), background: "#ffffff".into(),
|
|
header_bg: "#7f1d1d".into(), header_text: "#ffffff".into(), body_text: "#374151".into(),
|
|
table_header_bg: "#7f1d1d".into(), table_header_text: "#ffffff".into(),
|
|
table_row_alt: "#f5f5f4".into(), table_border: "#d6d3d1".into(), total_highlight: "#7f1d1d".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "modern".into(), name: "Modern".into(), layout: "modern".into(),
|
|
category: "creative".into(), description: "Asymmetric header with teal accents".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#0d9488".into(), secondary: "#14b8a6".into(), background: "#ffffff".into(),
|
|
header_bg: "#ffffff".into(), header_text: "#0f172a".into(), body_text: "#334155".into(),
|
|
table_header_bg: "#ffffff".into(), table_header_text: "#0d9488".into(),
|
|
table_row_alt: "#f0fdfa".into(), table_border: "#99f6e4".into(), total_highlight: "#0d9488".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "elegant".into(), name: "Elegant".into(), layout: "elegant".into(),
|
|
category: "creative".into(), description: "Gold double-rule accents on centered layout".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#a16207".into(), secondary: "#ca8a04".into(), background: "#ffffff".into(),
|
|
header_bg: "#ffffff".into(), header_text: "#422006".into(), body_text: "#57534e".into(),
|
|
table_header_bg: "#ffffff".into(), table_header_text: "#422006".into(),
|
|
table_row_alt: "#fefce8".into(), table_border: "#a16207".into(), total_highlight: "#a16207".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "creative".into(), name: "Creative".into(), layout: "creative".into(),
|
|
category: "creative".into(), description: "Purple sidebar with card-style rows".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#7c3aed".into(), secondary: "#a78bfa".into(), background: "#ffffff".into(),
|
|
header_bg: "#ffffff".into(), header_text: "#1f2937".into(), body_text: "#374151".into(),
|
|
table_header_bg: "#faf5ff".into(), table_header_text: "#7c3aed".into(),
|
|
table_row_alt: "#faf5ff".into(), table_border: "#e9d5ff".into(), total_highlight: "#7c3aed".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "compact".into(), name: "Compact".into(), layout: "compact".into(),
|
|
category: "creative".into(), description: "Data-dense layout with tight spacing".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#475569".into(), secondary: "#64748b".into(), background: "#ffffff".into(),
|
|
header_bg: "#ffffff".into(), header_text: "#0f172a".into(), body_text: "#334155".into(),
|
|
table_header_bg: "#f1f5f9".into(), table_header_text: "#334155".into(),
|
|
table_row_alt: "#f8fafc".into(), table_border: "#e2e8f0".into(), total_highlight: "#475569".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "dark".into(), name: "Dark".into(), layout: "dark".into(),
|
|
category: "warm".into(), description: "Near-black background with cyan highlights".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#06b6d4".into(), secondary: "#22d3ee".into(), background: "#0f172a".into(),
|
|
header_bg: "#020617".into(), header_text: "#e2e8f0".into(), body_text: "#cbd5e1".into(),
|
|
table_header_bg: "#020617".into(), table_header_text: "#06b6d4".into(),
|
|
table_row_alt: "#1e293b".into(), table_border: "#334155".into(), total_highlight: "#06b6d4".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "vibrant".into(), name: "Vibrant".into(), layout: "vibrant".into(),
|
|
category: "warm".into(), description: "Coral-to-orange gradient header band".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#ea580c".into(), secondary: "#f97316".into(), background: "#ffffff".into(),
|
|
header_bg: "#ea580c".into(), header_text: "#ffffff".into(), body_text: "#1f2937".into(),
|
|
table_header_bg: "#fff7ed".into(), table_header_text: "#9a3412".into(),
|
|
table_row_alt: "#fff7ed".into(), table_border: "#fed7aa".into(), total_highlight: "#ea580c".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "corporate".into(), name: "Corporate".into(), layout: "corporate".into(),
|
|
category: "warm".into(), description: "Deep blue header with info bar below".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#1e40af".into(), secondary: "#3b82f6".into(), background: "#ffffff".into(),
|
|
header_bg: "#1e40af".into(), header_text: "#ffffff".into(), body_text: "#1f2937".into(),
|
|
table_header_bg: "#1e40af".into(), table_header_text: "#ffffff".into(),
|
|
table_row_alt: "#eff6ff".into(), table_border: "#bfdbfe".into(), total_highlight: "#1e40af".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "fresh".into(), name: "Fresh".into(), layout: "fresh".into(),
|
|
category: "premium".into(), description: "Oversized watermark invoice number".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#0284c7".into(), secondary: "#38bdf8".into(), background: "#ffffff".into(),
|
|
header_bg: "#ffffff".into(), header_text: "#0c4a6e".into(), body_text: "#334155".into(),
|
|
table_header_bg: "#0284c7".into(), table_header_text: "#ffffff".into(),
|
|
table_row_alt: "#f0f9ff".into(), table_border: "#bae6fd".into(), total_highlight: "#0284c7".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "natural".into(), name: "Natural".into(), layout: "natural".into(),
|
|
category: "premium".into(), description: "Warm beige background with terracotta accents".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#c2703e".into(), secondary: "#d97706".into(), background: "#fdf6ec".into(),
|
|
header_bg: "#fdf6ec".into(), header_text: "#78350f".into(), body_text: "#57534e".into(),
|
|
table_header_bg: "#c2703e".into(), table_header_text: "#ffffff".into(),
|
|
table_row_alt: "#fef3c7".into(), table_border: "#d6d3d1".into(), total_highlight: "#c2703e".into(),
|
|
},
|
|
},
|
|
InvoiceTemplate {
|
|
id: "statement".into(), name: "Statement".into(), layout: "statement".into(),
|
|
category: "premium".into(), description: "Total-forward design with hero amount".into(),
|
|
colors: InvoiceTemplateColors {
|
|
primary: "#18181b".into(), secondary: "#be123c".into(), background: "#ffffff".into(),
|
|
header_bg: "#ffffff".into(), header_text: "#18181b".into(), body_text: "#3f3f46".into(),
|
|
table_header_bg: "#ffffff".into(), table_header_text: "#18181b".into(),
|
|
table_row_alt: "#ffffff".into(), table_border: "#e4e4e7".into(), total_highlight: "#be123c".into(),
|
|
},
|
|
},
|
|
]
|
|
}
|