diff options
Diffstat (limited to 'src/ui/categories.rs')
| -rw-r--r-- | src/ui/categories.rs | 892 |
1 files changed, 892 insertions, 0 deletions
diff --git a/src/ui/categories.rs b/src/ui/categories.rs new file mode 100644 index 0000000..3b119e5 --- /dev/null +++ b/src/ui/categories.rs @@ -0,0 +1,892 @@ +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<serde_json::Value>, + is_loading: bool, + last_error: Option<String>, + 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<i64>, // Changed from Option<i64> to Vec<i64> for bulk delete support + pending_edit_ids: Vec<i64>, // Changed from Option<i64> to Vec<i64> 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<i64> { + 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<String, Value>, + ) -> serde_json::Map<String, Value> { + 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::<i64>() { + 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<String, Value>, + ) { + // 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<String, Value>, + ) { + // 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<String> { + 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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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<i64> = 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::<i64>().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<i64>) -> 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<i64>) -> 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<DeferredCategoryAction>, +} + +impl<'a> TableEventHandler<Value> 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 + } +} |
