From 8323fdd73272a2882781aba3c499ba0be3dff2a6 Mon Sep 17 00:00:00 2001 From: UMTS at Teleco Date: Sat, 13 Dec 2025 02:51:15 +0100 Subject: committing to insanity --- src/core/workflows/add_from_template.rs | 1488 ++++++++++++++++++++++++++ src/core/workflows/audit.rs | 1719 +++++++++++++++++++++++++++++++ src/core/workflows/borrow_flow.rs | 1450 ++++++++++++++++++++++++++ src/core/workflows/mod.rs | 9 + src/core/workflows/return_flow.rs | 924 +++++++++++++++++ 5 files changed, 5590 insertions(+) create mode 100644 src/core/workflows/add_from_template.rs create mode 100644 src/core/workflows/audit.rs create mode 100644 src/core/workflows/borrow_flow.rs create mode 100644 src/core/workflows/mod.rs create mode 100644 src/core/workflows/return_flow.rs (limited to 'src/core/workflows') 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, + /// 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 + } +} diff --git a/src/core/workflows/audit.rs b/src/core/workflows/audit.rs new file mode 100644 index 0000000..69ae733 --- /dev/null +++ b/src/core/workflows/audit.rs @@ -0,0 +1,1719 @@ +use std::collections::HashMap; +use std::convert::TryFrom; + +use anyhow::{anyhow, Context, Result}; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine as _; +use chrono::{DateTime, Utc}; +use eframe::egui; +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +use crate::api::ApiClient; +use crate::core::components::interactions::ConfirmDialog; +use crate::core::tables::{ + find_asset_by_tag_or_numeric, find_zone_by_code, get_assets_in_zone, get_audit_task_definition, +}; + +const STATUS_OPTIONS: &[&str] = &[ + "Good", + "Attention", + "Faulty", + "Missing", + "Retired", + "In Repair", + "In Transit", + "Expired", + "Unmanaged", +]; + +const EXCEPTION_WRONG_ZONE: &str = "wrong-zone"; +const EXCEPTION_UNEXPECTED_ASSET: &str = "unexpected-asset"; +const EXCEPTION_OTHER: &str = "other"; +const DEFAULT_MISSING_DETAIL: &str = "Marked missing during audit"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuditMode { + FullZone, + SpotCheck, +} + +#[derive(Debug, Clone)] +struct ZoneInfo { + id: i64, + zone_code: Option, + zone_name: String, + _zone_type: Option, + audit_timeout_minutes: Option, +} + +impl ZoneInfo { + fn from_value(value: &Value) -> Result { + let id = value + .get("id") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("Zone record missing id"))?; + let zone_name = value + .get("zone_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok(Self { + id, + zone_code: value + .get("zone_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + zone_name, + _zone_type: value + .get("zone_type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + audit_timeout_minutes: value.get("audit_timeout_minutes").and_then(|v| v.as_i64()), + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AuditScanPolicy { + Required, + Ask, + Skip, +} + +impl AuditScanPolicy { + fn from_value(value: Option<&Value>) -> Self { + match value.and_then(|v| v.as_str()).map(|s| s.to_lowercase()) { + Some(ref s) if s == "yes" => AuditScanPolicy::Skip, + Some(ref s) if s == "ask" => AuditScanPolicy::Ask, + _ => AuditScanPolicy::Required, + } + } +} + +#[derive(Debug, Clone)] +struct AuditAssetState { + asset_id: i64, + asset_numeric_id: Option, + asset_tag: String, + name: String, + _status_before: Option, + scan_policy: AuditScanPolicy, + audit_task_id: Option, + expected: bool, + _expected_zone_id: Option, + _actual_zone_id: Option, + scanned: bool, + status_found: String, + notes: String, + task_responses: Option, + additional_fields: Map, + exception_type: Option, + exception_details: Option, + completed_at: Option>, +} + +impl AuditAssetState { + fn from_value(value: Value, expected_zone_id: Option, expected: bool) -> Result { + let asset_id = value + .get("id") + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow!("Asset record missing id"))?; + let asset_tag = value + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let name = value + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unnamed Asset") + .to_string(); + let status_before = value + .get("status") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let scan_policy = AuditScanPolicy::from_value(value.get("no_scan")); + let status_found = status_before.clone().unwrap_or_else(|| "Good".to_string()); + Ok(Self { + asset_id, + asset_numeric_id: value.get("asset_numeric_id").and_then(|v| v.as_i64()), + asset_tag, + name, + _status_before: status_before, + scan_policy, + audit_task_id: value.get("audit_task_id").and_then(|v| v.as_i64()), + expected, + _expected_zone_id: expected_zone_id, + _actual_zone_id: value.get("zone_id").and_then(|v| v.as_i64()), + scanned: matches!(scan_policy, AuditScanPolicy::Skip), + status_found, + notes: String::new(), + task_responses: None, + additional_fields: Map::new(), + exception_type: None, + exception_details: None, + completed_at: if matches!(scan_policy, AuditScanPolicy::Skip) { + Some(Utc::now()) + } else { + None + }, + }) + } + + fn requires_scan(&self) -> bool { + self.expected && matches!(self.scan_policy, AuditScanPolicy::Required) + } + + fn matches_identifier(&self, identifier: &str) -> bool { + let normalized = identifier.trim().to_lowercase(); + if normalized.is_empty() { + return false; + } + let tag_match = !self.asset_tag.is_empty() && self.asset_tag.to_lowercase() == normalized; + let numeric_match = self + .asset_numeric_id + .map(|n| n.to_string() == normalized) + .unwrap_or(false); + tag_match || numeric_match + } + + fn display_label(&self, mode: AuditMode) -> String { + let mut label = format!("{} — {}", self.asset_tag, self.name); + if !self.expected { + label.push_str(match mode { + AuditMode::FullZone => " (unexpected)", + AuditMode::SpotCheck => " (spot check)", + }); + } + if matches!(self.scan_policy, AuditScanPolicy::Ask) && !self.scanned { + label.push_str(" (confirm)"); + } else if !self.requires_scan() { + label.push_str(" (auto)"); + } else if !self.scanned { + label.push_str(" (pending)"); + } + label + } + + fn set_status(&mut self, status: &str, mark_scanned: bool) { + self.status_found = status.to_string(); + if mark_scanned { + self.scanned = true; + self.completed_at = Some(Utc::now()); + } + if status == "Missing" { + if matches!(self.scan_policy, AuditScanPolicy::Ask) && !self.scanned { + // Leave confirmation-driven assets pending until explicitly handled + self.status_found = "Missing".to_string(); + self.scanned = false; + self.completed_at = None; + return; + } + + self.exception_type = Some(EXCEPTION_OTHER.to_string()); + if self + .exception_details + .as_deref() + .map(|d| d == DEFAULT_MISSING_DETAIL) + .unwrap_or(true) + { + self.exception_details = Some(DEFAULT_MISSING_DETAIL.to_string()); + } + } else if self + .exception_type + .as_deref() + .map(|t| t == EXCEPTION_OTHER) + .unwrap_or(false) + && self + .exception_details + .as_deref() + .map(|d| d == DEFAULT_MISSING_DETAIL) + .unwrap_or(false) + { + self.exception_type = None; + self.exception_details = None; + } + } +} + +#[derive(Debug, Clone)] +struct TaskRunnerState { + asset_index: usize, + runner: AuditTaskRunner, +} + +#[derive(Debug, Clone)] +struct AuditTaskOutcome { + status_override: Option, + additional_fields: Map, + responses: Value, +} + +#[derive(Debug, Clone)] +struct TaskResponseEntry { + step: i64, + question: String, + answer: Value, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct AuditCompletion { + pub audit_id: i64, + pub status: String, +} + +#[derive(Debug, Clone, Copy)] +enum PendingFinalizeIntent { + FromButton { needs_force: bool }, + FromDialog { force_missing: bool }, +} + +pub struct AuditWorkflow { + is_open: bool, + mode: AuditMode, + zone_info: Option, + expected_assets: Vec, + selected_asset: Option, + scan_input: String, + notes: String, + audit_name: String, + started_at: Option>, + timeout_minutes: Option, + last_error: Option, + ask_dialog: ConfirmDialog, + pending_ask_index: Option, + cancel_dialog: ConfirmDialog, + finalize_dialog: ConfirmDialog, + current_task_runner: Option, + cached_tasks: HashMap, + has_recent_completion: bool, + completion_snapshot: Option, + user_id: Option, + pending_finalize: Option, +} + +impl AuditWorkflow { + pub fn new() -> Self { + Self { + is_open: false, + mode: AuditMode::FullZone, + zone_info: None, + expected_assets: Vec::new(), + selected_asset: None, + scan_input: String::new(), + notes: String::new(), + audit_name: String::new(), + started_at: None, + timeout_minutes: None, + last_error: None, + ask_dialog: ConfirmDialog::new( + "Confirm Asset", + "This asset is marked as 'Ask'. Confirm to include it in the audit progress.", + ) + .dangerous(false) + .confirm_text("Confirm") + .cancel_text("Skip"), + pending_ask_index: None, + cancel_dialog: ConfirmDialog::new( + "Cancel Audit", + "Are you sure you want to cancel the current audit? Progress will be lost.", + ) + .dangerous(true) + .confirm_text("Cancel Audit") + .cancel_text("Keep Working"), + finalize_dialog: ConfirmDialog::new( + "Complete Audit", + "Some required assets have not been scanned. They will be marked as Missing if you continue.", + ) + .dangerous(true) + .confirm_text("Mark Missing & Complete") + .cancel_text("Go Back"), + current_task_runner: None, + cached_tasks: HashMap::new(), + has_recent_completion: false, + completion_snapshot: None, + user_id: None, + pending_finalize: None, + } + } + + pub fn is_active(&self) -> bool { + self.is_open + } + + pub fn start_zone_audit( + &mut self, + api_client: &ApiClient, + zone_code: &str, + user_id: i64, + ) -> Result<()> { + let zone_value = find_zone_by_code(api_client, zone_code)? + .ok_or_else(|| anyhow!("Zone '{}' was not found", zone_code))?; + let zone = ZoneInfo::from_value(&zone_value)?; + let zone_id = i32::try_from(zone.id).context("Zone identifier exceeds i32 range")?; + let raw_assets = get_assets_in_zone(api_client, zone_id, Some(1_000))?; + + let mut assets = Vec::with_capacity(raw_assets.len()); + for value in raw_assets { + let mut state = AuditAssetState::from_value(value, Some(zone.id), true)?; + if matches!(state.scan_policy, AuditScanPolicy::Skip) { + state.completed_at = Some(Utc::now()); + } + assets.push(state); + } + + self.reset_core_state(); + self.has_recent_completion = false; + self.completion_snapshot = None; + self.is_open = true; + self.mode = AuditMode::FullZone; + self.zone_info = Some(zone.clone()); + self.expected_assets = assets; + self.started_at = Some(Utc::now()); + self.timeout_minutes = zone.audit_timeout_minutes; + self.audit_name = format!("Zone {} Audit", zone.zone_name); + self.user_id = Some(user_id); + self.last_error = None; + self.ensure_skip_assets_recorded(); + Ok(()) + } + + pub fn start_spot_check(&mut self, user_id: i64) { + self.reset_core_state(); + self.has_recent_completion = false; + self.completion_snapshot = None; + self.is_open = true; + self.mode = AuditMode::SpotCheck; + self.audit_name = format!("Spot Check {}", Utc::now().format("%Y-%m-%d %H:%M")); + self.started_at = Some(Utc::now()); + self.user_id = Some(user_id); + self.last_error = None; + } + + pub fn show(&mut self, ctx: &egui::Context, api_client: &ApiClient) -> bool { + if !self.is_open { + return false; + } + + let mut keep_open = self.is_open; + let window_title = match self.mode { + AuditMode::FullZone => "Zone Audit", + AuditMode::SpotCheck => "Spot Check", + }; + + let screen_rect = ctx.input(|i| { + i.viewport().inner_rect.unwrap_or(egui::Rect::from_min_max( + egui::pos2(0.0, 0.0), + egui::pos2(1920.0, 1080.0), + )) + }); + let mut max_size = screen_rect.size() - egui::vec2(32.0, 32.0); + max_size.x = max_size.x.max(860.0).min(screen_rect.width()); + max_size.y = max_size.y.max(520.0).min(screen_rect.height()); + let mut default_size = egui::vec2(1040.0, 680.0); + default_size.x = default_size.x.min(max_size.x); + default_size.y = default_size.y.min(max_size.y); + + egui::Window::new(window_title) + .id(egui::Id::new("audit_workflow_window")) + .collapsible(false) + .resizable(true) + .default_size(default_size) + .max_size(max_size) + .min_size(egui::vec2(820.0, 520.0)) + .open(&mut keep_open) + .show(ctx, |ui| { + if let Some(zone) = &self.zone_info { + ui.horizontal(|ui| { + ui.heading(format!( + "Auditing {} ({})", + zone.zone_name, + zone.zone_code.as_deref().unwrap_or("no-code") + )); + if let Some(timeout) = zone.audit_timeout_minutes { + ui.add_space(12.0); + ui.label(format!("Timeout: {} min", timeout)); + } + }); + } else { + ui.heading(&self.audit_name); + } + + if let Some(err) = &self.last_error { + ui.add_space(8.0); + ui.colored_label(egui::Color32::RED, err); + } + + ui.add_space(8.0); + self.render_scanning(ui, ctx, api_client); + }); + + if !keep_open { + self.cancel_without_saving(); + } + + if let Some(result) = self.ask_dialog.show_dialog(ctx) { + self.process_ask_dialog(result, api_client); + } + + if let Some(result) = self.cancel_dialog.show_dialog(ctx) { + if result { + match self.cancel_audit(api_client) { + Ok(()) => { + keep_open = false; + } + Err(err) => { + self.last_error = Some(err.to_string()); + } + } + } + } + + if let Some(result) = self.finalize_dialog.show_dialog(ctx) { + if result { + if self.trigger_pending_ask(PendingFinalizeIntent::FromDialog { + force_missing: true, + }) { + // Ask dialog opened; finalize will continue after confirmations. + } else if let Err(err) = self.finalize_audit(api_client, true) { + self.last_error = Some(err.to_string()); + } + } + } + + if let Some(mut state) = self.current_task_runner.take() { + if let Some(outcome) = state.runner.show(ctx) { + self.apply_task_outcome(state.asset_index, outcome); + } else if state.runner.is_open() { + self.current_task_runner = Some(state); + } + } + + if !self.is_open { + keep_open = false; + } + self.is_open = keep_open; + keep_open + } + + pub fn take_recent_completion(&mut self) -> Option { + if self.has_recent_completion { + self.has_recent_completion = false; + self.completion_snapshot.take() + } else { + None + } + } + + fn reset_core_state(&mut self) { + self.is_open = false; + self.zone_info = None; + self.expected_assets.clear(); + self.selected_asset = None; + self.scan_input.clear(); + self.notes.clear(); + self.audit_name.clear(); + self.started_at = None; + self.timeout_minutes = None; + self.last_error = None; + self.pending_ask_index = None; + self.current_task_runner = None; + self.user_id = None; + self.pending_finalize = None; + // Preserve cached_tasks so audit tasks are reused between runs + } + + fn cancel_without_saving(&mut self) { + self.reset_core_state(); + } + + fn cancel_audit(&mut self, api_client: &ApiClient) -> Result<()> { + if !self.is_open { + return Ok(()); + } + + if self.started_at.is_none() { + self.reset_core_state(); + return Ok(()); + } + + let user_id = self + .user_id + .ok_or_else(|| anyhow!("Missing current user id for audit session"))?; + let started_at = self.started_at.unwrap(); + let cancelled_at = Utc::now(); + + let required_total = self.required_total(); + let _scanned_total = self.expected_assets.iter().filter(|a| a.scanned).count(); + + let mut found_count = 0; + let mut missing_assets = Vec::new(); + let mut attention_assets = Vec::new(); + let mut exceptions = Vec::new(); + let mut unexpected_assets = Vec::new(); + + for asset in &self.expected_assets { + if !asset.scanned { + continue; + } + + if asset.expected && asset.requires_scan() { + if asset.status_found != "Missing" { + found_count += 1; + } else { + missing_assets.push(json!({ + "asset_id": asset.asset_id, + "asset_tag": asset.asset_tag, + "name": asset.name, + })); + } + + if asset.status_found != "Good" && asset.status_found != "Missing" { + attention_assets.push(json!({ + "asset_id": asset.asset_id, + "asset_tag": asset.asset_tag, + "name": asset.name, + "status": asset.status_found, + })); + } + } + + if let Some(ref exception) = asset.exception_type { + exceptions.push(json!({ + "asset_id": asset.asset_id, + "asset_tag": asset.asset_tag, + "name": asset.name, + "type": exception, + "details": asset.exception_details, + })); + } + + if !asset.expected { + unexpected_assets.push(json!({ + "asset_id": asset.asset_id, + "asset_tag": asset.asset_tag, + "name": asset.name, + "status": asset.status_found, + })); + } + } + + let mut issues = Map::new(); + if !missing_assets.is_empty() { + issues.insert("missing_assets".into(), Value::Array(missing_assets)); + } + if !attention_assets.is_empty() { + issues.insert("attention_assets".into(), Value::Array(attention_assets)); + } + if !exceptions.is_empty() { + issues.insert("exceptions".into(), Value::Array(exceptions)); + } + if !unexpected_assets.is_empty() { + issues.insert("unexpected_assets".into(), Value::Array(unexpected_assets)); + } + + let mut payload = Map::new(); + payload.insert( + "audit_type".into(), + Value::String(match self.mode { + AuditMode::FullZone => "full-zone".to_string(), + AuditMode::SpotCheck => "spot-check".to_string(), + }), + ); + if let Some(zone) = &self.zone_info { + payload.insert("zone_id".into(), json!(zone.id)); + } + if !self.audit_name.trim().is_empty() { + payload.insert("audit_name".into(), json!(self.audit_name.trim())); + } + payload.insert("started_by".into(), json!(user_id)); + payload.insert( + "started_at".into(), + json!(started_at.format("%Y-%m-%d %H:%M:%S").to_string()), + ); + payload.insert( + "completed_at".into(), + json!(cancelled_at.format("%Y-%m-%d %H:%M:%S").to_string()), + ); + payload.insert("status".into(), json!("cancelled")); + if let Some(timeout) = self.timeout_minutes { + payload.insert("timeout_minutes".into(), json!(timeout)); + } + if issues.is_empty() { + payload.insert("issues_found".into(), Value::Null); + } else { + payload.insert("issues_found".into(), Value::Object(issues)); + } + payload.insert("assets_expected".into(), json!(required_total as i64)); + payload.insert("assets_found".into(), json!(found_count as i64)); + if !self.notes.trim().is_empty() { + payload.insert("notes".into(), json!(self.notes.trim())); + } + let cancel_reason = if let Some(zone) = &self.zone_info { + format!( + "Audit cancelled for zone {} at {}", + zone.zone_name, + cancelled_at.format("%Y-%m-%d %H:%M:%S") + ) + } else { + format!( + "Spot check cancelled at {}", + cancelled_at.format("%Y-%m-%d %H:%M:%S") + ) + }; + payload.insert("cancelled_reason".into(), json!(cancel_reason)); + + let audit_insert = api_client.insert("physical_audits", Value::Object(payload))?; + if !audit_insert.success { + return Err(anyhow!( + "Failed to cancel audit session: {}", + audit_insert + .error + .unwrap_or_else(|| "unknown error".to_string()) + )); + } + let audit_id = audit_insert.data.unwrap_or(0) as i64; + + for asset in &self.expected_assets { + if !asset.scanned { + continue; + } + + let mut log_payload = Map::new(); + log_payload.insert("physical_audit_id".into(), json!(audit_id)); + log_payload.insert("asset_id".into(), json!(asset.asset_id)); + log_payload.insert("status_found".into(), json!(asset.status_found)); + if let Some(task_id) = asset.audit_task_id { + log_payload.insert("audit_task_id".into(), json!(task_id)); + } + if let Some(responses) = &asset.task_responses { + log_payload.insert("audit_task_responses".into(), responses.clone()); + } + if let Some(exception) = &asset.exception_type { + log_payload.insert("exception_type".into(), json!(exception)); + } + if let Some(details) = &asset.exception_details { + log_payload.insert("exception_details".into(), json!(details)); + } + if let Some(zone) = &self.zone_info { + log_payload.insert("found_in_zone_id".into(), json!(zone.id)); + } + if !asset.notes.trim().is_empty() { + log_payload.insert("notes".into(), json!(asset.notes.trim())); + } + let log_insert = + api_client.insert("physical_audit_logs", Value::Object(log_payload))?; + if !log_insert.success { + return Err(anyhow!( + "Failed to record cancellation log for asset {}", + asset.asset_tag + )); + } + } + + let completion = AuditCompletion { + audit_id, + status: "cancelled".to_string(), + }; + self.completion_snapshot = Some(completion); + self.has_recent_completion = true; + self.reset_core_state(); + Ok(()) + } + + fn ensure_skip_assets_recorded(&mut self) { + for asset in &mut self.expected_assets { + if !asset.scanned && matches!(asset.scan_policy, AuditScanPolicy::Skip) { + asset.scanned = true; + asset.completed_at = Some(Utc::now()); + } + } + } + + fn render_scanning(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, api_client: &ApiClient) { + let required_total = self.required_total(); + let completed_total = self.completed_total(); + let progress = if required_total > 0 { + completed_total as f32 / required_total as f32 + } else { + 0.0 + }; + let remaining_required = self.remaining_required(); + + ui.horizontal(|ui| { + ui.vertical(|ui| { + if required_total > 0 { + ui.add( + egui::ProgressBar::new(progress) + .text(format!("{}/{} processed", completed_total, required_total)) + .desired_width(320.0), + ); + } else { + ui.label("No required assets to scan"); + } + + if self.mode == AuditMode::FullZone && remaining_required > 0 { + ui.add_space(4.0); + ui.colored_label( + egui::Color32::from_rgb(255, 179, 0), + format!( + "{} required assets pending; finishing now marks them Missing.", + remaining_required + ), + ); + } + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut complete_button = ui.add(egui::Button::new("Complete Audit")); + if self.mode == AuditMode::FullZone && remaining_required > 0 { + complete_button = complete_button.on_hover_text(format!( + "{} required assets pending. Completing now will mark them as Missing.", + remaining_required + )); + } + if complete_button.clicked() { + let needs_force = self.mode == AuditMode::FullZone && remaining_required > 0; + if self.trigger_pending_ask(PendingFinalizeIntent::FromButton { needs_force }) { + // Ask dialog opened; completion will resume after confirmations. + } else if needs_force { + let name = format!("{} pending items", remaining_required); + let detail = "Unscanned assets will be marked Missing upon completion."; + self.finalize_dialog.open(name, detail); + } else if let Err(err) = self.finalize_audit(api_client, false) { + self.last_error = Some(err.to_string()); + } + } + if ui.button("Cancel Audit").clicked() { + if let Some(zone) = &self.zone_info { + self.cancel_dialog + .open(&zone.zone_name, zone.zone_code.clone().unwrap_or_default()); + } else { + self.cancel_dialog.open("Spot Check", &self.audit_name); + } + } + }); + }); + + ui.add_space(10.0); + ui.horizontal(|ui| { + let input = ui.add( + egui::TextEdit::singleline(&mut self.scan_input) + .hint_text("Scan asset tag or numeric ID") + .desired_width(260.0), + ); + let submitted = input.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if submitted { + if let Err(err) = self.handle_scan(api_client) { + self.last_error = Some(err.to_string()); + } + ctx.request_repaint(); + } + if ui.button("Submit").clicked() { + if let Err(err) = self.handle_scan(api_client) { + self.last_error = Some(err.to_string()); + } + ctx.request_repaint(); + } + if ui.button("Clear").clicked() { + self.scan_input.clear(); + } + }); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + ui.columns(2, |columns| { + let [left, right] = columns else { + return; + }; + + left.set_min_width(320.0); + left.set_max_width(360.0); + + left.heading("Assets"); + left.add_space(4.0); + let mut selection_change = None; + egui::ScrollArea::vertical() + .id_salt("audit_assets_scroll") + .auto_shrink([false; 2]) + .show(left, |ui| { + for idx in 0..self.expected_assets.len() { + let asset = &self.expected_assets[idx]; + let selected = self.selected_asset == Some(idx); + let label = asset.display_label(self.mode); + let response = ui.selectable_label(selected, label); + let response = if !asset.scanned && asset.requires_scan() { + response.on_hover_text("Pending scan") + } else { + response + }; + if response.clicked() { + selection_change = Some(idx); + } + } + }); + if let Some(idx) = selection_change { + self.selected_asset = Some(idx); + } + + right.set_min_width(right.available_width().max(420.0)); + right.heading("Details"); + right.add_space(4.0); + if let Some(idx) = self.selected_asset { + let mut run_task_clicked = None; + if let Some(asset) = self.expected_assets.get_mut(idx) { + right.label(format!("Asset Tag: {}", asset.asset_tag)); + right.label(format!("Name: {}", asset.name)); + if !asset.expected { + right.colored_label( + egui::Color32::from_rgb(255, 152, 0), + "Unexpected asset", + ); + } + if let Some(policy_text) = match asset.scan_policy { + AuditScanPolicy::Required => None, + AuditScanPolicy::Ask => Some("Requires confirmation"), + AuditScanPolicy::Skip => Some("Auto-completed"), + } { + right.label(policy_text); + } + + right.add_space(6.0); + let mut status_value = asset.status_found.clone(); + egui::ComboBox::from_label("Status") + .selected_text(&status_value) + .show_ui(right, |ui| { + for option in STATUS_OPTIONS { + ui.selectable_value(&mut status_value, option.to_string(), *option); + } + }); + if status_value != asset.status_found { + asset.set_status(&status_value, true); + } + + right.add_space(6.0); + right.label("Notes"); + right.add( + egui::TextEdit::multiline(&mut asset.notes) + .desired_rows(3) + .desired_width(right.available_width()) + .hint_text("Optional notes for this asset"), + ); + + right.add_space(6.0); + right.horizontal(|ui| { + if ui.button("Mark Good").clicked() { + asset.set_status("Good", true); + asset.exception_type = None; + asset.exception_details = None; + } + if ui.button("Mark Missing").clicked() { + asset.set_status("Missing", true); + asset.exception_type = Some(EXCEPTION_OTHER.to_string()); + asset.exception_details = Some(DEFAULT_MISSING_DETAIL.to_string()); + } + }); + + if let Some(task_id) = asset.audit_task_id { + right.add_space(6.0); + if right.button("Run Audit Task").clicked() { + run_task_clicked = Some(task_id); + } + } + + if asset.requires_scan() && !asset.scanned { + right.add_space(6.0); + if right.button("Mark Scanned").clicked() { + let current_status = asset.status_found.clone(); + asset.set_status(¤t_status, true); + } + } + } + + if let Some(task_id) = run_task_clicked { + if let Err(err) = self.launch_task_runner(idx, task_id, api_client) { + self.last_error = Some(err.to_string()); + } + } + } else { + right.label("Select an asset to see details."); + } + + right.add_space(12.0); + right.separator(); + right.add_space(8.0); + right.label("Audit Notes"); + right.add( + egui::TextEdit::multiline(&mut self.notes) + .desired_rows(4) + .desired_width(right.available_width()) + .hint_text("Optional notes for the entire audit"), + ); + }); + } + + fn required_total(&self) -> usize { + self.expected_assets + .iter() + .filter(|a| a.requires_scan()) + .count() + } + + fn completed_total(&self) -> usize { + self.expected_assets + .iter() + .filter(|a| a.requires_scan() && a.scanned) + .count() + } + + fn remaining_required(&self) -> usize { + self.expected_assets + .iter() + .filter(|asset| asset.requires_scan() && !asset.scanned) + .count() + } + + fn next_unresolved_ask(&self) -> Option { + self.expected_assets + .iter() + .enumerate() + .find(|(_, asset)| matches!(asset.scan_policy, AuditScanPolicy::Ask) && !asset.scanned) + .map(|(idx, _)| idx) + } + + fn trigger_pending_ask(&mut self, intent: PendingFinalizeIntent) -> bool { + if self.ask_dialog.show || self.pending_ask_index.is_some() { + self.pending_finalize = Some(intent); + return true; + } + + if let Some(idx) = self.next_unresolved_ask() { + if let Some(asset) = self.expected_assets.get(idx) { + self.pending_finalize = Some(intent); + self.pending_ask_index = Some(idx); + self.ask_dialog + .open(asset.name.clone(), asset.asset_tag.clone()); + return true; + } + } + + false + } + + fn handle_scan(&mut self, api_client: &ApiClient) -> Result<()> { + let input = self.scan_input.trim(); + if input.is_empty() { + return Ok(()); + } + + self.last_error = None; + + if let Some(idx) = self + .expected_assets + .iter() + .position(|asset| asset.matches_identifier(input)) + { + self.selected_asset = Some(idx); + self.process_matched_asset(idx, api_client)?; + self.scan_input.clear(); + return Ok(()); + } + + // Asset not in current list, try to fetch from the API + if let Some(value) = find_asset_by_tag_or_numeric(api_client, input)? { + let zone_id = value.get("zone_id").and_then(|v| v.as_i64()); + let mut state = AuditAssetState::from_value( + value, + self.zone_info.as_ref().map(|z| z.id), + self.mode == AuditMode::FullZone && self.zone_info.is_some(), + )?; + + if let Some(zone) = &self.zone_info { + if zone_id != Some(zone.id) { + state.expected = false; + state.exception_type = Some(EXCEPTION_WRONG_ZONE.to_string()); + state.exception_details = Some(format!( + "Asset assigned to zone {:?}, found in {}", + zone_id, zone.zone_name + )); + } else if self.mode == AuditMode::FullZone { + state.expected = false; + state.exception_type = Some(EXCEPTION_UNEXPECTED_ASSET.to_string()); + state.exception_details = Some("Asset not listed on zone roster".to_string()); + } + } else { + state.expected = false; + state.exception_type = Some(EXCEPTION_UNEXPECTED_ASSET.to_string()); + state.exception_details = Some("Captured during spot check".to_string()); + } + + let idx = self.expected_assets.len(); + self.expected_assets.push(state); + self.selected_asset = Some(idx); + self.process_matched_asset(idx, api_client)?; + self.scan_input.clear(); + return Ok(()); + } + + self.last_error = Some(format!("No asset found for '{}'.", input)); + self.scan_input.clear(); + Ok(()) + } + + fn process_matched_asset(&mut self, index: usize, api_client: &ApiClient) -> Result<()> { + if index >= self.expected_assets.len() { + return Ok(()); + } + + let (policy, already_scanned, task_id, name, tag, status_value) = { + let asset = &self.expected_assets[index]; + ( + asset.scan_policy, + asset.scanned, + asset.audit_task_id, + asset.name.clone(), + asset.asset_tag.clone(), + asset.status_found.clone(), + ) + }; + + if matches!(policy, AuditScanPolicy::Ask) && !already_scanned { + self.pending_ask_index = Some(index); + self.ask_dialog.open(name, tag); + return Ok(()); + } + + if let Some(task_id) = task_id { + if !already_scanned { + self.launch_task_runner(index, task_id, api_client)?; + return Ok(()); + } + } + + if !already_scanned { + self.expected_assets[index].set_status(&status_value, true); + } + + Ok(()) + } + + fn launch_task_runner( + &mut self, + index: usize, + task_id: i64, + api_client: &ApiClient, + ) -> Result<()> { + if let Some(state) = &self.current_task_runner { + if state.asset_index == index { + return Ok(()); // already running for this asset + } + } + + let definition = if let Some(def) = self.cached_tasks.get(&task_id) { + def.clone() + } else { + let task_value = get_audit_task_definition(api_client, task_id)? + .ok_or_else(|| anyhow!("Audit task {} not found", task_id))?; + let task_json = task_value + .get("json_sequence") + .cloned() + .unwrap_or(Value::Null); + let definition = AuditTaskDefinition::from_value(task_json)?; + self.cached_tasks.insert(task_id, definition.clone()); + definition + }; + + let asset_label = self.expected_assets[index].name.clone(); + let runner = AuditTaskRunner::new(definition, asset_label); + self.current_task_runner = Some(TaskRunnerState { + asset_index: index, + runner, + }); + Ok(()) + } + + fn process_ask_dialog(&mut self, confirmed: bool, api_client: &ApiClient) { + if let Some(idx) = self.pending_ask_index.take() { + if idx < self.expected_assets.len() { + let task_id = self.expected_assets[idx].audit_task_id; + if confirmed { + if let Some(task_id) = task_id { + if let Err(err) = self.launch_task_runner(idx, task_id, api_client) { + self.last_error = Some(err.to_string()); + } + } else { + let status_value = self.expected_assets[idx].status_found.clone(); + self.expected_assets[idx].set_status(&status_value, true); + } + } else { + self.expected_assets[idx].set_status("Missing", true); + self.expected_assets[idx].exception_type = Some(EXCEPTION_OTHER.to_string()); + if self.expected_assets[idx].exception_details.is_none() { + self.expected_assets[idx].exception_details = + Some(DEFAULT_MISSING_DETAIL.to_string()); + } + } + } + } + + if let Some(intent) = self.pending_finalize.take() { + if self.trigger_pending_ask(intent) { + return; + } + + match intent { + PendingFinalizeIntent::FromButton { needs_force } => { + if needs_force { + let remaining = self.remaining_required(); + if remaining > 0 { + let name = format!("{} pending items", remaining); + let detail = "Unscanned assets will be marked Missing upon completion."; + self.finalize_dialog.open(name, detail); + } else if let Err(err) = self.finalize_audit(api_client, false) { + self.last_error = Some(err.to_string()); + } + } else if let Err(err) = self.finalize_audit(api_client, false) { + self.last_error = Some(err.to_string()); + } + } + PendingFinalizeIntent::FromDialog { force_missing } => { + if let Err(err) = self.finalize_audit(api_client, force_missing) { + self.last_error = Some(err.to_string()); + } + } + } + } + } + + fn apply_task_outcome(&mut self, index: usize, mut outcome: AuditTaskOutcome) { + if let Some(asset) = self.expected_assets.get_mut(index) { + if let Some(status) = outcome.status_override.take() { + asset.set_status(&status, true); + } else { + let current_status = asset.status_found.clone(); + asset.set_status(¤t_status, true); + } + + if !outcome.additional_fields.is_empty() { + asset.additional_fields = outcome.additional_fields.clone(); + } + + let mut payload = Map::new(); + payload.insert("responses".into(), outcome.responses); + if !outcome.additional_fields.is_empty() { + payload.insert( + "additional_fields".into(), + Value::Object(outcome.additional_fields.clone()), + ); + } + asset.task_responses = Some(Value::Object(payload)); + } + } + + fn finalize_audit(&mut self, api_client: &ApiClient, force_missing: bool) -> Result<()> { + let remaining = self.remaining_required(); + if remaining > 0 { + if !force_missing { + return Err(anyhow!( + "Cannot finalize audit. {} required assets still pending.", + remaining + )); + } + + for asset in &mut self.expected_assets { + if asset.requires_scan() && !asset.scanned { + asset.set_status("Missing", true); + asset.exception_type = Some(EXCEPTION_OTHER.to_string()); + if asset.exception_details.is_none() { + asset.exception_details = Some(DEFAULT_MISSING_DETAIL.to_string()); + } + } + } + } + + let user_id = self + .user_id + .ok_or_else(|| anyhow!("Missing current user id for audit session"))?; + let started_at = self + .started_at + .unwrap_or_else(|| Utc::now() - chrono::Duration::minutes(1)); + let completed_at = Utc::now(); + + let required_total = self.required_total(); + let mut found_count = 0; + let mut missing_assets = Vec::new(); + let mut attention_assets = Vec::new(); + let mut exceptions = Vec::new(); + let mut unexpected_assets = Vec::new(); + + for asset in &self.expected_assets { + if asset.expected && asset.requires_scan() { + if asset.status_found != "Missing" { + found_count += 1; + } else { + missing_assets.push(json!({ + "asset_id": asset.asset_id, + "asset_tag": asset.asset_tag, + "name": asset.name, + })); + } + + if asset.status_found != "Good" && asset.status_found != "Missing" { + attention_assets.push(json!({ + "asset_id": asset.asset_id, + "asset_tag": asset.asset_tag, + "name": asset.name, + "status": asset.status_found, + })); + } + } + + if let Some(ref exception) = asset.exception_type { + exceptions.push(json!({ + "asset_id": asset.asset_id, + "asset_tag": asset.asset_tag, + "name": asset.name, + "type": exception, + "details": asset.exception_details, + })); + } + + if !asset.expected { + unexpected_assets.push(json!({ + "asset_id": asset.asset_id, + "asset_tag": asset.asset_tag, + "name": asset.name, + "status": asset.status_found, + })); + } + } + + let mut issues = Map::new(); + if !missing_assets.is_empty() { + issues.insert("missing_assets".into(), Value::Array(missing_assets)); + } + if !attention_assets.is_empty() { + issues.insert("attention_assets".into(), Value::Array(attention_assets)); + } + if !exceptions.is_empty() { + issues.insert("exceptions".into(), Value::Array(exceptions)); + } + if !unexpected_assets.is_empty() { + issues.insert("unexpected_assets".into(), Value::Array(unexpected_assets)); + } + + let status = if issues.contains_key("missing_assets") + || issues.contains_key("attention_assets") + { + "attention" + } else if issues.contains_key("exceptions") || issues.contains_key("unexpected_assets") { + "attention" + } else { + "all-good" + }; + + let mut payload = Map::new(); + payload.insert( + "audit_type".into(), + Value::String(match self.mode { + AuditMode::FullZone => "full-zone".to_string(), + AuditMode::SpotCheck => "spot-check".to_string(), + }), + ); + if let Some(zone) = &self.zone_info { + payload.insert("zone_id".into(), json!(zone.id)); + } + if !self.audit_name.trim().is_empty() { + payload.insert("audit_name".into(), json!(self.audit_name.trim())); + } + payload.insert("started_by".into(), json!(user_id)); + payload.insert( + "started_at".into(), + json!(started_at.format("%Y-%m-%d %H:%M:%S").to_string()), + ); + payload.insert( + "completed_at".into(), + json!(completed_at.format("%Y-%m-%d %H:%M:%S").to_string()), + ); + payload.insert("status".into(), json!(status)); + if let Some(timeout) = self.timeout_minutes { + payload.insert("timeout_minutes".into(), json!(timeout)); + } + if issues.is_empty() { + payload.insert("issues_found".into(), Value::Null); + } else { + payload.insert("issues_found".into(), Value::Object(issues)); + } + payload.insert("assets_expected".into(), json!(required_total as i64)); + payload.insert("assets_found".into(), json!(found_count as i64)); + if !self.notes.trim().is_empty() { + payload.insert("notes".into(), json!(self.notes.trim())); + } + + let audit_insert = api_client.insert("physical_audits", Value::Object(payload))?; + if !audit_insert.success { + return Err(anyhow!( + "Failed to create audit session: {}", + audit_insert + .error + .unwrap_or_else(|| "unknown error".to_string()) + )); + } + let audit_id = audit_insert.data.unwrap_or(0) as i64; + + // Insert audit logs + for asset in &self.expected_assets { + let mut log_payload = Map::new(); + log_payload.insert("physical_audit_id".into(), json!(audit_id)); + log_payload.insert("asset_id".into(), json!(asset.asset_id)); + log_payload.insert("status_found".into(), json!(asset.status_found)); + if let Some(task_id) = asset.audit_task_id { + log_payload.insert("audit_task_id".into(), json!(task_id)); + } + if let Some(responses) = &asset.task_responses { + log_payload.insert("audit_task_responses".into(), responses.clone()); + } + if let Some(exception) = &asset.exception_type { + log_payload.insert("exception_type".into(), json!(exception)); + } + if let Some(details) = &asset.exception_details { + log_payload.insert("exception_details".into(), json!(details)); + } + if let Some(zone) = &self.zone_info { + log_payload.insert("found_in_zone_id".into(), json!(zone.id)); + } + if !asset.notes.trim().is_empty() { + log_payload.insert("notes".into(), json!(asset.notes.trim())); + } + let log_insert = + api_client.insert("physical_audit_logs", Value::Object(log_payload))?; + if !log_insert.success { + return Err(anyhow!( + "Failed to record audit log for asset {}", + asset.asset_tag + )); + } + } + + let completion = AuditCompletion { + audit_id, + status: status.to_string(), + }; + self.completion_snapshot = Some(completion); + self.has_recent_completion = true; + self.reset_core_state(); + Ok(()) + } +} + +#[derive(Debug, Clone)] +struct AuditTaskDefinition { + steps: Vec, + index_by_step: HashMap, +} + +impl AuditTaskDefinition { + fn from_value(value: Value) -> Result { + let sequence_value = match value { + Value::Object(ref obj) if obj.contains_key("json_sequence") => { + obj.get("json_sequence").cloned().unwrap_or(Value::Null) + } + other => other, + }; + + let normalized_sequence = match sequence_value { + Value::String(ref s) => { + if let Ok(bytes) = BASE64_STANDARD.decode(s) { + serde_json::from_slice::(&bytes).map_err(|err| { + let raw_debug = String::from_utf8_lossy(&bytes).into_owned(); + anyhow!( + "Invalid audit task JSON sequence: {}\nDecoded payload: {}", + err, + raw_debug + ) + })? + } else if let Ok(parsed) = serde_json::from_str::(s) { + parsed + } else { + return Err(anyhow!( + "Invalid audit task JSON sequence: expected array but got string '{}'.", + s + )); + } + } + other => other, + }; + + let raw_debug = serde_json::to_string_pretty(&normalized_sequence) + .unwrap_or_else(|_| normalized_sequence.to_string()); + let steps: Vec = serde_json::from_value(normalized_sequence.clone()) + .map_err(|err| { + anyhow!( + "Invalid audit task JSON sequence: {}\nSequence payload: {}", + err, + raw_debug + ) + })?; + if steps.is_empty() { + return Err(anyhow!("Audit task contains no steps")); + } + let mut index_by_step = HashMap::new(); + for (idx, step) in steps.iter().enumerate() { + index_by_step.insert(step.step, idx); + } + Ok(Self { + steps, + index_by_step, + }) + } + + fn first_step(&self) -> i64 { + self.steps.first().map(|s| s.step).unwrap_or(1) + } + + fn get_step(&self, step_id: i64) -> Option<&AuditTaskStep> { + self.index_by_step + .get(&step_id) + .and_then(|idx| self.steps.get(*idx)) + } + + fn next_step(&self, current_id: i64) -> Option { + if let Some(idx) = self.index_by_step.get(¤t_id) { + self.steps.get(idx + 1).map(|s| s.step) + } else { + None + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct AuditTaskStep { + step: i64, + question: String, + #[serde(rename = "type")] + question_type: AuditQuestionType, + #[serde(default)] + options: Vec, + #[serde(default)] + actions: HashMap, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "snake_case")] +enum AuditQuestionType { + YesNo, + MultipleChoice, + TextInput, +} + +#[derive(Debug, Clone, Deserialize, Default)] +struct AuditTaskAction { + #[serde(default)] + next_step: Option, + #[serde(default)] + set_status: Option, + #[serde(default)] + set_additional_fields: Option>, + #[serde(default)] + end_audit: Option, +} + +#[derive(Debug, Clone)] +struct AuditTaskRunner { + definition: AuditTaskDefinition, + current_step: i64, + responses: Vec, + is_open: bool, + user_input: String, + asset_label: String, + collected_fields: Map, + status_override: Option, +} + +impl AuditTaskRunner { + fn new(definition: AuditTaskDefinition, asset_label: String) -> Self { + let first_step = definition.first_step(); + Self { + definition, + current_step: first_step, + responses: Vec::new(), + is_open: true, + user_input: String::new(), + asset_label, + collected_fields: Map::new(), + status_override: None, + } + } + + fn is_open(&self) -> bool { + self.is_open + } + + fn show(&mut self, ctx: &egui::Context) -> Option { + if !self.is_open { + return None; + } + + let mut keep_open = self.is_open; + let mut completed: Option = None; + + let title = format!("Audit Task – {}", self.asset_label); + egui::Window::new(title) + .id(egui::Id::new("audit_task_runner_window")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .open(&mut keep_open) + .show(ctx, |ui| { + if let Some(step) = self.definition.get_step(self.current_step).cloned() { + ui.heading(&step.question); + ui.add_space(8.0); + + match step.question_type { + AuditQuestionType::YesNo => { + ui.horizontal(|ui| { + if ui.button("Yes").clicked() { + completed = self.handle_answer( + &step, + "yes", + Value::String("Yes".to_string()), + None, + ); + } + if ui.button("No").clicked() { + completed = self.handle_answer( + &step, + "no", + Value::String("No".to_string()), + None, + ); + } + }); + } + AuditQuestionType::MultipleChoice => { + for option in &step.options { + if ui.button(option).clicked() { + completed = self.handle_answer( + &step, + option, + Value::String(option.clone()), + None, + ); + if completed.is_some() { + break; + } + } + } + } + AuditQuestionType::TextInput => { + ui.label("Answer:"); + ui.add( + egui::TextEdit::singleline(&mut self.user_input) + .desired_width(280.0), + ); + if ui.button("Submit").clicked() { + let answer_value = Value::String(self.user_input.clone()); + completed = self.handle_answer( + &step, + "any", + answer_value, + Some(self.user_input.clone()), + ); + self.user_input.clear(); + } + } + } + } else { + // No step found; close gracefully + completed = Some(self.finish()); + } + }); + + if !keep_open { + self.is_open = false; + } + + if let Some(result) = completed { + self.is_open = false; + Some(result) + } else { + None + } + } + + fn handle_answer( + &mut self, + step: &AuditTaskStep, + answer_key: &str, + answer_value: Value, + user_input: Option, + ) -> Option { + self.responses.push(TaskResponseEntry { + step: step.step, + question: step.question.clone(), + answer: answer_value.clone(), + }); + + let key_lower = answer_key.to_lowercase(); + let action = step + .actions + .get(&key_lower) + .or_else(|| step.actions.get(answer_key)) + .or_else(|| step.actions.get("any")); + + if let Some(act) = action { + if let Some(ref status) = act.set_status { + self.status_override = Some(status.clone()); + } + if let Some(ref fields) = act.set_additional_fields { + for (field, template) in fields { + let value = if let Some(ref input) = user_input { + template.replace("{user_input}", input) + } else { + template.clone() + }; + self.collected_fields + .insert(field.clone(), Value::String(value)); + } + } + if act.end_audit.unwrap_or(false) { + return Some(self.finish()); + } + if let Some(next_step) = act.next_step { + self.current_step = next_step; + return None; + } + } + + if let Some(next) = self.definition.next_step(step.step) { + self.current_step = next; + None + } else { + Some(self.finish()) + } + } + + fn finish(&mut self) -> AuditTaskOutcome { + let responses = Value::Array( + self.responses + .iter() + .map(|entry| { + json!({ + "step": entry.step, + "question": entry.question, + "answer": entry.answer, + }) + }) + .collect(), + ); + AuditTaskOutcome { + status_override: self.status_override.clone(), + additional_fields: self.collected_fields.clone(), + responses, + } + } +} diff --git a/src/core/workflows/borrow_flow.rs b/src/core/workflows/borrow_flow.rs new file mode 100644 index 0000000..08c287f --- /dev/null +++ b/src/core/workflows/borrow_flow.rs @@ -0,0 +1,1450 @@ +use anyhow::Result; +use chrono::{Duration, Local}; +use eframe::egui; +use egui_phosphor::variants::regular as icons; +use serde_json::Value; + +use crate::api::ApiClient; +use crate::models::QueryRequest; + +#[derive(Debug, Clone, PartialEq)] +pub enum BorrowStep { + SelectAsset, + SelectBorrower, + SelectDuration, + Confirm, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum BorrowerSelection { + None, + Existing(Value), // Existing borrower data + NewRegistration { + // New borrower being registered + name: String, + department: String, // "class" in the UI + borrower_type: String, // "role" in the UI + phone: String, + email: String, + }, +} + +pub struct BorrowFlow { + // State + pub is_open: bool, + pub current_step: BorrowStep, + + // Step 1: Asset Selection + pub scan_input: String, + pub available_assets: Vec, + pub selected_asset: Option, + pub asset_search: String, + pub asset_loading: bool, + + // Step 2: Borrower Selection + pub borrower_selection: BorrowerSelection, + pub registered_borrowers: Vec, + pub banned_borrowers: Vec, + pub borrower_search: String, + pub borrower_loading: bool, + + // New borrower registration fields + pub new_borrower_name: String, + pub new_borrower_class: String, + pub new_borrower_role: String, + pub new_borrower_phone: String, + pub new_borrower_email: String, + + // Step 3: Duration Selection + pub selected_duration_days: Option, + pub custom_due_date: String, + + // Step 4: Confirmation + pub lending_notes: String, + + // Confirmation for lending risky items (Faulty/Attention) + pub confirm_risky_asset: bool, + + // Error handling + pub error_message: Option, + pub success_message: Option, + pub just_completed_successfully: bool, +} + +impl Default for BorrowFlow { + fn default() -> Self { + Self { + is_open: false, + current_step: BorrowStep::SelectAsset, + + scan_input: String::new(), + available_assets: Vec::new(), + selected_asset: None, + asset_search: String::new(), + asset_loading: false, + + borrower_selection: BorrowerSelection::None, + registered_borrowers: Vec::new(), + banned_borrowers: Vec::new(), + borrower_search: String::new(), + borrower_loading: false, + + new_borrower_name: String::new(), + new_borrower_class: String::new(), + new_borrower_role: String::from("Student"), + new_borrower_phone: String::new(), + new_borrower_email: String::new(), + + selected_duration_days: None, + custom_due_date: String::new(), + + lending_notes: String::new(), + confirm_risky_asset: false, + + error_message: None, + success_message: None, + just_completed_successfully: false, + } + } +} + +impl BorrowFlow { + pub fn new() -> Self { + Self::default() + } + + pub fn open(&mut self, api_client: &ApiClient) { + self.is_open = true; + self.current_step = BorrowStep::SelectAsset; + self.reset_fields(); + self.just_completed_successfully = false; + self.load_available_assets(api_client); + } + + pub fn close(&mut self) { + self.is_open = false; + self.reset_fields(); + } + + pub fn take_recent_success(&mut self) -> bool { + if self.just_completed_successfully { + self.just_completed_successfully = false; + true + } else { + false + } + } + + fn reset_fields(&mut self) { + self.scan_input.clear(); + self.available_assets.clear(); + self.selected_asset = None; + self.asset_search.clear(); + + self.borrower_selection = BorrowerSelection::None; + self.registered_borrowers.clear(); + self.banned_borrowers.clear(); + self.borrower_search.clear(); + + self.new_borrower_name.clear(); + self.new_borrower_class.clear(); + self.new_borrower_role = String::from("Student"); + self.new_borrower_phone.clear(); + self.new_borrower_email.clear(); + + self.selected_duration_days = None; + self.custom_due_date.clear(); + self.lending_notes.clear(); + self.confirm_risky_asset = false; + + self.error_message = None; + self.success_message = None; + } + + pub fn show(&mut self, ctx: &egui::Context, api_client: &ApiClient) -> bool { + let was_open = self.is_open; + let mut keep_open = self.is_open; + + egui::Window::new("Borrow an Item") + .id(egui::Id::new("borrow_flow_main_window")) + .default_size(egui::vec2(1100.0, 800.0)) + .resizable(true) + .collapsible(false) + .open(&mut keep_open) + .show(ctx, |ui| { + // Progress indicator + self.show_progress_bar(ui); + + ui.separator(); + + // Show error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(egui::Color32::RED, err); + ui.separator(); + } + if let Some(msg) = &self.success_message { + ui.colored_label(egui::Color32::GREEN, msg); + ui.separator(); + } + + // Main content area + egui::ScrollArea::vertical() + .id_salt("borrow_flow_main_scroll") + .show(ui, |ui| match self.current_step { + BorrowStep::SelectAsset => self.show_asset_selection(ui, api_client), + BorrowStep::SelectBorrower => self.show_borrower_selection(ui, api_client), + BorrowStep::SelectDuration => self.show_duration_selection(ui), + BorrowStep::Confirm => self.show_confirmation(ui), + }); + + ui.separator(); + + // Navigation buttons + self.show_navigation_buttons(ui, api_client); + }); + if !self.is_open { + keep_open = false; + } + + self.is_open = keep_open; + + if !keep_open && was_open { + self.close(); + } + + keep_open + } + + fn show_progress_bar(&self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + let step_index = match self.current_step { + BorrowStep::SelectAsset => 0, + BorrowStep::SelectBorrower => 1, + BorrowStep::SelectDuration => 2, + BorrowStep::Confirm => 3, + }; + + let steps = [ + (icons::PACKAGE, "Asset"), + (icons::USER, "Borrower"), + (icons::CLOCK, "Duration"), + (icons::CHECK_CIRCLE, "Confirm"), + ]; + for (i, (icon, step_name)) in steps.iter().enumerate() { + let color = if i == step_index { + egui::Color32::from_rgb(100, 149, 237) + } else if i < step_index { + egui::Color32::from_rgb(60, 179, 113) + } else { + egui::Color32::GRAY + }; + + ui.colored_label(color, format!("{} {}", icon, step_name)); + if i < steps.len() - 1 { + ui.add_space(5.0); + ui.label(icons::CARET_RIGHT); + ui.add_space(5.0); + } + } + }); + } + + fn show_asset_selection(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.heading("What do you want to borrow?"); + ui.add_space(10.0); + + // Scan field + ui.horizontal(|ui| { + ui.label("Scan or Enter Asset Tag/ID:"); + let response = ui.add( + egui::TextEdit::singleline(&mut self.scan_input) + .id(egui::Id::new("borrow_flow_scan_input")) + .hint_text("Scan barcode or type asset tag...") + .desired_width(300.0), + ); + + if response.changed() && !self.scan_input.is_empty() { + self.try_scan_asset(api_client); + } + + if ui.button("Clear").clicked() { + self.scan_input.clear(); + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Search bar + ui.horizontal(|ui| { + ui.label("Search:"); + if ui + .add( + egui::TextEdit::singleline(&mut self.asset_search) + .id(egui::Id::new("borrow_flow_asset_search")), + ) + .changed() + { + // Filter is applied in the table rendering + } + if ui.button("Refresh").clicked() { + self.load_available_assets(api_client); + } + }); + + ui.add_space(5.0); + + // Assets table + ui.label(egui::RichText::new("All Lendable Items").strong()); + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), 300.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + self.render_assets_table(ui); + }, + ); + } + + fn show_borrower_selection(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.heading("Who will borrow it?"); + ui.add_space(10.0); + + // New borrower registration section + egui::CollapsingHeader::new(egui::RichText::new("Register New Borrower").strong()) + .id_salt("borrow_flow_new_borrower_header") + .default_open(false) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Name:"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_name) + .id(egui::Id::new("borrow_flow_new_borrower_name")), + ); + }); + + ui.horizontal(|ui| { + ui.label("Class:"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_class) + .id(egui::Id::new("borrow_flow_new_borrower_class")), + ); + }); + + ui.horizontal(|ui| { + ui.label("Role:"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_role) + .id(egui::Id::new("borrow_flow_new_borrower_role")) + .hint_text("e.g. Student, Faculty, Staff, External"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Phone (optional):"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_phone) + .id(egui::Id::new("borrow_flow_new_borrower_phone")), + ); + }); + + ui.horizontal(|ui| { + ui.label("Email (optional):"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_email) + .id(egui::Id::new("borrow_flow_new_borrower_email")), + ); + }); + + ui.add_space(5.0); + + if ui.button("Use This New Borrower").clicked() { + if self.new_borrower_name.trim().is_empty() { + self.error_message = Some("Name is required".to_string()); + } else { + self.borrower_selection = BorrowerSelection::NewRegistration { + name: self.new_borrower_name.clone(), + department: self.new_borrower_class.clone(), + borrower_type: self.new_borrower_role.clone(), + phone: self.new_borrower_phone.clone(), + email: self.new_borrower_email.clone(), + }; + self.error_message = None; + } + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Banned borrowers warning section + if !self.banned_borrowers.is_empty() { + ui.colored_label( + egui::Color32::RED, + egui::RichText::new("WARNING: DO NOT LEND TO THESE BORROWERS!").strong(), + ); + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), 150.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + self.render_banned_borrowers_table(ui); + }, + ); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + } + + // Registered borrowers section + ui.label(egui::RichText::new("Select Registered Borrower").strong()); + + ui.horizontal(|ui| { + ui.label("Search:"); + if ui + .add( + egui::TextEdit::singleline(&mut self.borrower_search) + .id(egui::Id::new("borrow_flow_borrower_search")), + ) + .changed() + { + // Filter is applied in table rendering + } + if ui.button("Refresh").clicked() { + self.load_borrowers(api_client); + } + }); + + ui.add_space(5.0); + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), 300.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + self.render_borrowers_table(ui); + }, + ); + } + + fn show_duration_selection(&mut self, ui: &mut egui::Ui) { + ui.heading("How long does the borrower need it?"); + ui.add_space(10.0); + + ui.label(egui::RichText::new("Common Timeframes:").strong()); + ui.add_space(5.0); + + // Common duration buttons in a grid + egui::Grid::new("duration_grid") + .num_columns(4) + .spacing(egui::vec2(8.0, 8.0)) + .show(ui, |ui| { + for (days, label) in [(1, "1 Day"), (2, "2 Days"), (3, "3 Days"), (4, "4 Days")] { + let selected = self.selected_duration_days == Some(days); + if ui.selectable_label(selected, label).clicked() { + self.selected_duration_days = Some(days); + self.custom_due_date.clear(); + } + } + ui.end_row(); + + for (days, label) in [(5, "5 Days"), (6, "6 Days"), (7, "1 Week"), (14, "2 Weeks")] + { + let selected = self.selected_duration_days == Some(days); + if ui.selectable_label(selected, label).clicked() { + self.selected_duration_days = Some(days); + self.custom_due_date.clear(); + } + } + ui.end_row(); + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Special option: Deploy (indefinite) - separate from time options + ui.horizontal(|ui| { + ui.label("Special:"); + let selected = self.selected_duration_days == Some(0); + let deploy_label = format!("{} Deploy (Indefinite)", icons::ROCKET_LAUNCH); + if ui.selectable_label(selected, deploy_label).clicked() { + self.selected_duration_days = Some(0); + self.custom_due_date.clear(); + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.label("Or specify a custom date (YYYY-MM-DD):"); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut self.custom_due_date) + .id(egui::Id::new("borrow_flow_custom_due_date")), + ); + if ui.button("Clear").clicked() { + self.custom_due_date.clear(); + self.selected_duration_days = None; + } + }); + + if !self.custom_due_date.is_empty() { + self.selected_duration_days = None; + } + } + + fn show_confirmation(&mut self, ui: &mut egui::Ui) { + ui.heading("Overview"); + ui.add_space(10.0); + + // Summary box + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.label(egui::RichText::new("You will authorize lending:").strong()); + ui.add_space(5.0); + + // Asset info + if let Some(asset) = &self.selected_asset { + let tag = asset + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let name = asset.get("name").and_then(|v| v.as_str()).unwrap_or("N/A"); + ui.label(format!("Asset: {} - {}", tag, name)); + } + + // Borrower info + match &self.borrower_selection { + BorrowerSelection::Existing(borrower) => { + let name = borrower + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let class = borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + ui.label(format!("Borrower: {} ({})", name, class)); + } + BorrowerSelection::NewRegistration { + name, + department, + borrower_type, + .. + } => { + ui.label(format!( + "New Borrower: {} ({}) - {}", + name, department, borrower_type + )); + } + BorrowerSelection::None => { + ui.colored_label(egui::Color32::RED, "WARNING: No borrower selected!"); + } + } + + // Duration info + if let Some(days) = self.selected_duration_days { + if days == 0 { + ui.label(format!( + "{} Deployed (Indefinite - No due date)", + icons::ROCKET_LAUNCH + )); + } else { + let due_date = Local::now() + Duration::days(days as i64); + ui.label(format!( + "Duration: {} days (Due: {})", + days, + due_date.format("%Y-%m-%d") + )); + } + } else if !self.custom_due_date.is_empty() { + ui.label(format!("Due Date: {}", self.custom_due_date)); + } else { + ui.colored_label(egui::Color32::RED, "WARNING: No duration selected!"); + } + }); + + ui.add_space(15.0); + + // Risk warning for Faulty/Attention assets + if let Some(asset) = &self.selected_asset { + let status = asset.get("status").and_then(|v| v.as_str()).unwrap_or(""); + if status == "Faulty" || status == "Attention" { + let (color, label) = if status == "Faulty" { + ( + egui::Color32::from_rgb(244, 67, 54), + "This item is marked as Faulty and may be unsafe or unusable.", + ) + } else { + ( + egui::Color32::from_rgb(255, 193, 7), + "This item has Attention status and may have minor defects.", + ) + }; + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.colored_label(color, label); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.checkbox( + &mut self.confirm_risky_asset, + "I acknowledge the issues and still wish to lend this item", + ); + }); + }); + ui.add_space(10.0); + } + } + + // Optional notes + ui.label("Optional Lending Notes:"); + ui.add( + egui::TextEdit::multiline(&mut self.lending_notes) + .id(egui::Id::new("borrow_flow_lending_notes")) + .desired_width(f32::INFINITY) + .desired_rows(4), + ); + } + + fn show_navigation_buttons(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.horizontal(|ui| { + // Back button + if self.current_step != BorrowStep::SelectAsset { + if ui.button(format!("{} Back", icons::ARROW_LEFT)).clicked() { + self.go_back(); + } + } + + ui.add_space(10.0); + + // Cancel button + if ui.button(format!("{} Cancel", icons::X)).clicked() { + self.close(); + } + + // Spacer + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Next/Approve button + match self.current_step { + BorrowStep::SelectAsset => { + let enabled = self.selected_asset.is_some(); + if ui + .add_enabled( + enabled, + egui::Button::new(format!("{} Next", icons::ARROW_RIGHT)), + ) + .clicked() + { + self.go_to_borrower_selection(api_client); + } + } + BorrowStep::SelectBorrower => { + let enabled = self.borrower_selection != BorrowerSelection::None; + if ui + .add_enabled( + enabled, + egui::Button::new(format!("{} Next", icons::ARROW_RIGHT)), + ) + .clicked() + { + self.current_step = BorrowStep::SelectDuration; + self.error_message = None; + } + } + BorrowStep::SelectDuration => { + let enabled = self.selected_duration_days.is_some() + || !self.custom_due_date.is_empty(); + if ui + .add_enabled( + enabled, + egui::Button::new(format!("{} Next", icons::ARROW_RIGHT)), + ) + .clicked() + { + self.current_step = BorrowStep::Confirm; + self.error_message = None; + } + } + BorrowStep::Confirm => { + // If asset is risky (Faulty/Attention), require explicit acknowledgment before enabling submit + let mut risky_requires_ack = false; + if let Some(asset) = &self.selected_asset { + let status = asset.get("status").and_then(|v| v.as_str()).unwrap_or(""); + if status == "Faulty" || status == "Attention" { + risky_requires_ack = true; + } + } + + let can_submit = !risky_requires_ack || self.confirm_risky_asset; + if ui + .add_enabled( + can_submit, + egui::Button::new(format!( + "{} Approve & Submit", + icons::ARROW_LEFT + )), + ) + .clicked() + { + self.submit_lending(api_client); + } + } + } + }); + }); + } + + fn go_back(&mut self) { + self.error_message = None; + self.current_step = match self.current_step { + BorrowStep::SelectAsset => BorrowStep::SelectAsset, + BorrowStep::SelectBorrower => BorrowStep::SelectAsset, + BorrowStep::SelectDuration => BorrowStep::SelectBorrower, + BorrowStep::Confirm => BorrowStep::SelectDuration, + }; + } + + fn go_to_borrower_selection(&mut self, api_client: &ApiClient) { + self.current_step = BorrowStep::SelectBorrower; + self.load_borrowers(api_client); + self.error_message = None; + } + + // Data loading methods + fn load_available_assets(&mut self, api_client: &ApiClient) { + self.asset_loading = true; + self.error_message = None; + + let request = QueryRequest { + action: "select".to_string(), + table: "assets".to_string(), + columns: Some(vec![ + "assets.id".to_string(), + "assets.asset_tag".to_string(), + "assets.name".to_string(), + "assets.category_id".to_string(), + "assets.lending_status".to_string(), + "categories.category_name".to_string(), + ]), + r#where: Some(serde_json::json!({ + "assets.lendable": true, + "assets.lending_status": "Available" + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: Some(vec![crate::models::Join { + table: "categories".to_string(), + on: "assets.category_id = categories.id".to_string(), + join_type: "LEFT".to_string(), + }]), + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + self.available_assets = arr.clone(); + } + } + } else { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to load assets".to_string()), + ); + } + } + Err(e) => { + self.error_message = Some(format!("Error loading assets: {}", e)); + } + } + + self.asset_loading = false; + } + + fn try_scan_asset(&mut self, api_client: &ApiClient) { + let scan_value = self.scan_input.trim(); + if scan_value.is_empty() { + return; + } + + // Try to find by asset_tag or id + let request = QueryRequest { + action: "select".to_string(), + table: "assets".to_string(), + columns: Some(vec![ + "assets.id".to_string(), + "assets.asset_tag".to_string(), + "assets.name".to_string(), + "assets.category_id".to_string(), + "assets.lending_status".to_string(), + "assets.lendable".to_string(), + "categories.category_name".to_string(), + ]), + r#where: Some(serde_json::json!({ + "assets.asset_tag": scan_value + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: Some(vec![crate::models::Join { + table: "categories".to_string(), + on: "assets.category_id = categories.id".to_string(), + join_type: "LEFT".to_string(), + }]), + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + if let Some(asset) = arr.first() { + // Verify it's lendable and available + let lendable = asset + .get("lendable") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let status = asset + .get("lending_status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if lendable && status == "Available" { + self.selected_asset = Some(asset.clone()); + self.error_message = None; + } else { + self.error_message = Some(format!( + "Asset '{}' is not available for lending", + scan_value + )); + } + } else { + self.error_message = + Some(format!("Asset '{}' not found", scan_value)); + } + } + } + } else { + self.error_message = + Some(response.error.unwrap_or_else(|| "Scan failed".to_string())); + } + } + Err(e) => { + self.error_message = Some(format!("Scan error: {}", e)); + } + } + } + + fn load_borrowers(&mut self, api_client: &ApiClient) { + self.borrower_loading = true; + self.error_message = None; + + // Load registered (non-banned) borrowers + let request = QueryRequest { + action: "select".to_string(), + table: "borrowers".to_string(), + columns: Some(vec![ + "id".to_string(), + "name".to_string(), + "email".to_string(), + "phone_number".to_string(), + "role".to_string(), + "class_name".to_string(), + "banned".to_string(), + ]), + r#where: Some(serde_json::json!({ + "banned": false + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + self.registered_borrowers = arr.clone(); + } + } + } + } + Err(e) => { + self.error_message = Some(format!("Error loading borrowers: {}", e)); + } + } + + // Load banned borrowers + let banned_request = QueryRequest { + action: "select".to_string(), + table: "borrowers".to_string(), + columns: Some(vec![ + "id".to_string(), + "name".to_string(), + "class_name".to_string(), + "unban_fine".to_string(), + ]), + r#where: Some(serde_json::json!({ + "banned": true + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&banned_request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + self.banned_borrowers = arr.clone(); + } + } + } + } + Err(_) => { + // Don't overwrite error message if we already have one + } + } + + self.borrower_loading = false; + } + + // Table rendering methods + fn render_assets_table(&mut self, ui: &mut egui::Ui) { + use egui_extras::{Column, TableBuilder}; + + // Filter assets based on search + let filtered_assets: Vec<&Value> = self + .available_assets + .iter() + .filter(|asset| { + if self.asset_search.is_empty() { + return true; + } + let search_lower = self.asset_search.to_lowercase(); + let tag = asset + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let name = asset.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let category = asset + .get("category_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + tag.to_lowercase().contains(&search_lower) + || name.to_lowercase().contains(&search_lower) + || category.to_lowercase().contains(&search_lower) + }) + .collect(); + + TableBuilder::new(ui) + .id_salt("borrow_flow_assets_table") + .striped(true) + .resizable(true) + .sense(egui::Sense::click()) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto().resizable(true)) + .column(Column::remainder().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .min_scrolled_height(0.0) + .max_scroll_height(300.0) + .header(22.0, |mut header| { + header.col(|ui| { + ui.strong("Asset Tag"); + }); + header.col(|ui| { + ui.strong("Name"); + }); + header.col(|ui| { + ui.strong("Category"); + }); + header.col(|ui| { + ui.strong("Action"); + }); + }) + .body(|mut body| { + for asset in filtered_assets { + body.row(20.0, |mut row| { + let asset_id = asset.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + let is_selected = self + .selected_asset + .as_ref() + .and_then(|s| s.get("id").and_then(|v| v.as_i64())) + .map(|id| id == asset_id) + .unwrap_or(false); + + row.col(|ui| { + ui.label( + asset + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label(asset.get("name").and_then(|v| v.as_str()).unwrap_or("N/A")); + }); + row.col(|ui| { + ui.label( + asset + .get("category_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + if is_selected { + ui.colored_label(egui::Color32::GREEN, "Selected"); + } else { + let button_id = format!("select_asset_{}", asset_id); + if ui.button("Select").on_hover_text(&button_id).clicked() { + self.selected_asset = Some((*asset).clone()); + } + } + }); + }); + } + }); + } + + fn render_borrowers_table(&mut self, ui: &mut egui::Ui) { + use egui_extras::{Column, TableBuilder}; + + // Filter borrowers based on search + let filtered_borrowers: Vec<&Value> = self + .registered_borrowers + .iter() + .filter(|borrower| { + if self.borrower_search.is_empty() { + return true; + } + let search_lower = self.borrower_search.to_lowercase(); + let name = borrower.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let class = borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let role = borrower.get("role").and_then(|v| v.as_str()).unwrap_or(""); + + name.to_lowercase().contains(&search_lower) + || class.to_lowercase().contains(&search_lower) + || role.to_lowercase().contains(&search_lower) + }) + .collect(); + + TableBuilder::new(ui) + .id_salt("borrow_flow_borrowers_table") + .striped(true) + .resizable(true) + .sense(egui::Sense::click()) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::remainder().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .min_scrolled_height(0.0) + .max_scroll_height(300.0) + .header(22.0, |mut header| { + header.col(|ui| { + ui.strong("Name"); + }); + header.col(|ui| { + ui.strong("Class"); + }); + header.col(|ui| { + ui.strong("Role"); + }); + header.col(|ui| { + ui.strong("Email"); + }); + header.col(|ui| { + ui.strong("Phone"); + }); + header.col(|ui| { + ui.strong("Action"); + }); + }) + .body(|mut body| { + for borrower in filtered_borrowers { + body.row(20.0, |mut row| { + let borrower_id = borrower.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + let is_selected = match &self.borrower_selection { + BorrowerSelection::Existing(b) => b + .get("id") + .and_then(|v| v.as_i64()) + .map(|id| id == borrower_id) + .unwrap_or(false), + _ => false, + }; + + row.col(|ui| { + ui.label( + borrower + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("role") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("email") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("phone_number") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + if is_selected { + ui.colored_label(egui::Color32::GREEN, "Selected"); + } else { + let button_id = format!("select_borrower_{}", borrower_id); + if ui.button("Select").on_hover_text(&button_id).clicked() { + self.borrower_selection = + BorrowerSelection::Existing((*borrower).clone()); + } + } + }); + }); + } + }); + } + + fn render_banned_borrowers_table(&self, ui: &mut egui::Ui) { + use egui_extras::{Column, TableBuilder}; + + TableBuilder::new(ui) + .id_salt("borrow_flow_banned_borrowers_table") + .striped(true) + .resizable(true) + .sense(egui::Sense::click()) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::remainder().resizable(true)) + .min_scrolled_height(0.0) + .max_scroll_height(150.0) + .header(22.0, |mut header| { + header.col(|ui| { + ui.strong("Name"); + }); + header.col(|ui| { + ui.strong("Class/Dept"); + }); + header.col(|ui| { + ui.strong("Unban Fine"); + }); + }) + .body(|mut body| { + for borrower in &self.banned_borrowers { + body.row(20.0, |mut row| { + row.col(|ui| { + ui.colored_label( + egui::Color32::RED, + borrower + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("unban_fine") + .and_then(|v| v.as_f64()) + .map(|f| format!("${:.2}", f)) + .unwrap_or("N/A".to_string()), + ); + }); + }); + } + }); + } + + // Submission method + fn submit_lending(&mut self, api_client: &ApiClient) { + self.error_message = None; + self.success_message = None; + + // Validate all required data + let asset = match &self.selected_asset { + Some(a) => a, + None => { + self.error_message = Some("No asset selected".to_string()); + return; + } + }; + + let asset_id = match asset.get("id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => { + self.error_message = Some("Invalid asset ID".to_string()); + return; + } + }; + + // Calculate due date (0 days = deployment/indefinite, no due date) + let due_date_str = if let Some(days) = self.selected_duration_days { + if days == 0 { + // Deployment mode: no due date + String::new() + } else { + let due = Local::now() + Duration::days(days as i64); + due.format("%Y-%m-%d").to_string() + } + } else if !self.custom_due_date.is_empty() { + self.custom_due_date.clone() + } else { + self.error_message = Some("No duration selected".to_string()); + return; + }; + + // Handle borrower (either create new or use existing) + let borrower_id = match &self.borrower_selection { + BorrowerSelection::Existing(borrower) => { + match borrower.get("id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => { + self.error_message = Some("Invalid borrower ID".to_string()); + return; + } + } + } + BorrowerSelection::NewRegistration { + name, + department, + borrower_type, + phone, + email, + } => { + // First register the new borrower + match self.register_new_borrower( + api_client, + name, + department, + borrower_type, + phone, + email, + ) { + Ok(id) => id, + Err(e) => { + self.error_message = Some(format!("Failed to register borrower: {}", e)); + return; + } + } + } + BorrowerSelection::None => { + self.error_message = Some("No borrower selected".to_string()); + return; + } + }; + + // Create lending history record + let checkout_date = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + let mut lending_data = serde_json::json!({ + "asset_id": asset_id, + "borrower_id": borrower_id, + "checkout_date": checkout_date + }); + + // Only set due_date if not deployment mode + if !due_date_str.is_empty() { + lending_data["due_date"] = serde_json::Value::String(due_date_str.clone()); + } + + if !self.lending_notes.is_empty() { + lending_data["notes"] = serde_json::Value::String(self.lending_notes.clone()); + } + + let lending_request = QueryRequest { + action: "insert".to_string(), + table: "lending_history".to_string(), + columns: None, + r#where: None, + data: Some(lending_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&lending_request) { + Ok(response) => { + if !response.success { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to create lending record".to_string()), + ); + return; + } + } + Err(e) => { + self.error_message = Some(format!("Error creating lending record: {}", e)); + return; + } + } + + // Update asset status to "Borrowed" or "Deployed" based on duration + let lending_status = if self.selected_duration_days == Some(0) { + "Deployed" + } else { + "Borrowed" + }; + + let mut asset_update_data = serde_json::json!({ + "lending_status": lending_status, + "current_borrower_id": borrower_id + }); + if !due_date_str.is_empty() { + asset_update_data["due_date"] = serde_json::Value::String(due_date_str.clone()); + } + + let asset_update = QueryRequest { + action: "update".to_string(), + table: "assets".to_string(), + columns: None, + r#where: Some(serde_json::json!({ + "id": asset_id + })), + data: Some(asset_update_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&asset_update) { + Ok(response) => { + if response.success { + self.just_completed_successfully = true; + self.success_message = Some("Item successfully lent!".to_string()); + // Auto-close after a brief success message + // In a real app, you might want to add a delay here + self.close(); + } else { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to update asset status".to_string()), + ); + } + } + Err(e) => { + self.error_message = Some(format!("Error updating asset: {}", e)); + } + } + } + + fn register_new_borrower( + &self, + api_client: &ApiClient, + name: &str, + department: &str, + borrower_type: &str, + phone: &str, + email: &str, + ) -> Result { + let mut borrower_data = serde_json::json!({ + "name": name, + "role": borrower_type, + "class_name": department, + }); + + if !phone.is_empty() { + borrower_data["phone_number"] = serde_json::Value::String(phone.to_string()); + } + if !email.is_empty() { + borrower_data["email"] = serde_json::Value::String(email.to_string()); + } + + let request = QueryRequest { + action: "insert".to_string(), + table: "borrowers".to_string(), + columns: None, + r#where: None, + data: Some(borrower_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to register borrower".to_string()))); + } + + // Get the newly created borrower ID from response + if let Some(data) = &response.data { + if let Some(id) = data.get("id").and_then(|v| v.as_i64()) { + Ok(id) + } else if let Some(id) = data.get("inserted_id").and_then(|v| v.as_i64()) { + Ok(id) + } else { + Err(anyhow::anyhow!( + "Failed to get new borrower ID from response" + )) + } + } else { + Err(anyhow::anyhow!( + "No data returned from borrower registration" + )) + } + } +} diff --git a/src/core/workflows/mod.rs b/src/core/workflows/mod.rs new file mode 100644 index 0000000..fd7e7e5 --- /dev/null +++ b/src/core/workflows/mod.rs @@ -0,0 +1,9 @@ +/// Multi-step workflows for complex operations +pub mod add_from_template; +pub mod audit; +pub mod borrow_flow; +pub mod return_flow; + +pub use add_from_template::AddFromTemplateWorkflow; +pub use audit::AuditWorkflow; +// borrow_flow and return_flow accessed via qualified paths in views diff --git a/src/core/workflows/return_flow.rs b/src/core/workflows/return_flow.rs new file mode 100644 index 0000000..3c4667a --- /dev/null +++ b/src/core/workflows/return_flow.rs @@ -0,0 +1,924 @@ +use anyhow::Result; +use chrono::Local; +use eframe::egui; +use egui_phosphor::variants::regular as icons; +use serde_json::Value; + +use crate::api::ApiClient; +use crate::models::QueryRequest; + +#[derive(Debug, Clone, PartialEq)] +pub enum ReturnStep { + SelectLoan, + Confirm, +} + +pub struct ReturnFlow { + // State + pub is_open: bool, + pub current_step: ReturnStep, + + // Step 1: Loan Selection + pub scan_input: String, + pub active_loans: Vec, + pub selected_loan: Option, + pub loan_search: String, + pub loan_loading: bool, + + // Step 2: Notes and Issue Reporting + pub return_notes: String, + + // Issue reporting (optional) + pub report_issue: bool, + pub issue_title: String, + pub issue_description: String, + pub issue_severity: String, + pub issue_priority: String, + + // Error handling + pub error_message: Option, + pub success_message: Option, + pub just_completed_successfully: bool, +} + +impl Default for ReturnFlow { + fn default() -> Self { + Self { + is_open: false, + current_step: ReturnStep::SelectLoan, + + scan_input: String::new(), + active_loans: Vec::new(), + selected_loan: None, + loan_search: String::new(), + loan_loading: false, + + return_notes: String::new(), + + report_issue: false, + issue_title: String::new(), + issue_description: String::new(), + issue_severity: String::from("Medium"), + issue_priority: String::from("Normal"), + + error_message: None, + success_message: None, + just_completed_successfully: false, + } + } +} + +impl ReturnFlow { + pub fn new() -> Self { + Self::default() + } + + pub fn open(&mut self, api_client: &ApiClient) { + self.is_open = true; + self.current_step = ReturnStep::SelectLoan; + self.reset_fields(); + self.just_completed_successfully = false; + self.load_active_loans(api_client); + } + + pub fn close(&mut self) { + self.is_open = false; + self.reset_fields(); + } + + pub fn take_recent_success(&mut self) -> bool { + if self.just_completed_successfully { + self.just_completed_successfully = false; + true + } else { + false + } + } + + fn reset_fields(&mut self) { + self.scan_input.clear(); + self.active_loans.clear(); + self.selected_loan = None; + self.loan_search.clear(); + + self.return_notes.clear(); + + self.report_issue = false; + self.issue_title.clear(); + self.issue_description.clear(); + self.issue_severity = String::from("Medium"); + self.issue_priority = String::from("Normal"); + + self.error_message = None; + self.success_message = None; + } + + pub fn show(&mut self, ctx: &egui::Context, api_client: &ApiClient) -> bool { + let mut keep_open = self.is_open; + + egui::Window::new("Return an Item") + .id(egui::Id::new("return_flow_main_window")) + .default_size(egui::vec2(1000.0, 700.0)) + .resizable(true) + .movable(true) + .collapsible(false) + .open(&mut keep_open) + .show(ctx, |ui| { + // Progress indicator + self.show_progress_bar(ui); + + ui.separator(); + + // Show error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(egui::Color32::RED, err); + ui.separator(); + } + if let Some(msg) = &self.success_message { + ui.colored_label(egui::Color32::GREEN, msg); + ui.separator(); + } + + // Main content area + egui::ScrollArea::vertical() + .id_salt("return_flow_main_scroll") + .show(ui, |ui| match self.current_step { + ReturnStep::SelectLoan => self.show_loan_selection(ui, api_client), + ReturnStep::Confirm => self.show_confirmation(ui), + }); + + ui.separator(); + + // Navigation buttons + self.show_navigation_buttons(ui, api_client); + }); + + if !keep_open { + self.close(); + } + + keep_open + } + + fn show_progress_bar(&self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + let step_index = match self.current_step { + ReturnStep::SelectLoan => 0, + ReturnStep::Confirm => 1, + }; + + let steps = [ + (icons::PACKAGE, "Select Item"), + (icons::CHECK_CIRCLE, "Confirm"), + ]; + for (i, (icon, step_name)) in steps.iter().enumerate() { + let color = if i == step_index { + egui::Color32::from_rgb(100, 149, 237) + } else if i < step_index { + egui::Color32::from_rgb(60, 179, 113) + } else { + egui::Color32::GRAY + }; + + ui.colored_label(color, format!("{} {}", icon, step_name)); + if i < steps.len() - 1 { + ui.add_space(5.0); + ui.label(icons::CARET_RIGHT); + ui.add_space(5.0); + } + } + }); + } + + fn show_loan_selection(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.heading("Which item is being returned?"); + ui.add_space(10.0); + + // Scan field + ui.horizontal(|ui| { + ui.label("Scan or Enter Asset Tag/ID:"); + let response = ui.add( + egui::TextEdit::singleline(&mut self.scan_input) + .id(egui::Id::new("return_flow_scan_input")) + .hint_text("Scan barcode or type asset tag...") + .desired_width(300.0), + ); + + if response.changed() && !self.scan_input.is_empty() { + self.try_scan_loan(api_client); + } + + if ui.button("Clear").clicked() { + self.scan_input.clear(); + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Search bar + ui.horizontal(|ui| { + ui.label("Search:"); + if ui + .add( + egui::TextEdit::singleline(&mut self.loan_search) + .id(egui::Id::new("return_flow_loan_search")), + ) + .changed() + { + // Filter is applied in the table rendering + } + if ui.button("Refresh").clicked() { + self.load_active_loans(api_client); + } + }); + + ui.add_space(5.0); + + // Active loans table + ui.label(egui::RichText::new("Currently Borrowed Items").strong()); + ui.push_id("return_flow_loans_section", |ui| { + self.render_loans_table(ui); + }); + } + + fn show_confirmation(&mut self, ui: &mut egui::Ui) { + ui.heading("Confirm Return"); + ui.add_space(10.0); + + // Summary box + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.label(egui::RichText::new("You are about to process this return:").strong()); + ui.add_space(5.0); + + // Loan info + if let Some(loan) = &self.selected_loan { + let asset_tag = loan + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let asset_name = loan + .get("asset_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let borrower_name = loan + .get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let due_date = loan + .get("due_date") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + + ui.label(format!("Asset: {} - {}", asset_tag, asset_name)); + ui.label(format!("Borrower: {}", borrower_name)); + ui.label(format!("Due Date: {}", due_date)); + + // Check if overdue + if let Some(due_str) = loan.get("due_date").and_then(|v| v.as_str()) { + let today = Local::now().format("%Y-%m-%d").to_string(); + if today.as_str() > due_str { + ui.colored_label(egui::Color32::RED, "⚠ This item is OVERDUE!"); + } else { + ui.colored_label(egui::Color32::GREEN, "✓ Returned on time"); + } + } + + if !self.return_notes.is_empty() { + ui.add_space(5.0); + ui.label(format!("Notes: {}", self.return_notes)); + } + + if self.report_issue { + ui.add_space(5.0); + ui.colored_label( + egui::Color32::from_rgb(255, 193, 7), + format!("⚠ Issue will be reported: {}", self.issue_title), + ); + } + } + }); + + ui.add_space(15.0); + + // Optional return notes + ui.label("Return Notes (optional):"); + ui.add( + egui::TextEdit::multiline(&mut self.return_notes) + .id(egui::Id::new("return_flow_notes_confirm")) + .desired_width(f32::INFINITY) + .desired_rows(3), + ); + + ui.add_space(15.0); + ui.separator(); + ui.add_space(10.0); + + // Issue reporting section + ui.horizontal(|ui| { + if ui.button("🚨 Report Issue with Item").clicked() { + self.report_issue = !self.report_issue; + } + if self.report_issue { + ui.colored_label( + egui::Color32::from_rgb(255, 193, 7), + "(Issue reporting enabled)", + ); + } + }); + + if self.report_issue { + ui.add_space(10.0); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.label(egui::RichText::new("Issue Details:").strong()); + + ui.horizontal(|ui| { + ui.label("Title:"); + ui.add( + egui::TextEdit::singleline(&mut self.issue_title) + .id(egui::Id::new("return_flow_issue_title")) + .hint_text("Brief description of the issue") + .desired_width(400.0), + ); + }); + + ui.horizontal(|ui| { + ui.label("Severity:"); + egui::ComboBox::from_id_salt("return_flow_issue_severity") + .selected_text(&self.issue_severity) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.issue_severity, "Low".to_string(), "Low"); + ui.selectable_value( + &mut self.issue_severity, + "Medium".to_string(), + "Medium", + ); + ui.selectable_value( + &mut self.issue_severity, + "High".to_string(), + "High", + ); + ui.selectable_value( + &mut self.issue_severity, + "Critical".to_string(), + "Critical", + ); + }); + + ui.label("Priority:"); + egui::ComboBox::from_id_salt("return_flow_issue_priority") + .selected_text(&self.issue_priority) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.issue_priority, "Low".to_string(), "Low"); + ui.selectable_value( + &mut self.issue_priority, + "Normal".to_string(), + "Normal", + ); + ui.selectable_value( + &mut self.issue_priority, + "High".to_string(), + "High", + ); + ui.selectable_value( + &mut self.issue_priority, + "Urgent".to_string(), + "Urgent", + ); + }); + }); + + ui.label("Description:"); + ui.add( + egui::TextEdit::multiline(&mut self.issue_description) + .id(egui::Id::new("return_flow_issue_description")) + .hint_text("What's wrong with the item?") + .desired_width(f32::INFINITY) + .desired_rows(4), + ); + }); + } + } + + fn show_navigation_buttons(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.horizontal(|ui| { + // Back button + if self.current_step != ReturnStep::SelectLoan { + if ui.button(format!("{} Back", icons::ARROW_LEFT)).clicked() { + self.go_back(); + } + } + + ui.add_space(10.0); + + // Cancel button + if ui.button(format!("{} Cancel", icons::X)).clicked() { + self.close(); + } + + // Spacer + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Next/Process button + match self.current_step { + ReturnStep::SelectLoan => { + let enabled = self.selected_loan.is_some(); + if ui + .add_enabled( + enabled, + egui::Button::new(format!("{} Next", icons::ARROW_RIGHT)), + ) + .clicked() + { + self.current_step = ReturnStep::Confirm; + self.error_message = None; + } + } + ReturnStep::Confirm => { + if ui + .button(format!("{} Process Return", icons::ARROW_RIGHT)) + .clicked() + { + self.submit_return(api_client); + } + } + } + }); + }); + } + + fn go_back(&mut self) { + self.error_message = None; + self.current_step = match self.current_step { + ReturnStep::SelectLoan => ReturnStep::SelectLoan, + ReturnStep::Confirm => ReturnStep::SelectLoan, + }; + } + + // Data loading methods + fn load_active_loans(&mut self, api_client: &ApiClient) { + self.loan_loading = true; + self.error_message = None; + + let request = QueryRequest { + action: "select".to_string(), + table: "lending_history".to_string(), + columns: Some(vec![ + "lending_history.id".to_string(), + "lending_history.asset_id".to_string(), + "lending_history.borrower_id".to_string(), + "lending_history.checkout_date".to_string(), + "lending_history.due_date".to_string(), + "lending_history.notes".to_string(), + "assets.asset_tag".to_string(), + "assets.name AS asset_name".to_string(), + "borrowers.name AS borrower_name".to_string(), + "borrowers.class_name".to_string(), + ]), + r#where: Some(serde_json::json!({ + "lending_history.return_date": null + })), + data: None, + filter: None, + order_by: Some(vec![crate::models::OrderBy { + column: "lending_history.due_date".to_string(), + direction: "ASC".to_string(), + }]), + limit: None, + offset: None, + joins: Some(vec![ + crate::models::Join { + table: "assets".to_string(), + on: "lending_history.asset_id = assets.id".to_string(), + join_type: "LEFT".to_string(), + }, + crate::models::Join { + table: "borrowers".to_string(), + on: "lending_history.borrower_id = borrowers.id".to_string(), + join_type: "LEFT".to_string(), + }, + ]), + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + self.active_loans = arr.clone(); + } + } + } else { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to load active loans".to_string()), + ); + } + } + Err(e) => { + self.error_message = Some(format!("Error loading loans: {}", e)); + } + } + + self.loan_loading = false; + } + + fn try_scan_loan(&mut self, api_client: &ApiClient) { + let scan_value = self.scan_input.trim(); + if scan_value.is_empty() { + return; + } + + // Try to find active loan by asset_tag + let request = QueryRequest { + action: "select".to_string(), + table: "lending_history".to_string(), + columns: Some(vec![ + "lending_history.id".to_string(), + "lending_history.asset_id".to_string(), + "lending_history.borrower_id".to_string(), + "lending_history.checkout_date".to_string(), + "lending_history.due_date".to_string(), + "lending_history.notes".to_string(), + "assets.asset_tag".to_string(), + "assets.name AS asset_name".to_string(), + "borrowers.name AS borrower_name".to_string(), + "borrowers.class_name".to_string(), + ]), + r#where: Some(serde_json::json!({ + "assets.asset_tag": scan_value, + "lending_history.return_date": null + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: Some(vec![ + crate::models::Join { + table: "assets".to_string(), + on: "lending_history.asset_id = assets.id".to_string(), + join_type: "LEFT".to_string(), + }, + crate::models::Join { + table: "borrowers".to_string(), + on: "lending_history.borrower_id = borrowers.id".to_string(), + join_type: "LEFT".to_string(), + }, + ]), + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + if let Some(loan) = arr.first() { + self.selected_loan = Some(loan.clone()); + self.error_message = None; + } else { + self.error_message = Some(format!( + "No active loan found for asset '{}'", + scan_value + )); + } + } + } + } else { + self.error_message = + Some(response.error.unwrap_or_else(|| "Scan failed".to_string())); + } + } + Err(e) => { + self.error_message = Some(format!("Scan error: {}", e)); + } + } + } + + // Table rendering methods + fn render_loans_table(&mut self, ui: &mut egui::Ui) { + use egui_extras::{Column, TableBuilder}; + + // Filter loans based on search + let filtered_loans: Vec<&Value> = self + .active_loans + .iter() + .filter(|loan| { + if self.loan_search.is_empty() { + return true; + } + let search_lower = self.loan_search.to_lowercase(); + let asset_tag = loan.get("asset_tag").and_then(|v| v.as_str()).unwrap_or(""); + let asset_name = loan + .get("asset_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let borrower_name = loan + .get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + asset_tag.to_lowercase().contains(&search_lower) + || asset_name.to_lowercase().contains(&search_lower) + || borrower_name.to_lowercase().contains(&search_lower) + }) + .collect(); + + TableBuilder::new(ui) + .id_salt("return_flow_loans_table") + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::initial(100.0).resizable(true).at_least(80.0)) + .column(Column::initial(180.0).resizable(true).at_least(120.0)) + .column(Column::initial(150.0).resizable(true).at_least(100.0)) + .column(Column::initial(100.0).resizable(true).at_least(80.0)) + .column(Column::initial(100.0).resizable(true).at_least(80.0)) + .column(Column::initial(100.0).resizable(true).at_least(80.0)) + .max_scroll_height(350.0) + .header(22.0, |mut header| { + header.col(|ui| { + ui.strong("Asset Tag"); + }); + header.col(|ui| { + ui.strong("Name"); + }); + header.col(|ui| { + ui.strong("Borrower"); + }); + header.col(|ui| { + ui.strong("Class"); + }); + header.col(|ui| { + ui.strong("Due Date"); + }); + header.col(|ui| { + ui.strong("Action"); + }); + }) + .body(|mut body| { + for loan in filtered_loans { + body.row(20.0, |mut row| { + let loan_id = loan.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + let is_selected = self + .selected_loan + .as_ref() + .and_then(|s| s.get("id").and_then(|v| v.as_i64())) + .map(|id| id == loan_id) + .unwrap_or(false); + + // Check if overdue + let due_date = loan.get("due_date").and_then(|v| v.as_str()).unwrap_or(""); + let today = Local::now().format("%Y-%m-%d").to_string(); + let is_overdue = !due_date.is_empty() && today.as_str() > due_date; + + row.col(|ui| { + let tag = loan + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + if is_overdue { + ui.colored_label(egui::Color32::RED, tag); + } else { + ui.label(tag); + } + }); + row.col(|ui| { + ui.label( + loan.get("asset_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + loan.get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + loan.get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + if is_overdue { + ui.colored_label(egui::Color32::RED, format!("{} ⚠", due_date)); + } else { + ui.label(due_date); + } + }); + row.col(|ui| { + if is_selected { + ui.colored_label(egui::Color32::GREEN, "Selected"); + } else { + let button_id = format!("select_loan_{}", loan_id); + if ui.button("Select").on_hover_text(&button_id).clicked() { + self.selected_loan = Some((*loan).clone()); + } + } + }); + }); + } + }); + } + + // Submission method + fn submit_return(&mut self, api_client: &ApiClient) { + self.error_message = None; + self.success_message = None; + + // Validate required data + let loan = match &self.selected_loan { + Some(l) => l, + None => { + self.error_message = Some("No loan selected".to_string()); + return; + } + }; + + let loan_id = match loan.get("id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => { + self.error_message = Some("Invalid loan ID".to_string()); + return; + } + }; + + let asset_id = match loan.get("asset_id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => { + self.error_message = Some("Invalid asset ID".to_string()); + return; + } + }; + + let return_date = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + // Update lending history record - just set return_date + let mut update_data = serde_json::json!({ + "return_date": return_date + }); + + // Add notes if provided + if !self.return_notes.is_empty() { + let existing_notes = loan.get("notes").and_then(|v| v.as_str()).unwrap_or(""); + let combined_notes = if existing_notes.is_empty() { + format!("[Return] {}", self.return_notes) + } else { + format!("{}\n[Return] {}", existing_notes, self.return_notes) + }; + update_data["notes"] = serde_json::Value::String(combined_notes); + } + + let update_request = QueryRequest { + action: "update".to_string(), + table: "lending_history".to_string(), + columns: None, + r#where: Some(serde_json::json!({ + "id": loan_id + })), + data: Some(update_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&update_request) { + Ok(response) => { + if !response.success { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to update lending record".to_string()), + ); + return; + } + } + Err(e) => { + self.error_message = Some(format!("Error updating lending record: {}", e)); + return; + } + } + + // Update asset status to "Available" and move current->previous borrower, clear current/due_date + // Note: Use two-step update to read borrower_id from selected loan without another select. + let current_borrower_id = loan.get("borrower_id").and_then(|v| v.as_i64()); + let mut asset_update_payload = serde_json::json!({ + "lending_status": "Available", + "current_borrower_id": serde_json::Value::Null, + "due_date": serde_json::Value::Null + }); + if let Some(cb) = current_borrower_id { + asset_update_payload["previous_borrower_id"] = serde_json::Value::from(cb); + } + + let asset_update = QueryRequest { + action: "update".to_string(), + table: "assets".to_string(), + columns: None, + r#where: Some(serde_json::json!({ + "id": asset_id + })), + data: Some(asset_update_payload), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&asset_update) { + Ok(response) => { + if !response.success { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to update asset status".to_string()), + ); + return; + } + } + Err(e) => { + self.error_message = Some(format!("Error updating asset: {}", e)); + return; + } + } + + // If issue reporting is enabled, create an issue + if self.report_issue { + if let Err(e) = self.create_issue( + api_client, + asset_id, + loan.get("borrower_id").and_then(|v| v.as_i64()), + ) { + // Don't fail the whole return if issue creation fails, just log it + self.error_message = Some(format!( + "Return processed but failed to create issue: {}", + e + )); + return; + } + } + + self.just_completed_successfully = true; + self.success_message = Some("Item successfully returned!".to_string()); + self.close(); + } + + fn create_issue( + &self, + api_client: &ApiClient, + asset_id: i64, + borrower_id: Option, + ) -> Result<()> { + if self.issue_title.trim().is_empty() { + return Err(anyhow::anyhow!("Issue title is required")); + } + + let mut issue_data = serde_json::json!({ + "issue_type": "Asset Issue", + "asset_id": asset_id, + "title": self.issue_title.clone(), + "description": self.issue_description.clone(), + "severity": self.issue_severity.clone(), + "priority": self.issue_priority.clone(), + "status": "Open", + "auto_detected": false, + "detection_trigger": "Manual - Return Flow" + }); + + if let Some(bid) = borrower_id { + issue_data["borrower_id"] = serde_json::Value::Number(bid.into()); + } + + let request = QueryRequest { + action: "insert".to_string(), + table: "issue_tracker".to_string(), + columns: None, + r#where: None, + data: Some(issue_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to create issue".to_string()))); + } + + Ok(()) + } +} -- cgit v1.2.3-70-g09d2