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