use crate::api::ApiClient; use crate::core::components::help::{show_help_window, HelpWindowOptions}; use crate::core::workflows::borrow_flow::{BorrowFlow, BorrowStep}; use crate::core::workflows::return_flow::{ReturnFlow, ReturnStep}; use crate::core::{ components::form_builder::FormBuilder, components::interactions::ConfirmDialog, workflows::AddFromTemplateWorkflow, AssetFieldBuilder, AssetOperations, ColumnConfig, DataLoader, LoadingState, TableEventHandler, TableRenderer, }; use eframe::egui; use egui_commonmark::CommonMarkCache; use serde_json::Value; use std::collections::HashMap; pub struct InventoryView { // Data assets: Vec, loading_state: LoadingState, // Table and UI components table_renderer: TableRenderer, show_column_panel: bool, // Filter state tracking last_show_retired_state: bool, last_item_lookup: String, // Dialogs delete_dialog: ConfirmDialog, edit_dialog: FormBuilder, add_dialog: FormBuilder, advanced_edit_dialog: FormBuilder, print_dialog: Option, show_print_dialog: bool, // Bulk action state pending_delete_ids: Vec, pending_edit_ids: Vec, is_bulk_edit: bool, // Workflows add_from_template_workflow: AddFromTemplateWorkflow, borrow_flow: BorrowFlow, return_flow: ReturnFlow, // Help show_help: bool, help_cache: CommonMarkCache, } impl InventoryView { pub fn new() -> Self { // Define all available columns from the assets table schema let columns = vec![ ColumnConfig::new("ID", "id").with_width(60.0).hidden(), ColumnConfig::new("Asset Tag", "asset_tag").with_width(120.0), ColumnConfig::new("Numeric ID", "asset_numeric_id") .with_width(100.0) .hidden(), ColumnConfig::new("Type", "asset_type").with_width(60.0), ColumnConfig::new("Name", "name").with_width(180.0), ColumnConfig::new("Category", "category_name").with_width(90.0), ColumnConfig::new("Manufacturer", "manufacturer").with_width(100.0), ColumnConfig::new("Model", "model").with_width(100.0), ColumnConfig::new("Serial Number", "serial_number") .with_width(130.0) .hidden(), ColumnConfig::new("Zone", "zone_code").with_width(80.0), ColumnConfig::new("Label Template", "label_template_name") .with_width(160.0) .hidden(), ColumnConfig::new("Label Template ID", "label_template_id") .with_width(80.0) .hidden(), ColumnConfig::new("Zone Plus", "zone_plus") .with_width(100.0) .hidden(), ColumnConfig::new("Zone Note", "zone_note") .with_width(150.0) .hidden(), ColumnConfig::new("Status", "status").with_width(80.0), ColumnConfig::new("Last Audit", "last_audit") .with_width(100.0) .hidden(), ColumnConfig::new("Last Audit Status", "last_audit_status") .with_width(120.0) .hidden(), ColumnConfig::new("Price", "price") .with_width(90.0) .hidden(), ColumnConfig::new("Purchase Date", "purchase_date") .with_width(110.0) .hidden(), ColumnConfig::new("Warranty Until", "warranty_until") .with_width(110.0) .hidden(), ColumnConfig::new("Expiry Date", "expiry_date") .with_width(100.0) .hidden(), ColumnConfig::new("Qty Available", "quantity_available") .with_width(90.0) .hidden(), ColumnConfig::new("Qty Total", "quantity_total") .with_width(80.0) .hidden(), ColumnConfig::new("Qty Used", "quantity_used") .with_width(80.0) .hidden(), ColumnConfig::new("Supplier", "supplier_name") .with_width(120.0) .hidden(), ColumnConfig::new("Lendable", "lendable") .with_width(80.0) .hidden(), ColumnConfig::new("Min Role", "minimum_role_for_lending") .with_width(80.0) .hidden(), ColumnConfig::new("Lending Status", "lending_status").with_width(70.0), ColumnConfig::new("Current Borrower", "current_borrower_name") .with_width(130.0) .hidden(), ColumnConfig::new("Due Date", "due_date") .with_width(100.0) .hidden(), ColumnConfig::new("Previous Borrower", "previous_borrower_name") .with_width(130.0) .hidden(), ColumnConfig::new("No Scan", "no_scan") .with_width(70.0) .hidden(), ColumnConfig::new("Notes", "notes") .with_width(200.0) .hidden(), ColumnConfig::new("Created Date", "created_date") .with_width(140.0) .hidden(), ColumnConfig::new("Created By", "created_by_username") .with_width(100.0) .hidden(), ColumnConfig::new("Last Modified", "last_modified_date").with_width(70.0), // Visible by default ColumnConfig::new("Modified By", "last_modified_by_username") .with_width(100.0) .hidden(), ]; Self { assets: Vec::new(), loading_state: LoadingState::new(), table_renderer: TableRenderer::new() .with_columns(columns) .with_default_sort("last_modified_date", false), // Sort by last modified, newest first show_column_panel: false, last_show_retired_state: true, // Default to showing retired items (matches ribbon default) last_item_lookup: String::new(), delete_dialog: ConfirmDialog::new( "Delete Asset", "Are you sure you want to delete this asset?", ), edit_dialog: FormBuilder::new("Edit Asset", vec![]), add_dialog: FormBuilder::new("Add Asset", vec![]), advanced_edit_dialog: FormBuilder::new("Advanced Edit Asset", vec![]), print_dialog: None, show_print_dialog: false, pending_delete_ids: Vec::new(), pending_edit_ids: Vec::new(), is_bulk_edit: false, add_from_template_workflow: AddFromTemplateWorkflow::new(), borrow_flow: BorrowFlow::new(), return_flow: ReturnFlow::new(), show_help: false, help_cache: CommonMarkCache::default(), } } /// Load assets from the API fn load_assets( &mut self, api_client: &ApiClient, limit: Option, where_clause: Option, filter: Option, ) { self.loading_state.start_loading(); match DataLoader::load_assets(api_client, limit, where_clause, filter) { Ok(assets) => { self.assets = assets; // Enrich borrower/due_date columns from lending_history for accuracy self.enrich_loans_for_visible_assets(api_client); self.loading_state.finish_success(); } Err(e) => { self.loading_state.finish_error(e); } } } /// Load assets with retired filter applied fn load_assets_with_filter( &mut self, api_client: &ApiClient, limit: Option, show_retired: bool, ) { let filter = if show_retired { None // Show all items including retired } else { // Filter out retired items: WHERE status != 'Retired' Some(serde_json::json!({ "and": [ { "column": "assets.status", "op": "!=", "value": "Retired" } ] })) }; self.load_assets(api_client, limit, None, filter); } /// Enrich current/previous borrower and due_date using active and recent loans fn enrich_loans_for_visible_assets(&mut self, api_client: &ApiClient) { // Collect visible asset IDs let mut ids: Vec = Vec::new(); for asset in &self.assets { if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) { ids.push(id); } } if ids.is_empty() { return; } // Build active loans map: asset_id -> (borrower_name, due_date) let mut active_map: HashMap, Option)> = HashMap::new(); if let Ok(active_loans) = crate::core::get_active_loans(api_client, None) { for row in active_loans { let aid = row.get("asset_id").and_then(|v| v.as_i64()); if let Some(asset_id) = aid { let borrower_name = row .get("borrower_name") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let due = row .get("due_date") .and_then(|v| v.as_str()) .map(|s| s.to_string()); active_map.insert(asset_id, (borrower_name, due)); } } } // Build recent returns map: asset_id -> borrower_name (most recent return) let mut recent_return_map: HashMap = HashMap::new(); if let Ok(recent_returns) = crate::core::get_recent_returns_for_assets(api_client, &ids, Some(1), None) { for row in recent_returns { if let Some(asset_id) = row.get("asset_id").and_then(|v| v.as_i64()) { if let Some(name) = row.get("borrower_name").and_then(|v| v.as_str()) { // Only set if not already set (keep most recent as we sorted desc server-side) recent_return_map .entry(asset_id) .or_insert_with(|| name.to_string()); } } } } // Apply enrichment to assets for asset in &mut self.assets { let aid = asset.get("id").and_then(|v| v.as_i64()); if let Some(asset_id) = aid { // Current borrower and due_date from active loan (authoritative) if let Some((borrower_name_opt, due_opt)) = active_map.get(&asset_id) { if let Some(obj) = asset.as_object_mut() { if let Some(bname) = borrower_name_opt { obj.insert( "current_borrower_name".to_string(), Value::String(bname.clone()), ); } if let Some(due) = due_opt { obj.insert("due_date".to_string(), Value::String(due.clone())); } } } // Previous borrower from most recent returned loan (only when not currently borrowed) if !active_map.contains_key(&asset_id) { if let Some(prev_name) = recent_return_map.get(&asset_id) { if let Some(obj) = asset.as_object_mut() { obj.insert( "previous_borrower_name".to_string(), Value::String(prev_name.clone()), ); } } } } } } /// Get selected asset IDs for bulk operations (works with filtered view) fn get_selected_ids(&self) -> Vec { let filtered_data = self.table_renderer.prepare_json_data(&self.assets); let mut ids = Vec::new(); for &row_idx in &self.table_renderer.selection.selected_rows { if let Some((_, asset)) = filtered_data.get(row_idx) { if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) { ids.push(id); } else if let Some(s) = asset.get("id").and_then(|v| v.as_str()) { if let Ok(n) = s.parse::() { ids.push(n); } } } } ids } /// Find an asset by ID fn find_asset_by_id(&self, id: i64) -> Option { AssetOperations::find_by_id(&self.assets, id, |asset| { asset.get("id").and_then(|v| v.as_i64()) }) } /// Prepare field configurations for different dialog types fn prepare_advanced_edit_fields(&mut self, api_client: &ApiClient) { self.advanced_edit_dialog = AssetFieldBuilder::create_advanced_edit_dialog(api_client); } fn prepare_easy_edit_fields(&mut self, api_client: &ApiClient) { self.edit_dialog = AssetFieldBuilder::create_easy_edit_dialog(api_client); } fn prepare_add_asset_editor(&mut self, api_client: &ApiClient) { self.add_dialog = AssetFieldBuilder::create_add_dialog_with_preset(api_client); } /// Helper method to open easy edit dialog with specific asset fn open_easy_edit_with(&mut self, item: &serde_json::Value, api_client: Option<&ApiClient>) { log::info!("=== OPENING EASY EDIT ==="); log::info!("Asset data: {:?}", item); let asset_id = item.get("id").and_then(|v| v.as_i64()).or_else(|| { item.get("id") .and_then(|v| v.as_str()) .and_then(|s| s.parse().ok()) }); log::info!("Extracted asset ID: {:?}", asset_id); if let Some(client) = api_client { self.prepare_easy_edit_fields(client); } self.edit_dialog.title = "Easy Edit Asset".to_string(); self.edit_dialog.open(item); log::info!( "After opening, dialog item_id: {:?}", self.edit_dialog.item_id ); } /// Perform item lookup based on asset tag or numeric ID fn perform_item_lookup( &mut self, lookup_text: &str, api_client: Option<&ApiClient>, limit: Option, ) { if lookup_text != self.last_item_lookup { self.last_item_lookup = lookup_text.to_string(); if !lookup_text.is_empty() { if let Some(client) = api_client { // Build filter with OR and LIKE for asset_tag OR asset_numeric_id let filter = serde_json::json!({ "or": [ { "column": "assets.asset_tag", "op": "like", "value": format!("%{}%", lookup_text) }, { "column": "assets.asset_numeric_id", "op": "like", "value": format!("%{}%", lookup_text) } ] }); self.load_assets(client, limit, None, Some(filter)); } } else { // Clear search when lookup is empty if let Some(client) = api_client { self.load_assets(client, limit, None, None); } } } } /// Apply updates using the extracted operations module fn apply_updates( &mut self, api: &ApiClient, updated: serde_json::Map, limit: Option, ) { let assets = self.assets.clone(); let easy_id = self.edit_dialog.item_id.clone(); let advanced_id = self.advanced_edit_dialog.item_id.clone(); AssetOperations::apply_updates( api, updated, &mut self.pending_edit_ids, easy_id.as_deref(), advanced_id.as_deref(), |id| { AssetOperations::find_by_id(&assets, id, |asset| { asset.get("id").and_then(|v| v.as_i64()) }) }, limit, |api_client, limit| { match DataLoader::load_assets(api_client, limit, None, None) { Ok(_) => { /* Assets will be reloaded after this call */ } Err(e) => log::error!("Failed to reload assets: {}", e), } }, ); // Reload assets after update match DataLoader::load_assets(api, limit, None, None) { Ok(assets) => { self.assets = assets; } Err(e) => log::error!("Failed to reload assets after update: {}", e), } // Reset selection state after updates self.is_bulk_edit = false; self.table_renderer.selection.clear_selection(); } fn render_table_with_events( &mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>, session_manager: &std::sync::Arc>, ) { // We need to work around Rust's borrowing rules here // First, get the data we need let assets_clone = self.assets.clone(); let prepared_data = self.table_renderer.prepare_json_data(&assets_clone); // Create a temporary event handler that stores actions for later processing let mut deferred_actions: Vec = Vec::new(); let mut temp_handler = TempInventoryEventHandler { deferred_actions: &mut deferred_actions, }; // Render table with the temporary event handler self.table_renderer .render_json_table(ui, &prepared_data, Some(&mut temp_handler)); // Process the deferred actions self.process_temp_deferred_actions(deferred_actions, api_client, session_manager); } fn process_temp_deferred_actions( &mut self, actions: Vec, api_client: Option<&ApiClient>, session_manager: &std::sync::Arc>, ) { for action in actions { match action { DeferredAction::DoubleClick(asset) => { log::info!( "Processing double-click edit for asset: {:?}", asset.get("name") ); self.open_easy_edit_with(&asset, api_client); } DeferredAction::ContextClone(asset) => { log::info!( "Processing context menu clone for asset: {:?}", asset.get("name") ); if let Some(client) = api_client { // Use full add dialog so all fields are available when cloning self.add_dialog = crate::core::asset_fields::AssetFieldBuilder::create_full_add_dialog( client, ); } // Prepare cloned payload using shared helper let cloned = crate::core::components::prepare_cloned_value( &asset, &[ "id", "asset_numeric_id", "created_date", "created_at", "last_modified_date", "last_modified", "last_modified_by", "last_modified_by_username", "current_borrower_name", "previous_borrower_name", "last_audit", "last_audit_status", "due_date", ], Some("name"), Some(""), ); self.add_dialog.title = "Add Asset".to_string(); if let Some(obj) = cloned.as_object() { // Use open_new so all preset fields are treated as new values and will be saved self.add_dialog.open_new(Some(obj)); } else { self.add_dialog.open(&cloned); } } DeferredAction::ContextEdit(asset) => { log::info!( "Processing context menu edit for asset: {:?}", asset.get("name") ); self.open_easy_edit_with(&asset, api_client); } DeferredAction::ContextAdvancedEdit(asset) => { log::info!( "Processing context menu advanced edit for asset: {:?}", asset.get("name") ); if let Some(client) = api_client { self.prepare_advanced_edit_fields(client); } self.advanced_edit_dialog.open(&asset); } DeferredAction::ContextDelete(asset) => { if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) { log::info!("Processing context menu delete for asset ID: {}", id); self.pending_delete_ids = vec![id]; let name = asset .get("name") .and_then(|v| v.as_str()) .unwrap_or("Unknown") .to_string(); self.delete_dialog.open(name, id.to_string()); } } DeferredAction::ContextLend(asset) => { log::info!( "Opening borrow flow from inventory for asset: {:?}", asset.get("name") ); if let Some(client) = api_client { // Open flow, then preselect this asset and skip to borrower step self.borrow_flow.open(client); self.borrow_flow.selected_asset = Some(asset.clone()); self.borrow_flow.current_step = BorrowStep::SelectBorrower; } } DeferredAction::ContextReturn(asset) => { log::info!( "Opening return flow from inventory for asset: {:?}", asset.get("name") ); if let Some(client) = api_client { self.return_flow.open(client); // Try to preselect the matching active loan by asset_id or asset_tag let asset_id = asset.get("id").and_then(|v| v.as_i64()); let asset_tag = asset.get("asset_tag").and_then(|v| v.as_str()); if let Some(loan) = self .return_flow .active_loans .iter() .find(|loan| { let loan_asset_id = loan.get("asset_id").and_then(|v| v.as_i64()); let loan_tag = loan.get("asset_tag").and_then(|v| v.as_str()); (asset_id.is_some() && loan_asset_id == asset_id) || (asset_tag.is_some() && loan_tag == asset_tag) }) .cloned() { self.return_flow.selected_loan = Some(loan); self.return_flow.current_step = ReturnStep::Confirm; } } } DeferredAction::ContextPrintLabel(asset) => { log::info!("Processing print label for asset: {:?}", asset.get("name")); self.open_print_dialog(&asset, api_client, false, session_manager); } DeferredAction::ContextAdvancedPrint(asset) => { log::info!( "Processing advanced print for asset: {:?}", asset.get("name") ); self.open_print_dialog(&asset, api_client, true, session_manager); } } } } /// Insert new asset and return its DB id if available fn insert_new_asset( &mut self, client: &ApiClient, limit: Option, mut data: serde_json::Map, ) -> Option { AssetOperations::preprocess_quick_adds(client, &mut data); AssetOperations::insert_new_asset(client, data, limit, |api_client, limit| { self.load_assets(api_client, limit, None, None) }) } /// Open print dialog for an asset fn open_print_dialog( &mut self, asset: &Value, api_client: Option<&ApiClient>, force_advanced: bool, session_manager: &std::sync::Arc>, ) { use std::collections::HashMap; // Extract asset data as strings for template rendering let mut asset_data = HashMap::new(); if let Some(obj) = asset.as_object() { for (key, value) in obj { let value_str = match value { Value::String(s) => s.clone(), Value::Number(n) => n.to_string(), Value::Bool(b) => b.to_string(), Value::Null => String::new(), _ => serde_json::to_string(value).unwrap_or_default(), }; asset_data.insert(key.clone(), value_str); } } // Get label template ID from asset let label_template_id = asset.get("label_template_id").and_then(|v| v.as_i64()); // Get default and last-used printer from session let (default_printer_id, last_printer_id) = { let guard = session_manager.blocking_lock(); let default_id = guard.get_default_printer_id(); let last_printer = guard.get_last_print_preferences(); (default_id, last_printer) }; // Smart logic: if not forcing advanced AND both default printer and template are set, print directly if !force_advanced && default_printer_id.is_some() && label_template_id.is_some() { // Print directly without dialog log::info!("Printing directly with default printer and template"); if let Some(client) = api_client { if let (Some(printer_id), Some(template_id)) = (default_printer_id, label_template_id) { self.execute_print(client, printer_id, template_id, &asset_data); } } } else { // Show dialog let mut dialog = crate::core::print::PrintDialog::new(asset_data); dialog = dialog.with_defaults(default_printer_id, label_template_id, last_printer_id); if let Some(client) = api_client { if let Err(e) = dialog.load_data(client) { log::error!("Failed to load print dialog data: {}", e); } } self.print_dialog = Some(dialog); self.show_print_dialog = true; } } /// Execute print job via print dialog fn execute_print( &mut self, api_client: &ApiClient, printer_id: i64, template_id: i64, asset_data: &HashMap, ) { log::info!( "Executing print: printer_id={}, template_id={}", printer_id, template_id ); // Create a print dialog with the options and execute // The dialog handles all printer settings loading, JSON parsing, and printing let mut dialog = crate::core::print::PrintDialog::new(asset_data.clone()).with_defaults( Some(printer_id), Some(template_id), None, ); match dialog.execute_print(api_client) { Ok(_) => { log::info!("Successfully printed label"); self.log_print_history(api_client, asset_data, printer_id, template_id, "Success"); } Err(e) => { log::error!("Failed to print label: {}", e); self.log_print_history( api_client, asset_data, printer_id, template_id, &format!("Error: {}", e), ); } } } fn log_print_history( &self, _api_client: &ApiClient, _asset_data: &HashMap, _printer_id: i64, _template_id: i64, status: &str, ) { // Print history logging disabled - backend doesn't support raw SQL queries // and using insert() requires __editor_item_id column which is a log table issue // TODO: Either add raw SQL support to backend or create a dedicated /print-history endpoint log::debug!("Print job status: {}", status); } pub fn show( &mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>, ribbon_ui: Option<&mut crate::ui::ribbon::RibbonUI>, session_manager: &std::sync::Arc>, ) { // Handle initial load if needed - but ONLY if we've never loaded before // Don't auto-load if assets are empty due to filters returning 0 results // Also skip auto-load if there's a pending filter change (which will load filtered results) let has_pending_filter = ribbon_ui .as_ref() .map(|r| { *r.checkboxes .get("inventory_filter_changed") .unwrap_or(&false) }) .unwrap_or(false); if self.assets.is_empty() && !self.loading_state.is_loading && self.loading_state.last_error.is_none() && self.loading_state.last_load_time.is_none() // Only auto-load if we've never attempted a load && !has_pending_filter // Don't auto-load if a filter is about to be applied { if let Some(client) = api_client { log::info!("Inventory view never loaded, triggering initial auto-load"); // Respect retired filter state from ribbon during auto-load let show_retired = ribbon_ui .as_ref() .map(|r| *r.checkboxes.get("show_retired").unwrap_or(&true)) .unwrap_or(true); self.load_assets_with_filter(client, Some(100), show_retired); } } // Render content and get flags to clear let flags_to_clear = self.render_content( ui, api_client, Some(100), ribbon_ui.as_ref().map(|r| &**r), session_manager, ); // Clear the flags after processing if let Some(ribbon) = ribbon_ui { for flag in flags_to_clear { ribbon.checkboxes.insert(flag, false); } } } fn render_content( &mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>, limit: Option, ribbon_ui: Option<&crate::ui::ribbon::RibbonUI>, session_manager: &std::sync::Arc>, ) -> Vec { // Handle ribbon actions first let flags_to_clear = if let Some(ribbon) = ribbon_ui { self.handle_ribbon_actions(ribbon, api_client, session_manager) } else { Vec::new() }; // Top toolbar with search and actions ui.horizontal(|ui| { ui.label("Search:"); let mut search_changed = false; ui.add_enabled_ui(!self.loading_state.is_loading, |ui| { search_changed = ui .text_edit_singleline(&mut self.table_renderer.search_query) .changed(); }); ui.separator(); // Action buttons if let Some(client) = api_client { if ui.button("Refresh").clicked() { // Clear any previous error state when user explicitly refreshes self.loading_state.last_error = None; // Respect current filters when refreshing if let Some(ribbon) = ribbon_ui { let show_retired = ribbon .checkboxes .get("show_retired") .copied() .unwrap_or(true); let user_filter = ribbon.filter_builder.get_filter_json("assets"); let combined_filter = self.combine_filters(show_retired, user_filter); self.load_assets(client, limit, None, combined_filter); } else { self.load_assets(client, limit, None, None); } } if ui.button("➕ Add Asset").clicked() { self.prepare_add_asset_editor(client); } // Show selection count but no buttons (use right-click instead) let selected_count = self.table_renderer.selection.get_selected_count(); if selected_count > 0 { ui.label(format!("{} selected", selected_count)); } } ui.separator(); if ui.button("⚙ Columns").clicked() { self.show_column_panel = !self.show_column_panel; } }); ui.separator(); // Show loading state if self.loading_state.is_loading { ui.horizontal(|ui| { ui.spinner(); ui.label("Loading assets..."); }); } // Show errors if let Some(error) = self.loading_state.get_error() { ui.colored_label(egui::Color32::RED, format!("Error: {}", error)); } // Column configuration panel if self.show_column_panel { egui::Window::new("Column Configuration") .open(&mut self.show_column_panel) .resizable(true) .movable(true) .default_width(350.0) .min_width(300.0) .max_width(500.0) .max_height(600.0) .default_pos([200.0, 150.0]) .show(ui.ctx(), |ui| { ui.label("Show/Hide Columns:"); ui.separator(); // Scrollable area for columns egui::ScrollArea::vertical() .max_height(450.0) .show(ui, |ui| { // Use columns layout to make better use of width while keeping groups intact ui.columns(2, |columns| { // Left column columns[0].group(|ui| { ui.strong("Basic Information"); ui.separator(); for column in &mut self.table_renderer.columns { if matches!( column.field.as_str(), "name" | "asset_tag" | "asset_type" | "status" ) { ui.checkbox(&mut column.visible, &column.name); } } }); columns[0].add_space(5.0); columns[0].group(|ui| { ui.strong("Location & Status"); ui.separator(); for column in &mut self.table_renderer.columns { if matches!( column.field.as_str(), "zone_code" | "zone_plus" | "zone_note" | "lending_status" ) { ui.checkbox(&mut column.visible, &column.name); } } }); columns[0].add_space(5.0); columns[0].group(|ui| { ui.strong("Quantities & Lending"); ui.separator(); for column in &mut self.table_renderer.columns { if matches!( column.field.as_str(), "quantity_available" | "quantity_total" | "quantity_used" | "lendable" | "minimum_role_for_lending" | "current_borrower_name" | "due_date" | "previous_borrower_name" ) { ui.checkbox(&mut column.visible, &column.name); } } }); // Right column columns[1].group(|ui| { ui.strong("Classification"); ui.separator(); for column in &mut self.table_renderer.columns { if matches!( column.field.as_str(), "category_name" | "manufacturer" | "model" | "serial_number" ) { ui.checkbox(&mut column.visible, &column.name); } } }); columns[1].add_space(5.0); columns[1].group(|ui| { ui.strong("Financial & Dates"); ui.separator(); for column in &mut self.table_renderer.columns { if matches!( column.field.as_str(), "price" | "purchase_date" | "warranty_until" | "expiry_date" | "last_audit" | "last_audit_status" ) { ui.checkbox(&mut column.visible, &column.name); } } }); columns[1].add_space(5.0); columns[1].group(|ui| { ui.strong("Metadata & Other"); ui.separator(); for column in &mut self.table_renderer.columns { if matches!( column.field.as_str(), "id" | "asset_numeric_id" | "supplier_name" | "no_scan" | "notes" | "created_date" | "created_by_username" | "last_modified_date" | "last_modified_by_username" ) { ui.checkbox(&mut column.visible, &column.name); } } }); }); }); ui.separator(); ui.columns(3, |columns| { if columns[0].button("Show All").clicked() { for column in &mut self.table_renderer.columns { column.visible = true; } } if columns[1].button("Hide All").clicked() { for column in &mut self.table_renderer.columns { column.visible = false; } } if columns[2].button("Reset to Default").clicked() { // Reset to default visibility for column in &mut self.table_renderer.columns { column.visible = matches!( column.field.as_str(), "asset_tag" | "asset_type" | "name" | "category_name" | "manufacturer" | "model" | "zone_code" | "status" | "lending_status" | "last_modified_date" ); } } }); }); } // Render table with event handling self.render_table_with_events(ui, api_client, session_manager); // Handle dialogs self.handle_dialogs(ui, api_client, limit, session_manager); // Process deferred actions from table events self.process_deferred_actions(ui, api_client, limit, session_manager); flags_to_clear } fn handle_dialogs( &mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>, limit: Option, session_manager: &std::sync::Arc>, ) { // Delete confirmation dialog if let Some(confirmed) = self.delete_dialog.show_dialog(ui.ctx()) { log::info!( "Delete dialog result: confirmed={}, pending_delete_ids={:?}", confirmed, self.pending_delete_ids ); if confirmed && !self.pending_delete_ids.is_empty() { if let Some(client) = api_client { for id in &self.pending_delete_ids { let where_clause = serde_json::json!({"id": id}); log::info!( "Sending DELETE for asset id {} with where {:?}", id, where_clause ); match client.delete("assets", where_clause) { Ok(resp) => { if resp.success { let deleted = resp.data.unwrap_or(0); log::info!( "Delete success for asset {} ({} row(s) affected)", id, deleted ); } else { log::error!( "Server rejected delete for asset {}: {:?}", id, resp.error ); } } Err(e) => { log::error!("Failed to delete asset {}: {}", id, e); } } } // Reload after attempting deletes self.load_assets(client, limit, None, None); } else { log::error!("No API client available for delete operation"); } self.pending_delete_ids.clear(); } } // Edit dialogs if let Some(Some(updated)) = self.edit_dialog.show_editor(ui.ctx()) { if let Some(client) = api_client { self.apply_updates(client, updated, limit); } } if let Some(Some(updated)) = self.advanced_edit_dialog.show_editor(ui.ctx()) { if let Some(client) = api_client { self.apply_updates(client, updated, limit); } } if let Some(Some(mut new_data)) = self.add_dialog.show_editor(ui.ctx()) { if let Some(client) = api_client { // Check if user requested label printing after add let print_after_add = new_data .get("print_label") .and_then(|v| v.as_bool()) .unwrap_or(false); // Preflight: enforce asset_tag uniqueness if provided if let Some(tag) = new_data.get("asset_tag").and_then(|v| v.as_str()) { let tag = tag.trim(); if !tag.is_empty() { let where_clause = serde_json::json!({ "asset_tag": tag }); if let Ok(resp) = client.select( "assets", Some(vec!["id".into()]), Some(where_clause), None, Some(1), ) { if resp.success { if let Some(rows) = resp.data { if !rows.is_empty() { // Tag already exists; reopen editor and require change log::warn!("Asset tag '{}' already exists; prompting user to change it", tag); // Put back the print flag and reopen the dialog with current values self.add_dialog.title = "Add Asset".to_string(); self.add_dialog .open(&serde_json::Value::Object(new_data.clone())); return; // Don't proceed to insert or print } } } else { log::error!("Tag uniqueness check failed: {:?}", resp.error); } } } } // Prepare asset data snapshot for printing (before filtering removes fields) let mut print_snapshot = new_data.clone(); // Remove the UI-only flag so it doesn't get sent to the server new_data.remove("print_label"); // Insert the asset and capture DB id let inserted_id = self.insert_new_asset(client, limit, new_data); // If requested, trigger printing with smart defaults: // - If default printer and label_template_id are set -> print directly // - Otherwise open the print dialog to ask what to do if print_after_add { if let Some(id) = inserted_id { let id_val = Value::Number(id.into()); // Expose id under both `id` and `asset_id` so label templates can pick it up print_snapshot.insert("id".to_string(), id_val.clone()); print_snapshot.insert("asset_id".to_string(), id_val); // Try to also include asset_numeric_id from the freshly reloaded assets if let Some(row) = self .assets .iter() .find(|r| r.get("id").and_then(|v| v.as_i64()) == Some(id)) { if let Some(n) = row.get("asset_numeric_id").and_then(|v| v.as_i64()) { print_snapshot.insert( "asset_numeric_id".to_string(), Value::Number(n.into()), ); } } } let asset_val = Value::Object(print_snapshot); self.open_print_dialog(&asset_val, api_client, false, session_manager); } } } // Workflow handling if let Some(client) = api_client { // Handle add from template workflow if let Some(asset_data) = self.add_from_template_workflow.show(ui, client) { if let Value::Object(mut map) = asset_data { // Extract optional print flag before insert let print_after_add = map .get("print_label") .and_then(|v| v.as_bool()) .unwrap_or(false); // Keep a snapshot for printing (includes label_template_id etc.) let mut print_snapshot = map.clone(); // Remove UI-only field so it doesn't get sent to server map.remove("print_label"); // Insert the asset and capture DB id let inserted_id = self.insert_new_asset(client, limit, map); // If requested, perform printing via smart defaults if print_after_add { if let Some(id) = inserted_id { let id_val = Value::Number(id.into()); print_snapshot.insert("id".to_string(), id_val.clone()); print_snapshot.insert("asset_id".to_string(), id_val); // Try to also include asset_numeric_id from the freshly reloaded assets if let Some(row) = self .assets .iter() .find(|r| r.get("id").and_then(|v| v.as_i64()) == Some(id)) { if let Some(n) = row.get("asset_numeric_id").and_then(|v| v.as_i64()) { print_snapshot.insert( "asset_numeric_id".to_string(), Value::Number(n.into()), ); } } } let asset_val = Value::Object(print_snapshot); self.open_print_dialog(&asset_val, api_client, false, session_manager); } } } // Show help window if requested if self.show_help { const HELP_TEXT: &str = r#"# Inventory Management ## Quick Actions - **Add Asset**: Create new assets from templates or blank forms - **Edit Asset**: Modify existing asset details - **Delete Asset**: Remove assets (requires confirmation) - **Print Label**: Generate and print asset labels ## Borrowing/Lending - **Borrow**: Check out assets to users - **Return**: Check assets back in ## Filtering - Use **Show Retired** to include/exclude retired assets - **Item Lookup** searches across multiple fields - Click **Filters** to build advanced queries ## Tips - Double-click a row to quick-edit - Right-click for context menu options - Use Ctrl+Click to select multiple items "#; show_help_window( ui.ctx(), &mut self.help_cache, "inventory_help", "Inventory Help", HELP_TEXT, &mut self.show_help, HelpWindowOptions::default(), ); } // Show borrow/return flows if open and reload when they complete successfully self.borrow_flow.show(ui.ctx(), client); if self.borrow_flow.take_recent_success() { self.load_assets(client, limit, None, None); } if self.return_flow.show(ui.ctx(), client) { // still open } else if self.return_flow.success_message.is_some() { self.load_assets(client, limit, None, None); } } // Print dialog if self.show_print_dialog { let mut should_clear_dialog = false; let mut completed_options: Option = None; let mut completed_asset_data: Option> = None; if let Some(dialog) = self.print_dialog.as_mut() { let mut open = self.show_print_dialog; let completed = dialog.show(ui.ctx(), &mut open, api_client); self.show_print_dialog = open; if completed { completed_options = Some(dialog.options().clone()); completed_asset_data = Some(dialog.asset_data().clone()); should_clear_dialog = true; } else if !self.show_print_dialog { // Dialog was closed without completing the print job should_clear_dialog = true; } } else { self.show_print_dialog = false; } if should_clear_dialog { self.print_dialog = None; } if let (Some(options), Some(asset_data)) = (completed_options, completed_asset_data) { if let (Some(printer_id), Some(template_id)) = (options.printer_id, options.label_template_id) { if let Some(client) = api_client { self.log_print_history( client, &asset_data, printer_id, template_id, "Success", ); } } } } } fn process_deferred_actions( &mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>, _limit: Option, session_manager: &std::sync::Arc>, ) { // Handle double-click edit if let Some(asset) = ui .ctx() .data_mut(|d| d.remove_temp::(egui::Id::new("double_click_edit"))) { log::info!( "Processing double-click edit for asset: {:?}", asset.get("name") ); self.open_easy_edit_with(&asset, api_client); } // Handle context menu actions if let Some(asset) = ui .ctx() .data_mut(|d| d.remove_temp::(egui::Id::new("context_menu_edit"))) { log::info!( "Processing context menu edit for asset: {:?}", asset.get("name") ); self.open_easy_edit_with(&asset, api_client); } if let Some(asset) = ui .ctx() .data_mut(|d| d.remove_temp::(egui::Id::new("context_menu_edit_adv"))) { log::info!( "Processing context menu advanced edit for asset: {:?}", asset.get("name") ); if let Some(client) = api_client { self.prepare_advanced_edit_fields(client); } self.advanced_edit_dialog.open(&asset); } if let Some(asset) = ui .ctx() .data_mut(|d| d.remove_temp::(egui::Id::new("context_menu_delete"))) { if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) { log::info!("Processing context menu delete for asset ID: {}", id); self.pending_delete_ids = vec![id]; self.delete_dialog.show = true; } } // Handle print label actions if let Some(asset) = ui .ctx() .data_mut(|d| d.remove_temp::(egui::Id::new("context_menu_print"))) { log::info!( "Processing context menu print for asset: {:?}", asset.get("name") ); self.open_print_dialog(&asset, api_client, false, session_manager); } if let Some(asset) = ui .ctx() .data_mut(|d| d.remove_temp::(egui::Id::new("context_menu_print_adv"))) { log::info!( "Processing context menu advanced print for asset: {:?}", asset.get("name") ); self.open_print_dialog(&asset, api_client, true, session_manager); } } /// Handle ribbon checkbox actions (main integration point) /// Returns a list of flags that should be cleared after processing fn handle_ribbon_actions( &mut self, ribbon: &crate::ui::ribbon::RibbonUI, api_client: Option<&ApiClient>, session_manager: &std::sync::Arc>, ) -> Vec { let mut flags_to_clear = Vec::new(); // Handle help button if *ribbon .checkboxes .get("inventory_show_help") .unwrap_or(&false) { flags_to_clear.push("inventory_show_help".to_string()); self.show_help = true; } // Handle limit settings first let limit = if *ribbon .checkboxes .get("inventory_no_limit") .unwrap_or(&false) { None } else { Some(*ribbon.number_fields.get("inventory_limit").unwrap_or(&100)) }; // Check if retired filter state changed and trigger refresh if needed let show_retired = *ribbon.checkboxes.get("show_retired").unwrap_or(&true); if self.last_show_retired_state != show_retired { self.last_show_retired_state = show_retired; if let Some(client) = api_client { self.loading_state.last_error = None; self.load_assets_with_filter(client, limit, show_retired); return flags_to_clear; // Early return to avoid duplicate loading } } // Handle filter builder changes if *ribbon .checkboxes .get("inventory_filter_changed") .unwrap_or(&false) { flags_to_clear.push("inventory_filter_changed".to_string()); if let Some(client) = api_client { self.loading_state.last_error = None; // Get current show_retired setting let show_retired = ribbon .checkboxes .get("show_retired") .copied() .unwrap_or(true); // Get user-defined filters from FilterBuilder let user_filter = ribbon.filter_builder.get_filter_json("assets"); // Combine retired filter with user filters let combined_filter = self.combine_filters(show_retired, user_filter); // Debug: Log the filter to see what we're getting if let Some(ref cf) = combined_filter { log::info!("Combined filter: {:?}", cf); } else { log::info!("No filter conditions (showing all assets)"); } self.load_assets(client, limit, None, combined_filter); return flags_to_clear; // Early return to avoid duplicate loading } } let selected_ids = self.get_selected_ids(); if *ribbon .checkboxes .get("inventory_action_add") .unwrap_or(&false) { if let Some(client) = api_client { self.prepare_add_asset_editor(client); } else { let mut preset = serde_json::Map::new(); preset.insert( "asset_type".to_string(), serde_json::Value::String("N".to_string()), ); preset.insert( "status".to_string(), serde_json::Value::String("Good".to_string()), ); self.add_dialog.title = "Add Asset".to_string(); self.add_dialog.open_new(Some(&preset)); } } if *ribbon .checkboxes .get("inventory_action_delete") .unwrap_or(&false) { if !selected_ids.is_empty() { self.pending_delete_ids = selected_ids.clone(); if selected_ids.len() == 1 { if let Some(asset) = self.find_asset_by_id(selected_ids[0]) { let name = asset .get("name") .and_then(|v| v.as_str()) .unwrap_or("Unknown") .to_string(); self.delete_dialog.open(name, selected_ids[0].to_string()); } } else { self.delete_dialog.title = "Delete Assets".to_string(); self.delete_dialog.message = format!( "Are you sure you want to delete {} selected assets?", selected_ids.len() ); self.delete_dialog.open( format!("Multiple items ({} selected)", selected_ids.len()), "multiple".to_string(), ); } } } if *ribbon .checkboxes .get("inventory_action_edit_easy") .unwrap_or(&false) { if !selected_ids.is_empty() { self.pending_edit_ids = selected_ids.clone(); self.is_bulk_edit = selected_ids.len() > 1; if let Some(client) = api_client { self.prepare_easy_edit_fields(client); } if selected_ids.len() == 1 { if let Some(asset) = self.find_asset_by_id(selected_ids[0]) { self.edit_dialog.open(&asset); } } else { self.edit_dialog.open_new(None); } } } if *ribbon .checkboxes .get("inventory_action_edit_adv") .unwrap_or(&false) { if !selected_ids.is_empty() { self.pending_edit_ids = selected_ids.clone(); self.is_bulk_edit = selected_ids.len() > 1; if let Some(client) = api_client { self.prepare_advanced_edit_fields(client); } if selected_ids.len() == 1 { if let Some(asset) = self.find_asset_by_id(selected_ids[0]) { self.advanced_edit_dialog.open(&asset); } } else { self.advanced_edit_dialog.open_new(None); } } } if *ribbon .checkboxes .get("inventory_action_print_label") .unwrap_or(&false) { if !selected_ids.is_empty() && selected_ids.len() == 1 { if let Some(asset) = self.find_asset_by_id(selected_ids[0]) { log::info!("Print label requested for asset: {:?}", asset.get("name")); // Check if Alt is held for advanced print (ctx not available here, so just default) self.open_print_dialog(&asset, api_client, false, session_manager); } } else if !selected_ids.is_empty() { log::warn!("Bulk label printing not yet implemented"); } } // Handle item lookup if *ribbon .checkboxes .get("item_lookup_trigger") .unwrap_or(&false) { if let Some(lookup_text) = ribbon.search_texts.get("item_lookup") { self.perform_item_lookup(lookup_text, api_client, limit); } } // Handle limit refresh trigger (when limit changes or refresh is needed) if *ribbon .checkboxes .get("inventory_limit_refresh_trigger") .unwrap_or(&false) { if let Some(client) = api_client { // Clear any previous error state when refreshing via ribbon self.loading_state.last_error = None; self.load_assets(client, limit, None, None); } } // Handle workflow actions if *ribbon .checkboxes .get("inventory_add_from_template_single") .unwrap_or(&false) { flags_to_clear.push("inventory_add_from_template_single".to_string()); if let Some(client) = api_client { self.add_from_template_workflow.start_single_mode(client); } } if *ribbon .checkboxes .get("inventory_add_from_template_multiple") .unwrap_or(&false) { flags_to_clear.push("inventory_add_from_template_multiple".to_string()); if let Some(client) = api_client { self.add_from_template_workflow.start_multiple_mode(client); } } flags_to_clear } /// Combine retired filter with user-defined filters fn combine_filters(&self, show_retired: bool, user_filter: Option) -> Option { if show_retired && user_filter.is_none() { // No filtering needed return None; } let mut conditions = Vec::new(); // Add retired filter if needed if !show_retired { conditions.push(serde_json::json!({ "column": "assets.status", "op": "!=", "value": "Retired" })); } // Add user filter conditions (sanitized for inventory joins) if let Some(mut filter) = user_filter { // Map columns from other views (e.g., borrowers.*) to inventory's JOIN aliases self.sanitize_filter_for_inventory(&mut filter); if let Some(and_array) = filter.get("and").and_then(|v| v.as_array()) { conditions.extend(and_array.iter().cloned()); } else if let Some(_or_array) = filter.get("or").and_then(|v| v.as_array()) { // Wrap OR conditions to maintain precedence conditions.push(filter); } else { // Single condition conditions.push(filter); } } // Return appropriate filter structure match conditions.len() { 0 => None, 1 => { if let Some(mut only) = conditions.into_iter().next() { // Final pass to sanitize single-condition filters self.sanitize_filter_for_inventory(&mut only); Some(only) } else { None } } _ => { let mut combined = serde_json::json!({ "and": conditions }); self.sanitize_filter_for_inventory(&mut combined); Some(combined) } } } /// Rewrite filter column names to match inventory JOIN aliases fn sanitize_filter_for_inventory(&self, filter: &mut Value) { fn rewrite_column(col: &str) -> String { match col { // Borrowing view columns → inventory aliases "borrowers.name" => "current_borrower.name".to_string(), "borrowers.class_name" => "current_borrower.class_name".to_string(), // Fallback: leave unchanged _ => col.to_string(), } } match filter { Value::Object(map) => { // If this object has a `column`, rewrite it if let Some(Value::String(col)) = map.get_mut("column") { let new_col = rewrite_column(col); *col = new_col; } // Recurse into possible logical groups if let Some(Value::Array(arr)) = map.get_mut("and") { for v in arr.iter_mut() { self.sanitize_filter_for_inventory(v); } } if let Some(Value::Array(arr)) = map.get_mut("or") { for v in arr.iter_mut() { self.sanitize_filter_for_inventory(v); } } } Value::Array(arr) => { for v in arr.iter_mut() { self.sanitize_filter_for_inventory(v); } } _ => {} } } } impl Default for InventoryView { fn default() -> Self { Self::new() } } #[derive(Clone)] enum DeferredAction { DoubleClick(Value), ContextEdit(Value), ContextAdvancedEdit(Value), ContextDelete(Value), ContextLend(Value), ContextReturn(Value), ContextPrintLabel(Value), ContextAdvancedPrint(Value), ContextClone(Value), } // Temporary event handler that collects actions for later processing struct TempInventoryEventHandler<'a> { deferred_actions: &'a mut Vec, } impl<'a> TableEventHandler for TempInventoryEventHandler<'a> { fn on_double_click(&mut self, item: &Value, _row_index: usize) { log::info!("Double-click detected on asset: {:?}", item.get("name")); self.deferred_actions .push(DeferredAction::DoubleClick(item.clone())); } fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) { if ui .button(format!("{} Edit", egui_phosphor::regular::PENCIL)) .clicked() { log::info!( "Context menu edit clicked for asset: {:?}", item.get("name") ); self.deferred_actions .push(DeferredAction::ContextEdit(item.clone())); ui.close(); } if ui .button(format!("{} Clone Asset", egui_phosphor::regular::COPY)) .clicked() { log::info!( "Context menu clone clicked for asset: {:?}", item.get("name") ); self.deferred_actions .push(DeferredAction::ContextClone(item.clone())); ui.close(); } if ui .button(format!("{} Advanced Edit", egui_phosphor::regular::GEAR)) .clicked() { log::info!( "Context menu advanced edit clicked for asset: {:?}", item.get("name") ); self.deferred_actions .push(DeferredAction::ContextAdvancedEdit(item.clone())); ui.close(); } ui.separator(); if ui .button(format!("{} Print Label", egui_phosphor::regular::PRINTER)) .clicked() { log::info!( "Context menu print label clicked for asset: {:?}", item.get("name") ); self.deferred_actions .push(DeferredAction::ContextPrintLabel(item.clone())); ui.close(); } if ui .button(format!( "{} Advanced Print", egui_phosphor::regular::PRINTER )) .clicked() { log::info!( "Context menu advanced print clicked for asset: {:?}", item.get("name") ); self.deferred_actions .push(DeferredAction::ContextAdvancedPrint(item.clone())); ui.close(); } ui.separator(); // Lend/Return options for lendable assets let lendable = match item.get("lendable") { Some(serde_json::Value::Bool(b)) => *b, Some(serde_json::Value::Number(n)) => n.as_i64() == Some(1) || n.as_u64() == Some(1), Some(serde_json::Value::String(s)) => { let s = s.to_lowercase(); s == "true" || s == "1" || s == "yes" || s == "y" } _ => false, }; // Only act when we have an explicit lending_status; blank usually means non-lendable or unmanaged let status = item .get("lending_status") .and_then(|v| v.as_str()) .map(|s| s.trim()) .unwrap_or(""); if lendable && !status.is_empty() { if status == "Available" { if ui .button(format!("{} Lend Item", egui_phosphor::regular::ARROW_LEFT)) .clicked() { log::info!( "Context menu lend clicked for asset: {:?}", item.get("name") ); self.deferred_actions .push(DeferredAction::ContextLend(item.clone())); ui.close(); } } else if matches!( status, "Borrowed" | "Overdue" | "Stolen" | "Illegally Handed Out" | "Deployed" ) { if ui .button(format!( "{} Return Item", egui_phosphor::regular::ARROW_RIGHT )) .clicked() { log::info!( "Context menu return clicked for asset: {:?}", item.get("name") ); self.deferred_actions .push(DeferredAction::ContextReturn(item.clone())); ui.close(); } } ui.separator(); } if ui .button(format!("{} Delete", egui_phosphor::regular::TRASH)) .clicked() { log::info!( "Context menu delete clicked for asset: {:?}", item.get("name") ); self.deferred_actions .push(DeferredAction::ContextDelete(item.clone())); ui.close(); } } fn on_selection_changed(&mut self, selected_indices: &[usize]) { log::debug!("Selection changed: {:?}", selected_indices); } }