feat: tooltips, two-column timer, font selector, tray behavior, icons, readme
- Custom tooltip directive (WCAG AAA) on every button in the app - Two-column timer layout with sticky hero and recent entries sidebar - Timer font selector with 16 monospace Google Fonts and live preview - UI font selector with 15+ Google Fonts - Close-to-tray and minimize-to-tray settings - New app icons (no-glow variants), platform icon set - Mini timer pop-out window - Favorites strip with drag-reorder and inline actions - Comprehensive README with feature documentation - Remove tracked files that belong in gitignore
44
src-tauri/Cargo.lock
generated
@@ -2100,28 +2100,6 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||
|
||||
[[package]]
|
||||
name = "local-time-tracker"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
"log",
|
||||
"png",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-window-state",
|
||||
"windows 0.58.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -5763,6 +5741,28 @@ dependencies = [
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroclock"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
"log",
|
||||
"png",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-window-state",
|
||||
"windows 0.58.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "local-time-tracker"
|
||||
name = "zeroclock"
|
||||
version = "1.0.0"
|
||||
description = "A local time tracking app with invoicing"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "local_time_tracker_lib"
|
||||
name = "zeroclock_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
|
Before Width: | Height: | Size: 306 B After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 761 B After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 99 B After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 239 B |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 328 B |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 347 B |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 761 B |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 103 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 852 B |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 121 B |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 171 B |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 199 B |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 130 B |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 172 B |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 374 B |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 277 B |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 171 B |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 240 B |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 273 B |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 281 B |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 515 B |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 485 B |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 425 B |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 901 B |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 714 B |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 563 B |
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 901 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 761 B After Width: | Height: | Size: 209 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 91 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 116 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 116 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 146 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 104 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 143 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 143 B |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 193 B |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 116 B |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 185 B |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 185 B |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 263 B |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 263 B |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 419 B |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 179 B |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 352 B |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 388 B |
BIN
src-tauri/icons/with-glow/128x128.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/with-glow/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
src-tauri/icons/with-glow/32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/with-glow/64x64.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
src-tauri/icons/with-glow/icon.ico
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
src-tauri/icons/with-glow/icon.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
@@ -15,6 +15,7 @@ pub struct Client {
|
||||
pub tax_id: Option<String>,
|
||||
pub payment_terms: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub currency: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -28,6 +29,8 @@ pub struct Project {
|
||||
pub budget_hours: Option<f64>,
|
||||
pub budget_amount: Option<f64>,
|
||||
pub rounding_override: Option<i32>,
|
||||
pub notes: Option<String>,
|
||||
pub currency: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -36,6 +39,7 @@ pub struct Task {
|
||||
pub project_id: i64,
|
||||
pub name: String,
|
||||
pub estimated_hours: Option<f64>,
|
||||
pub hourly_rate: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -72,7 +76,7 @@ pub struct Invoice {
|
||||
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"
|
||||
"SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes, currency FROM clients ORDER BY name"
|
||||
).map_err(|e| e.to_string())?;
|
||||
let clients = stmt.query_map([], |row| {
|
||||
Ok(Client {
|
||||
@@ -85,6 +89,7 @@ pub fn get_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||
tax_id: row.get(6)?,
|
||||
payment_terms: row.get(7)?,
|
||||
notes: row.get(8)?,
|
||||
currency: row.get(9)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
clients.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
@@ -94,8 +99,8 @@ pub fn get_clients(state: State<AppState>) -> Result<Vec<Client>, String> {
|
||||
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],
|
||||
"INSERT INTO clients (name, email, address, company, phone, tax_id, payment_terms, notes, currency) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes, client.currency],
|
||||
).map_err(|e| e.to_string())?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
@@ -104,8 +109,8 @@ pub fn create_client(state: State<AppState>, client: Client) -> Result<i64, Stri
|
||||
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],
|
||||
"UPDATE clients SET name = ?1, email = ?2, address = ?3, company = ?4, phone = ?5, tax_id = ?6, payment_terms = ?7, notes = ?8, currency = ?9 WHERE id = ?10",
|
||||
params![client.name, client.email, client.address, client.company, client.phone, client.tax_id, client.payment_terms, client.notes, client.currency, client.id],
|
||||
).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -143,18 +148,23 @@ pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
};
|
||||
|
||||
for pid in &project_ids {
|
||||
conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![pid])?;
|
||||
conn.execute("DELETE FROM invoice_items WHERE time_entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![pid])?;
|
||||
conn.execute("DELETE FROM entry_tags WHERE entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![pid])?;
|
||||
conn.execute("DELETE FROM time_entries WHERE project_id = ?1", params![pid])?;
|
||||
conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![pid])?;
|
||||
conn.execute("DELETE FROM tracked_apps WHERE project_id = ?1", params![pid])?;
|
||||
conn.execute("DELETE FROM favorites WHERE project_id = ?1", params![pid])?;
|
||||
conn.execute("DELETE FROM recurring_entries WHERE project_id = ?1", params![pid])?;
|
||||
conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![pid])?;
|
||||
conn.execute("DELETE FROM entry_templates WHERE project_id = ?1", params![pid])?;
|
||||
conn.execute("DELETE FROM timesheet_rows WHERE project_id = ?1", params![pid])?;
|
||||
conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![pid])?;
|
||||
}
|
||||
|
||||
conn.execute("DELETE FROM expenses WHERE client_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM invoice_payments WHERE invoice_id IN (SELECT id FROM invoices WHERE client_id = ?1)", params![id])?;
|
||||
conn.execute("DELETE FROM invoice_items WHERE invoice_id IN (SELECT id FROM invoices WHERE client_id = ?1)", params![id])?;
|
||||
conn.execute("DELETE FROM invoices WHERE client_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM recurring_invoices WHERE client_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM projects WHERE client_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM clients WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
@@ -177,7 +187,7 @@ pub fn delete_client(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
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"
|
||||
"SELECT id, client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency FROM projects ORDER BY name"
|
||||
).map_err(|e| e.to_string())?;
|
||||
let projects = stmt.query_map([], |row| {
|
||||
Ok(Project {
|
||||
@@ -190,6 +200,8 @@ pub fn get_projects(state: State<AppState>) -> Result<Vec<Project>, String> {
|
||||
budget_hours: row.get(6)?,
|
||||
budget_amount: row.get(7)?,
|
||||
rounding_override: row.get(8)?,
|
||||
notes: row.get(9)?,
|
||||
currency: row.get(10)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
projects.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
@@ -199,8 +211,8 @@ pub fn get_projects(state: State<AppState>) -> Result<Vec<Project>, String> {
|
||||
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],
|
||||
"INSERT INTO projects (client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
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.notes, project.currency],
|
||||
).map_err(|e| e.to_string())?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
@@ -209,8 +221,8 @@ pub fn create_project(state: State<AppState>, project: Project) -> Result<i64, S
|
||||
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],
|
||||
"UPDATE projects SET client_id = ?1, name = ?2, hourly_rate = ?3, color = ?4, archived = ?5, budget_hours = ?6, budget_amount = ?7, rounding_override = ?8, notes = ?9, currency = ?10 WHERE id = ?11",
|
||||
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.notes, project.currency, project.id],
|
||||
).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -255,6 +267,7 @@ pub fn delete_project(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
|
||||
let result = (|| -> Result<(), rusqlite::Error> {
|
||||
conn.execute("DELETE FROM timeline_events WHERE project_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM invoice_items WHERE time_entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)", params![id])?;
|
||||
conn.execute(
|
||||
"DELETE FROM entry_tags WHERE entry_id IN (SELECT id FROM time_entries WHERE project_id = ?1)",
|
||||
params![id],
|
||||
@@ -264,6 +277,8 @@ pub fn delete_project(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
conn.execute("DELETE FROM expenses WHERE project_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM recurring_entries WHERE project_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM tracked_apps WHERE project_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM entry_templates WHERE project_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM timesheet_rows WHERE project_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM tasks WHERE project_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
@@ -285,13 +300,14 @@ pub fn delete_project(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
#[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, estimated_hours FROM tasks WHERE project_id = ?1 ORDER BY name").map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours, hourly_rate 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)?,
|
||||
estimated_hours: row.get(3)?,
|
||||
hourly_rate: row.get(4)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
tasks.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
@@ -301,8 +317,8 @@ pub fn get_tasks(state: State<AppState>, project_id: i64) -> Result<Vec<Task>, S
|
||||
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, estimated_hours) VALUES (?1, ?2, ?3)",
|
||||
params![task.project_id, task.name, task.estimated_hours],
|
||||
"INSERT INTO tasks (project_id, name, estimated_hours, hourly_rate) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![task.project_id, task.name, task.estimated_hours, task.hourly_rate],
|
||||
).map_err(|e| e.to_string())?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
@@ -310,6 +326,9 @@ pub fn create_task(state: State<AppState>, task: Task) -> Result<i64, String> {
|
||||
#[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 entry_templates WHERE task_id = ?1", params![id]).map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM timesheet_rows WHERE task_id = ?1", params![id]).map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM recurring_entries WHERE task_id = ?1", params![id]).map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM tasks WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -318,8 +337,8 @@ pub fn delete_task(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
pub fn update_task(state: State<AppState>, task: Task) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"UPDATE tasks SET name = ?1, estimated_hours = ?2 WHERE id = ?3",
|
||||
params![task.name, task.estimated_hours, task.id],
|
||||
"UPDATE tasks SET name = ?1, estimated_hours = ?2, hourly_rate = ?3 WHERE id = ?4",
|
||||
params![task.name, task.estimated_hours, task.hourly_rate, task.id],
|
||||
).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -442,6 +461,8 @@ pub fn delete_time_entry(state: State<AppState>, id: i64) -> Result<(), String>
|
||||
if locked {
|
||||
return Err("Cannot modify entries in a locked week".to_string());
|
||||
}
|
||||
conn.execute("DELETE FROM invoice_items WHERE time_entry_id = ?1", params![id]).map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM entry_tags WHERE entry_id = ?1", params![id]).map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM time_entries WHERE id = ?1", params![id]).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -543,6 +564,7 @@ pub fn update_invoice(state: State<AppState>, invoice: Invoice) -> Result<(), St
|
||||
#[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_payments WHERE invoice_id = ?1", params![id]).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(())
|
||||
@@ -681,7 +703,7 @@ 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 mut stmt = conn.prepare("SELECT id, name, email, address, company, phone, tax_id, payment_terms, notes, currency 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)?,
|
||||
@@ -692,14 +714,15 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
|
||||
"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)?
|
||||
"notes": row.get::<_, Option<String>>(8)?,
|
||||
"currency": row.get::<_, Option<String>>(9)?
|
||||
}))
|
||||
}).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 mut stmt = conn.prepare("SELECT id, client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency 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)?,
|
||||
@@ -710,20 +733,23 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
|
||||
"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)?
|
||||
"rounding_override": row.get::<_, Option<i32>>(8)?,
|
||||
"notes": row.get::<_, Option<String>>(9)?,
|
||||
"currency": row.get::<_, Option<String>>(10)?
|
||||
}))
|
||||
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
|
||||
rows
|
||||
};
|
||||
|
||||
let tasks = {
|
||||
let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours FROM tasks").map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours, hourly_rate FROM tasks").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)?,
|
||||
"name": row.get::<_, String>(2)?,
|
||||
"estimated_hours": row.get::<_, Option<f64>>(3)?
|
||||
"estimated_hours": row.get::<_, Option<f64>>(3)?,
|
||||
"hourly_rate": row.get::<_, Option<f64>>(4)?
|
||||
}))
|
||||
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
|
||||
rows
|
||||
@@ -1004,13 +1030,26 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
|
||||
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 entry_tags;
|
||||
DELETE FROM invoice_payments;
|
||||
DELETE FROM invoice_items;
|
||||
DELETE FROM recurring_invoices;
|
||||
DELETE FROM invoices;
|
||||
DELETE FROM favorites;
|
||||
DELETE FROM recurring_entries;
|
||||
DELETE FROM entry_templates;
|
||||
DELETE FROM timesheet_rows;
|
||||
DELETE FROM timesheet_locks;
|
||||
DELETE FROM timeline_events;
|
||||
DELETE FROM expenses;
|
||||
DELETE FROM tracked_apps;
|
||||
DELETE FROM time_entries;
|
||||
DELETE FROM tasks;
|
||||
DELETE FROM projects;
|
||||
DELETE FROM clients;"
|
||||
DELETE FROM clients;
|
||||
DELETE FROM tags;
|
||||
DELETE FROM calendar_events;
|
||||
DELETE FROM calendar_sources;"
|
||||
).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1669,7 +1708,7 @@ pub fn get_goal_progress(state: State<AppState>, today: String) -> Result<serde_
|
||||
}))
|
||||
}
|
||||
|
||||
// Profitability report command
|
||||
// Profitability report command - includes expenses for net profit
|
||||
#[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())?;
|
||||
@@ -1687,7 +1726,8 @@ pub fn get_profitability_report(state: State<AppState>, start_date: String, end_
|
||||
ORDER BY total_seconds DESC"
|
||||
).map_err(|e| e.to_string())?;
|
||||
|
||||
let rows = stmt.query_map(params![start_date, end_date], |row| {
|
||||
let rows: Vec<serde_json::Value> = stmt.query_map(params![start_date, end_date], |row| {
|
||||
let project_id: i64 = row.get(0)?;
|
||||
let total_seconds: i64 = row.get(7)?;
|
||||
let hourly_rate: f64 = row.get(3)?;
|
||||
let hours = total_seconds as f64 / 3600.0;
|
||||
@@ -1696,22 +1736,38 @@ pub fn get_profitability_report(state: State<AppState>, start_date: String, end_
|
||||
let budget_amount: Option<f64> = row.get(5)?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"project_id": row.get::<_, i64>(0)?,
|
||||
"project_id": project_id,
|
||||
"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,
|
||||
"total_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 }),
|
||||
"budget_used_pct": 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())?;
|
||||
}).map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
// Add expense totals per project for the date range
|
||||
let mut result: Vec<serde_json::Value> = Vec::new();
|
||||
for mut row in rows {
|
||||
let pid = row["project_id"].as_i64().unwrap_or(0);
|
||||
let expense_total: f64 = conn.query_row(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM expenses WHERE project_id = ?1 AND date >= ?2 AND date <= ?3",
|
||||
params![pid, start_date, end_date],
|
||||
|r| r.get(0),
|
||||
).unwrap_or(0.0);
|
||||
let revenue = row["revenue"].as_f64().unwrap_or(0.0);
|
||||
row.as_object_mut().unwrap().insert("expenses".into(), serde_json::json!(expense_total));
|
||||
row.as_object_mut().unwrap().insert("net_profit".into(), serde_json::json!(revenue - expense_total));
|
||||
result.push(row);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// Timesheet data command
|
||||
@@ -2239,6 +2295,392 @@ pub fn auto_backup(state: State<AppState>, backup_dir: String) -> Result<String,
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_backup_files(backup_dir: String) -> Result<Vec<serde_json::Value>, String> {
|
||||
let dir = std::path::Path::new(&backup_dir);
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut files: Vec<serde_json::Value> = std::fs::read_dir(dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.path().extension().and_then(|ext| ext.to_str()) == Some("json")
|
||||
&& e.file_name().to_string_lossy().starts_with("zeroclock-backup-")
|
||||
})
|
||||
.filter_map(|e| {
|
||||
let meta = e.metadata().ok()?;
|
||||
let modified = meta.modified().ok()?;
|
||||
Some(serde_json::json!({
|
||||
"path": e.path().to_string_lossy().to_string(),
|
||||
"name": e.file_name().to_string_lossy().to_string(),
|
||||
"size": meta.len(),
|
||||
"modified": modified.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(),
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
files.sort_by(|a, b| {
|
||||
b.get("modified").and_then(|v| v.as_u64())
|
||||
.cmp(&a.get("modified").and_then(|v| v.as_u64()))
|
||||
});
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_backup_file(path: String) -> Result<(), String> {
|
||||
std::fs::remove_file(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// Get recent unique descriptions for autocomplete
|
||||
#[tauri::command]
|
||||
pub fn get_recent_descriptions(state: State<AppState>) -> Result<Vec<String>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT description, COUNT(*) as cnt FROM time_entries
|
||||
WHERE description IS NOT NULL AND description != ''
|
||||
GROUP BY description ORDER BY cnt DESC LIMIT 50"
|
||||
).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
row.get::<_, String>(0)
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// Check for overlapping time entries
|
||||
#[tauri::command]
|
||||
pub fn check_entry_overlap(
|
||||
state: State<AppState>,
|
||||
start_time: String,
|
||||
end_time: String,
|
||||
exclude_id: Option<i64>,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let query = if let Some(eid) = exclude_id {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT te.id, te.description, te.start_time, te.end_time, p.name as project_name
|
||||
FROM time_entries te
|
||||
JOIN projects p ON te.project_id = p.id
|
||||
WHERE te.end_time IS NOT NULL
|
||||
AND te.id != ?3
|
||||
AND te.start_time < ?2
|
||||
AND te.end_time > ?1
|
||||
ORDER BY te.start_time"
|
||||
).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map(params![start_time, end_time, eid], |row| {
|
||||
Ok(serde_json::json!({
|
||||
"id": row.get::<_, i64>(0)?,
|
||||
"description": row.get::<_, Option<String>>(1)?,
|
||||
"start_time": row.get::<_, String>(2)?,
|
||||
"end_time": row.get::<_, Option<String>>(3)?,
|
||||
"project_name": row.get::<_, String>(4)?
|
||||
}))
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
|
||||
} else {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT te.id, te.description, te.start_time, te.end_time, p.name as project_name
|
||||
FROM time_entries te
|
||||
JOIN projects p ON te.project_id = p.id
|
||||
WHERE te.end_time IS NOT NULL
|
||||
AND te.start_time < ?2
|
||||
AND te.end_time > ?1
|
||||
ORDER BY te.start_time"
|
||||
).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map(params![start_time, end_time], |row| {
|
||||
Ok(serde_json::json!({
|
||||
"id": row.get::<_, i64>(0)?,
|
||||
"description": row.get::<_, Option<String>>(1)?,
|
||||
"start_time": row.get::<_, String>(2)?,
|
||||
"end_time": row.get::<_, Option<String>>(3)?,
|
||||
"project_name": row.get::<_, String>(4)?
|
||||
}))
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?
|
||||
};
|
||||
Ok(query)
|
||||
}
|
||||
|
||||
// Get actual hours by task for a project (estimates vs actuals)
|
||||
#[tauri::command]
|
||||
pub fn get_task_actuals(state: State<AppState>, project_id: i64) -> Result<Vec<serde_json::Value>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT t.id, t.name, t.estimated_hours, t.hourly_rate,
|
||||
COALESCE(SUM(te.duration), 0) as actual_seconds
|
||||
FROM tasks t
|
||||
LEFT JOIN time_entries te ON te.task_id = t.id
|
||||
WHERE t.project_id = ?1
|
||||
GROUP BY t.id
|
||||
ORDER BY t.name"
|
||||
).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map(params![project_id], |row| {
|
||||
let estimated: Option<f64> = row.get(2)?;
|
||||
let actual_seconds: i64 = row.get(4)?;
|
||||
let actual_hours = actual_seconds as f64 / 3600.0;
|
||||
let variance = estimated.map(|est| actual_hours - est);
|
||||
let progress = estimated.map(|est| if est > 0.0 { (actual_hours / est) * 100.0 } else { 0.0 });
|
||||
Ok(serde_json::json!({
|
||||
"task_id": row.get::<_, i64>(0)?,
|
||||
"task_name": row.get::<_, String>(1)?,
|
||||
"estimated_hours": estimated,
|
||||
"hourly_rate": row.get::<_, Option<f64>>(3)?,
|
||||
"actual_seconds": actual_seconds,
|
||||
"actual_hours": actual_hours,
|
||||
"variance": variance,
|
||||
"progress": progress
|
||||
}))
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// Invoice payment struct and commands
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct InvoicePayment {
|
||||
pub id: Option<i64>,
|
||||
pub invoice_id: i64,
|
||||
pub amount: f64,
|
||||
pub date: String,
|
||||
pub method: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_invoice_payments(state: State<AppState>, invoice_id: i64) -> Result<Vec<InvoicePayment>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, invoice_id, amount, date, method, notes FROM invoice_payments WHERE invoice_id = ?1 ORDER BY date"
|
||||
).map_err(|e| e.to_string())?;
|
||||
let payments = stmt.query_map(params![invoice_id], |row| {
|
||||
Ok(InvoicePayment {
|
||||
id: Some(row.get(0)?),
|
||||
invoice_id: row.get(1)?,
|
||||
amount: row.get(2)?,
|
||||
date: row.get(3)?,
|
||||
method: row.get(4)?,
|
||||
notes: row.get(5)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
payments.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_invoice_payment(state: State<AppState>, payment: InvoicePayment) -> Result<i64, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"INSERT INTO invoice_payments (invoice_id, amount, date, method, notes) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![payment.invoice_id, payment.amount, payment.date, payment.method, payment.notes],
|
||||
).map_err(|e| e.to_string())?;
|
||||
|
||||
// Update invoice status based on total paid
|
||||
let total_paid: f64 = conn.query_row(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM invoice_payments WHERE invoice_id = ?1",
|
||||
params![payment.invoice_id],
|
||||
|row| row.get(0),
|
||||
).map_err(|e| e.to_string())?;
|
||||
let invoice_total: f64 = conn.query_row(
|
||||
"SELECT total FROM invoices WHERE id = ?1",
|
||||
params![payment.invoice_id],
|
||||
|row| row.get(0),
|
||||
).map_err(|e| e.to_string())?;
|
||||
|
||||
let new_status = if total_paid >= invoice_total { "paid" } else { "partial" };
|
||||
conn.execute(
|
||||
"UPDATE invoices SET status = ?1 WHERE id = ?2",
|
||||
params![new_status, payment.invoice_id],
|
||||
).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_invoice_payment(state: State<AppState>, id: i64, invoice_id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM invoice_payments WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Recalculate invoice status
|
||||
let total_paid: f64 = conn.query_row(
|
||||
"SELECT COALESCE(SUM(amount), 0) FROM invoice_payments WHERE invoice_id = ?1",
|
||||
params![invoice_id],
|
||||
|row| row.get(0),
|
||||
).map_err(|e| e.to_string())?;
|
||||
let invoice_total: f64 = conn.query_row(
|
||||
"SELECT total FROM invoices WHERE id = ?1",
|
||||
params![invoice_id],
|
||||
|row| row.get(0),
|
||||
).map_err(|e| e.to_string())?;
|
||||
|
||||
let new_status = if total_paid >= invoice_total {
|
||||
"paid"
|
||||
} else if total_paid > 0.0 {
|
||||
"partial"
|
||||
} else {
|
||||
"sent"
|
||||
};
|
||||
conn.execute(
|
||||
"UPDATE invoices SET status = ?1 WHERE id = ?2",
|
||||
params![new_status, invoice_id],
|
||||
).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Recurring invoice struct and commands
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RecurringInvoice {
|
||||
pub id: Option<i64>,
|
||||
pub client_id: i64,
|
||||
pub template_id: Option<String>,
|
||||
pub line_items_json: String,
|
||||
pub tax_rate: f64,
|
||||
pub discount: f64,
|
||||
pub notes: Option<String>,
|
||||
pub recurrence_rule: String,
|
||||
pub next_due_date: String,
|
||||
pub enabled: Option<i64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_recurring_invoices(state: State<AppState>) -> Result<Vec<RecurringInvoice>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled
|
||||
FROM recurring_invoices ORDER BY next_due_date"
|
||||
).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(RecurringInvoice {
|
||||
id: Some(row.get(0)?),
|
||||
client_id: row.get(1)?,
|
||||
template_id: row.get(2)?,
|
||||
line_items_json: row.get(3)?,
|
||||
tax_rate: row.get(4)?,
|
||||
discount: row.get(5)?,
|
||||
notes: row.get(6)?,
|
||||
recurrence_rule: row.get(7)?,
|
||||
next_due_date: row.get(8)?,
|
||||
enabled: row.get(9)?,
|
||||
})
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_recurring_invoice(state: State<AppState>, invoice: RecurringInvoice) -> Result<i64, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"INSERT INTO recurring_invoices (client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![invoice.client_id, invoice.template_id, invoice.line_items_json, invoice.tax_rate,
|
||||
invoice.discount, invoice.notes, invoice.recurrence_rule, invoice.next_due_date, invoice.enabled.unwrap_or(1)],
|
||||
).map_err(|e| e.to_string())?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_recurring_invoice(state: State<AppState>, invoice: RecurringInvoice) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute(
|
||||
"UPDATE recurring_invoices SET client_id = ?1, template_id = ?2, line_items_json = ?3,
|
||||
tax_rate = ?4, discount = ?5, notes = ?6, recurrence_rule = ?7, next_due_date = ?8, enabled = ?9
|
||||
WHERE id = ?10",
|
||||
params![invoice.client_id, invoice.template_id, invoice.line_items_json, invoice.tax_rate,
|
||||
invoice.discount, invoice.notes, invoice.recurrence_rule, invoice.next_due_date, invoice.enabled, invoice.id],
|
||||
).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_recurring_invoice(state: State<AppState>, id: i64) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM recurring_invoices WHERE id = ?1", params![id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Check recurring invoices and auto-create drafts when due
|
||||
#[tauri::command]
|
||||
pub fn check_recurring_invoices(state: State<AppState>, today: String) -> Result<Vec<i64>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date
|
||||
FROM recurring_invoices WHERE enabled = 1 AND date(next_due_date) <= date(?1)"
|
||||
).map_err(|e| e.to_string())?;
|
||||
|
||||
let due: Vec<(i64, i64, Option<String>, String, f64, f64, Option<String>, String, String)> = stmt
|
||||
.query_map(params![today], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?,
|
||||
row.get(5)?, row.get(6)?, row.get(7)?, row.get(8)?))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut created_ids: Vec<i64> = Vec::new();
|
||||
for (ri_id, client_id, template_id, line_items_json, tax_rate, discount, notes, rule, next_due) in &due {
|
||||
// Generate invoice number
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM invoices", [], |row| row.get(0)
|
||||
).map_err(|e| e.to_string())?;
|
||||
let inv_number = format!("INV-{:04}", count + 1);
|
||||
|
||||
// Parse line items to calculate totals
|
||||
let items: Vec<serde_json::Value> = serde_json::from_str(line_items_json).unwrap_or_default();
|
||||
let subtotal: f64 = items.iter().map(|item| {
|
||||
let qty = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let rate = item.get("unit_price").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
qty * rate
|
||||
}).sum();
|
||||
let tax_amount = subtotal * tax_rate / 100.0;
|
||||
let total = subtotal + tax_amount - discount;
|
||||
|
||||
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, 'draft', ?11)",
|
||||
params![client_id, inv_number, next_due, next_due, subtotal, tax_rate, tax_amount, discount, total, notes, template_id],
|
||||
).map_err(|e| e.to_string())?;
|
||||
let invoice_id = conn.last_insert_rowid();
|
||||
|
||||
// Insert line items
|
||||
for item in &items {
|
||||
let desc = item.get("description").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let qty = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let rate = item.get("unit_price").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let amount = qty * rate;
|
||||
conn.execute(
|
||||
"INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![invoice_id, desc, qty, rate, amount],
|
||||
).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
created_ids.push(invoice_id);
|
||||
|
||||
// Advance next_due_date based on recurrence rule
|
||||
let next: String = match rule.as_str() {
|
||||
"weekly" => conn.query_row(
|
||||
"SELECT date(?1, '+7 days')", params![next_due], |row| row.get(0)
|
||||
).map_err(|e| e.to_string())?,
|
||||
"biweekly" => conn.query_row(
|
||||
"SELECT date(?1, '+14 days')", params![next_due], |row| row.get(0)
|
||||
).map_err(|e| e.to_string())?,
|
||||
"quarterly" => conn.query_row(
|
||||
"SELECT date(?1, '+3 months')", params![next_due], |row| row.get(0)
|
||||
).map_err(|e| e.to_string())?,
|
||||
"yearly" => conn.query_row(
|
||||
"SELECT date(?1, '+1 year')", params![next_due], |row| row.get(0)
|
||||
).map_err(|e| e.to_string())?,
|
||||
_ => conn.query_row(
|
||||
"SELECT date(?1, '+1 month')", params![next_due], |row| row.get(0)
|
||||
).map_err(|e| e.to_string())?,
|
||||
};
|
||||
conn.execute(
|
||||
"UPDATE recurring_invoices SET next_due_date = ?1 WHERE id = ?2",
|
||||
params![next, ri_id],
|
||||
).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(created_ids)
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -2296,6 +2738,8 @@ struct ParsedCalendarEvent {
|
||||
start_time: Option<String>,
|
||||
end_time: Option<String>,
|
||||
location: Option<String>,
|
||||
description: Option<String>,
|
||||
duration: i64,
|
||||
}
|
||||
|
||||
fn parse_ics_datetime(dt: &str) -> Option<String> {
|
||||
@@ -2323,7 +2767,67 @@ fn parse_ics_datetime(dt: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn unfold_ics_lines(content: &str) -> String {
|
||||
let mut result = String::new();
|
||||
for line in content.lines() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
result.push_str(line.trim_start());
|
||||
} else {
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(line);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn parse_ics_duration(dur: &str) -> Option<i64> {
|
||||
let dur = dur.strip_prefix("PT")?;
|
||||
let mut seconds: i64 = 0;
|
||||
let mut num_buf = String::new();
|
||||
for ch in dur.chars() {
|
||||
if ch.is_ascii_digit() {
|
||||
num_buf.push(ch);
|
||||
} else {
|
||||
let n: i64 = num_buf.parse().ok()?;
|
||||
num_buf.clear();
|
||||
match ch {
|
||||
'H' => seconds += n * 3600,
|
||||
'M' => seconds += n * 60,
|
||||
'S' => seconds += n,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(seconds)
|
||||
}
|
||||
|
||||
fn calc_ics_duration_from_times(start: &str, end: &str) -> i64 {
|
||||
let parse_ts = |s: &str| -> Option<i64> {
|
||||
let s = s.trim();
|
||||
if s.len() >= 15 {
|
||||
let year: i64 = s[0..4].parse().ok()?;
|
||||
let month: i64 = s[4..6].parse().ok()?;
|
||||
let day: i64 = s[6..8].parse().ok()?;
|
||||
let hour: i64 = s[9..11].parse().ok()?;
|
||||
let min: i64 = s[11..13].parse().ok()?;
|
||||
let sec: i64 = s[13..15].parse().ok()?;
|
||||
// Approximate seconds since epoch (good enough for duration calc)
|
||||
Some(((year * 365 + month * 30 + day) * 86400) + hour * 3600 + min * 60 + sec)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
match (parse_ts(start), parse_ts(end)) {
|
||||
(Some(s), Some(e)) if e > s => e - s,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ics_content(content: &str) -> Vec<ParsedCalendarEvent> {
|
||||
let unfolded = unfold_ics_lines(content);
|
||||
let mut events = Vec::new();
|
||||
let mut in_event = false;
|
||||
let mut uid = String::new();
|
||||
@@ -2331,9 +2835,10 @@ fn parse_ics_content(content: &str) -> Vec<ParsedCalendarEvent> {
|
||||
let mut dtstart = String::new();
|
||||
let mut dtend = String::new();
|
||||
let mut location = String::new();
|
||||
let mut description = String::new();
|
||||
let mut duration_str = String::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
for line in unfolded.lines() {
|
||||
if line == "BEGIN:VEVENT" {
|
||||
in_event = true;
|
||||
uid.clear();
|
||||
@@ -2341,22 +2846,25 @@ fn parse_ics_content(content: &str) -> Vec<ParsedCalendarEvent> {
|
||||
dtstart.clear();
|
||||
dtend.clear();
|
||||
location.clear();
|
||||
description.clear();
|
||||
duration_str.clear();
|
||||
} else if line == "END:VEVENT" {
|
||||
if in_event {
|
||||
let duration = if !duration_str.is_empty() {
|
||||
parse_ics_duration(&duration_str).unwrap_or(0)
|
||||
} else if !dtstart.is_empty() && !dtend.is_empty() {
|
||||
calc_ics_duration_from_times(&dtstart, &dtend)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
events.push(ParsedCalendarEvent {
|
||||
uid: if uid.is_empty() { None } else { Some(uid.clone()) },
|
||||
summary: if summary.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(summary.clone())
|
||||
},
|
||||
summary: if summary.is_empty() { None } else { Some(summary.clone()) },
|
||||
start_time: parse_ics_datetime(&dtstart),
|
||||
end_time: parse_ics_datetime(&dtend),
|
||||
location: if location.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(location.clone())
|
||||
},
|
||||
location: if location.is_empty() { None } else { Some(location.clone()) },
|
||||
description: if description.is_empty() { None } else { Some(description.clone()) },
|
||||
duration,
|
||||
});
|
||||
}
|
||||
in_event = false;
|
||||
@@ -2375,6 +2883,12 @@ fn parse_ics_content(content: &str) -> Vec<ParsedCalendarEvent> {
|
||||
}
|
||||
} else if let Some(val) = line.strip_prefix("LOCATION:") {
|
||||
location = val.to_string();
|
||||
} else if let Some(val) = line.strip_prefix("DESCRIPTION:") {
|
||||
description = val.replace("\\n", "\n").replace("\\,", ",");
|
||||
} else if line.starts_with("DURATION") {
|
||||
if let Some(idx) = line.find(':') {
|
||||
duration_str = line[idx + 1..].to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2485,15 +2999,17 @@ pub fn import_ics_file(
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO calendar_events (source_id, uid, summary, start_time, end_time, duration, location, synced_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7)",
|
||||
"INSERT INTO calendar_events (source_id, uid, summary, start_time, end_time, duration, location, description, synced_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
source_id,
|
||||
event.uid,
|
||||
event.summary,
|
||||
event.start_time,
|
||||
event.end_time,
|
||||
event.duration,
|
||||
event.location,
|
||||
event.description,
|
||||
now
|
||||
],
|
||||
)
|
||||
@@ -2710,6 +3226,33 @@ pub fn get_time_entries_paginated(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_entries(state: State<AppState>, query: String, limit: Option<i64>) -> Result<Vec<serde_json::Value>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let limit = limit.unwrap_or(10);
|
||||
let pattern = format!("%{}%", query);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT te.id, te.project_id, te.description, te.start_time, te.duration, p.name as project_name, p.color as project_color
|
||||
FROM time_entries te
|
||||
LEFT JOIN projects p ON te.project_id = p.id
|
||||
WHERE te.description LIKE ?1
|
||||
ORDER BY te.start_time DESC
|
||||
LIMIT ?2"
|
||||
).map_err(|e| e.to_string())?;
|
||||
let rows = stmt.query_map(params![pattern, limit], |row| {
|
||||
Ok(serde_json::json!({
|
||||
"id": row.get::<_, i64>(0)?,
|
||||
"project_id": row.get::<_, i64>(1)?,
|
||||
"description": row.get::<_, Option<String>>(2)?,
|
||||
"start_time": row.get::<_, String>(3)?,
|
||||
"duration": row.get::<_, i64>(4)?,
|
||||
"project_name": row.get::<_, Option<String>>(5)?,
|
||||
"project_color": row.get::<_, Option<String>>(6)?,
|
||||
}))
|
||||
}).map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn bulk_delete_entries(state: State<AppState>, ids: Vec<i64>) -> Result<(), String> {
|
||||
if ids.is_empty() { return Ok(()); }
|
||||
@@ -2718,6 +3261,7 @@ pub fn bulk_delete_entries(state: State<AppState>, ids: Vec<i64>) -> Result<(),
|
||||
|
||||
let result = (|| -> Result<(), rusqlite::Error> {
|
||||
for id in &ids {
|
||||
conn.execute("DELETE FROM invoice_items WHERE time_entry_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM entry_tags WHERE entry_id = ?1", params![id])?;
|
||||
conn.execute("DELETE FROM time_entries WHERE id = ?1", params![id])?;
|
||||
}
|
||||
@@ -2852,6 +3396,23 @@ pub fn delete_entry_template(state: State<AppState>, id: i64) -> Result<(), Stri
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_entry_template(state: State<AppState>, template: serde_json::Value) -> Result<(), String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
let id = template.get("id").and_then(|v| v.as_i64()).ok_or("id required")?;
|
||||
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(
|
||||
"UPDATE entry_templates SET name=?1, project_id=?2, task_id=?3, description=?4, duration=?5, billable=?6 WHERE id=?7",
|
||||
params![name, project_id, task_id, description, duration, billable, id],
|
||||
).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_timesheet_rows(state: State<AppState>, week_start: String) -> Result<Vec<serde_json::Value>, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
@@ -3080,3 +3641,10 @@ fn get_default_templates() -> Vec<InvoiceTemplate> {
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn seed_sample_data(state: State<AppState>) -> Result<String, String> {
|
||||
let conn = state.db.lock().map_err(|e| e.to_string())?;
|
||||
crate::seed::seed(&conn)?;
|
||||
Ok("Sample data loaded".to_string())
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
"ALTER TABLE clients ADD COLUMN tax_id TEXT",
|
||||
"ALTER TABLE clients ADD COLUMN payment_terms TEXT",
|
||||
"ALTER TABLE clients ADD COLUMN notes TEXT",
|
||||
"ALTER TABLE clients ADD COLUMN currency TEXT",
|
||||
];
|
||||
for sql in &migration_columns {
|
||||
match conn.execute(sql, []) {
|
||||
@@ -52,6 +53,8 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
"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",
|
||||
"ALTER TABLE projects ADD COLUMN notes TEXT",
|
||||
"ALTER TABLE projects ADD COLUMN currency TEXT",
|
||||
];
|
||||
for sql in &project_migrations {
|
||||
match conn.execute(sql, []) {
|
||||
@@ -76,9 +79,10 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Migrate tasks table - add estimated_hours column (safe to re-run)
|
||||
// Migrate tasks table (safe to re-run)
|
||||
let task_migrations = [
|
||||
"ALTER TABLE tasks ADD COLUMN estimated_hours REAL DEFAULT NULL",
|
||||
"ALTER TABLE tasks ADD COLUMN hourly_rate REAL DEFAULT NULL",
|
||||
];
|
||||
for sql in &task_migrations {
|
||||
match conn.execute(sql, []) {
|
||||
@@ -302,6 +306,22 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Migrate calendar_events table - add description column (safe to re-run)
|
||||
let calendar_migrations = [
|
||||
"ALTER TABLE calendar_events ADD COLUMN description TEXT",
|
||||
];
|
||||
for sql in &calendar_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 timesheet_locks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -345,6 +365,38 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS invoice_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
invoice_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
method TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS recurring_invoices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id INTEGER NOT NULL,
|
||||
template_id TEXT,
|
||||
line_items_json TEXT NOT NULL,
|
||||
tax_rate REAL DEFAULT 0,
|
||||
discount REAL DEFAULT 0,
|
||||
notes TEXT,
|
||||
recurrence_rule TEXT NOT NULL,
|
||||
next_due_date TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (client_id) REFERENCES clients(id)
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Insert default settings
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES ('hourly_rate', '50')",
|
||||
|
||||
@@ -6,6 +6,7 @@ use tauri::Manager;
|
||||
mod database;
|
||||
mod commands;
|
||||
mod os_detection;
|
||||
mod seed;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Mutex<Connection>,
|
||||
@@ -135,10 +136,26 @@ pub fn run() {
|
||||
commands::get_entry_templates,
|
||||
commands::create_entry_template,
|
||||
commands::delete_entry_template,
|
||||
commands::update_entry_template,
|
||||
commands::get_timesheet_rows,
|
||||
commands::save_timesheet_rows,
|
||||
commands::get_previous_week_structure,
|
||||
commands::auto_backup,
|
||||
commands::search_entries,
|
||||
commands::list_backup_files,
|
||||
commands::delete_backup_file,
|
||||
commands::get_recent_descriptions,
|
||||
commands::check_entry_overlap,
|
||||
commands::get_task_actuals,
|
||||
commands::get_invoice_payments,
|
||||
commands::add_invoice_payment,
|
||||
commands::delete_invoice_payment,
|
||||
commands::get_recurring_invoices,
|
||||
commands::create_recurring_invoice,
|
||||
commands::update_recurring_invoice,
|
||||
commands::delete_recurring_invoice,
|
||||
commands::check_recurring_invoices,
|
||||
commands::seed_sample_data,
|
||||
])
|
||||
.setup(|app| {
|
||||
#[cfg(desktop)]
|
||||
@@ -151,6 +168,7 @@ pub fn run() {
|
||||
let menu = Menu::with_items(app, &[&show, &quit])?;
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| {
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
)]
|
||||
|
||||
fn main() {
|
||||
local_time_tracker_lib::run();
|
||||
zeroclock_lib::run();
|
||||
}
|
||||
|
||||
348
src-tauri/src/os_detection.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use windows::core::{PCWSTR, PWSTR};
|
||||
use windows::Win32::Foundation::{BOOL, HWND, LPARAM};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
CreateCompatibleDC, DeleteDC, DeleteObject, GetDIBits, GetObjectW, BITMAP, BITMAPINFO,
|
||||
BITMAPINFOHEADER, DIB_RGB_COLORS, HBITMAP,
|
||||
};
|
||||
use windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES;
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};
|
||||
use windows::Win32::UI::Shell::{SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_SMALLICON};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
DestroyIcon, EnumWindows, GetIconInfo, GetWindowTextLengthW, GetWindowTextW,
|
||||
GetWindowThreadProcessId, IsIconic, IsWindowVisible, ICONINFO,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct WindowInfo {
|
||||
pub exe_name: String,
|
||||
pub exe_path: String,
|
||||
pub title: String,
|
||||
pub display_name: String,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
pub fn get_system_idle_seconds() -> u64 {
|
||||
unsafe {
|
||||
let mut info = LASTINPUTINFO {
|
||||
cbSize: std::mem::size_of::<LASTINPUTINFO>() as u32,
|
||||
dwTime: 0,
|
||||
};
|
||||
if GetLastInputInfo(&mut info).as_bool() {
|
||||
let tick_count = windows::Win32::System::SystemInformation::GetTickCount();
|
||||
let idle_ms = tick_count.wrapping_sub(info.dwTime);
|
||||
(idle_ms / 1000) as u64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_process_exe_path(pid: u32) -> Option<String> {
|
||||
unsafe {
|
||||
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
|
||||
let mut buf = [0u16; 1024];
|
||||
let mut size = buf.len() as u32;
|
||||
QueryFullProcessImageNameW(handle, PROCESS_NAME_FORMAT(0), PWSTR(buf.as_mut_ptr()), &mut size).ok()?;
|
||||
let _ = windows::Win32::Foundation::CloseHandle(handle);
|
||||
let path = String::from_utf16_lossy(&buf[..size as usize]);
|
||||
Some(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_window_title(hwnd: HWND) -> String {
|
||||
unsafe {
|
||||
let len = GetWindowTextLengthW(hwnd);
|
||||
if len == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let mut buf = vec![0u16; (len + 1) as usize];
|
||||
let copied = GetWindowTextW(hwnd, &mut buf);
|
||||
String::from_utf16_lossy(&buf[..copied as usize])
|
||||
}
|
||||
}
|
||||
|
||||
fn exe_name_from_path(path: &str) -> String {
|
||||
path.rsplit('\\').next().unwrap_or(path).to_string()
|
||||
}
|
||||
|
||||
fn display_name_from_exe(exe_name: &str) -> String {
|
||||
exe_name
|
||||
.strip_suffix(".exe")
|
||||
.or_else(|| exe_name.strip_suffix(".EXE"))
|
||||
.unwrap_or(exe_name)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
struct EnumState {
|
||||
windows: Vec<WindowInfo>,
|
||||
include_minimized: bool,
|
||||
}
|
||||
|
||||
unsafe extern "system" fn enum_windows_callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
|
||||
let state = &mut *(lparam.0 as *mut EnumState);
|
||||
|
||||
if !IsWindowVisible(hwnd).as_bool() {
|
||||
return BOOL(1);
|
||||
}
|
||||
|
||||
if !state.include_minimized && IsIconic(hwnd).as_bool() {
|
||||
return BOOL(1);
|
||||
}
|
||||
|
||||
let title = get_window_title(hwnd);
|
||||
if title.is_empty() {
|
||||
return BOOL(1);
|
||||
}
|
||||
|
||||
let mut pid: u32 = 0;
|
||||
GetWindowThreadProcessId(hwnd, Some(&mut pid));
|
||||
if pid == 0 {
|
||||
return BOOL(1);
|
||||
}
|
||||
|
||||
if let Some(exe_path) = get_process_exe_path(pid) {
|
||||
let exe_name = exe_name_from_path(&exe_path);
|
||||
let display_name = display_name_from_exe(&exe_name);
|
||||
state.windows.push(WindowInfo {
|
||||
exe_name,
|
||||
exe_path,
|
||||
title,
|
||||
display_name,
|
||||
icon: None,
|
||||
});
|
||||
}
|
||||
|
||||
BOOL(1)
|
||||
}
|
||||
|
||||
pub fn enumerate_visible_windows() -> Vec<WindowInfo> {
|
||||
let mut state = EnumState {
|
||||
windows: Vec::new(),
|
||||
include_minimized: false,
|
||||
};
|
||||
unsafe {
|
||||
let _ = EnumWindows(
|
||||
Some(enum_windows_callback),
|
||||
LPARAM(&mut state as *mut EnumState as isize),
|
||||
);
|
||||
}
|
||||
state.windows
|
||||
}
|
||||
|
||||
pub fn enumerate_running_processes() -> Vec<WindowInfo> {
|
||||
let mut state = EnumState {
|
||||
windows: Vec::new(),
|
||||
include_minimized: true,
|
||||
};
|
||||
unsafe {
|
||||
let _ = EnumWindows(
|
||||
Some(enum_windows_callback),
|
||||
LPARAM(&mut state as *mut EnumState as isize),
|
||||
);
|
||||
}
|
||||
// Deduplicate by exe_path (case-insensitive)
|
||||
let mut seen = HashMap::new();
|
||||
let mut result = Vec::new();
|
||||
for w in state.windows {
|
||||
let key = w.exe_path.to_lowercase();
|
||||
if !seen.contains_key(&key) {
|
||||
seen.insert(key, true);
|
||||
result.push(w);
|
||||
}
|
||||
}
|
||||
result.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()));
|
||||
|
||||
// Extract icons for the deduplicated list
|
||||
for w in &mut result {
|
||||
w.icon = extract_icon_data_url(&w.exe_path);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// --- Icon extraction ---
|
||||
|
||||
fn extract_icon_data_url(exe_path: &str) -> Option<String> {
|
||||
unsafe {
|
||||
let wide: Vec<u16> = exe_path.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
let mut fi = SHFILEINFOW::default();
|
||||
let res = SHGetFileInfoW(
|
||||
PCWSTR(wide.as_ptr()),
|
||||
FILE_FLAGS_AND_ATTRIBUTES(0),
|
||||
Some(&mut fi),
|
||||
std::mem::size_of::<SHFILEINFOW>() as u32,
|
||||
SHGFI_ICON | SHGFI_SMALLICON,
|
||||
);
|
||||
if res == 0 || fi.hIcon.is_invalid() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hicon = fi.hIcon;
|
||||
let mut ii = ICONINFO::default();
|
||||
if GetIconInfo(hicon, &mut ii).is_err() {
|
||||
let _ = DestroyIcon(hicon);
|
||||
return None;
|
||||
}
|
||||
|
||||
let result = extract_icon_pixels(ii.hbmColor, ii.hbmMask).and_then(|(rgba, w, h)| {
|
||||
let png_bytes = encode_rgba_to_png(&rgba, w, h)?;
|
||||
Some(format!("data:image/png;base64,{}", base64_encode(&png_bytes)))
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
if !ii.hbmColor.is_invalid() {
|
||||
let _ = DeleteObject(ii.hbmColor);
|
||||
}
|
||||
if !ii.hbmMask.is_invalid() {
|
||||
let _ = DeleteObject(ii.hbmMask);
|
||||
}
|
||||
let _ = DestroyIcon(hicon);
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn extract_icon_pixels(
|
||||
hbm_color: HBITMAP,
|
||||
hbm_mask: HBITMAP,
|
||||
) -> Option<(Vec<u8>, u32, u32)> {
|
||||
if hbm_color.is_invalid() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut bm = BITMAP::default();
|
||||
if GetObjectW(
|
||||
hbm_color,
|
||||
std::mem::size_of::<BITMAP>() as i32,
|
||||
Some(&mut bm as *mut BITMAP as *mut std::ffi::c_void),
|
||||
) == 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let w = bm.bmWidth as u32;
|
||||
let h = bm.bmHeight as u32;
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hdc = CreateCompatibleDC(None);
|
||||
|
||||
// Read color bitmap as 32-bit BGRA
|
||||
let mut bmi = make_bmi(w, h);
|
||||
let mut bgra = vec![0u8; (w * h * 4) as usize];
|
||||
let lines = GetDIBits(
|
||||
hdc,
|
||||
hbm_color,
|
||||
0,
|
||||
h,
|
||||
Some(bgra.as_mut_ptr() as *mut std::ffi::c_void),
|
||||
&mut bmi,
|
||||
DIB_RGB_COLORS,
|
||||
);
|
||||
if lines == 0 {
|
||||
let _ = DeleteDC(hdc);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check if any pixel has a non-zero alpha
|
||||
let has_alpha = bgra.chunks_exact(4).any(|px| px[3] != 0);
|
||||
|
||||
if !has_alpha && !hbm_mask.is_invalid() {
|
||||
// Read the mask bitmap as 32-bit to determine transparency
|
||||
let mut mask_bmi = make_bmi(w, h);
|
||||
let mut mask = vec![0u8; (w * h * 4) as usize];
|
||||
GetDIBits(
|
||||
hdc,
|
||||
hbm_mask,
|
||||
0,
|
||||
h,
|
||||
Some(mask.as_mut_ptr() as *mut std::ffi::c_void),
|
||||
&mut mask_bmi,
|
||||
DIB_RGB_COLORS,
|
||||
);
|
||||
// Mask: black (0,0,0) = opaque, white = transparent
|
||||
for i in (0..bgra.len()).step_by(4) {
|
||||
bgra[i + 3] = if mask[i] == 0 && mask[i + 1] == 0 && mask[i + 2] == 0 {
|
||||
255
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
} else if !has_alpha {
|
||||
// No mask, assume fully opaque
|
||||
for px in bgra.chunks_exact_mut(4) {
|
||||
px[3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = DeleteDC(hdc);
|
||||
|
||||
// BGRA -> RGBA
|
||||
for px in bgra.chunks_exact_mut(4) {
|
||||
px.swap(0, 2);
|
||||
}
|
||||
|
||||
Some((bgra, w, h))
|
||||
}
|
||||
|
||||
fn make_bmi(w: u32, h: u32) -> BITMAPINFO {
|
||||
BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: w as i32,
|
||||
biHeight: -(h as i32), // top-down
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: 0, // BI_RGB
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_rgba_to_png(pixels: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut buf, width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().ok()?;
|
||||
writer.write_image_data(pixels).ok()?;
|
||||
writer.finish().ok()?;
|
||||
}
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
const CHARS: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b = [
|
||||
chunk[0],
|
||||
chunk.get(1).copied().unwrap_or(0),
|
||||
chunk.get(2).copied().unwrap_or(0),
|
||||
];
|
||||
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
|
||||
out.push(CHARS[((n >> 18) & 63) as usize] as char);
|
||||
out.push(CHARS[((n >> 12) & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 {
|
||||
CHARS[((n >> 6) & 63) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
out.push(if chunk.len() > 2 {
|
||||
CHARS[(n & 63) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
672
src-tauri/src/seed.rs
Normal file
@@ -0,0 +1,672 @@
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn hash(n: u32) -> u32 {
|
||||
let x = n.wrapping_mul(2654435761);
|
||||
let y = (x ^ (x >> 16)).wrapping_mul(2246822519);
|
||||
y ^ (y >> 13)
|
||||
}
|
||||
|
||||
fn offset_to_ymd(offset: u32) -> (u32, u32, u32) {
|
||||
static MONTHS: [(u32, u32, u32); 12] = [
|
||||
(2025, 3, 31),
|
||||
(2025, 4, 30),
|
||||
(2025, 5, 31),
|
||||
(2025, 6, 30),
|
||||
(2025, 7, 31),
|
||||
(2025, 8, 31),
|
||||
(2025, 9, 30),
|
||||
(2025, 10, 31),
|
||||
(2025, 11, 30),
|
||||
(2025, 12, 31),
|
||||
(2026, 1, 31),
|
||||
(2026, 2, 28),
|
||||
];
|
||||
let mut rem = offset;
|
||||
for &(y, m, d) in &MONTHS {
|
||||
if rem < d {
|
||||
return (y, m, rem + 1);
|
||||
}
|
||||
rem -= d;
|
||||
}
|
||||
(2026, 2, 28)
|
||||
}
|
||||
|
||||
struct ProjPeriod {
|
||||
project_id: i64,
|
||||
task_ids: &'static [i64],
|
||||
desc_pool: usize,
|
||||
start_day: u32,
|
||||
end_day: u32,
|
||||
billable: i64,
|
||||
}
|
||||
|
||||
static PROJ_PERIODS: &[ProjPeriod] = &[
|
||||
ProjPeriod { project_id: 1, task_ids: &[1, 2, 3, 4], desc_pool: 0, start_day: 2, end_day: 75, billable: 1 },
|
||||
ProjPeriod { project_id: 2, task_ids: &[5, 6, 7, 8], desc_pool: 1, start_day: 2, end_day: 155, billable: 1 },
|
||||
ProjPeriod { project_id: 3, task_ids: &[9, 10, 11, 12], desc_pool: 1, start_day: 33, end_day: 122, billable: 1 },
|
||||
ProjPeriod { project_id: 4, task_ids: &[13, 14, 15, 16], desc_pool: 0, start_day: 63, end_day: 183, billable: 1 },
|
||||
ProjPeriod { project_id: 5, task_ids: &[17, 18], desc_pool: 7, start_day: 2, end_day: 356, billable: 0 },
|
||||
ProjPeriod { project_id: 6, task_ids: &[19, 20, 21], desc_pool: 3, start_day: 93, end_day: 155, billable: 1 },
|
||||
ProjPeriod { project_id: 7, task_ids: &[22, 23, 24, 25], desc_pool: 5, start_day: 122, end_day: 183, billable: 1 },
|
||||
ProjPeriod { project_id: 8, task_ids: &[26, 27, 28, 29], desc_pool: 0, start_day: 155, end_day: 214, billable: 1 },
|
||||
ProjPeriod { project_id: 9, task_ids: &[30, 31, 32, 33], desc_pool: 4, start_day: 184, end_day: 244, billable: 1 },
|
||||
ProjPeriod { project_id: 10, task_ids: &[34, 35, 36], desc_pool: 1, start_day: 214, end_day: 244, billable: 1 },
|
||||
ProjPeriod { project_id: 11, task_ids: &[37, 38, 39, 40], desc_pool: 5, start_day: 214, end_day: 275, billable: 1 },
|
||||
ProjPeriod { project_id: 12, task_ids: &[41, 42, 43], desc_pool: 3, start_day: 245, end_day: 336, billable: 1 },
|
||||
ProjPeriod { project_id: 13, task_ids: &[44, 45, 46, 47], desc_pool: 1, start_day: 275, end_day: 356, billable: 1 },
|
||||
ProjPeriod { project_id: 14, task_ids: &[48, 49, 50], desc_pool: 1, start_day: 306, end_day: 356, billable: 1 },
|
||||
ProjPeriod { project_id: 15, task_ids: &[51, 52], desc_pool: 0, start_day: 306, end_day: 336, billable: 1 },
|
||||
ProjPeriod { project_id: 16, task_ids: &[53, 54, 55], desc_pool: 1, start_day: 306, end_day: 356, billable: 1 },
|
||||
ProjPeriod { project_id: 17, task_ids: &[56, 57, 58], desc_pool: 1, start_day: 93, end_day: 356, billable: 1 },
|
||||
ProjPeriod { project_id: 18, task_ids: &[59, 60], desc_pool: 7, start_day: 214, end_day: 244, billable: 0 },
|
||||
];
|
||||
|
||||
static DESC_POOLS: &[&[&str]] = &[
|
||||
// 0: Logo/brand
|
||||
&[
|
||||
"Concept sketches - exploring directions",
|
||||
"Color palette tests on paper",
|
||||
"Wordmark spacing and kerning",
|
||||
"Symbol refinement - tightening curves",
|
||||
"Client presentation deck",
|
||||
"Applying revision notes from call",
|
||||
"Final vector cleanup and export",
|
||||
"Brand guidelines page layout",
|
||||
"Moodboard assembly",
|
||||
"Scanning hand-drawn letterforms",
|
||||
],
|
||||
// 1: Illustration
|
||||
&[
|
||||
"Thumbnail compositions",
|
||||
"Reference gathering and mood board",
|
||||
"Rough pencil sketches",
|
||||
"Inking line art",
|
||||
"Flat color blocking",
|
||||
"Rendering pass - light and shadow",
|
||||
"Background and texture work",
|
||||
"Final cleanup and detail pass",
|
||||
"Scanning and color correction",
|
||||
"Exploring alternate compositions",
|
||||
"Detail work on foreground elements",
|
||||
"Adding halftone textures",
|
||||
],
|
||||
// 2: Typography/layout
|
||||
&[
|
||||
"Typography pairing tests",
|
||||
"Page layout drafts",
|
||||
"Grid and margin adjustments",
|
||||
"Hierarchy and scale refinement",
|
||||
"Print proof review",
|
||||
"Spread layout and flow",
|
||||
],
|
||||
// 3: Web/digital
|
||||
&[
|
||||
"Wireframe sketches on paper",
|
||||
"Homepage hero illustration",
|
||||
"Responsive layout mockups",
|
||||
"Icon set - first batch",
|
||||
"Custom divider illustrations",
|
||||
"Gallery page layout",
|
||||
"Color theme adjustments for screen",
|
||||
"Asset export for dev handoff",
|
||||
],
|
||||
// 4: Packaging
|
||||
&[
|
||||
"Die-cut template measurements",
|
||||
"Repeating pattern tile design",
|
||||
"Label artwork - front panel",
|
||||
"Box mockup rendering",
|
||||
"Press-ready PDF export",
|
||||
"Color proofing adjustments",
|
||||
"Tissue paper pattern",
|
||||
"Sticker sheet layout",
|
||||
],
|
||||
// 5: Book cover
|
||||
&[
|
||||
"Reading manuscript excerpts for feel",
|
||||
"Cover thumbnail sketches",
|
||||
"Main illustration - rough draft",
|
||||
"Color composition study",
|
||||
"Title lettering and spine layout",
|
||||
"Full cover rendering",
|
||||
"Back cover synopsis layout",
|
||||
"Author photo placement and bio",
|
||||
],
|
||||
// 6: Meeting/admin
|
||||
&[
|
||||
"Client call - project kickoff",
|
||||
"Reviewing feedback document",
|
||||
"Scope and timeline email",
|
||||
"Invoice prep and send",
|
||||
"File organization and archiving",
|
||||
],
|
||||
// 7: Personal
|
||||
&[
|
||||
"Selecting portfolio pieces",
|
||||
"Writing case study notes",
|
||||
"Photographing finished prints",
|
||||
"Updating website gallery",
|
||||
"Sketching for fun",
|
||||
"Ink drawing - daily prompt",
|
||||
"Scanning and posting work",
|
||||
"Reorganizing reference library",
|
||||
],
|
||||
];
|
||||
|
||||
pub fn seed(conn: &Connection) -> Result<(), String> {
|
||||
let e = |err: rusqlite::Error| err.to_string();
|
||||
|
||||
conn.execute_batch(
|
||||
"PRAGMA foreign_keys = OFF;
|
||||
DELETE FROM entry_tags;
|
||||
DELETE FROM invoice_payments;
|
||||
DELETE FROM invoice_items;
|
||||
DELETE FROM recurring_invoices;
|
||||
DELETE FROM invoices;
|
||||
DELETE FROM favorites;
|
||||
DELETE FROM recurring_entries;
|
||||
DELETE FROM entry_templates;
|
||||
DELETE FROM timesheet_rows;
|
||||
DELETE FROM timesheet_locks;
|
||||
DELETE FROM timeline_events;
|
||||
DELETE FROM expenses;
|
||||
DELETE FROM tracked_apps;
|
||||
DELETE FROM time_entries;
|
||||
DELETE FROM tasks;
|
||||
DELETE FROM projects;
|
||||
DELETE FROM clients;
|
||||
DELETE FROM tags;
|
||||
DELETE FROM calendar_events;
|
||||
DELETE FROM calendar_sources;
|
||||
DELETE FROM sqlite_sequence;
|
||||
PRAGMA foreign_keys = ON;",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
// ==========================================
|
||||
// CLIENTS
|
||||
// ==========================================
|
||||
conn.execute_batch(
|
||||
"INSERT INTO clients (id, name, email, company, phone, payment_terms, notes) VALUES
|
||||
(1, 'Anna Kowalski', 'anna@moonlightbakery.com', 'Moonlight Bakery', '555-0142', 'net_30', 'Longtime client. Loves warm earth tones and hand-drawn feel.'),
|
||||
(2, 'James Okonkwo', 'james@riverandstone.com', 'River & Stone Pottery', '555-0238', 'net_15', 'Prefers email. Needs high-res for print catalog.'),
|
||||
(3, 'Rosa Delgado', 'rosa@velvetsparrow.com', 'The Velvet Sparrow', '555-0319', 'net_30', 'Band manager. Quick feedback, clear direction.'),
|
||||
(4, 'Tom Brennan', 'tom@fernandwillow.com', 'Fern & Willow Cafe', '555-0421', 'net_30', 'Very responsive. The cafe on Elm St has great coffee.'),
|
||||
(5, 'Marcus Chen', 'marcus@marcuschen.com', NULL, '555-0517', 'due_on_receipt', 'Photographer. Good referral source.'),
|
||||
(6, 'Diane Huang', 'diane@wildfieldpress.com', 'Wildfield Press', '555-0634', 'net_45', 'Publisher - steady ongoing work. Pays reliably.'),
|
||||
(7, 'Kai Nishimura', 'kai@sableandco.com', 'Sable & Co Tattoo', '555-0728', 'net_15', 'Expects fast turnaround. Loves bold linework.');",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
// ==========================================
|
||||
// PROJECTS
|
||||
// ==========================================
|
||||
conn.execute_batch(
|
||||
"INSERT INTO projects (id, client_id, name, hourly_rate, color, archived, budget_hours, notes) VALUES
|
||||
(1, 1, 'Moonlight Logo Redesign', 65, '#F59E0B', 1, 50, 'Modernizing the logo. Keep the crescent moon motif.'),
|
||||
(2, 2, 'Product Catalog', 70, '#8B5CF6', 1, 130, '48-page catalog for spring/summer pottery collection.'),
|
||||
(3, 3, 'Album Cover - Quiet Hours', 75, '#EF4444', 1, 65, 'Debut album. Dreamy watercolor feel, night sky theme.'),
|
||||
(4, 4, 'Fern & Willow Rebrand', 70, '#10B981', 1, 110, 'Full rebrand - logo, menu boards, signage, socials.'),
|
||||
(5, NULL, 'Portfolio Update', 0, '#6B7280', 0, NULL, 'Ongoing portfolio maintenance and case studies.'),
|
||||
(6, 5, 'Portfolio Website', 60, '#3B82F6', 1, 55, 'Custom illustrations for photography portfolio.'),
|
||||
(7, 6, 'Tide Pool Dreams - Cover', 75, '#06B6D4', 1, 60, 'Middle-grade novel cover. Lush underwater scene.'),
|
||||
(8, 7, 'Sable & Co Brand Kit', 80, '#A855F7', 1, 55, 'Full identity - logo, cards, signage, flash sheet.'),
|
||||
(9, 1, 'Seasonal Packaging', 60, '#EC4899', 1, 70, 'Holiday gift box designs and labels.'),
|
||||
(10, 3, 'Tour Poster - West Coast', 60, '#DC2626', 1, 35, 'Screenprint poster for 12-city tour.'),
|
||||
(11, 6, 'Moth & Lantern - Cover', 75, '#0EA5E9', 1, 60, 'YA fantasy novel cover. Moths, lantern light, forest.'),
|
||||
(12, 2, 'Website Illustrations', 65, '#6366F1', 0, 85, 'Custom spot illustrations for new e-commerce site.'),
|
||||
(13, 4, 'Mural Design', 65, '#34D399', 0, 75, 'Interior mural - botanical garden theme, 8ft x 12ft.'),
|
||||
(14, 1, 'Menu Illustrations', 55, '#F97316', 0, 45, 'Hand-drawn food illos for seasonal menu refresh.'),
|
||||
(15, 5, 'Business Cards', 50, '#60A5FA', 1, 18, 'Custom illustrated business card with foil stamp.'),
|
||||
(16, 3, 'Merch Designs', 55, '#F43F5E', 0, 40, 'T-shirt, sticker, and tote bag art for online store.'),
|
||||
(17, 6, 'Monthly Spot Illustrations', 50, '#14B8A6', 0, 100, 'Recurring spot illos for chapter headers in books.'),
|
||||
(18, NULL, 'Inktober 2025', 0, '#1F2937', 1, NULL, 'Personal daily ink drawing challenge.');",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
// ==========================================
|
||||
// TASKS (60 tasks across 18 projects)
|
||||
// ==========================================
|
||||
conn.execute_batch(
|
||||
"INSERT INTO tasks (id, project_id, name, estimated_hours) VALUES
|
||||
(1, 1, 'Research', 8),
|
||||
(2, 1, 'Sketching', 15),
|
||||
(3, 1, 'Refinement', 15),
|
||||
(4, 1, 'Final Delivery', 10),
|
||||
(5, 2, 'Photography Layout', 30),
|
||||
(6, 2, 'Illustration', 50),
|
||||
(7, 2, 'Typography', 25),
|
||||
(8, 2, 'Print Prep', 20),
|
||||
(9, 3, 'Concept Art', 15),
|
||||
(10, 3, 'Main Illustration', 25),
|
||||
(11, 3, 'Lettering', 12),
|
||||
(12, 3, 'File Prep', 8),
|
||||
(13, 4, 'Brand Strategy', 15),
|
||||
(14, 4, 'Logo Design', 35),
|
||||
(15, 4, 'Collateral', 35),
|
||||
(16, 4, 'Signage', 20),
|
||||
(17, 5, 'Curation', NULL),
|
||||
(18, 5, 'Photography', NULL),
|
||||
(19, 6, 'Wireframes', 12),
|
||||
(20, 6, 'Visual Design', 25),
|
||||
(21, 6, 'Asset Creation', 15),
|
||||
(22, 7, 'Reading', 8),
|
||||
(23, 7, 'Sketches', 15),
|
||||
(24, 7, 'Cover Art', 25),
|
||||
(25, 7, 'Layout', 10),
|
||||
(26, 8, 'Research', 10),
|
||||
(27, 8, 'Concepts', 15),
|
||||
(28, 8, 'Refinement', 18),
|
||||
(29, 8, 'Brand Kit', 12),
|
||||
(30, 9, 'Template Setup', 10),
|
||||
(31, 9, 'Pattern Design', 20),
|
||||
(32, 9, 'Label Art', 25),
|
||||
(33, 9, 'Press Files', 12),
|
||||
(34, 10, 'Layout', 10),
|
||||
(35, 10, 'Illustration', 18),
|
||||
(36, 10, 'Print Prep', 5),
|
||||
(37, 11, 'Reading', 8),
|
||||
(38, 11, 'Sketches', 15),
|
||||
(39, 11, 'Cover Art', 25),
|
||||
(40, 11, 'Layout', 10),
|
||||
(41, 12, 'Page Illustrations', 30),
|
||||
(42, 12, 'Icon Set', 25),
|
||||
(43, 12, 'Banner Art', 20),
|
||||
(44, 13, 'Concept', 12),
|
||||
(45, 13, 'Scale Drawing', 20),
|
||||
(46, 13, 'Color Studies', 18),
|
||||
(47, 13, 'Detail Work', 22),
|
||||
(48, 14, 'Food Illustrations', 20),
|
||||
(49, 14, 'Layout', 12),
|
||||
(50, 14, 'Spot Art', 10),
|
||||
(51, 15, 'Design', 12),
|
||||
(52, 15, 'Print Prep', 5),
|
||||
(53, 16, 'T-shirt Art', 15),
|
||||
(54, 16, 'Sticker Designs', 12),
|
||||
(55, 16, 'Tote Bag Art', 10),
|
||||
(56, 17, 'Sketching', 35),
|
||||
(57, 17, 'Inking', 35),
|
||||
(58, 17, 'Coloring', 25),
|
||||
(59, 18, 'Daily Prompts', NULL),
|
||||
(60, 18, 'Scanning', NULL);",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
// ==========================================
|
||||
// TAGS
|
||||
// ==========================================
|
||||
conn.execute_batch(
|
||||
"INSERT INTO tags (id, name, color) VALUES
|
||||
(1, 'rush', '#EF4444'),
|
||||
(2, 'revision', '#F59E0B'),
|
||||
(3, 'pro-bono', '#10B981'),
|
||||
(4, 'personal', '#6B7280'),
|
||||
(5, 'concept', '#8B5CF6'),
|
||||
(6, 'final', '#3B82F6'),
|
||||
(7, 'meeting', '#EC4899'),
|
||||
(8, 'admin', '#6366F1');",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
// ==========================================
|
||||
// TIME ENTRIES (generated)
|
||||
// ==========================================
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"INSERT INTO time_entries (project_id, task_id, description, start_time, end_time, duration, billable)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
let session_starts: [(u32, u32); 4] = [(9, 0), (11, 0), (13, 30), (16, 0)];
|
||||
let session_maxmins: [u32; 4] = [120, 150, 150, 120];
|
||||
|
||||
let mut entry_count: i64 = 0;
|
||||
|
||||
for day_offset in 0u32..357 {
|
||||
let dow = (6 + day_offset) % 7;
|
||||
if dow == 0 || dow == 6 {
|
||||
// Weekend: only Inktober gets weekend work
|
||||
if day_offset >= 214 && day_offset <= 244 {
|
||||
let h = hash(day_offset);
|
||||
if h % 3 == 0 {
|
||||
let (y, m, d) = offset_to_ymd(day_offset);
|
||||
let date = format!("{:04}-{:02}-{:02}", y, m, d);
|
||||
let di = (h / 7) as usize % DESC_POOLS[7].len();
|
||||
let ti = if h % 2 == 0 { 59i64 } else { 60 };
|
||||
let dur_mins = 30 + (h % 60);
|
||||
let start = format!("{}T10:{:02}:00", date, h % 45);
|
||||
let dur_secs = (dur_mins * 60) as i64;
|
||||
let end_mins = 10 * 60 + (h % 45) + dur_mins;
|
||||
let end = format!("{}T{:02}:{:02}:00", date, end_mins / 60, end_mins % 60);
|
||||
stmt.execute(rusqlite::params![18i64, ti, DESC_POOLS[7][di], start, end, dur_secs, 0i64]).map_err(e)?;
|
||||
entry_count += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let h = hash(day_offset);
|
||||
// Skip ~5% of weekdays (sick/vacation)
|
||||
if h % 20 == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (y, m, d) = offset_to_ymd(day_offset);
|
||||
let date = format!("{:04}-{:02}-{:02}", y, m, d);
|
||||
|
||||
// Collect active projects
|
||||
let active: Vec<&ProjPeriod> = PROJ_PERIODS
|
||||
.iter()
|
||||
.filter(|p| day_offset >= p.start_day && day_offset <= p.end_day)
|
||||
.filter(|p| {
|
||||
// Personal/portfolio only shows up ~15% of days
|
||||
if p.project_id == 5 {
|
||||
return hash(day_offset.wrapping_mul(5)) % 7 == 0;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
if active.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let n_sessions = 2 + (h % 2) as usize; // 2-3 sessions
|
||||
let n_sessions = n_sessions.min(active.len().max(2));
|
||||
|
||||
for s in 0..n_sessions {
|
||||
if s >= 4 {
|
||||
break;
|
||||
}
|
||||
let sh = hash(day_offset * 100 + s as u32);
|
||||
let proj_idx = (sh as usize) % active.len();
|
||||
let proj = active[proj_idx];
|
||||
|
||||
let task_idx = (sh / 3) as usize % proj.task_ids.len();
|
||||
let task_id = proj.task_ids[task_idx];
|
||||
let pool = DESC_POOLS[proj.desc_pool];
|
||||
let desc_idx = (sh / 7) as usize % pool.len();
|
||||
let desc = pool[desc_idx];
|
||||
|
||||
let (base_h, base_m) = session_starts[s];
|
||||
let max_mins = session_maxmins[s];
|
||||
let dur_mins = 45 + sh % (max_mins - 44);
|
||||
let start_offset_mins = (sh / 11) % 20;
|
||||
let start_h = base_h + (base_m + start_offset_mins) / 60;
|
||||
let start_m = (base_m + start_offset_mins) % 60;
|
||||
let end_total = start_h * 60 + start_m + dur_mins;
|
||||
let end_h = end_total / 60;
|
||||
let end_m = end_total % 60;
|
||||
|
||||
if end_h >= 19 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let start = format!("{}T{:02}:{:02}:00", date, start_h, start_m);
|
||||
let end = format!("{}T{:02}:{:02}:00", date, end_h, end_m);
|
||||
let dur_secs = (dur_mins * 60) as i64;
|
||||
|
||||
stmt.execute(rusqlite::params![
|
||||
proj.project_id,
|
||||
task_id,
|
||||
desc,
|
||||
start,
|
||||
end,
|
||||
dur_secs,
|
||||
proj.billable
|
||||
])
|
||||
.map_err(e)?;
|
||||
entry_count += 1;
|
||||
}
|
||||
|
||||
// Occasional admin/meeting entry (~20% of days)
|
||||
if h % 5 == 0 && !active.is_empty() {
|
||||
let sh = hash(day_offset * 200);
|
||||
let proj = active[0];
|
||||
let admin_descs = DESC_POOLS[6];
|
||||
let di = (sh / 3) as usize % admin_descs.len();
|
||||
let dur_mins = 15 + sh % 30;
|
||||
let start = format!("{}T08:{:02}:00", date, 30 + sh % 25);
|
||||
let end_total_mins = 8 * 60 + 30 + (sh % 25) + dur_mins;
|
||||
let end = format!(
|
||||
"{}T{:02}:{:02}:00",
|
||||
date,
|
||||
end_total_mins / 60,
|
||||
end_total_mins % 60
|
||||
);
|
||||
stmt.execute(rusqlite::params![
|
||||
proj.project_id,
|
||||
proj.task_ids[0],
|
||||
admin_descs[di],
|
||||
start,
|
||||
end,
|
||||
(dur_mins * 60) as i64,
|
||||
proj.billable
|
||||
])
|
||||
.map_err(e)?;
|
||||
entry_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
drop(stmt);
|
||||
|
||||
// ==========================================
|
||||
// ENTRY TAGS (tag ~15% of entries)
|
||||
// ==========================================
|
||||
let total_entries = entry_count;
|
||||
let tag_assignments: Vec<(i64, i64)> = (1..=total_entries)
|
||||
.filter_map(|id| {
|
||||
let h = hash(id as u32 * 31);
|
||||
if h % 7 != 0 {
|
||||
return None;
|
||||
}
|
||||
let tag = match h % 40 {
|
||||
0..=5 => 1, // rush
|
||||
6..=15 => 2, // revision
|
||||
16..=20 => 5, // concept
|
||||
21..=28 => 6, // final
|
||||
29..=33 => 7, // meeting
|
||||
34..=37 => 8, // admin
|
||||
_ => 2, // revision
|
||||
};
|
||||
Some((id, tag))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut tag_stmt = conn
|
||||
.prepare("INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?1, ?2)")
|
||||
.map_err(e)?;
|
||||
for (eid, tid) in &tag_assignments {
|
||||
tag_stmt.execute(rusqlite::params![eid, tid]).map_err(e)?;
|
||||
}
|
||||
drop(tag_stmt);
|
||||
|
||||
// ==========================================
|
||||
// EXPENSES
|
||||
// ==========================================
|
||||
conn.execute_batch(
|
||||
"INSERT INTO expenses (project_id, client_id, category, description, amount, date, invoiced) VALUES
|
||||
-- Software subscriptions (monthly)
|
||||
(5, NULL, 'software', 'Clip Studio Paint Pro - annual', 49.99, '2025-03-15', 0),
|
||||
(5, NULL, 'software', 'Affinity Designer 2 license', 69.99, '2025-04-02', 0),
|
||||
(5, NULL, 'software', 'Dropbox Plus - annual renewal', 119.88, '2025-06-01', 0),
|
||||
(5, NULL, 'software', 'Squarespace portfolio site - annual', 192.00, '2025-07-15', 0),
|
||||
(17, 6, 'software', 'Font license - Recoleta family', 45.00, '2025-08-20', 1),
|
||||
|
||||
-- Art supplies
|
||||
(1, 1, 'supplies', 'Copic markers (12 pack, warm grays)', 89.99, '2025-03-08', 0),
|
||||
(3, 3, 'supplies', 'Winsor & Newton watercolor set', 124.50, '2025-04-10', 0),
|
||||
(2, 2, 'supplies', 'A3 hot press watercolor paper (50 sheets)', 42.00, '2025-05-05', 0),
|
||||
(4, 4, 'supplies', 'Posca paint markers (8 pack)', 34.99, '2025-06-18', 0),
|
||||
(18, NULL,'supplies', 'India ink - Sumi (3 bottles)', 27.50, '2025-10-01', 0),
|
||||
(18, NULL,'supplies', 'Micron pen set (8 widths)', 22.99, '2025-10-03', 0),
|
||||
(13, 4, 'supplies', 'Acrylic paint (mural - bulk order)', 187.00, '2025-12-20', 0),
|
||||
(14, 1, 'supplies', 'Brush pen set for menu illos', 18.50, '2026-01-12', 0),
|
||||
|
||||
-- Printing
|
||||
(2, 2, 'printing', 'Test prints - catalog spreads', 85.00, '2025-07-22', 1),
|
||||
(9, 1, 'printing', 'Packaging prototypes (6 units)', 120.00, '2025-10-15', 1),
|
||||
(10, 3, 'printing', 'Poster screenprint run (50 copies)', 275.00, '2025-11-01', 1),
|
||||
(15, 5, 'printing', 'Business card print run (250)', 65.00, '2026-01-28', 1),
|
||||
|
||||
-- Reference materials
|
||||
(5, NULL, 'other', 'Illustration annual 2025', 38.00, '2025-04-22', 0),
|
||||
(5, NULL, 'other', 'Color and Light by James Gurney', 28.50, '2025-05-30', 0),
|
||||
(7, 6, 'other', 'Marine biology reference photos (stock)', 29.00, '2025-07-10', 1),
|
||||
|
||||
-- Travel
|
||||
(4, 4, 'travel', 'Bus pass - client site visits (monthly)', 45.00, '2025-06-01', 0),
|
||||
(4, 4, 'travel', 'Bus pass - client site visits (monthly)', 45.00, '2025-07-01', 0),
|
||||
(8, 7, 'travel', 'Transit to tattoo parlor for measurements', 8.50, '2025-08-12', 0),
|
||||
(13, 4, 'travel', 'Transit to cafe for mural measurements', 8.50, '2025-12-15', 0),
|
||||
(13, 4, 'travel', 'Transit to cafe - mural install day', 8.50, '2026-02-10', 0),
|
||||
|
||||
-- Equipment
|
||||
(5, NULL, 'equipment', 'Tablet screen protector replacement', 24.99, '2025-09-05', 0),
|
||||
(5, NULL, 'equipment', 'Desk lamp (daylight bulb)', 45.00, '2025-11-20', 0);",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
// ==========================================
|
||||
// INVOICES
|
||||
// ==========================================
|
||||
conn.execute_batch(
|
||||
"INSERT INTO invoices (id, client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status) VALUES
|
||||
(1, 1, 'INV-2025-001', '2025-05-28', '2025-06-27', 3120.00, 0, 0, 0, 3120.00, 'Logo redesign - concept through final delivery', 'paid'),
|
||||
(2, 2, 'INV-2025-002', '2025-06-15', '2025-06-30', 4550.00, 0, 0, 0, 4550.00, 'Product catalog - first milestone (layout and illustrations)', 'paid'),
|
||||
(3, 3, 'INV-2025-003', '2025-06-30', '2025-07-30', 4500.00, 0, 0, 0, 4500.00, 'Album cover art - Quiet Hours', 'paid'),
|
||||
(4, 4, 'INV-2025-004', '2025-07-31', '2025-08-30', 3850.00, 0, 0, 0, 3850.00, 'Rebrand milestone 1 - logo and primary collateral', 'paid'),
|
||||
(5, 2, 'INV-2025-005', '2025-08-15', '2025-08-30', 3850.00, 0, 0, 0, 3850.00, 'Product catalog - final milestone (print prep)', 'paid'),
|
||||
(6, 5, 'INV-2025-006', '2025-08-20', '2025-08-20', 3000.00, 0, 0, 0, 3000.00, 'Portfolio website illustrations', 'paid'),
|
||||
(7, 6, 'INV-2025-007', '2025-09-10', '2025-10-25', 4125.00, 0, 0, 0, 4125.00, 'Tide Pool Dreams - cover art', 'paid'),
|
||||
(8, 4, 'INV-2025-008', '2025-09-30', '2025-10-30', 3500.00, 0, 0, 0, 3500.00, 'Rebrand milestone 2 - signage and social templates', 'paid'),
|
||||
(9, 7, 'INV-2025-009', '2025-10-20', '2025-11-04', 4160.00, 0, 0, 0, 4160.00, 'Sable & Co - full brand kit', 'paid'),
|
||||
(10, 6, 'INV-2025-010', '2025-09-30', '2025-11-14', 2250.00, 0, 0, 0, 2250.00, 'Monthly spot illustrations - Q3 (Jul-Sep)', 'overdue'),
|
||||
(11, 1, 'INV-2025-011', '2025-11-25', '2025-12-25', 3780.00, 0, 0, 0, 3780.00, 'Seasonal packaging - holiday gift line', 'paid'),
|
||||
(12, 3, 'INV-2025-012', '2025-11-20', '2025-12-20', 1860.00, 0, 0, 0, 1860.00, 'Tour poster - West Coast (design + print mgmt)', 'paid'),
|
||||
(13, 6, 'INV-2025-013', '2025-12-20', '2026-02-03', 4275.00, 0, 0, 0, 4275.00, 'Moth & Lantern - cover art', 'sent'),
|
||||
(14, 6, 'INV-2025-014', '2025-12-31', '2026-02-14', 2500.00, 0, 0, 0, 2500.00, 'Monthly spot illustrations - Q4 (Oct-Dec)', 'sent'),
|
||||
(15, 2, 'INV-2026-001', '2026-01-31', '2026-02-14', 4225.00, 0, 0, 0, 4225.00, 'Website illustrations - first half', 'sent'),
|
||||
(16, 4, 'INV-2026-002', '2026-02-15', '2026-03-17', 2600.00, 0, 0, 0, 2600.00, 'Mural design - concept and scale drawing', 'draft'),
|
||||
(17, 1, 'INV-2026-003', '2026-02-20', '2026-03-22', 1100.00, 0, 0, 0, 1100.00, 'Menu illustrations - in progress', 'draft'),
|
||||
(18, 5, 'INV-2026-004', '2026-01-25', '2026-01-25', 750.00, 0, 0, 0, 750.00, 'Business card design and print coordination', 'paid');",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
// ==========================================
|
||||
// INVOICE ITEMS
|
||||
// ==========================================
|
||||
conn.execute_batch(
|
||||
"INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount) VALUES
|
||||
(1, 'Logo redesign - research and concepts', 12, 65, 780),
|
||||
(1, 'Logo refinement and final artwork', 24, 65, 1560),
|
||||
(1, 'Brand guidelines document', 12, 65, 780),
|
||||
(2, 'Catalog layout - 24 spreads', 30, 70, 2100),
|
||||
(2, 'Product illustrations', 35, 70, 2450),
|
||||
(3, 'Album cover art - concept through final', 48, 75, 3600),
|
||||
(3, 'File preparation and print variants', 12, 75, 900),
|
||||
(4, 'Brand strategy and logo design', 35, 70, 2450),
|
||||
(4, 'Collateral design (menu, cards, social)', 20, 70, 1400),
|
||||
(5, 'Catalog - typography and print preparation', 25, 70, 1750),
|
||||
(5, 'Final revisions and press files', 30, 70, 2100),
|
||||
(6, 'Website illustrations and icons', 50, 60, 3000),
|
||||
(7, 'Cover illustration - concept to final', 45, 75, 3375),
|
||||
(7, 'Layout, spine, and back cover', 10, 75, 750),
|
||||
(8, 'Signage designs (3 pieces)', 25, 70, 1750),
|
||||
(8, 'Social media template set', 25, 70, 1750),
|
||||
(9, 'Logo and brand identity development', 32, 80, 2560),
|
||||
(9, 'Brand kit - cards, signage, flash style', 20, 80, 1600),
|
||||
(10, 'Spot illustrations - July', 15, 50, 750),
|
||||
(10, 'Spot illustrations - August', 15, 50, 750),
|
||||
(10, 'Spot illustrations - September', 15, 50, 750),
|
||||
(11, 'Packaging design - 4 box sizes', 40, 60, 2400),
|
||||
(11, 'Label art and tissue paper pattern', 23, 60, 1380),
|
||||
(12, 'Poster illustration and layout', 24, 60, 1440),
|
||||
(12, 'Print management and color proofing', 7, 60, 420),
|
||||
(13, 'Cover illustration - Moth & Lantern', 45, 75, 3375),
|
||||
(13, 'Layout and final files', 12, 75, 900),
|
||||
(14, 'Spot illustrations - October', 18, 50, 900),
|
||||
(14, 'Spot illustrations - November', 16, 50, 800),
|
||||
(14, 'Spot illustrations - December', 16, 50, 800),
|
||||
(15, 'Page illustrations - 12 pieces', 40, 65, 2600),
|
||||
(15, 'Icon set - first batch (20 icons)', 25, 65, 1625),
|
||||
(16, 'Mural concept sketches', 15, 65, 975),
|
||||
(16, 'Scale drawing and color studies', 25, 65, 1625),
|
||||
(17, 'Food illustrations (8 of 15)', 20, 55, 1100),
|
||||
(18, 'Business card design - illustration + layout', 12, 50, 600),
|
||||
(18, 'Print coordination', 3, 50, 150);",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
// ==========================================
|
||||
// INVOICE PAYMENTS
|
||||
// ==========================================
|
||||
conn.execute_batch(
|
||||
"INSERT INTO invoice_payments (invoice_id, amount, date, method, notes) VALUES
|
||||
(1, 3120.00, '2025-06-20', 'bank_transfer', 'Paid in full'),
|
||||
(2, 4550.00, '2025-06-28', 'bank_transfer', NULL),
|
||||
(3, 4500.00, '2025-07-25', 'bank_transfer', NULL),
|
||||
(4, 3850.00, '2025-08-28', 'bank_transfer', NULL),
|
||||
(5, 3850.00, '2025-08-29', 'bank_transfer', NULL),
|
||||
(6, 3000.00, '2025-08-20', 'bank_transfer', 'Paid same day'),
|
||||
(7, 4125.00, '2025-10-22', 'bank_transfer', NULL),
|
||||
(8, 3500.00, '2025-10-28', 'bank_transfer', NULL),
|
||||
(9, 4160.00, '2025-11-02', 'bank_transfer', 'Paid early'),
|
||||
(11, 3780.00, '2025-12-18', 'bank_transfer', NULL),
|
||||
(12, 1860.00, '2025-12-15', 'bank_transfer', NULL),
|
||||
(18, 750.00, '2026-01-25', 'bank_transfer', 'Paid on receipt');",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
// ==========================================
|
||||
// FAVORITES
|
||||
// ==========================================
|
||||
conn.execute_batch(
|
||||
"INSERT INTO favorites (project_id, task_id, description, sort_order) VALUES
|
||||
(13, 44, 'Mural concept work', 0),
|
||||
(14, 48, 'Menu food illustrations', 1),
|
||||
(17, 56, 'Monthly spot illo', 2),
|
||||
(16, 53, 'Merch design session', 3);",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
// ==========================================
|
||||
// ENTRY TEMPLATES
|
||||
// ==========================================
|
||||
conn.execute_batch(
|
||||
"INSERT INTO entry_templates (name, project_id, task_id, description, duration, billable) VALUES
|
||||
('Quick sketch session', 13, 44, 'Concept sketching', 5400, 1),
|
||||
('Spot illustration', 17, 57, 'Inking spot illustration', 7200, 1),
|
||||
('Portfolio photo session', 5, 18, 'Photographing prints', 3600, 0),
|
||||
('Menu illo', 14, 48, 'Food illustration', 5400, 1);",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
// ==========================================
|
||||
// TRACKED APPS
|
||||
// ==========================================
|
||||
conn.execute_batch(
|
||||
"INSERT INTO tracked_apps (project_id, exe_name, display_name) VALUES
|
||||
(13, 'clip_studio_paint.exe', 'Clip Studio Paint'),
|
||||
(14, 'clip_studio_paint.exe', 'Clip Studio Paint'),
|
||||
(12, 'affinity_designer.exe', 'Affinity Designer'),
|
||||
(16, 'affinity_designer.exe', 'Affinity Designer');",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
// ==========================================
|
||||
// BUSINESS IDENTITY (for invoice previews)
|
||||
// ==========================================
|
||||
conn.execute_batch(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES
|
||||
('business_name', 'Mika Sato Illustration'),
|
||||
('business_address', '47 Brush & Ink Lane\nPortland, OR 97205'),
|
||||
('business_email', 'hello@mikasato.art'),
|
||||
('business_phone', '(503) 555-0147'),
|
||||
('hourly_rate', '95');",
|
||||
)
|
||||
.map_err(e)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "LocalTimeTracker",
|
||||
"productName": "ZeroClock",
|
||||
"version": "1.0.0",
|
||||
"identifier": "com.localtimetracker.app",
|
||||
"build": {
|
||||
@@ -12,7 +12,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "LocalTimeTracker",
|
||||
"title": "ZeroClock",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
@@ -20,12 +20,21 @@
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"resizable": true
|
||||
},
|
||||
{
|
||||
"label": "mini-timer",
|
||||
"url": "mini-timer.html",
|
||||
"title": "Timer",
|
||||
"width": 300,
|
||||
"height": 80,
|
||||
"decorations": false,
|
||||
"transparent": false,
|
||||
"resizable": false,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/icon.png",
|
||||
"iconAsTemplate": true
|
||||
},
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
|
||||