use crate::api::ApiClient; use crate::models::api_error_detail; use serde_json::{Map, Value}; /// Asset CRUD operations that can be reused across different entity types pub struct AssetOperations; impl AssetOperations { /// Apply updates to one or more assets pub fn apply_updates( api: &ApiClient, updated: Map, pending_edit_ids: &mut Vec, easy_dialog_item_id: Option<&str>, advanced_dialog_item_id: Option<&str>, find_asset_fn: impl Fn(i64) -> Option, limit: Option, reload_fn: impl FnOnce(&ApiClient, Option), ) where T: serde::Serialize, { let ids: Vec = std::mem::take(pending_edit_ids); log::info!("Pending edit IDs from ribbon: {:?}", ids); if ids.is_empty() { // Try to get ID from either dialog or from the embedded ID in the diff let item_id = updated .get("__editor_item_id") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .or_else(|| easy_dialog_item_id.map(|s| s.to_string())) .or_else(|| advanced_dialog_item_id.map(|s| s.to_string())); if let Some(id_str) = item_id { if let Ok(id) = id_str.parse::() { // Compute diff against the current asset so we only send changed fields let only_changed = if let Some(orig) = find_asset_fn(id) { log::info!("Found original asset data for comparison"); let orig_value = serde_json::to_value(&orig).unwrap_or_default(); let mut diff = Map::new(); for (k, v) in updated.iter() { match orig_value.get(k) { Some(ov) if ov == v => { log::debug!("Field '{}' unchanged: {:?}", k, v); } _ => { log::info!( "Field '{}' CHANGED: old={:?}, new={:?}", k, orig_value.get(k), v ); diff.insert(k.clone(), v.clone()); } } } log::info!("Final diff map to send: {:?}", diff); diff } else { log::warn!( "Asset ID {} not found in local cache, sending full update", id ); updated.clone() }; if only_changed.is_empty() { log::warn!("No changes detected - update will be skipped!"); } else { log::info!("Calling update_one with {} changes", only_changed.len()); } Self::update_one(api, id, &only_changed); } else { log::error!("FAILED to parse asset ID: '{}' - UPDATE SKIPPED!", id_str); } } else { log::error!("NO ITEM ID FOUND - This is the bug! UPDATE COMPLETELY SKIPPED!"); log::error!("Easy dialog item_id: {:?}", easy_dialog_item_id); log::error!("Advanced dialog item_id: {:?}", advanced_dialog_item_id); } } else { log::info!("Bulk edit mode for {} assets", ids.len()); // Bulk edit: apply provided fields to each id without diffing per-record for id in ids { log::info!("Bulk updating asset ID: {}", id); Self::update_one(api, id, &updated); } } reload_fn(api, limit); } /// Handle quick-add fields for category, zone, and supplier when editing pub fn preprocess_quick_adds(api: &ApiClient, data: &mut Map) { // CATEGORY let new_cat_name = data .get("new_category_name") .and_then(|v| v.as_str()) .unwrap_or("") .trim() .to_string(); let new_cat_code = data .get("new_category_code") .and_then(|v| v.as_str()) .unwrap_or("") .trim() .to_string(); let has_selected_cat = data.get("category_id").and_then(|v| v.as_i64()).is_some() || data .get("category_id") .and_then(|v| v.as_str()) .map(|s| !s.trim().is_empty()) .unwrap_or(false); if !has_selected_cat && !new_cat_name.is_empty() && !new_cat_code.is_empty() { let values = serde_json::json!({ "category_name": new_cat_name, "category_code": new_cat_code, }); match api.insert("categories", values) { Ok(resp) if resp.success => { if let Some(id) = resp.data { data.insert("category_id".into(), Value::Number((id as i64).into())); } } Ok(resp) => { log::error!( "Quick-add category failed: {}", api_error_detail(&resp.error) ); } Err(e) => { log::error!("Quick-add category err: {}", e); } } } // ZONE let new_zone_name = data .get("new_zone_name") .and_then(|v| v.as_str()) .unwrap_or("") .trim() .to_string(); // Prefer new_zone_mini_code (new), fallback to legacy new_zone_code let new_zone_mini_code = data .get("new_zone_mini_code") .and_then(|v| v.as_str()) .unwrap_or("") .trim() .to_string(); let legacy_new_zone_code = data .get("new_zone_code") .and_then(|v| v.as_str()) .unwrap_or("") .trim() .to_string(); let parent_id_val = data.get("new_zone_parent_id").cloned(); let has_selected_zone = data.get("zone_id").and_then(|v| v.as_i64()).is_some() || data .get("zone_id") .and_then(|v| v.as_str()) .map(|s| !s.trim().is_empty()) .unwrap_or(false); if !has_selected_zone && !new_zone_name.is_empty() { // parent optional, parse int if provided let mut zone_obj = Map::new(); zone_obj.insert("zone_name".into(), Value::String(new_zone_name)); // Determine mini_code to use let mini_code = if !new_zone_mini_code.is_empty() { new_zone_mini_code.clone() } else { legacy_new_zone_code.clone() }; if !mini_code.is_empty() { // Compute full zone_code using parent if provided let full_code = if let Some(v) = parent_id_val.clone() { if let Some(pid) = v .as_i64() .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) { // Fetch parent's zone_code if let Ok(resp) = api.select( "zones", Some(vec!["zone_code".into()]), Some(serde_json::json!({"id": pid})), None, Some(1), ) { if resp.success { if let Some(rows) = resp.data { if let Some(row) = rows.into_iter().next() { let pcode = row .get("zone_code") .and_then(|v| v.as_str()) .unwrap_or(""); format!("{}-{}", pcode, mini_code) } else { mini_code.clone() } } else { mini_code.clone() } } else { mini_code.clone() } } else { mini_code.clone() } } else { mini_code.clone() } } else { mini_code.clone() }; // Send both mini_code and computed full zone_code zone_obj.insert("mini_code".into(), Value::String(mini_code)); zone_obj.insert("zone_code".into(), Value::String(full_code)); } if let Some(v) = parent_id_val { if let Some(n) = v.as_i64() { zone_obj.insert("parent_id".into(), Value::Number(n.into())); } else if let Some(s) = v.as_str() { if let Ok(n) = s.parse::() { zone_obj.insert("parent_id".into(), Value::Number(n.into())); } } } match api.insert("zones", Value::Object(zone_obj)) { Ok(resp) if resp.success => { if let Some(id) = resp.data { data.insert("zone_id".into(), Value::Number((id as i64).into())); } } Ok(resp) => { log::error!("Quick-add zone failed: {}", api_error_detail(&resp.error)); } Err(e) => { log::error!("Quick-add zone err: {}", e); } } } // SUPPLIER let new_supplier_name = data .get("new_supplier_name") .and_then(|v| v.as_str()) .unwrap_or("") .trim() .to_string(); let has_selected_supplier = data.get("supplier_id").and_then(|v| v.as_i64()).is_some() || data .get("supplier_id") .and_then(|v| v.as_str()) .map(|s| !s.trim().is_empty()) .unwrap_or(false); if !has_selected_supplier && !new_supplier_name.is_empty() { let values = serde_json::json!({ "name": new_supplier_name }); match api.insert("suppliers", values) { Ok(resp) if resp.success => { if let Some(id) = resp.data { data.insert("supplier_id".into(), Value::Number((id as i64).into())); } } Ok(resp) => { log::error!( "Quick-add supplier failed: {}", api_error_detail(&resp.error) ); } Err(e) => { log::error!("Quick-add supplier err: {}", e); } } } } /// Filter update data to only include allowed fields with proper type coercion pub fn filtered_update_fields(data: &Map) -> Map { // Allow only writable/meaningful asset fields (exclude IDs, timestamps, joined names) let allowed = [ "asset_tag", "asset_type", "name", "category_id", "zone_id", "zone_plus", "zone_note", "manufacturer", "model", "serial_number", "status", "label_template_id", "price", "purchase_date", "warranty_until", "expiry_date", "supplier_id", "lendable", "lending_status", "due_date", "no_scan", "quantity_available", "quantity_total", "quantity_used", "minimum_role_for_lending", "audit_task_id", "asset_image", "notes", "additional_fields", ]; let allowed_set: std::collections::HashSet<&str> = allowed.iter().copied().collect(); let mut out = Map::new(); for (k, v) in data.iter() { // Skip internal editor fields if k.starts_with("__editor_") { continue; } // Map template-only "description" to asset "notes" to avoid DB column mismatch if k == "description" { let coerced = if let Some(s) = v.as_str() { Value::String(s.to_string()) } else if v.is_null() { Value::Null } else { Value::String(v.to_string()) }; if !out.contains_key("notes") { out.insert("notes".to_string(), coerced); } continue; } if !allowed_set.contains(k.as_str()) { continue; } // Coerce common types where Advanced Editor may send strings let coerced = match k.as_str() { // Integers (IDs and quantities) "category_id" | "zone_id" | "label_template_id" | "supplier_id" | "audit_task_id" | "quantity_available" | "quantity_total" | "quantity_used" | "minimum_role_for_lending" => { if let Some(n) = v.as_i64() { Value::Number(n.into()) } else if let Some(s) = v.as_str() { if s.trim().is_empty() { Value::Null } else if let Ok(n) = s.trim().parse::() { Value::Number(n.into()) } else { Value::Null } } else { v.clone() } } // Booleans "lendable" => { if let Some(b) = v.as_bool() { Value::Bool(b) } else if let Some(s) = v.as_str() { match s.trim().to_lowercase().as_str() { "true" | "1" | "yes" => Value::Bool(true), "false" | "0" | "no" => Value::Bool(false), _ => v.clone(), } } else { v.clone() } } // Price as decimal number "price" => { if let Some(f) = v.as_f64() { Value::Number( serde_json::Number::from_f64(f) .unwrap_or_else(|| serde_json::Number::from(0)), ) } else if let Some(s) = v.as_str() { if s.trim().is_empty() { Value::Null } else if let Ok(f) = s.trim().parse::() { Value::Number( serde_json::Number::from_f64(f) .unwrap_or_else(|| serde_json::Number::from(0)), ) } else { Value::Null } } else { v.clone() } } // Date fields: accept YYYY-MM-DD strings; treat empty strings as NULL "purchase_date" | "warranty_until" | "expiry_date" | "due_date" => { if let Some(s) = v.as_str() { let t = s.trim(); if t.is_empty() { Value::Null } else { Value::String(t.to_string()) } } else if v.is_null() { Value::Null } else { // Fallback: stringify other types Value::String(v.to_string()) } } // String fields - ensure they're strings (not null if empty) "asset_tag" | "asset_type" | "name" | "manufacturer" | "model" | "serial_number" | "status" | "zone_plus" | "zone_note" | "lending_status" | "no_scan" | "notes" => { if let Some(s) = v.as_str() { Value::String(s.to_string()) } else if v.is_null() { Value::Null } else { Value::String(v.to_string()) } } _ => v.clone(), }; out.insert(k.clone(), coerced); } out } /// Update a single entity record pub fn update_one(api: &ApiClient, id: i64, data: &Map) { Self::update_one_table(api, "assets", id, data); } /// Update a single record in any table pub fn update_one_table(api: &ApiClient, table: &str, id: i64, data: &Map) { log::info!("=== UPDATE_ONE START for {} ID {} ===", table, id); log::info!("Raw input data: {:?}", data); let values_map = Self::filtered_update_fields(data); log::info!("Filtered values_map: {:?}", values_map); if values_map.is_empty() { log::warn!( "No allowed fields found after filtering for {} ID {}, SKIPPING UPDATE", table, id ); log::warn!("Original data keys: {:?}", data.keys().collect::>()); return; } let values = Value::Object(values_map.clone()); let where_clause = serde_json::json!({"id": id}); log::info!("SENDING UPDATE to server:"); log::info!(" TABLE: {}", table); log::info!(" WHERE: {:?}", where_clause); log::info!(" VALUES: {:?}", values); match api.update(table, values, where_clause) { Ok(resp) if resp.success => { log::info!("Successfully updated {} ID {}", table, id); } Ok(resp) => { log::error!( "Server rejected update for {} ID {}: {}", table, id, api_error_detail(&resp.error) ); } Err(e) => { log::error!("Network/API error updating {} ID {}: {}", table, id, e); } } log::info!("=== UPDATE_ONE END ==="); } /// Insert a new asset record with preprocessing and return its DB id (if available) pub fn insert_new_asset( api: &ApiClient, mut data: Map, limit: Option, reload_fn: impl FnOnce(&ApiClient, Option), ) -> Option { log::info!("=== INSERT_NEW_ASSET START ==="); log::info!("Raw asset data: {:?}", data); // Process quick-add fields first Self::preprocess_quick_adds(api, &mut data); // Ensure mandatory defaults if missing or blank let needs_default = |v: Option<&Value>| -> bool { match v { None => true, Some(Value::Null) => true, Some(Value::String(s)) => s.trim().is_empty(), _ => false, } }; if needs_default(data.get("asset_type")) { data.insert("asset_type".into(), Value::String("N".to_string())); } if needs_default(data.get("status")) { data.insert("status".into(), Value::String("Good".to_string())); } // Filter to allowed fields let filtered_data = Self::filtered_update_fields(&data); log::info!("Filtered asset data: {:?}", filtered_data); if filtered_data.is_empty() { log::error!("No valid data to insert"); return None; } let values = Value::Object(filtered_data); log::info!("SENDING INSERT to server: {:?}", values); let result = match api.insert("assets", values) { Ok(resp) if resp.success => { log::info!("Successfully created new asset"); let id = resp.data.map(|d| d as i64); log::info!("New asset DB id from server: {:?}", id); reload_fn(api, limit); id } Ok(resp) => { log::error!( "Server rejected asset creation: {}", api_error_detail(&resp.error) ); None } Err(e) => { log::error!("Network/API error creating asset: {}", e); None } }; log::info!("=== INSERT_NEW_ASSET END ==="); result } /// Find an asset by ID in a collection pub fn find_by_id( collection: &[T], id: i64, id_extractor: impl Fn(&T) -> Option, ) -> Option where T: Clone, { collection .iter() .find(|item| id_extractor(item) == Some(id)) .cloned() } /// Get selected IDs from a collection based on row indices #[allow(dead_code)] pub fn get_selected_ids( collection: &[T], selected_rows: &std::collections::HashSet, id_extractor: impl Fn(&T) -> Option, ) -> Vec { let mut ids = Vec::new(); for &row in selected_rows { if let Some(item) = collection.get(row) { if let Some(id) = id_extractor(item) { ids.push(id); } } } ids } /// Filter and search through JSON data #[allow(dead_code)] pub fn filter_and_search( data: &[Value], search_query: &str, search_fields: &[&str], ) -> Vec { if search_query.is_empty() { return data.to_vec(); } let search_lower = search_query.to_lowercase(); data.iter() .filter(|item| { search_fields.iter().any(|field| { item.get(field) .and_then(|v| v.as_str()) .map(|s| s.to_lowercase().contains(&search_lower)) .unwrap_or(false) }) }) .cloned() .collect() } }