diff options
Diffstat (limited to 'src/core/workflows/add_from_template.rs')
| -rw-r--r-- | src/core/workflows/add_from_template.rs | 1488 |
1 files changed, 1488 insertions, 0 deletions
diff --git a/src/core/workflows/add_from_template.rs b/src/core/workflows/add_from_template.rs new file mode 100644 index 0000000..d1028c6 --- /dev/null +++ b/src/core/workflows/add_from_template.rs @@ -0,0 +1,1488 @@ +/* + * 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<FormBuilder>, + /// Current selected template data + selected_template: Option<Value>, + /// 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<AssetTagConfirmation>, + /// 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<String>, +} + +/// Template selector component +struct TemplateSelector { + /// Available templates + templates: Vec<Value>, + /// Filter text for searching templates + filter_text: String, + /// Currently selected template index + selected_index: Option<usize>, + /// Whether the selector dialog is open + is_open: bool, + /// Loading state + is_loading: bool, + /// Error message if any + error_message: Option<String>, +} + +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<Value> { + 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<Value> { + 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<String, Vec<String>> { + let mut result = generation_string.to_string(); + + log::info!( + "Available asset_data keys: {:?}", + asset_data.as_object().map(|o| o.keys().collect::<Vec<_>>()) + ); + + // 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<String, Vec<String>> { + 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<String> { + // 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::<i64>().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::<i64>().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<String> { + // 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::<i64>().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<std::collections::HashMap<String, String>> { + 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<serde_json::Value> { + 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<String> { + 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::<i64>().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::<i64>().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<Value> { + 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<Value> { + 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 + } +} |
