/* * Asset Tag Generation System * * The asset tag generation string uses placeholders that get replaced with actual values * when creating new assets from templates. * * Example Generation String: * {BUILDINGCODE}-{CATEGORYCODE}-{FLOORCODE}{ROOMCODE}-{ZONEASC} * * Example Asset Details: * - Building code: ps52 * - Category code: fire * - Floor code: 1 * - Room code: 08 * - 3rd item in that zone and category * * Generated Tag: ps52-fire-108-03 * * Available Placeholders: * * Location-based: * - {BUILDINGCODE} - Building identifier code * - {FLOORCODE} - Floor number/code * - {ROOMCODE} - Room number/code * - {ZONECODE} - Zone identifier code * - (Zone name was removed, we cant have spaces in asset tags) * * Category-based: * - {CATEGORYCODE} - Category short code * - (Category name was removed, we cant have spaces in asset tags) * * Asset-based: * - {ASSETTYPE} - Asset type (N/B/L/C) * - {MANUFACTURER} - Manufacturer name but with spaces replaced with underscores * - {MODEL} - Model name/number with spaces replaced with underscores * * Counters: * - {ZONEASC} - Ascending counter for items in the same zone and category (01, 02, 03...) * - {GLOBALASC} - Global ascending counter for all items in same category (useful for laptops, cables, portable items) * * Date/Time: * - {YEAR} - Current year (2025) * - {MONTH} - Current month (12) * - {DAY} - Current day (31) * - {YEARSHORT} - Short year (25) * * Special: * - {SERIAL} - Serial number (if available) * - {RANDOM4} - 4-digit random number * - {RANDOM6} - 6-digit random number * - {USER} - Ask for user input during asset creation * * Examples: * Firewall: {BUILDINGCODE}-{CATEGORYCODE}-{FLOORCODE}{ROOMCODE}-{ZONEASC} * Home Office Laptop: {CATEGORYCODE}-{GLOBALASC} * Cable: CBL-{YEAR}-{CATEGORYASC} * License: LIC-{MODEL}-{CATEGORYASC} */ use chrono::Utc; use eframe::egui; use rand::Rng; use regex::Regex; use serde_json::Value; use crate::api::ApiClient; use crate::core::asset_fields::AssetFieldBuilder; use crate::core::components::form_builder::FormBuilder; use crate::core::{EditorField, FieldType}; /// Workflow for adding assets from templates pub struct AddFromTemplateWorkflow { /// Template selection dialog state template_selector: TemplateSelector, /// Asset editor dialog for filling in template details asset_editor: Option, /// Current selected template data selected_template: Option, /// Whether the workflow is currently active is_active: bool, /// Whether we're in single or multiple mode is_multiple_mode: bool, /// Asset tag confirmation dialog state asset_tag_confirmation: Option, /// User preference to skip confirmation dialog unless there are errors skip_confirmation_unless_error: bool, } /// Asset tag confirmation dialog struct AssetTagConfirmation { /// The asset data ready for creation asset_data: Value, /// The generated asset tag generated_tag: String, /// User-editable asset tag edited_tag: String, /// Whether the dialog is currently open is_open: bool, /// Generation errors if any generation_errors: Vec, } /// Template selector component struct TemplateSelector { /// Available templates templates: Vec, /// Filter text for searching templates filter_text: String, /// Currently selected template index selected_index: Option, /// Whether the selector dialog is open is_open: bool, /// Loading state is_loading: bool, /// Error message if any error_message: Option, } impl AddFromTemplateWorkflow { pub fn new() -> Self { Self { template_selector: TemplateSelector::new(), asset_editor: None, selected_template: None, is_active: false, is_multiple_mode: false, asset_tag_confirmation: None, skip_confirmation_unless_error: true, // Default to skipping unless error } } /// Start the workflow in single mode pub fn start_single_mode(&mut self, api_client: &ApiClient) { self.is_active = true; self.is_multiple_mode = false; self.template_selector.load_templates(api_client); self.template_selector.is_open = true; } /// Start the workflow in multiple mode pub fn start_multiple_mode(&mut self, api_client: &ApiClient) { self.is_active = true; self.is_multiple_mode = true; self.template_selector.load_templates(api_client); self.template_selector.is_open = true; } /// Show the workflow UI and handle user interactions /// Returns Some(asset_data) if an asset should be created, None if workflow continues or is cancelled pub fn show(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) -> Option { if !self.is_active { return None; } let mut result = None; // Show template selector first if self.template_selector.is_open { let selected_template = self.template_selector.show(ui, api_client); if let Some(template) = selected_template { // Template selected, prepare asset editor self.selected_template = Some(template.clone()); self.prepare_asset_editor(&template, api_client); self.template_selector.is_open = false; } else if !self.template_selector.is_open { // Template selector was cancelled self.cancel(); } } // Show asset editor if template is selected if let Some(ref mut editor) = self.asset_editor { if let Some(editor_result) = editor.show_editor(ui.ctx()) { match editor_result { Some(asset_data_diff) => { // Reconstruct full data: original + diff (editor.data is cleared on close) let mut full_asset_data = editor.original_data.clone(); for (k, v) in asset_data_diff.iter() { full_asset_data.insert(k.clone(), v.clone()); } // Read and persist the skip confirmation preference (stored as an editor field) if let Some(skip) = full_asset_data .get("skip_tag_confirmation") .and_then(|v| v.as_bool()) { self.skip_confirmation_unless_error = skip; } // Remove UI-only field from the final asset payload full_asset_data.remove("skip_tag_confirmation"); log::info!( "Editor diff data: {}", serde_json::to_string_pretty(&asset_data_diff) .unwrap_or_else(|_| "failed to serialize".to_string()) ); log::info!( "Full asset data from editor: {}", serde_json::to_string_pretty(&full_asset_data) .unwrap_or_else(|_| "failed to serialize".to_string()) ); // Apply auto-generation logic for purchase_date_now (safety: re-apply in case template requested it) if let Some(template) = &self.selected_template { if let Some(purchase_date_now) = template.get("purchase_date_now").and_then(|v| v.as_bool()) { if purchase_date_now { let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); full_asset_data.insert( "purchase_date".to_string(), Value::String(today.clone()), ); log::info!("Auto-generated purchase_date: {}", today); } } // Apply warranty auto-calculation if enabled if let Some(warranty_auto) = template.get("warranty_auto").and_then(|v| v.as_bool()) { if warranty_auto { if let (Some(amount), Some(unit)) = ( template .get("warranty_auto_amount") .and_then(|v| v.as_i64()), template.get("warranty_auto_unit").and_then(|v| v.as_str()), ) { let today = chrono::Utc::now().date_naive(); let warranty_date = match unit { "days" => today + chrono::Duration::days(amount), "months" => today + chrono::Duration::days(amount * 30), // Approximate "years" => today + chrono::Duration::days(amount * 365), // Approximate _ => today, }; let warranty_str = warranty_date.format("%Y-%m-%d").to_string(); full_asset_data.insert( "warranty_until".to_string(), Value::String(warranty_str.clone()), ); log::info!( "Auto-calculated warranty_until: {} ({} {})", warranty_str, amount, unit ); } } } // Apply expiry auto-calculation if enabled if let Some(expiry_auto) = template.get("expiry_auto").and_then(|v| v.as_bool()) { if expiry_auto { if let (Some(amount), Some(unit)) = ( template.get("expiry_auto_amount").and_then(|v| v.as_i64()), template.get("expiry_auto_unit").and_then(|v| v.as_str()), ) { let today = chrono::Utc::now().date_naive(); let expiry_date = match unit { "days" => today + chrono::Duration::days(amount), "months" => today + chrono::Duration::days(amount * 30), // Approximate "years" => today + chrono::Duration::days(amount * 365), // Approximate _ => today, }; let expiry_str = expiry_date.format("%Y-%m-%d").to_string(); full_asset_data.insert( "expiry_date".to_string(), Value::String(expiry_str.clone()), ); log::info!( "Auto-calculated expiry_date: {} ({} {})", expiry_str, amount, unit ); } } } } // Validate and prepare asset tag confirmation self.prepare_asset_tag_confirmation( Value::Object(full_asset_data), api_client, ); self.asset_editor = None; // Close the asset editor } None => { // Asset editor was cancelled if self.is_multiple_mode { // In multiple mode, go back to template selector self.template_selector.is_open = true; self.asset_editor = None; self.selected_template = None; } else { // In single mode, cancel entire workflow self.cancel(); } } } } } // Show asset tag confirmation dialog (or handle skipped case) if let Some(ref mut confirmation) = self.asset_tag_confirmation { if confirmation.is_open { // Show the dialog if let Some(confirmed_asset) = confirmation.show(ui) { result = Some(confirmed_asset); self.asset_tag_confirmation = None; // Close confirmation dialog if !self.is_multiple_mode { self.cancel(); // Single mode - finish after one item } else { // Multiple mode - reset for next item but keep template selected self.reset_for_next_item(api_client); } } } else { // Dialog was skipped - return asset data immediately result = Some(confirmation.asset_data.clone()); self.asset_tag_confirmation = None; if !self.is_multiple_mode { self.cancel(); // Single mode - finish after one item } else { // Multiple mode - reset for next item but keep template selected self.reset_for_next_item(api_client); } } } result } /// Prepare the asset editor with template data fn prepare_asset_editor(&mut self, template: &Value, api_client: &ApiClient) { log::info!( "Preparing asset editor with template: {}", serde_json::to_string_pretty(template) .unwrap_or_else(|_| "failed to serialize".to_string()) ); // Create editor with all fields (using existing asset field builder) let mut editor = AssetFieldBuilder::create_advanced_edit_dialog(api_client); // Pre-populate with template data let mut asset_data = template.clone(); // Clear ID and other fields that shouldn't be copied from template asset_data["id"] = Value::String("".to_string()); asset_data["asset_tag"] = Value::String("".to_string()); // Will be auto-generated asset_data["created_date"] = Value::Null; asset_data["last_modified_date"] = Value::Null; asset_data["created_at"] = Value::Null; // Template creation date shouldn't be copied // Map joined template data to field names expected by asset tag generation if let Some(category_code) = template.get("category_code").and_then(|v| v.as_str()) { asset_data["category_code"] = Value::String(category_code.to_string()); log::info!("Mapped category_code from template: {}", category_code); } else { log::warn!("Template has no category_code field"); } if let Some(zone_code) = template.get("zone_code").and_then(|v| v.as_str()) { asset_data["zone_code"] = Value::String(zone_code.to_string()); log::info!("Mapped zone_code from template: {}", zone_code); } else { log::warn!("Template has no zone_code field (this is normal if zone_id is null)"); } // Apply initial auto-generation so the user sees defaults inside the editor // 1) Purchase date now if template .get("purchase_date_now") .and_then(|v| v.as_bool()) .unwrap_or(false) { let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); asset_data["purchase_date"] = Value::String(today.clone()); log::info!("[Editor init] Auto-set purchase_date: {}", today); } // 2) Warranty auto-calc if template .get("warranty_auto") .and_then(|v| v.as_bool()) .unwrap_or(false) { if let (Some(amount), Some(unit)) = ( template .get("warranty_auto_amount") .and_then(|v| v.as_i64()), template.get("warranty_auto_unit").and_then(|v| v.as_str()), ) { // Base date: purchase_date if present, else today let base_date = asset_data.get("purchase_date").and_then(|v| v.as_str()); let start = if let Some(d) = base_date { chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d") .unwrap_or_else(|_| chrono::Utc::now().date_naive()) } else { chrono::Utc::now().date_naive() }; let warranty_date = match unit { "days" => start + chrono::Duration::days(amount), "months" => start + chrono::Duration::days(amount * 30), // approx "years" => start + chrono::Duration::days(amount * 365), // approx _ => start, }; let warranty_str = warranty_date.format("%Y-%m-%d").to_string(); asset_data["warranty_until"] = Value::String(warranty_str.clone()); log::info!( "[Editor init] Auto-set warranty_until: {} ({} {})", warranty_str, amount, unit ); } } // 3) Expiry auto-calc if template .get("expiry_auto") .and_then(|v| v.as_bool()) .unwrap_or(false) { if let (Some(amount), Some(unit)) = ( template.get("expiry_auto_amount").and_then(|v| v.as_i64()), template.get("expiry_auto_unit").and_then(|v| v.as_str()), ) { // Base date: purchase_date if present, else today let base_date = asset_data.get("purchase_date").and_then(|v| v.as_str()); let start = if let Some(d) = base_date { chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d") .unwrap_or_else(|_| chrono::Utc::now().date_naive()) } else { chrono::Utc::now().date_naive() }; let expiry_date = match unit { "days" => start + chrono::Duration::days(amount), "months" => start + chrono::Duration::days(amount * 30), // approx "years" => start + chrono::Duration::days(amount * 365), // approx _ => start, }; let expiry_str = expiry_date.format("%Y-%m-%d").to_string(); asset_data["expiry_date"] = Value::String(expiry_str.clone()); log::info!( "[Editor init] Auto-set expiry_date: {} ({} {})", expiry_str, amount, unit ); } } // Note: Zone hierarchy extraction will happen later when we have the actual zone_id // from the user's selection in the asset editor, not from the template // Set dialog title let template_name = template .get("name") .and_then(|v| v.as_str()) .unwrap_or("Unknown Template"); let template_code = template .get("template_code") .and_then(|v| v.as_str()) .unwrap_or(""); editor.title = if !template_code.is_empty() { format!( "Add Asset from Template: {} ({})", template_name, template_code ) } else { format!("Add Asset from Template: {}", template_name) }; // Add an in-editor UX toggle: skip confirmation unless errors // Seed the data so the checkbox shows current preference asset_data["skip_tag_confirmation"] = Value::Bool(self.skip_confirmation_unless_error); // Add Print Label option (default on) so user can immediately print after creation asset_data["print_label"] = Value::Bool(true); // Open editor with pre-populated data editor.open(&asset_data); // Inject extra editor fields so they show inside the editor window editor.fields.push(EditorField { name: "skip_tag_confirmation".into(), label: "Skip tag confirmation unless errors".into(), field_type: FieldType::Checkbox, required: false, read_only: false, }); editor.fields.push(EditorField { name: "print_label".into(), label: "Print Label".into(), field_type: FieldType::Checkbox, required: false, read_only: false, }); self.asset_editor = Some(editor); } /// Validate asset data and prepare for creation #[allow(dead_code)] fn validate_and_prepare_asset( &self, api_client: &ApiClient, mut asset_data: Value, ) -> Option { let template = self.selected_template.as_ref()?; log::info!( "Validating asset data: {}", serde_json::to_string_pretty(&asset_data) .unwrap_or_else(|_| "failed to serialize".to_string()) ); // Check if asset tag generation string is required if let Some(generation_string) = template .get("asset_tag_generation_string") .and_then(|v| v.as_str()) { if !generation_string.is_empty() { log::info!("Using asset tag generation string: '{}'", generation_string); // Generate asset tag using the template's asset generation string match self.generate_asset_tag(api_client, &asset_data, generation_string) { Ok(asset_tag) => { log::info!("Generated asset tag: '{}'", asset_tag); asset_data["asset_tag"] = Value::String(asset_tag); } Err(missing_fields) => { // Show error about missing required fields log::error!( "Cannot generate asset tag: missing fields: {:?}", missing_fields ); return None; // Don't allow creation until all required fields are filled } } } else { // No generation string - asset tag is required field if let Some(tag) = asset_data.get("asset_tag").and_then(|v| v.as_str()) { if tag.trim().is_empty() { log::error!("Asset tag is required when template has no generation string"); return None; } } else { log::error!("Asset tag is required when template has no generation string"); return None; } } } else { log::warn!("No asset_tag_generation_string found in template"); } log::info!( "Asset validation successful, final data: {}", serde_json::to_string_pretty(&asset_data) .unwrap_or_else(|_| "failed to serialize".to_string()) ); Some(asset_data) } /// Generate partial asset tag (showing what we can resolve, leaving placeholders for missing fields) fn generate_partial_asset_tag( &self, api_client: &ApiClient, asset_data: &Value, generation_string: &str, ) -> Result> { let mut result = generation_string.to_string(); log::info!( "Available asset_data keys: {:?}", asset_data.as_object().map(|o| o.keys().collect::>()) ); // Get current date/time let now = Utc::now(); // Find all placeholders in the generation string let re = Regex::new(r"\{([^}]+)\}").unwrap(); for cap in re.captures_iter(generation_string) { let placeholder = &cap[1]; // Generate replacement value as owned string - only replace if we have a value let replacement_value = match placeholder { // Location-based placeholders "BUILDINGCODE" => asset_data .get("building_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), "FLOORCODE" => asset_data .get("floor_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), "ROOMCODE" => asset_data .get("room_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), "ZONECODE" => asset_data .get("zone_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), "FULLZONECODE" => asset_data .get("zone_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), // Category-based placeholders "CATEGORYCODE" => asset_data .get("category_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), // Asset-based placeholders "ASSETTYPE" => asset_data .get("asset_type") .and_then(|v| v.as_str()) .map(|s| s.to_string()), "MANUFACTURER" => asset_data .get("manufacturer") .and_then(|v| v.as_str()) .map(|s| s.replace(" ", "_")), "MODEL" => asset_data .get("model") .and_then(|v| v.as_str()) .map(|s| s.replace(" ", "_")), // Date/Time placeholders - these always work "YEAR" => Some(now.format("%Y").to_string()), "MONTH" => Some(now.format("%m").to_string()), "DAY" => Some(now.format("%d").to_string()), "YEARSHORT" => Some(now.format("%y").to_string()), // Special placeholders "SERIAL" => asset_data .get("serial_number") .and_then(|v| v.as_str()) .map(|s| s.to_string()), "RANDOM4" => Some(format!("{:04}", rand::thread_rng().gen_range(0..10000))), "RANDOM6" => Some(format!("{:06}", rand::thread_rng().gen_range(0..1000000))), // Counter placeholders "ZONEASC" => self.get_next_zone_counter(api_client, asset_data), "GLOBALASC" => self.get_next_global_counter(api_client, asset_data), // Fallback: try direct field lookup _ => asset_data .get(placeholder) .and_then(|v| v.as_str()) .map(|s| s.to_string()), }; // Only replace if we have a valid value if let Some(value) = replacement_value { if !value.trim().is_empty() { log::info!("Replacing {{{}}} with '{}'", placeholder, value); result = result.replace(&format!("{{{}}}", placeholder), &value); } else { log::warn!( "Placeholder {{{}}} has empty value, leaving as placeholder", placeholder ); } } else { log::warn!( "No value found for placeholder {{{}}}, leaving as placeholder", placeholder ); } // If no value, leave the placeholder as-is in the result } Ok(result) } /// Generate asset tag from template's generation string fn generate_asset_tag( &self, api_client: &ApiClient, asset_data: &Value, generation_string: &str, ) -> Result> { let mut result = generation_string.to_string(); let mut missing_fields = Vec::new(); // Get current date/time let now = Utc::now(); // Find all placeholders in the generation string let re = Regex::new(r"\{([^}]+)\}").unwrap(); for cap in re.captures_iter(generation_string) { let placeholder = &cap[1]; // Generate replacement value as owned string let replacement_value = match placeholder { // Location-based placeholders "BUILDINGCODE" => asset_data .get("building_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), "FLOORCODE" => asset_data .get("floor_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), "ROOMCODE" => asset_data .get("room_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), "ZONECODE" => asset_data .get("zone_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), "FULLZONECODE" => asset_data .get("zone_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), // Category-based placeholders "CATEGORYCODE" => asset_data .get("category_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), // Asset-based placeholders "ASSETTYPE" => asset_data .get("asset_type") .and_then(|v| v.as_str()) .map(|s| s.to_string()), "MANUFACTURER" => asset_data .get("manufacturer") .and_then(|v| v.as_str()) .map(|s| s.replace(" ", "_")), "MODEL" => asset_data .get("model") .and_then(|v| v.as_str()) .map(|s| s.replace(" ", "_")), // Date/Time placeholders "YEAR" => Some(now.format("%Y").to_string()), "MONTH" => Some(now.format("%m").to_string()), "DAY" => Some(now.format("%d").to_string()), "YEARSHORT" => Some(now.format("%y").to_string()), // Special placeholders "SERIAL" => asset_data .get("serial_number") .and_then(|v| v.as_str()) .map(|s| s.to_string()), "RANDOM4" => Some(format!("{:04}", rand::thread_rng().gen_range(0..10000))), "RANDOM6" => Some(format!("{:06}", rand::thread_rng().gen_range(0..1000000))), // Counter placeholders "ZONEASC" => { // Get next counter for zone+category combination self.get_next_zone_counter(api_client, asset_data) } "GLOBALASC" => { // Get next global counter for category self.get_next_global_counter(api_client, asset_data) } // Fallback: try direct field lookup _ => asset_data .get(placeholder) .and_then(|v| v.as_str()) .map(|s| s.to_string()), }; match replacement_value { Some(value) if !value.trim().is_empty() => { result = result.replace(&format!("{{{}}}", placeholder), &value); } _ => { // For counter placeholders, treat as missing (TODO items) if placeholder.starts_with("ZONEASC") || placeholder.starts_with("GLOBALASC") { // Skip - already handled above } else if matches!(placeholder, "BUILDINGCODE" | "FLOORCODE" | "ROOMCODE") { // These are often missing in templates, use placeholder values let placeholder_value = match placeholder { "BUILDINGCODE" => "BLD", "FLOORCODE" => "00", "ROOMCODE" => "00", _ => "UNK", }; result = result.replace(&format!("{{{}}}", placeholder), placeholder_value); log::warn!( "Using placeholder '{}' for missing field {}", placeholder_value, placeholder ); } else { // Other missing fields are required missing_fields.push(placeholder.to_string()); } } } } if missing_fields.is_empty() { Ok(result) } else { Err(missing_fields) } } /// Reset for next item in multiple mode fn reset_for_next_item(&mut self, api_client: &ApiClient) { if let Some(template) = self.selected_template.clone() { self.prepare_asset_editor(&template, api_client); } } /// Cancel the workflow pub fn cancel(&mut self) { self.is_active = false; self.template_selector.is_open = false; self.asset_editor = None; self.selected_template = None; self.is_multiple_mode = false; self.asset_tag_confirmation = None; // Don't reset skip_confirmation_unless_error - let user preference persist } /// Get next zone-based counter (ZONEASC) for assets in same zone and category fn get_next_zone_counter(&self, api_client: &ApiClient, asset_data: &Value) -> Option { // Determine next ascending number for assets in the same zone and category // Uses: COUNT(*) WHERE zone_id = ? AND category_id = ? let zone_id = asset_data .get("zone_id") .and_then(|v| v.as_i64()) .or_else(|| { asset_data .get("zone_id") .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()) }); let category_id = asset_data .get("category_id") .and_then(|v| v.as_i64()) .or_else(|| { asset_data .get("category_id") .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()) }); let (zone_id, category_id) = match (zone_id, category_id) { (Some(z), Some(c)) => (z, c), _ => return None, }; let where_clause = serde_json::json!({ "zone_id": zone_id, "category_id": category_id }); log::info!( "Calculating ZONEASC with where: zone_id={}, category_id={}", zone_id, category_id ); match api_client.count("assets", Some(where_clause)) { Ok(resp) if resp.success => { let current = resp.data.unwrap_or(0); let next = (current as i64) + 1; // pad to 2 digits minimum Some(format!("{:02}", next)) } Ok(resp) => { log::error!("Failed to count ZONEASC: {:?}", resp.error); None } Err(e) => { log::error!("API error counting ZONEASC: {}", e); None } } } /// Get next global counter (GLOBALASC) for assets in same category fn get_next_global_counter( &self, api_client: &ApiClient, asset_data: &Value, ) -> Option { // Determine next ascending number for assets in the same category (global) let category_id = asset_data .get("category_id") .and_then(|v| v.as_i64()) .or_else(|| { asset_data .get("category_id") .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()) }); let category_id = match category_id { Some(c) => c, None => return None, }; let where_clause = serde_json::json!({ "category_id": category_id }); log::info!( "Calculating GLOBALASC with where: category_id={}", category_id ); match api_client.count("assets", Some(where_clause)) { Ok(resp) if resp.success => { let current = resp.data.unwrap_or(0); let next = (current as i64) + 1; // pad to 3 digits minimum for global Some(format!("{:03}", next)) } Ok(resp) => { log::error!("Failed to count GLOBALASC: {:?}", resp.error); None } Err(e) => { log::error!("API error counting GLOBALASC: {}", e); None } } } /// Get zone hierarchy information (building, floor, room codes) by walking up the zone tree fn get_zone_hierarchy( &self, api_client: &ApiClient, zone_id: i64, ) -> Option> { use std::collections::HashMap; let mut hierarchy = HashMap::new(); let mut current_zone_id = zone_id; // Walk up the zone hierarchy to collect codes for depth in 0..10 { // Prevent infinite loops log::debug!( "Zone hierarchy depth {}: looking up zone_id {}", depth, current_zone_id ); match self.get_zone_info(api_client, current_zone_id) { Some(zone_info) => { log::debug!( "Found zone info: {}", serde_json::to_string_pretty(&zone_info) .unwrap_or_else(|_| "failed to serialize".to_string()) ); let zone_type = zone_info .get("zone_type") .and_then(|v| v.as_str()) .unwrap_or(""); let zone_code_full = zone_info .get("zone_code") .and_then(|v| v.as_str()) .unwrap_or(""); // Backward-compatible: if mini_code missing (pre-migration), fall back to zone_code let mini_code = zone_info .get("mini_code") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .unwrap_or(zone_code_full); log::info!( "Zone {} (type: {}) has mini_code='{}' full_code='{}'", current_zone_id, zone_type, mini_code, zone_code_full ); if depth == 0 { if !zone_code_full.is_empty() { hierarchy .insert("full_zone_code".to_string(), zone_code_full.to_string()); } } match zone_type { "Building" => { hierarchy.insert("building_code".to_string(), mini_code.to_string()); log::info!("Added building_code (mini): {}", mini_code); } "Floor" => { hierarchy.insert("floor_code".to_string(), mini_code.to_string()); log::info!("Added floor_code (mini): {}", mini_code); } "Room" => { hierarchy.insert("room_code".to_string(), mini_code.to_string()); log::info!("Added room_code (mini): {}", mini_code); } _ => { log::warn!( "Unknown zone type '{}' for zone {}", zone_type, current_zone_id ); } } // Move to parent zone if let Some(parent_id) = zone_info.get("parent_id").and_then(|v| v.as_i64()) { current_zone_id = parent_id; } else { break; // No parent, reached root } } None => { log::error!("Failed to get zone info for zone_id: {}", current_zone_id); break; // Zone not found } } } Some(hierarchy) } /// Get zone information by ID fn get_zone_info(&self, api_client: &ApiClient, zone_id: i64) -> Option { let columns = Some(vec![ "id".to_string(), "zone_code".to_string(), "mini_code".to_string(), "zone_type".to_string(), "parent_id".to_string(), ]); let where_clause = Some(serde_json::json!({"id": zone_id})); log::debug!( "Querying zones table for zone_id: {} with columns: {:?}", zone_id, columns ); match api_client.select("zones", columns, where_clause, None, Some(1)) { Ok(resp) => { log::debug!( "Zone query response success: {}, data: {:?}", resp.success, resp.data ); if resp.success { resp.data.and_then(|data| data.into_iter().next()) } else { log::error!( "Zone query failed: {}", resp.message.unwrap_or_else(|| "Unknown error".to_string()) ); None } } Err(e) => { log::error!("Zone query API error: {}", e); None } } } /// Get category code by category ID fn get_category_code(&self, api_client: &ApiClient, category_id: i64) -> Option { let columns = Some(vec!["id".to_string(), "category_code".to_string()]); let where_clause = Some(serde_json::json!({"id": category_id})); log::debug!( "Querying categories table for category_id: {} with columns: {:?}", category_id, columns ); match api_client.select("categories", columns, where_clause, None, Some(1)) { Ok(resp) => { log::debug!( "Category query response success: {}, data: {:?}", resp.success, resp.data ); if resp.success { resp.data .and_then(|data| data.into_iter().next()) .and_then(|category| { category .get("category_code") .and_then(|v| v.as_str().map(|s| s.to_string())) }) } else { log::error!( "Category query failed: {}", resp.message.unwrap_or_else(|| "Unknown error".to_string()) ); None } } Err(e) => { log::error!("Category query API error: {}", e); None } } } /// Prepare asset tag confirmation dialog fn prepare_asset_tag_confirmation(&mut self, mut asset_data: Value, api_client: &ApiClient) { let template = match self.selected_template.as_ref() { Some(t) => t, None => { log::error!("No template selected for asset tag confirmation"); return; } }; log::info!("Preparing asset tag confirmation with full asset data"); // Extract zone hierarchy NOW that we have the actual zone_id from the user's selection let zone_id_parsed = asset_data.get("zone_id").and_then(|v| { // Handle both string and integer zone_id values if let Some(id_int) = v.as_i64() { Some(id_int) } else if let Some(id_str) = v.as_str() { id_str.parse::().ok() } else { None } }); if let Some(zone_id) = zone_id_parsed { log::info!( "Asset has zone_id: {}, extracting zone hierarchy for tag generation", zone_id ); if let Some(zone_hierarchy) = self.get_zone_hierarchy(api_client, zone_id) { log::info!( "Successfully extracted zone hierarchy for asset: {:?}", zone_hierarchy ); if let Some(building_code) = zone_hierarchy.get("building_code") { asset_data["building_code"] = Value::String(building_code.clone()); log::info!("Set building_code to: {}", building_code); } if let Some(floor_code) = zone_hierarchy.get("floor_code") { asset_data["floor_code"] = Value::String(floor_code.clone()); log::info!("Set floor_code to: {}", floor_code); } if let Some(room_code) = zone_hierarchy.get("room_code") { asset_data["room_code"] = Value::String(room_code.clone()); log::info!("Set room_code to: {}", room_code); } if let Some(full_zone_code) = zone_hierarchy.get("full_zone_code") { // Ensure ZONECODE/FULLZONECODE map to the full path asset_data["zone_code"] = Value::String(full_zone_code.clone()); log::info!("Set zone_code (full) to: {}", full_zone_code); } } else { log::error!( "Failed to extract zone hierarchy for asset zone_id: {}", zone_id ); } } else { log::warn!("Asset has no zone_id set, cannot extract zone hierarchy"); } // Also ensure category_code is available from the asset's category_id let category_id_parsed = asset_data.get("category_id").and_then(|v| { // Handle both string and integer category_id values if let Some(id_int) = v.as_i64() { Some(id_int) } else if let Some(id_str) = v.as_str() { id_str.parse::().ok() } else { None } }); if let Some(category_id) = category_id_parsed { if let Some(category_code) = self.get_category_code(api_client, category_id) { asset_data["category_code"] = Value::String(category_code.clone()); log::info!( "Set category_code from category_id {}: {}", category_id, category_code ); } else { log::error!( "Failed to get category_code for category_id: {}", category_id ); } } let mut generated_tag = String::new(); let mut generation_errors = Vec::new(); let skip_unless_error = self.skip_confirmation_unless_error; // Check if asset tag was manually filled let asset_tag_manually_set = asset_data .get("asset_tag") .and_then(|v| v.as_str()) .map_or(false, |s| !s.trim().is_empty()); // Try to generate asset tag if not manually set if !asset_tag_manually_set { if let Some(generation_string) = template .get("asset_tag_generation_string") .and_then(|v| v.as_str()) { if !generation_string.is_empty() { match self.generate_asset_tag(api_client, &asset_data, generation_string) { Ok(tag) => { generated_tag = tag; asset_data["asset_tag"] = Value::String(generated_tag.clone()); } Err(errors) => { generation_errors = errors; // Generate partial tag showing what we could resolve match self.generate_partial_asset_tag( api_client, &asset_data, generation_string, ) { Ok(partial_tag) => { generated_tag = partial_tag; log::warn!( "Generated partial asset tag due to missing fields: {}", generated_tag ); } Err(_) => { generated_tag = generation_string.to_string(); // Fallback to original template } } } } } } } // Show confirmation dialog if: // 1. Asset tag wasn't manually set AND generation failed, OR // 2. Skip unless error is unchecked let should_show_dialog = (!asset_tag_manually_set && !generation_errors.is_empty()) || !skip_unless_error; if should_show_dialog { self.asset_tag_confirmation = Some(AssetTagConfirmation { asset_data: asset_data.clone(), generated_tag: generated_tag.clone(), edited_tag: generated_tag, is_open: true, generation_errors, }); } else { // Skip dialog - create confirmation that immediately returns the asset data self.asset_tag_confirmation = Some(AssetTagConfirmation { asset_data: asset_data.clone(), generated_tag: generated_tag.clone(), edited_tag: generated_tag, is_open: false, // Don't show dialog, just return data immediately generation_errors, }); log::info!("Skipping asset tag confirmation dialog - no errors and skip_unless_error is enabled"); } } } impl TemplateSelector { fn new() -> Self { Self { templates: Vec::new(), filter_text: String::new(), selected_index: None, is_open: false, is_loading: false, error_message: None, } } fn load_templates(&mut self, api_client: &ApiClient) { self.is_loading = true; self.error_message = None; // Load templates from API match crate::core::tables::get_templates(api_client, None) { Ok(templates) => { self.templates = templates; self.is_loading = false; } Err(e) => { self.error_message = Some(format!("Failed to load templates: {}", e)); self.is_loading = false; } } } /// Show template selector dialog /// Returns Some(template) if selected, None if cancelled or still selecting fn show(&mut self, ui: &mut egui::Ui, _api_client: &ApiClient) -> Option { let mut result = None; let mut close_dialog = false; let _response = egui::Window::new("Select Template") .default_size([500.0, 400.0]) .open(&mut self.is_open) .show(ui.ctx(), |ui| { if self.is_loading { ui.spinner(); ui.label("Loading templates..."); return; } if let Some(ref error) = self.error_message { ui.colored_label(egui::Color32::RED, error); return; } // Search filter ui.horizontal(|ui| { ui.label("Search:"); ui.text_edit_singleline(&mut self.filter_text); }); ui.separator(); // Filter templates based on search let filtered_templates: Vec<(usize, &Value)> = self .templates .iter() .enumerate() .filter(|(_, template)| { if self.filter_text.is_empty() { return true; } let filter_lower = self.filter_text.to_lowercase(); // Search in template code, name, and description template .get("template_code") .and_then(|v| v.as_str()) .map_or(false, |s| s.to_lowercase().contains(&filter_lower)) || template .get("name") .and_then(|v| v.as_str()) .map_or(false, |s| s.to_lowercase().contains(&filter_lower)) || template .get("description") .and_then(|v| v.as_str()) .map_or(false, |s| s.to_lowercase().contains(&filter_lower)) }) .collect(); // Template list egui::ScrollArea::vertical().show(ui, |ui| { for (original_index, template) in filtered_templates { let template_code = template .get("template_code") .and_then(|v| v.as_str()) .unwrap_or(""); let template_name = template .get("name") .and_then(|v| v.as_str()) .unwrap_or("Unnamed Template"); let description = template .get("description") .and_then(|v| v.as_str()) .unwrap_or(""); let label = if !template_code.is_empty() { format!("{} - {}", template_code, template_name) } else { template_name.to_string() }; let is_selected = self.selected_index == Some(original_index); if ui.selectable_label(is_selected, &label).clicked() { self.selected_index = Some(original_index); } // Show description if available if !description.is_empty() { ui.indent("desc", |ui| { ui.small(description); }); } } }); ui.separator(); // Buttons ui.horizontal(|ui| { let can_select = self.selected_index.is_some() && self.selected_index.unwrap() < self.templates.len(); if ui .add_enabled(can_select, egui::Button::new("Select")) .clicked() { if let Some(index) = self.selected_index { result = Some(self.templates[index].clone()); close_dialog = true; } } if ui.button("Cancel").clicked() { close_dialog = true; } }); }); if close_dialog { self.is_open = false; } result } } impl AssetTagConfirmation { /// Show the asset tag confirmation dialog /// Returns Some(asset_data) if confirmed, None if still editing or cancelled fn show(&mut self, ui: &mut egui::Ui) -> Option { if !self.is_open { return None; } let mut result = None; let mut close_dialog = false; egui::Window::new("Confirm Asset Tag") .default_size([500.0, 400.0]) .resizable(true) .show(ui.ctx(), |ui| { ui.vertical(|ui| { ui.heading("Asset Tag Generation"); ui.add_space(10.0); // Show generation errors if any if !self.generation_errors.is_empty() { ui.colored_label(egui::Color32::RED, "⚠ Generation Errors:"); for error in &self.generation_errors { ui.colored_label(egui::Color32::RED, format!("• {}", error)); } ui.add_space(10.0); } // Asset tag input ui.horizontal(|ui| { ui.label("Asset Tag:"); ui.text_edit_singleline(&mut self.edited_tag); }); if !self.generated_tag.is_empty() && self.generation_errors.is_empty() { ui.small(format!("Generated: {}", self.generated_tag)); } ui.add_space(20.0); // Buttons ui.horizontal(|ui| { if ui.button("Create Asset").clicked() { // Update asset data with edited tag let mut final_asset_data = self.asset_data.clone(); final_asset_data["asset_tag"] = Value::String(self.edited_tag.clone()); result = Some(final_asset_data); close_dialog = true; } if ui.button("Cancel").clicked() { close_dialog = true; } }); }); }); if close_dialog { self.is_open = false; } result } }