aboutsummaryrefslogtreecommitdiff
path: root/src/core/workflows/add_from_template.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/workflows/add_from_template.rs')
-rw-r--r--src/core/workflows/add_from_template.rs1488
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
+ }
+}