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())
}