use crate::api::ApiClient; use crate::core::components::form_builder::FormBuilder; use crate::core::components::interactions::ConfirmDialog; use crate::core::table_renderer::{ColumnConfig, TableEventHandler, TableRenderer}; use crate::core::tables::get_categories; use crate::core::{EditorField, FieldType}; use crate::ui::ribbon::RibbonUI; use eframe::egui; use serde_json::Value; pub struct CategoriesView { categories: Vec, is_loading: bool, last_error: Option, initial_load_done: bool, load_attempted: bool, // New field to track if we've tried loading // Editor dialogs edit_dialog: FormBuilder, add_dialog: FormBuilder, delete_dialog: ConfirmDialog, // Pending operations pending_delete_ids: Vec, // Changed from Option to Vec for bulk delete support pending_edit_ids: Vec, // Changed from Option to Vec for bulk edit support // Table rendering table_renderer: crate::core::table_renderer::TableRenderer, } impl CategoriesView { pub fn new() -> Self { let edit_dialog = Self::create_edit_dialog(); let add_dialog = Self::create_placeholder_add_dialog(); // Define columns for categories table - code before name as requested let columns = vec![ ColumnConfig::new("ID", "id").with_width(60.0).hidden(), ColumnConfig::new("Category Code", "category_code").with_width(120.0), ColumnConfig::new("Category Name", "category_name").with_width(200.0), ColumnConfig::new("Description", "category_description").with_width(300.0), ColumnConfig::new("Parent ID", "parent_id") .with_width(80.0) .hidden(), ColumnConfig::new("Parent Category", "parent_category_name").with_width(150.0), ]; Self { categories: Vec::new(), is_loading: false, last_error: None, initial_load_done: false, load_attempted: false, edit_dialog, add_dialog, delete_dialog: ConfirmDialog::new( "Delete Category", "Are you sure you want to delete this category? This will affect all assets using this category.", ), pending_delete_ids: Vec::new(), pending_edit_ids: Vec::new(), table_renderer: TableRenderer::new() .with_columns(columns) .with_default_sort("category_code", true) // Sort by category code alphabetically .with_search_fields(vec![ "category_name".to_string(), "category_code".to_string(), "category_description".to_string(), "parent_category_name".to_string(), ]), } } fn create_edit_dialog() -> FormBuilder { FormBuilder::new( "Edit Category", vec![ EditorField { name: "id".into(), label: "ID".into(), field_type: FieldType::Text, required: false, read_only: true, }, EditorField { name: "category_name".into(), label: "Category Name".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "category_code".into(), label: "Category Code".into(), field_type: FieldType::Text, required: false, read_only: false, }, EditorField { name: "category_description".into(), label: "Description".into(), field_type: FieldType::MultilineText, required: false, read_only: false, }, EditorField { name: "parent_id".into(), label: "Parent Category".into(), field_type: FieldType::Text, // TODO: Make this a dropdown with other categories required: false, read_only: false, }, ], ) } fn create_placeholder_add_dialog() -> FormBuilder { FormBuilder::new( "Add Category", vec![ EditorField { name: "category_name".into(), label: "Category Name".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "category_code".into(), label: "Category Code".into(), field_type: FieldType::Text, required: false, read_only: false, }, EditorField { name: "category_description".into(), label: "Description".into(), field_type: FieldType::MultilineText, required: false, read_only: false, }, EditorField { name: "parent_id".into(), label: "Parent Category".into(), field_type: FieldType::Text, // Will be updated to dropdown when opened required: false, read_only: false, }, ], ) } fn create_add_dialog_with_options(&self) -> FormBuilder { let category_options = self.create_category_dropdown_options(None); FormBuilder::new( "Add Category", vec![ EditorField { name: "category_name".into(), label: "Category Name".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "category_code".into(), label: "Category Code".into(), field_type: FieldType::Text, required: false, read_only: false, }, EditorField { name: "category_description".into(), label: "Description".into(), field_type: FieldType::MultilineText, required: false, read_only: false, }, EditorField { name: "parent_id".into(), label: "Parent Category".into(), field_type: FieldType::Dropdown(category_options), required: false, read_only: false, }, ], ) } fn load_categories(&mut self, api_client: &ApiClient) { // Don't start a new load if we're already loading if self.is_loading { return; } self.is_loading = true; self.last_error = None; self.load_attempted = true; match get_categories(api_client, Some(200)) { Ok(categories) => { self.categories = categories; self.initial_load_done = true; log::info!( "Categories loaded successfully: {} items", self.categories.len() ); } Err(e) => { let error_msg = format!("Error loading categories: {}", e); log::error!("{}", error_msg); self.last_error = Some(error_msg); } } self.is_loading = false; } /// Get selected category IDs for bulk operations (works with filtered view) fn get_selected_ids(&self) -> Vec { let filtered_data = self.table_renderer.prepare_json_data(&self.categories); let mut ids = Vec::new(); for &row_idx in &self.table_renderer.selection.selected_rows { // prepared_data contains tuples of (original_index, &Value) if let Some((_orig_idx, category)) = filtered_data.get(row_idx) { if let Some(id) = category.get("id").and_then(|v| v.as_i64()) { ids.push(id); } } } ids } /// Sanitize form data for categories before sending to the API. /// - Removes internal editor fields prefixed with __editor_ /// - Converts empty-string parent_id to JSON null /// - Coerces numeric parent_id strings to numbers fn sanitize_category_map( form_data: &serde_json::Map, ) -> serde_json::Map { let mut out = serde_json::Map::new(); for (k, v) in form_data.iter() { // Skip internal editor fields if k.starts_with("__editor_") { continue; } if k == "parent_id" { // parent_id might be sent as "" for None. Convert to null. if v.is_null() { out.insert(k.clone(), Value::Null); continue; } if let Some(s) = v.as_str() { let s_trim = s.trim(); if s_trim.is_empty() { out.insert(k.clone(), Value::Null); continue; } // Try parse integer if let Ok(n) = s_trim.parse::() { out.insert(k.clone(), Value::Number((n).into())); continue; } // Fallback: keep as string out.insert(k.clone(), Value::String(s_trim.to_string())); continue; } // If it's already a number, keep it if v.is_i64() || v.is_u64() || v.is_f64() { out.insert(k.clone(), v.clone()); continue; } // Anything else -> keep as-is out.insert(k.clone(), v.clone()); continue; } // For everything else, just copy through out.insert(k.clone(), v.clone()); } out } fn create_category( &mut self, api_client: &ApiClient, form_data: &serde_json::Map, ) { // Sanitize and coerce form data (convert empty parent_id -> null, remove internal fields) let sanitized = Self::sanitize_category_map(form_data); let values = serde_json::Value::Object(sanitized); match api_client.insert("categories", values) { Ok(resp) if resp.success => { log::info!("Category created successfully"); self.load_categories(api_client); // Reload to get fresh data } Ok(resp) => { let error_msg = format!("Create failed: {:?}", resp.error); log::error!("{}", error_msg); self.last_error = Some(error_msg); } Err(e) => { let error_msg = format!("Create error: {}", e); log::error!("{}", error_msg); self.last_error = Some(error_msg); } } } fn update_category( &mut self, api_client: &ApiClient, category_id: i64, form_data: &serde_json::Map, ) { // Sanitize form data (remove internal fields, coerce parent_id). Also ensure we don't send id. let mut filtered_data = Self::sanitize_category_map(form_data); filtered_data.remove("id"); // Convert form data to JSON object let values = serde_json::Value::Object(filtered_data); let where_clause = serde_json::json!({"id": category_id}); match api_client.update("categories", values, where_clause) { Ok(resp) if resp.success => { log::info!("Category updated successfully"); self.load_categories(api_client); // Reload to get fresh data } Ok(resp) => { let error_msg = format!("Update failed: {:?}", resp.error); log::error!("{}", error_msg); // Check for foreign key constraint errors if let Some(err_str) = resp.error.as_ref() { if err_str.contains("foreign key constraint") { self.last_error = Some( "Cannot update category: Invalid parent category reference." .to_string(), ); } else { self.last_error = Some(error_msg); } } else { self.last_error = Some(error_msg); } } Err(e) => { let error_msg = format!("Update error: {}", e); log::error!("{}", error_msg); self.last_error = Some(error_msg); } } } fn delete_category(&mut self, api_client: &ApiClient, category_id: i64) { let where_clause = serde_json::json!({"id": category_id}); match api_client.delete("categories", where_clause) { Ok(resp) if resp.success => { log::info!("Category deleted successfully"); self.load_categories(api_client); // Reload to get fresh data } Ok(resp) => { let error_msg = format!("Delete failed: {:?}", resp.error); log::error!("{}", error_msg); // Check for foreign key constraint errors and provide user-friendly message if let Some(err_str) = resp.error.as_ref() { if err_str.contains("foreign key constraint") || err_str.contains("Cannot delete or update a parent row") { self.last_error = Some( "Cannot delete category: It is being used by other categories as their parent, or by assets. \ Please reassign dependent items first.".to_string() ); } else { self.last_error = Some(error_msg); } } else { self.last_error = Some(error_msg); } } Err(e) => { let error_msg = format!("Delete error: {}", e); log::error!("{}", error_msg); // Check for foreign key constraint in error message let err_lower = error_msg.to_lowercase(); if err_lower.contains("foreign key") || err_lower.contains("constraint") { self.last_error = Some( "Cannot delete category: It is being used by other categories as their parent, or by assets. \ Please reassign dependent items first.".to_string() ); } else { self.last_error = Some(error_msg); } } } } pub fn show( &mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>, ribbon: Option<&mut RibbonUI>, ) -> Vec { let mut flags_to_clear = Vec::new(); // Handle context menu actions and double-click if let Some(item) = ui.ctx().data_mut(|d| { d.remove_temp::(egui::Id::new("cat_double_click_edit")) }) { self.open_editor_with(&item); ui.ctx().request_repaint(); } if let Some(item) = ui.ctx().data_mut(|d| { d.remove_temp::(egui::Id::new("cat_context_menu_edit")) }) { self.open_editor_with(&item); ui.ctx().request_repaint(); } if let Some(item) = ui.ctx().data_mut(|d| { d.remove_temp::(egui::Id::new("cat_context_menu_delete")) }) { let name = item .get("category_name") .and_then(|v| v.as_str()) .unwrap_or("Unknown") .to_string(); let id = item.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); self.pending_delete_ids = vec![id]; // Changed to vector self.delete_dialog.open(name, id.to_string()); ui.ctx().request_repaint(); } // Auto-load on first show, but only try once unless user explicitly requests retry if !self.initial_load_done && !self.is_loading && !self.load_attempted { if let Some(client) = api_client { log::info!("Categories view never loaded, triggering initial auto-load"); self.load_categories(client); } } // Extract search query and handle ribbon actions let search_query = if let Some(ribbon) = ribbon.as_ref() { let query = ribbon .search_texts .get("categories_search") .filter(|s| !s.trim().is_empty()) .map(|s| s.as_str()); // Handle ribbon actions if ribbon .checkboxes .get("categories_refresh") .copied() .unwrap_or(false) { if let Some(client) = api_client { // Reset error state and allow fresh load self.last_error = None; self.load_categories(client); } flags_to_clear.push("categories_refresh".to_string()); } if ribbon .checkboxes .get("categories_add") .copied() .unwrap_or(false) { // Create a new add dialog with current category options self.add_dialog = self.create_add_dialog_with_options(); self.add_dialog.open(&serde_json::json!({})); // Open with empty data flags_to_clear.push("categories_add".to_string()); } if ribbon .checkboxes .get("categories_edit") .copied() .unwrap_or(false) { // Get selected category IDs let selected_ids = self.get_selected_ids(); if !selected_ids.is_empty() { // For edit, only edit the first selected category (bulk edit of categories is complex) if let Some(&first_id) = selected_ids.first() { // Clone the category to avoid borrowing issues let category = self .categories .iter() .find(|c| c.get("id").and_then(|v| v.as_i64()) == Some(first_id)) .cloned(); if let Some(cat) = category { self.open_editor_with(&cat); } } } else { log::warn!("Edit requested but no categories selected"); } flags_to_clear.push("categories_edit".to_string()); } if ribbon .checkboxes .get("categories_delete") .copied() .unwrap_or(false) { // Get selected category IDs for bulk delete let selected_ids = self.get_selected_ids(); if !selected_ids.is_empty() { self.pending_delete_ids = selected_ids.clone(); let count = selected_ids.len(); // Show dialog with appropriate message for single or multiple deletes let message = if count == 1 { // Get the category name for single delete if let Some(category) = self.categories.iter().find(|c| { c.get("id").and_then(|v| v.as_i64()) == Some(selected_ids[0]) }) { category .get("category_name") .and_then(|v| v.as_str()) .unwrap_or("Unknown") .to_string() } else { "Unknown".to_string() } } else { format!("{} categories", count) }; self.delete_dialog .open(message, format!("IDs: {:?}", selected_ids)); } else { log::warn!("Delete requested but no categories selected"); } flags_to_clear.push("categories_delete".to_string()); } query } else { None }; // Top toolbar ui.horizontal(|ui| { ui.heading("Categories"); if self.is_loading { ui.spinner(); ui.label("Loading..."); } else { ui.label(format!("{} categories", self.categories.len())); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("➕ Add Category").clicked() { self.add_dialog.open_new(None); } if ui.button("Refresh").clicked() { if let Some(client) = api_client { // Reset error state and allow fresh load self.last_error = None; self.load_categories(client); } } }); }); ui.separator(); // Error display with retry option if let Some(error) = &self.last_error { ui.colored_label(egui::Color32::RED, format!("Error: {}", error)); ui.horizontal(|ui| { if ui.button("Try Again").clicked() { if let Some(client) = api_client { // Reset state and try loading again self.load_attempted = false; self.initial_load_done = false; self.load_categories(client); } } if ui.button("Clear Error").clicked() { self.last_error = None; } }); ui.separator(); } // Categories table if !self.is_loading && !self.categories.is_empty() { self.render_table(ui, search_query); } else if !self.is_loading { ui.centered_and_justified(|ui| { ui.label("No categories found. Click 'Add Category' to create one."); }); } // Handle dialogs if let Some(api_client) = api_client { // Add dialog if let Some(result) = self.add_dialog.show_editor(ui.ctx()) { if let Some(category_data) = result { log::info!("Creating new category: {:?}", category_data); self.create_category(api_client, &category_data); } } // Edit dialog if let Some(result) = self.edit_dialog.show_editor(ui.ctx()) { if let Some(category_data) = result { // Support bulk edit: if pending_edit_ids is empty, try to get ID from dialog let ids_to_edit: Vec = if !self.pending_edit_ids.is_empty() { std::mem::take(&mut self.pending_edit_ids) } else { // Single edit from dialog - extract ID from __editor_item_id or category data category_data .get("__editor_item_id") .and_then(|v| v.as_str()) .or_else(|| category_data.get("id").and_then(|v| v.as_str())) .and_then(|s| s.parse::().ok()) .map(|id| vec![id]) .unwrap_or_default() }; for category_id in ids_to_edit { log::info!("Updating category {}: {:?}", category_id, category_data); self.update_category(api_client, category_id, &category_data); } } } // Delete dialog - support bulk delete 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() { // Clone the IDs to avoid borrowing issues let ids_to_delete = self.pending_delete_ids.clone(); for category_id in ids_to_delete { log::info!("Deleting category: {}", category_id); self.delete_category(api_client, category_id); } } self.pending_delete_ids.clear(); } } flags_to_clear } fn render_table(&mut self, ui: &mut egui::Ui, search_query: Option<&str>) { // Apply search query to TableRenderer (clear if empty) match search_query { Some(query) => self.table_renderer.set_search_query(query.to_string()), None => self.table_renderer.set_search_query(String::new()), // Clear search when empty } // Prepare sorted/filtered data let prepared_data = self.table_renderer.prepare_json_data(&self.categories); // Create temporary event handler for deferred actions let mut deferred_actions = Vec::new(); let mut event_handler = TempCategoriesEventHandler { deferred_actions: &mut deferred_actions, }; // Render the table with TableRenderer self.table_renderer .render_json_table(ui, &prepared_data, Some(&mut event_handler)); // Process deferred actions for action in deferred_actions { match action { DeferredCategoryAction::DoubleClick(category) => { self.open_editor_with(&category); } DeferredCategoryAction::ContextEdit(category) => { self.open_editor_with(&category); } DeferredCategoryAction::ContextDelete(category) => { let name = category .get("category_name") .and_then(|v| v.as_str()) .unwrap_or("Unknown") .to_string(); let id = category.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); self.pending_delete_ids = vec![id]; // Changed to vector self.delete_dialog.open(name, id.to_string()); } DeferredCategoryAction::ContextClone(category) => { // Prepare Add dialog with up-to-date dropdown options self.add_dialog = self.create_add_dialog_with_options(); // Use the shared helper to clear ID/code and suffix the name let cloned = crate::core::components::prepare_cloned_value( &category, &["id", "category_code"], Some("category_name"), Some(""), ); self.add_dialog.title = "Add Category".to_string(); self.add_dialog.open(&cloned); } } } } fn create_category_dropdown_options(&self, exclude_id: Option) -> Vec<(String, String)> { let mut options = vec![("".to_string(), "None (Root Category)".to_string())]; for category in &self.categories { if let Some(id) = category.get("id").and_then(|v| v.as_i64()) { // Exclude the current category to prevent circular references if let Some(exclude) = exclude_id { if id == exclude { continue; } } let name = category .get("category_name") .and_then(|v| v.as_str()) .unwrap_or("Unknown") .to_string(); let code = category .get("category_code") .and_then(|v| v.as_str()) .unwrap_or(""); let display_name = if code.is_empty() { name } else { format!("{} - {}", code, name) }; options.push((id.to_string(), display_name)); } } options } fn create_edit_dialog_with_options(&self, exclude_id: Option) -> FormBuilder { let category_options = self.create_category_dropdown_options(exclude_id); FormBuilder::new( "Edit Category", vec![ EditorField { name: "id".into(), label: "ID".into(), field_type: FieldType::Text, required: false, read_only: true, }, EditorField { name: "category_name".into(), label: "Category Name".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "category_code".into(), label: "Category Code".into(), field_type: FieldType::Text, required: false, read_only: false, }, EditorField { name: "category_description".into(), label: "Description".into(), field_type: FieldType::MultilineText, required: false, read_only: false, }, EditorField { name: "parent_id".into(), label: "Parent Category".into(), field_type: FieldType::Dropdown(category_options), required: false, read_only: false, }, ], ) } fn open_editor_with(&mut self, item: &serde_json::Value) { let category_id = item.get("id").and_then(|v| v.as_i64()); // Clear pending_edit_ids since we're opening a single-item editor // The ID will be extracted from the dialog data when saving self.pending_edit_ids.clear(); // Create a new editor with current category options (excluding this category) self.edit_dialog = self.create_edit_dialog_with_options(category_id); self.edit_dialog.open(item); } } // Deferred actions for categories table #[derive(Debug)] enum DeferredCategoryAction { DoubleClick(Value), ContextEdit(Value), ContextDelete(Value), ContextClone(Value), } // Temporary event handler that collects actions for later processing struct TempCategoriesEventHandler<'a> { deferred_actions: &'a mut Vec, } impl<'a> TableEventHandler for TempCategoriesEventHandler<'a> { fn on_double_click(&mut self, item: &Value, _row_index: usize) { log::info!( "Double-click detected on category: {:?}", item.get("category_name") ); self.deferred_actions .push(DeferredCategoryAction::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 category: {:?}", item.get("category_name") ); self.deferred_actions .push(DeferredCategoryAction::ContextEdit(item.clone())); ui.close(); } ui.separator(); if ui .button(format!("{} Clone Category", egui_phosphor::regular::COPY)) .clicked() { log::info!( "Context menu clone clicked for category: {:?}", item.get("category_name") ); self.deferred_actions .push(DeferredCategoryAction::ContextClone(item.clone())); ui.close(); } ui.separator(); if ui .button(format!("{} Delete", egui_phosphor::regular::TRASH)) .clicked() { log::info!( "Context menu delete clicked for category: {:?}", item.get("category_name") ); self.deferred_actions .push(DeferredCategoryAction::ContextDelete(item.clone())); ui.close(); } } fn on_selection_changed(&mut self, _selected_indices: &[usize]) { // Selection handling is managed by the main CategoriesView // We don't need to do anything here for now } }