use eframe::egui; use std::collections::{HashMap, HashSet}; use crate::core::components::form_builder::FormBuilder; use crate::core::components::interactions::ConfirmDialog; use crate::core::{EditorField, FieldType}; use crate::api::ApiClient; use crate::core::tables::get_assets_in_zone; pub struct ZonesView { zones: Vec, is_loading: bool, last_error: Option, // UI state show_items: bool, search_query: String, // Cache: assets per zone id zone_assets: HashMap>, // Request guards initial_load_done: bool, zone_assets_attempted: HashSet, zone_assets_failed: HashSet, // Editor dialogs for zones edit_dialog: FormBuilder, add_dialog: FormBuilder, delete_dialog: ConfirmDialog, // Pending operation pending_delete_id: Option, pending_parent_id: Option, // For "Add Child Zone" // Navigation request pub switch_to_inventory_with_zone: Option, // zone_code to filter by // Print dialog print_dialog: Option, show_print_dialog: bool, force_expand_state: Option, } impl ZonesView { pub fn new() -> Self { // Create basic editors first, they'll be updated with dropdowns when API client is available let edit_dialog = Self::create_edit_dialog(Vec::new()); let add_dialog = Self::create_add_dialog(Vec::new()); Self { zones: Vec::new(), is_loading: false, last_error: None, show_items: true, search_query: String::new(), zone_assets: HashMap::new(), initial_load_done: false, zone_assets_attempted: HashSet::new(), zone_assets_failed: HashSet::new(), edit_dialog, add_dialog, delete_dialog: ConfirmDialog::new("Delete Zone", "Are you sure you want to delete this zone? All items in this zone will lose their zone assignment."), pending_delete_id: None, pending_parent_id: None, switch_to_inventory_with_zone: None, print_dialog: None, show_print_dialog: false, force_expand_state: None, } } /// Check if a zone or any of its descendants have items fn zone_or_descendants_have_items( &self, zone_id: i32, all_zones: &[serde_json::Value], ) -> bool { // Check if this zone has items if self .zone_assets .get(&zone_id) .map(|assets| !assets.is_empty()) .unwrap_or(false) { return true; } // Check if any children have items (recursively) for z in all_zones { if let Some(parent_id) = z .get("parent_id") .and_then(|v| v.as_i64()) .map(|v| v as i32) { if parent_id == zone_id { if let Some(child_id) = z.get("id").and_then(|v| v.as_i64()).map(|v| v as i32) { if self.zone_or_descendants_have_items(child_id, all_zones) { return true; } } } } } false } /// Create edit dialog with zone options for parent selection fn create_edit_dialog(parent_options: Vec<(String, String)>) -> FormBuilder { let zone_type_options = vec![ ("Building".to_string(), "Building".to_string()), ("Floor".to_string(), "Floor".to_string()), ("Room".to_string(), "Room".to_string()), ("Storage Area".to_string(), "Storage Area".to_string()), ]; FormBuilder::new( "Edit Zone", vec![ EditorField { name: "id".into(), label: "ID".into(), field_type: FieldType::Text, required: false, read_only: true, }, EditorField { name: "mini_code".into(), label: "Mini Code".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "zone_code".into(), label: "Full Zone Code".into(), field_type: FieldType::Text, required: false, read_only: false, }, EditorField { name: "zone_name".into(), label: "Name".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "zone_type".into(), label: "Type".into(), field_type: FieldType::Dropdown(zone_type_options.clone()), required: false, read_only: false, }, EditorField { name: "parent_id".into(), label: "Parent Zone".into(), field_type: FieldType::Dropdown(parent_options.clone()), required: false, read_only: false, }, EditorField { name: "include_in_parent".into(), label: "Include in Parent".into(), field_type: FieldType::Checkbox, required: false, read_only: false, }, EditorField { name: "audit_timeout_minutes".into(), label: "Audit Timeout (minutes)".into(), field_type: FieldType::Text, required: false, read_only: false, }, EditorField { name: "zone_notes".into(), label: "Notes".into(), field_type: FieldType::MultilineText, required: false, read_only: false, }, ], ) } /// Create add dialog with zone options for parent selection fn create_add_dialog(parent_options: Vec<(String, String)>) -> FormBuilder { let zone_type_options = vec![ ("Building".to_string(), "Building".to_string()), ("Floor".to_string(), "Floor".to_string()), ("Room".to_string(), "Room".to_string()), ("Storage Area".to_string(), "Storage Area".to_string()), ]; FormBuilder::new( "Add Zone", vec![ EditorField { name: "mini_code".into(), label: "Mini Code".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "zone_code".into(), label: "Full Zone Code".into(), field_type: FieldType::Text, required: false, read_only: false, }, EditorField { name: "zone_name".into(), label: "Name".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "zone_type".into(), label: "Type".into(), field_type: FieldType::Dropdown(zone_type_options), required: false, read_only: false, }, EditorField { name: "parent_id".into(), label: "Parent Zone".into(), field_type: FieldType::Dropdown(parent_options), required: false, read_only: false, }, EditorField { name: "include_in_parent".into(), label: "Include in Parent".into(), field_type: FieldType::Checkbox, required: false, read_only: false, }, EditorField { name: "audit_timeout_minutes".into(), label: "Audit Timeout (minutes)".into(), field_type: FieldType::Text, required: false, read_only: false, }, EditorField { name: "zone_notes".into(), label: "Notes".into(), field_type: FieldType::MultilineText, required: false, read_only: false, }, ], ) } /// Update editor dialogs with current zone list for parent dropdown fn update_editor_dropdowns(&mut self) { let parent_options: Vec<(String, String)> = std::iter::once(("".to_string(), "(None)".to_string())) .chain(self.zones.iter().filter_map(|z| { let id = z.get("id")?.as_i64()?.to_string(); let code = z.get("zone_code")?.as_str()?; let name = z.get("name").or_else(|| z.get("zone_name"))?.as_str()?; Some((id, format!("{} - {}", code, name))) })) .collect(); self.edit_dialog = Self::create_edit_dialog(parent_options.clone()); self.add_dialog = Self::create_add_dialog(parent_options); } fn ensure_loaded(&mut self, api_client: Option<&ApiClient>) { if self.is_loading || self.initial_load_done { return; } if let Some(client) = api_client { self.load_zones(client, None); } } /// Load zones with optional filter fn load_zones(&mut self, api_client: &ApiClient, filter: Option) { use crate::core::tables::get_all_zones_with_filter; self.is_loading = true; self.last_error = None; match get_all_zones_with_filter(api_client, filter) { Ok(list) => { self.zones = list; self.is_loading = false; self.initial_load_done = true; // Update editor dropdowns with new zone list self.update_editor_dropdowns(); } Err(e) => { self.last_error = Some(e.to_string()); self.is_loading = false; self.initial_load_done = true; } } } /// Refresh zones data from the API pub fn refresh(&mut self, api_client: Option<&ApiClient>) { self.zones.clear(); self.is_loading = false; self.initial_load_done = false; self.zone_assets.clear(); self.zone_assets_attempted.clear(); self.zone_assets_failed.clear(); self.last_error = None; self.ensure_loaded(api_client); } pub fn show( &mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>, ribbon: &mut crate::ui::ribbon::RibbonUI, ) { self.ensure_loaded(api_client); // Handle filter changes from FilterBuilder if ribbon .checkboxes .get("zones_filter_changed") .copied() .unwrap_or(false) { ribbon .checkboxes .insert("zones_filter_changed".to_string(), false); if let Some(client) = api_client { self.last_error = None; // Get user-defined filters from FilterBuilder let user_filter = ribbon.filter_builder.get_filter_json("zones"); // Debug: Log the filter to see what we're getting if let Some(ref cf) = user_filter { log::info!("Zones filter: {:?}", cf); } else { log::info!("No filter conditions (showing all zones)"); } self.load_zones(client, user_filter); } } // Handle ribbon actions if ribbon .checkboxes .get("zones_action_add") .copied() .unwrap_or(false) { self.update_editor_dropdowns(); self.add_dialog.open_new(None); } if ribbon .checkboxes .get("zones_action_edit") .copied() .unwrap_or(false) { // Edit needs a selected zone - will be handled via context menu log::info!("Edit zone clicked - use right-click context menu on a zone"); } if ribbon .checkboxes .get("zones_action_remove") .copied() .unwrap_or(false) { // Remove needs a selected zone - will be handled via context menu log::info!("Remove zone clicked - use right-click context menu on a zone"); } // Update show_items from ribbon self.show_items = ribbon .checkboxes .get("zones_show_items") .copied() .unwrap_or(false); // Get show_empty preference from ribbon let show_empty = ribbon .checkboxes .get("zones_show_empty") .copied() .unwrap_or(true); ui.horizontal(|ui| { ui.heading("Zones"); if self.is_loading { ui.spinner(); ui.label("Loading zones..."); } if let Some(err) = &self.last_error { ui.colored_label(egui::Color32::RED, err); if ui.button("Refresh").clicked() { self.refresh(api_client); } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Collapse All").clicked() { self.set_all_open(false, ui.ctx()); } if ui.button("Expand All").clicked() { self.set_all_open(true, ui.ctx()); } if ui.button("➕ Add Zone").clicked() { self.update_editor_dropdowns(); self.add_dialog.open_new(None); } }); }); // Search bar ui.horizontal(|ui| { ui.label("Search:"); ui.text_edit_singleline(&mut self.search_query); if ui.button("Clear").clicked() { self.search_query.clear(); } }); ui.separator(); // If there was an error loading zones, stop here until user refreshes if self.last_error.is_some() { return; } if self.zones.is_empty() { return; } // Default: expand all once on first successful load // Filter zones by search query (case-insensitive) let search_lower = self.search_query.to_lowercase(); let filtered_zones: Vec = self .zones .iter() .filter(|z| { // Apply search filter if !search_lower.is_empty() { let code_match = z .get("zone_code") .and_then(|v| v.as_str()) .map(|s| s.to_lowercase().contains(&search_lower)) .unwrap_or(false); let mini_match = z .get("mini_code") .and_then(|v| v.as_str()) .map(|s| s.to_lowercase().contains(&search_lower)) .unwrap_or(false); let name_match = z .get("name") .or_else(|| z.get("zone_name")) .and_then(|v| v.as_str()) .map(|s| s.to_lowercase().contains(&search_lower)) .unwrap_or(false); if !code_match && !mini_match && !name_match { return false; } } // Apply empty filter if !show_empty { let zone_id = z.get("id").and_then(|v| v.as_i64()).map(|v| v as i32); if let Some(id) = zone_id { // Only show zones that have items or have descendants with items if !self.zone_or_descendants_have_items(id, &self.zones) { return false; } } } true }) .cloned() .collect(); // Build parent -> children map let mut children: HashMap, Vec> = HashMap::new(); for z in &filtered_zones { let parent = z .get("parent_id") .and_then(|v| v.as_i64()) .map(|v| v as i32); children.entry(parent).or_default().push(z.clone()); } // Sort children lists by zone_code for list in children.values_mut() { list.sort_by(|a, b| { a.get("zone_code") .and_then(|v| v.as_str()) .cmp(&b.get("zone_code").and_then(|v| v.as_str())) }); } // Render roots (parent = None) if let Some(roots) = children.get(&None) { egui::ScrollArea::both() .auto_shrink([false; 2]) .show(ui, |ui| { for root in roots { self.render_zone_node(ui, root, &children, api_client); } }); } // Clear the one-shot expand/collapse request after applying it if self.force_expand_state.is_some() { self.force_expand_state = None; } // Show zone editor dialogs if let Some(result) = self.edit_dialog.show_editor(ui.ctx()) { if let Some(updated) = result { log::info!("Zone updated: {:?}", updated); if let Some(client) = api_client { self.update_zone(client, &updated); } } } if let Some(result) = self.add_dialog.show_editor(ui.ctx()) { if let Some(new_zone) = result { log::info!("Zone added: {:?}", new_zone); if let Some(client) = api_client { self.create_zone(client, &new_zone); } } } // Show delete confirmation dialog if let Some(should_delete) = self.delete_dialog.show_dialog(ui.ctx()) { if should_delete { if let Some(zone_id) = self.pending_delete_id { log::info!("Deleting zone ID: {}", zone_id); if let Some(client) = api_client { self.delete_zone(client, zone_id); } } } self.pending_delete_id = None; } // Show print dialog if let Some(print_dialog) = &mut self.print_dialog { let print_complete = print_dialog.show(ui.ctx(), &mut self.show_print_dialog, api_client); if print_complete || !self.show_print_dialog { self.print_dialog = None; } } } fn render_zone_node( &mut self, ui: &mut egui::Ui, zone: &serde_json::Value, children: &HashMap, Vec>, api_client: Option<&ApiClient>, ) { let id = zone.get("id").and_then(|v| v.as_i64()).unwrap_or_default() as i32; let code = zone .get("zone_code") .and_then(|v| v.as_str()) .unwrap_or("?"); let mini = zone.get("mini_code").and_then(|v| v.as_str()).unwrap_or(""); let name = zone.get("name").and_then(|v| v.as_str()).unwrap_or(""); let label = if mini.is_empty() { format!("{} — {}", code, name) } else { // Subtle mini code display in parentheses format!("{} — {} ({})", code, name, mini) }; let mut header = egui::CollapsingHeader::new(label.clone()) .id_salt(("zone", id)) .default_open(true); if let Some(force) = self.force_expand_state { let ctx = ui.ctx(); let id_key = egui::Id::new(label.clone()).with(("zone", id)); let mut state = egui::collapsing_header::CollapsingState::load_with_default_open( ctx, id_key, force, ); state.set_open(force); state.store(ctx); header = header.open(Some(force)); } let resp = header.show(ui, |ui| { // Children zones if let Some(kids) = children.get(&Some(id)) { ui.indent(egui::Id::new(("zone_indent", id)), |ui| { for child in kids { self.render_zone_node(ui, child, children, api_client); } }); } // Optional: items in this zone if self.show_items { ui.indent(egui::Id::new(("zone_items", id)), |ui| { ui.spacing_mut().item_spacing.y = 2.0; // Load from cache or fetch if !self.zone_assets.contains_key(&id) && !self.zone_assets_attempted.contains(&id) && !self.zone_assets_failed.contains(&id) { self.zone_assets_attempted.insert(id); if let Some(client) = api_client { match get_assets_in_zone(client, id, Some(200)) { Ok(list) => { self.zone_assets.insert(id, list); } Err(e) => { self.zone_assets_failed.insert(id); ui.colored_label( egui::Color32::RED, format!("Failed to load items: {}", e), ); } } } } if self.zone_assets_failed.contains(&id) && !self.zone_assets.contains_key(&id) { ui.colored_label( egui::Color32::RED, "(items failed to load – use Refresh at top)", ); } if let Some(items) = self.zone_assets.get(&id) { for a in items { let tag = a.get("asset_tag").and_then(|v| v.as_str()).unwrap_or("?"); let nm = a.get("name").and_then(|v| v.as_str()).unwrap_or(""); let status = a.get("status").and_then(|v| v.as_str()).unwrap_or(""); ui.label(format!("• [{}] {} ({})", tag, nm, status)); } if items.is_empty() { ui.label("(no items)"); } } }); } }); // Add context menu to header for editing resp.header_response.context_menu(|ui| { if ui .button(format!("{} Edit Zone", egui_phosphor::regular::PENCIL)) .clicked() { // Update dropdowns with current zone list self.update_editor_dropdowns(); // Map the zone data to match the editor field names if let Some(zone_obj) = zone.as_object() { let mut zone_for_editor = zone_obj.clone(); // The data comes in with "name" (aliased from zone_name), but editor expects "zone_name" if let Some(name_value) = zone_for_editor.remove("name") { zone_for_editor.insert("zone_name".to_string(), name_value); } // Convert integer fields to strings if they're numbers if let Some(audit_timeout) = zone_for_editor.get("audit_timeout_minutes") { if let Some(num) = audit_timeout.as_i64() { zone_for_editor.insert( "audit_timeout_minutes".to_string(), serde_json::json!(num.to_string()), ); } } self.edit_dialog .open(&serde_json::Value::Object(zone_for_editor)); ui.close(); } else { log::error!("Zone data is not an object, cannot edit"); } } if ui .button(format!("{} Delete Zone", egui_phosphor::regular::TRASH)) .clicked() { self.pending_delete_id = Some(id); self.delete_dialog .open(format!("{} - {}", code, name), id.to_string()); ui.close(); } ui.separator(); if ui .button(format!("{} Add Child Zone", egui_phosphor::regular::PLUS)) .clicked() { // Open add dialog with parent_id pre-filled self.pending_parent_id = Some(id); self.update_editor_dropdowns(); // Create initial data with parent_id let mut initial_data = serde_json::Map::new(); initial_data.insert("parent_id".to_string(), serde_json::json!(id.to_string())); self.add_dialog.open_new(Some(&initial_data)); ui.close(); } if ui .button(format!( "{} Show Items in this Zone", egui_phosphor::regular::PACKAGE )) .clicked() { // Set the flag to switch to inventory with this zone filter self.switch_to_inventory_with_zone = Some(code.to_string()); ui.close(); } if ui .button(format!( "{} Print Zone Label", egui_phosphor::regular::PRINTER )) .clicked() { // Prepare zone data for printing let mut print_data = std::collections::HashMap::new(); print_data.insert("zone_code".to_string(), code.to_string()); print_data.insert("zone_name".to_string(), name.to_string()); // Extract additional fields from zone JSON if let Some(zone_type) = zone.get("zone_type").and_then(|v| v.as_str()) { print_data.insert("zone_type".to_string(), zone_type.to_string()); } if let Some(zone_barcode) = zone.get("zone_barcode").and_then(|v| v.as_str()) { print_data.insert("zone_barcode".to_string(), zone_barcode.to_string()); } // Create new print dialog with zone data self.print_dialog = Some(crate::core::print::PrintDialog::new(print_data)); self.show_print_dialog = true; ui.close(); } ui.separator(); if ui .button(format!("{} Clone Zone", egui_phosphor::regular::COPY)) .clicked() { // Open add dialog prefilled with cloned values self.update_editor_dropdowns(); // Start from original zone object let mut clone_map = zone.as_object().cloned().unwrap_or_default(); // Editor expects zone_name instead of name if let Some(name_val) = clone_map.remove("name") { clone_map.insert("zone_name".to_string(), name_val); } // Clear identifiers and codes that must be unique clone_map.remove("id"); clone_map.insert( "zone_code".to_string(), serde_json::Value::String(String::new()), ); // Mini code is required but typically unique — leave empty to force user choice clone_map.insert( "mini_code".to_string(), serde_json::Value::String(String::new()), ); // Convert parent_id to string for dropdown if present if let Some(p) = clone_map.get("parent_id").cloned() { let as_str = match p { serde_json::Value::Number(n) => { n.as_i64().map(|i| i.to_string()).unwrap_or_default() } serde_json::Value::String(s) => s, _ => String::new(), }; clone_map.insert("parent_id".to_string(), serde_json::Value::String(as_str)); } // Ensure audit_timeout_minutes is string if let Some(a) = clone_map.get("audit_timeout_minutes").cloned() { if let Some(num) = a.as_i64() { clone_map.insert( "audit_timeout_minutes".to_string(), serde_json::json!(num.to_string()), ); } } // Suffix the name to indicate copy if let Some(serde_json::Value::String(nm)) = clone_map.get_mut("zone_name") { nm.push_str(""); } // Open prefilled Add dialog self.add_dialog.open_new(Some(&clone_map)); ui.close(); } }); } /// Create a new zone via API fn create_zone( &mut self, api_client: &ApiClient, zone_data: &serde_json::Map, ) { use crate::models::QueryRequest; // Clean the data - remove empty parent_id and convert audit_timeout_minutes to integer let mut clean_data = zone_data.clone(); if let Some(parent_id) = clean_data.get("parent_id") { if parent_id.as_str() == Some("") || parent_id.is_null() { clean_data.remove("parent_id"); } } // Do not auto-generate full zone_code here; use user-provided value as-is for now. // Convert audit_timeout_minutes string to integer if present and not empty if let Some(audit_timeout) = clean_data.get("audit_timeout_minutes") { if let Some(s) = audit_timeout.as_str() { if !s.is_empty() { if let Ok(num) = s.parse::() { clean_data .insert("audit_timeout_minutes".to_string(), serde_json::json!(num)); } } else { clean_data.remove("audit_timeout_minutes"); } } } let request = QueryRequest { action: "insert".to_string(), table: "zones".to_string(), columns: None, data: Some(serde_json::Value::Object(clean_data)), r#where: None, filter: None, order_by: None, limit: None, offset: None, joins: None, }; match api_client.query(&request) { Ok(response) => { if response.success { self.refresh(Some(api_client)); } else { log::error!("Failed to create zone: {:?}", response.error); } } Err(e) => { log::error!("Error creating zone: {}", e); } } } /// Update an existing zone via API fn update_zone( &mut self, api_client: &ApiClient, zone_data: &serde_json::Map, ) { use crate::models::QueryRequest; // Try to get ID from various possible locations let zone_id = zone_data .get("id") .and_then(|v| { v.as_i64() .or_else(|| v.as_str().and_then(|s| s.parse().ok())) }) .or_else(|| { // Check for __editor_item_id which is set by the editor for read-only ID fields zone_data .get("__editor_item_id") .and_then(|v| v.as_str().and_then(|s| s.parse().ok())) }) .unwrap_or_else(|| { log::error!("Zone update attempted without ID"); 0 }); // Create a clean data object without the editor metadata and without the id field let mut clean_data = zone_data.clone(); clean_data.remove("__editor_item_id"); clean_data.remove("id"); // Don't include id in the update data, only in WHERE // Remove empty parent_id if let Some(parent_id) = clean_data.get("parent_id") { if parent_id.as_str() == Some("") || parent_id.is_null() { clean_data.remove("parent_id"); } } // Do not auto-generate full zone_code during updates either; keep user value intact. // Convert audit_timeout_minutes string to integer if present and not empty if let Some(audit_timeout) = clean_data.get("audit_timeout_minutes") { if let Some(s) = audit_timeout.as_str() { if !s.is_empty() { if let Ok(num) = s.parse::() { clean_data .insert("audit_timeout_minutes".to_string(), serde_json::json!(num)); } } else { clean_data.remove("audit_timeout_minutes"); } } } let request = QueryRequest { action: "update".to_string(), table: "zones".to_string(), columns: None, data: Some(serde_json::Value::Object(clean_data)), r#where: Some(serde_json::json!({ "id": zone_id })), filter: None, order_by: None, limit: None, offset: None, joins: None, }; match api_client.query(&request) { Ok(response) => { if response.success { self.refresh(Some(api_client)); } else { log::error!("Failed to update zone: {:?}", response.error); } } Err(e) => { log::error!("Error updating zone: {}", e); } } } /// Delete a zone via API fn delete_zone(&mut self, api_client: &ApiClient, zone_id: i32) { use crate::models::QueryRequest; let request = QueryRequest { action: "delete".to_string(), table: "zones".to_string(), columns: None, data: None, r#where: Some(serde_json::json!({ "id": zone_id })), filter: None, order_by: None, limit: None, offset: None, joins: None, }; match api_client.query(&request) { Ok(response) => { if response.success { self.refresh(Some(api_client)); } else { log::error!("Failed to delete zone: {:?}", response.error); } } Err(e) => { log::error!("Error deleting zone: {}", e); } } } fn set_all_open(&mut self, open: bool, ctx: &egui::Context) { self.force_expand_state = Some(open); ctx.request_repaint(); } }