aboutsummaryrefslogtreecommitdiff
path: root/src/ui/zones.rs
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/ui/zones.rs
committing to insanityHEADmaster
Diffstat (limited to 'src/ui/zones.rs')
-rw-r--r--src/ui/zones.rs990
1 files changed, 990 insertions, 0 deletions
diff --git a/src/ui/zones.rs b/src/ui/zones.rs
new file mode 100644
index 0000000..d331642
--- /dev/null
+++ b/src/ui/zones.rs
@@ -0,0 +1,990 @@
+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<serde_json::Value>,
+ is_loading: bool,
+ last_error: Option<String>,
+ // UI state
+ show_items: bool,
+ search_query: String,
+ // Cache: assets per zone id
+ zone_assets: HashMap<i32, Vec<serde_json::Value>>,
+ // Request guards
+ initial_load_done: bool,
+ zone_assets_attempted: HashSet<i32>,
+ zone_assets_failed: HashSet<i32>,
+ // Editor dialogs for zones
+ edit_dialog: FormBuilder,
+ add_dialog: FormBuilder,
+ delete_dialog: ConfirmDialog,
+ // Pending operation
+ pending_delete_id: Option<i32>,
+ pending_parent_id: Option<i32>, // For "Add Child Zone"
+ // Navigation request
+ pub switch_to_inventory_with_zone: Option<String>, // zone_code to filter by
+ // Print dialog
+ print_dialog: Option<crate::core::print::PrintDialog>,
+ show_print_dialog: bool,
+ force_expand_state: Option<bool>,
+}
+
+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<serde_json::Value>) {
+ 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<serde_json::Value> = 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<Option<i32>, Vec<serde_json::Value>> = 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<Option<i32>, Vec<serde_json::Value>>,
+ 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<String, serde_json::Value>,
+ ) {
+ 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::<i64>() {
+ 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<String, serde_json::Value>,
+ ) {
+ 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::<i64>() {
+ 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();
+ }
+}