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:
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user