feat: complete export/import cycle and remove sample data

Export now includes invoice_payments and recurring_invoices tables.
Import restored to use ID-based lookups and all fields for clients,
projects, tasks, and time entries. Added missing import support for
timeline_events, calendar_sources, calendar_events, invoice_payments,
and recurring_invoices. Export uses native save dialog instead of blob
download. Removed sample data seeding (seed.rs, UI, command).
This commit is contained in:
Your Name
2026-02-21 01:34:26 +02:00
parent a0bb7d3ea8
commit eb0c65c29a
5 changed files with 177 additions and 796 deletions

View File

@@ -991,6 +991,42 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
rows
};
let invoice_payments = {
let mut stmt = conn.prepare("SELECT id, invoice_id, amount, date, method, notes, created_at FROM invoice_payments").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)?,
"invoice_id": row.get::<_, i64>(1)?,
"amount": row.get::<_, f64>(2)?,
"date": row.get::<_, String>(3)?,
"method": row.get::<_, Option<String>>(4)?,
"notes": row.get::<_, Option<String>>(5)?,
"created_at": row.get::<_, Option<String>>(6)?
}))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
rows
};
let recurring_invoices = {
let mut stmt = conn.prepare("SELECT id, client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled, created_at FROM recurring_invoices").map_err(|e| e.to_string())?;
let rows: Vec<serde_json::Value> = stmt.query_map([], |row| {
Ok(serde_json::json!({
"id": row.get::<_, i64>(0)?,
"client_id": row.get::<_, i64>(1)?,
"template_id": row.get::<_, Option<String>>(2)?,
"line_items_json": row.get::<_, String>(3)?,
"tax_rate": row.get::<_, f64>(4)?,
"discount": row.get::<_, f64>(5)?,
"notes": row.get::<_, Option<String>>(6)?,
"recurrence_rule": row.get::<_, String>(7)?,
"next_due_date": row.get::<_, String>(8)?,
"enabled": row.get::<_, i32>(9)?,
"created_at": row.get::<_, Option<String>>(10)?
}))
}).map_err(|e| e.to_string())?.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())?;
rows
};
let settings = {
let mut stmt = conn.prepare("SELECT key, value FROM settings").map_err(|e| e.to_string())?;
let rows: Vec<serde_json::Value> = stmt.query_map([], |row| {
@@ -1011,6 +1047,8 @@ pub fn export_data(state: State<AppState>) -> Result<serde_json::Value, String>
"entry_tags": entry_tags,
"invoices": invoices,
"invoice_items": invoice_items,
"invoice_payments": invoice_payments,
"recurring_invoices": recurring_invoices,
"tracked_apps": tracked_apps,
"favorites": favorites,
"recurring_entries": recurring_entries,
@@ -1902,9 +1940,15 @@ pub fn import_json_data(state: State<AppState>, data: serde_json::Value) -> Resu
if name.is_empty() { continue; }
let email = client.get("email").and_then(|v| v.as_str());
let address = client.get("address").and_then(|v| v.as_str());
let company = client.get("company").and_then(|v| v.as_str());
let phone = client.get("phone").and_then(|v| v.as_str());
let tax_id = client.get("tax_id").and_then(|v| v.as_str());
let payment_terms = client.get("payment_terms").and_then(|v| v.as_str());
let notes = client.get("notes").and_then(|v| v.as_str());
let currency = client.get("currency").and_then(|v| v.as_str());
conn.execute(
"INSERT OR IGNORE INTO clients (name, email, address) VALUES (?1, ?2, ?3)",
params![name, email, address],
"INSERT OR IGNORE INTO clients (name, email, address, company, phone, tax_id, payment_terms, notes, currency) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![name, email, address, company, phone, tax_id, payment_terms, notes, currency],
).map_err(|e| e.to_string())?;
counts["clients"] = serde_json::json!(counts["clients"].as_i64().unwrap_or(0) + 1);
}
@@ -1915,34 +1959,20 @@ pub fn import_json_data(state: State<AppState>, data: serde_json::Value) -> Resu
for project in projects {
let name = project.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name.is_empty() { continue; }
let client_id = project.get("client_id").and_then(|v| v.as_i64());
let hourly_rate = project.get("hourly_rate").and_then(|v| v.as_f64()).unwrap_or(0.0);
let color = project.get("color").and_then(|v| v.as_str()).unwrap_or("#F59E0B");
// Find client_id if client_name is provided
let client_id: Option<i64> = project.get("client_name")
.and_then(|v| v.as_str())
.and_then(|client_name| {
conn.query_row(
"SELECT id FROM clients WHERE name = ?1",
params![client_name],
|row| row.get(0),
).ok()
});
// Check if project already exists
let exists: bool = conn.query_row(
"SELECT COUNT(*) FROM projects WHERE name = ?1",
params![name],
|row| row.get::<_, i64>(0),
).map_err(|e| e.to_string())? > 0;
if !exists {
conn.execute(
"INSERT INTO projects (client_id, name, hourly_rate, color, archived) VALUES (?1, ?2, ?3, ?4, 0)",
params![client_id, name, hourly_rate, color],
).map_err(|e| e.to_string())?;
counts["projects"] = serde_json::json!(counts["projects"].as_i64().unwrap_or(0) + 1);
}
let archived = if project.get("archived").and_then(|v| v.as_bool()).unwrap_or(false) { 1 } else { 0 };
let budget_hours = project.get("budget_hours").and_then(|v| v.as_f64());
let budget_amount = project.get("budget_amount").and_then(|v| v.as_f64());
let rounding_override = project.get("rounding_override").and_then(|v| v.as_i64()).map(|v| v as i32);
let notes = project.get("notes").and_then(|v| v.as_str());
let currency = project.get("currency").and_then(|v| v.as_str());
conn.execute(
"INSERT OR IGNORE 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![client_id, name, hourly_rate, color, archived, budget_hours, budget_amount, rounding_override, notes, currency],
).map_err(|e| e.to_string())?;
counts["projects"] = serde_json::json!(counts["projects"].as_i64().unwrap_or(0) + 1);
}
}
@@ -1968,9 +1998,10 @@ pub fn import_json_data(state: State<AppState>, data: serde_json::Value) -> Resu
let project_id = task.get("project_id").and_then(|v| v.as_i64());
if let Some(pid) = project_id {
let estimated_hours = task.get("estimated_hours").and_then(|v| v.as_f64());
let hourly_rate = task.get("hourly_rate").and_then(|v| v.as_f64());
conn.execute(
"INSERT OR IGNORE INTO tasks (project_id, name, estimated_hours) VALUES (?1, ?2, ?3)",
params![pid, name, estimated_hours],
"INSERT OR IGNORE INTO tasks (project_id, name, estimated_hours, hourly_rate) VALUES (?1, ?2, ?3, ?4)",
params![pid, name, estimated_hours, hourly_rate],
).map_err(|e| e.to_string())?;
counts["tasks"] = serde_json::json!(counts["tasks"].as_i64().unwrap_or(0) + 1);
}
@@ -1980,29 +2011,19 @@ pub fn import_json_data(state: State<AppState>, data: serde_json::Value) -> Resu
// Import time entries
if let Some(entries) = data.get("time_entries").and_then(|v| v.as_array()) {
for entry in entries {
let project_name = entry.get("project_name").and_then(|v| v.as_str()).unwrap_or("");
let start_time = entry.get("start_time").and_then(|v| v.as_str()).unwrap_or("");
if start_time.is_empty() { continue; }
let project_id: Option<i64> = if !project_name.is_empty() {
conn.query_row(
"SELECT id FROM projects WHERE name = ?1",
params![project_name],
|row| row.get(0),
).ok()
} else {
entry.get("project_id").and_then(|v| v.as_i64())
};
let project_id = entry.get("project_id").and_then(|v| v.as_i64());
if let Some(pid) = project_id {
let task_id = entry.get("task_id").and_then(|v| v.as_i64());
let description = entry.get("description").and_then(|v| v.as_str());
let end_time = entry.get("end_time").and_then(|v| v.as_str());
let duration = entry.get("duration").and_then(|v| v.as_i64()).unwrap_or(0);
let billable = entry.get("billable").and_then(|v| v.as_i64()).unwrap_or(1);
conn.execute(
"INSERT INTO time_entries (project_id, description, start_time, end_time, duration, billable) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![pid, description, start_time, end_time, duration, billable],
"INSERT INTO time_entries (project_id, task_id, description, start_time, end_time, duration, billable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![pid, task_id, description, start_time, end_time, duration, billable],
).map_err(|e| e.to_string())?;
counts["entries"] = serde_json::json!(counts["entries"].as_i64().unwrap_or(0) + 1);
}
@@ -2130,6 +2151,97 @@ pub fn import_json_data(state: State<AppState>, data: serde_json::Value) -> Resu
}
}
// Import timeline_events
if let Some(events) = data.get("timeline_events").and_then(|v| v.as_array()) {
for evt in events {
let project_id = evt.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0);
let exe_name = evt.get("exe_name").and_then(|v| v.as_str());
let exe_path = evt.get("exe_path").and_then(|v| v.as_str());
let window_title = evt.get("window_title").and_then(|v| v.as_str());
let started_at = evt.get("started_at").and_then(|v| v.as_str()).unwrap_or("");
if started_at.is_empty() { continue; }
let ended_at = evt.get("ended_at").and_then(|v| v.as_str());
let duration = evt.get("duration").and_then(|v| v.as_i64()).unwrap_or(0);
conn.execute(
"INSERT INTO timeline_events (project_id, exe_name, exe_path, window_title, started_at, ended_at, duration) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![project_id, exe_name, exe_path, window_title, started_at, ended_at, duration],
).ok();
}
}
// Import calendar_sources
if let Some(sources) = data.get("calendar_sources").and_then(|v| v.as_array()) {
for src in sources {
let name = src.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name.is_empty() { continue; }
let source_type = src.get("source_type").and_then(|v| v.as_str()).unwrap_or("");
let url = src.get("url").and_then(|v| v.as_str());
let last_synced = src.get("last_synced").and_then(|v| v.as_str());
let sync_interval = src.get("sync_interval").and_then(|v| v.as_i64()).unwrap_or(3600) as i32;
let enabled = src.get("enabled").and_then(|v| v.as_i64()).unwrap_or(1) as i32;
conn.execute(
"INSERT INTO calendar_sources (name, type, url, last_synced, sync_interval, enabled) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![name, source_type, url, last_synced, sync_interval, enabled],
).ok();
}
}
// Import calendar_events
if let Some(cal_events) = data.get("calendar_events").and_then(|v| v.as_array()) {
for evt in cal_events {
let source_id = evt.get("source_id").and_then(|v| v.as_i64()).unwrap_or(0);
let uid = evt.get("uid").and_then(|v| v.as_str());
let summary = evt.get("summary").and_then(|v| v.as_str());
let start_time = evt.get("start_time").and_then(|v| v.as_str());
let end_time = evt.get("end_time").and_then(|v| v.as_str());
let duration = evt.get("duration").and_then(|v| v.as_i64()).unwrap_or(0);
let location = evt.get("location").and_then(|v| v.as_str());
conn.execute(
"INSERT INTO calendar_events (source_id, uid, summary, start_time, end_time, duration, location) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![source_id, uid, summary, start_time, end_time, duration, location],
).ok();
}
}
// Import invoice_payments
if let Some(payments) = data.get("invoice_payments").and_then(|v| v.as_array()) {
for pay in payments {
let invoice_id = pay.get("invoice_id").and_then(|v| v.as_i64()).unwrap_or(0);
let amount = pay.get("amount").and_then(|v| v.as_f64()).unwrap_or(0.0);
let date = pay.get("date").and_then(|v| v.as_str()).unwrap_or("");
if date.is_empty() { continue; }
let method = pay.get("method").and_then(|v| v.as_str());
let notes = pay.get("notes").and_then(|v| v.as_str());
let created_at = pay.get("created_at").and_then(|v| v.as_str());
conn.execute(
"INSERT INTO invoice_payments (invoice_id, amount, date, method, notes, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![invoice_id, amount, date, method, notes, created_at],
).ok();
}
}
// Import recurring_invoices
if let Some(rec_invs) = data.get("recurring_invoices").and_then(|v| v.as_array()) {
for ri in rec_invs {
let client_id = ri.get("client_id").and_then(|v| v.as_i64()).unwrap_or(0);
let template_id = ri.get("template_id").and_then(|v| v.as_str());
let line_items_json = ri.get("line_items_json").and_then(|v| v.as_str()).unwrap_or("[]");
let tax_rate = ri.get("tax_rate").and_then(|v| v.as_f64()).unwrap_or(0.0);
let discount = ri.get("discount").and_then(|v| v.as_f64()).unwrap_or(0.0);
let notes = ri.get("notes").and_then(|v| v.as_str());
let recurrence_rule = ri.get("recurrence_rule").and_then(|v| v.as_str()).unwrap_or("");
if recurrence_rule.is_empty() { continue; }
let next_due_date = ri.get("next_due_date").and_then(|v| v.as_str()).unwrap_or("");
if next_due_date.is_empty() { continue; }
let enabled = ri.get("enabled").and_then(|v| v.as_i64()).unwrap_or(1) as i32;
let created_at = ri.get("created_at").and_then(|v| v.as_str());
conn.execute(
"INSERT INTO recurring_invoices (client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params![client_id, template_id, line_items_json, tax_rate, discount, notes, recurrence_rule, next_due_date, enabled, created_at],
).ok();
}
}
// Import settings
if let Some(settings_list) = data.get("settings").and_then(|v| v.as_array()) {
for setting in settings_list {
@@ -3642,9 +3754,3 @@ 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())
}

View File

@@ -6,7 +6,6 @@ use tauri::Manager;
mod database;
mod commands;
mod os_detection;
mod seed;
pub struct AppState {
pub db: Mutex<Connection>,
@@ -155,7 +154,6 @@ pub fn run() {
commands::update_recurring_invoice,
commands::delete_recurring_invoice,
commands::check_recurring_invoices,
commands::seed_sample_data,
])
.setup(|app| {
#[cfg(desktop)]

View File

@@ -1,672 +0,0 @@
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(())
}

View File

@@ -26,11 +26,20 @@ const entityLabels: Record<string, string> = {
tasks: 'Tasks',
time_entries: 'Time Entries',
tags: 'Tags',
entry_tags: 'Entry Tags',
invoices: 'Invoices',
invoice_items: 'Invoice Items',
invoice_payments: 'Invoice Payments',
recurring_invoices: 'Recurring Invoices',
expenses: 'Expenses',
favorites: 'Favorites',
recurring_entries: 'Recurring Entries',
tracked_apps: 'Tracked Apps',
timeline_events: 'Timeline Events',
calendar_sources: 'Calendar Sources',
calendar_events: 'Calendar Events',
timesheet_locks: 'Timesheet Locks',
timesheet_rows: 'Timesheet Rows',
entry_templates: 'Entry Templates',
settings: 'Settings',
}

View File

@@ -1402,7 +1402,7 @@
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-[0.8125rem] font-medium text-text-primary">Import Data</h3>
<button @click="showJsonImportWizard = true" class="px-3 py-1.5 text-[0.6875rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors">
<button @click="showJsonImportWizard = true" class="px-3 py-1.5 text-[0.8125rem] border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors">
Restore from Backup
</button>
</div>
@@ -1463,19 +1463,6 @@
<div class="rounded-xl border border-status-error/20 p-5">
<h3 class="text-xs text-status-error-text uppercase tracking-[0.08em] font-medium mb-4">Danger Zone</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Load Sample Data</p>
<p class="text-[0.6875rem] text-text-tertiary mt-0.5">Populate with demo data for screenshots (clears existing data first)</p>
</div>
<button
@click="showSeedDialog = true"
:disabled="isSeedingData"
class="px-4 py-1.5 border border-status-warning text-status-warning text-[0.8125rem] font-medium rounded-lg hover:bg-status-warning/10 transition-colors duration-150 disabled:opacity-40"
>
{{ isSeedingData ? 'Loading...' : 'Load Demo' }}
</button>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-[0.8125rem] text-text-primary">Clear All Data</p>
@@ -1530,38 +1517,6 @@
</div>
</Transition>
<!-- Seed Data Confirmation Dialog -->
<Transition name="modal">
<div
v-if="showSeedDialog"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
@click.self="showSeedDialog = false"
>
<div role="alertdialog" aria-modal="true" aria-labelledby="seed-data-title" aria-describedby="seed-data-desc" class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6">
<h2 id="seed-data-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary mb-2">Load Sample Data</h2>
<p id="seed-data-desc" class="text-[0.75rem] text-text-secondary mb-4">
This will clear all existing data and replace it with demo content.
</p>
<p class="text-[0.6875rem] text-status-warning mb-6">
All current entries, projects, clients, and invoices will be replaced.
</p>
<div class="flex justify-end gap-3">
<button
@click="showSeedDialog = false"
class="px-4 py-2 border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150"
>
Cancel
</button>
<button
@click="seedSampleData"
class="px-4 py-2 bg-status-warning text-white font-medium rounded-lg hover:bg-status-warning/80 transition-colors duration-150"
>
Load Demo Data
</button>
</div>
</div>
</div>
</Transition>
<JsonImportWizard
:show="showJsonImportWizard"
@@ -1965,9 +1920,6 @@ const showClearDataDialog = ref(false)
const clearDialogRef = ref<HTMLElement | null>(null)
const { activate: activateClearTrap, deactivate: deactivateClearTrap } = useFocusTrap()
const showSeedDialog = ref(false)
const isSeedingData = ref(false)
watch(showClearDataDialog, (val) => {
if (val) {
setTimeout(() => {
@@ -2450,20 +2402,23 @@ async function executeImport() {
// Export all data
async function exportData() {
try {
const data = await invoke('export_data')
const { save } = await import('@tauri-apps/plugin-dialog')
const { writeTextFile } = await import('@tauri-apps/plugin-fs')
const filePath = await save({
defaultPath: `zeroclock-export-${new Date().toISOString().split('T')[0]}.json`,
filters: [{ name: 'JSON', extensions: ['json'] }],
})
if (!filePath) return
const data = await invoke('export_data')
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `zeroclock-export-${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
await writeTextFile(filePath, json)
const now = new Date().toISOString()
await settingsStore.updateSetting('last_exported', now)
lastExported.value = now
toastStore.success('Data exported successfully')
} catch (error) {
console.error('Failed to export data:', error)
toastStore.error('Failed to export data')
@@ -2482,21 +2437,6 @@ async function clearAllData() {
}
}
async function seedSampleData() {
isSeedingData.value = true
showSeedDialog.value = false
try {
await invoke('seed_sample_data')
toastStore.success('Sample data loaded successfully')
setTimeout(() => { window.location.reload() }, 500)
} catch (error) {
console.error('Failed to load sample data:', error)
toastStore.error('Failed to load sample data')
} finally {
isSeedingData.value = false
}
}
// Load settings on mount
onMounted(async () => {
await settingsStore.fetchSettings()