diff options
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/app.rs | 1268 | ||||
| -rw-r--r-- | src/ui/audits.rs | 898 | ||||
| -rw-r--r-- | src/ui/borrowing.rs | 1618 | ||||
| -rw-r--r-- | src/ui/categories.rs | 892 | ||||
| -rw-r--r-- | src/ui/dashboard.rs | 384 | ||||
| -rw-r--r-- | src/ui/inventory.rs | 1933 | ||||
| -rw-r--r-- | src/ui/issues.rs | 773 | ||||
| -rw-r--r-- | src/ui/label_templates.rs | 607 | ||||
| -rw-r--r-- | src/ui/login.rs | 272 | ||||
| -rw-r--r-- | src/ui/mod.rs | 14 | ||||
| -rw-r--r-- | src/ui/printers.rs | 943 | ||||
| -rw-r--r-- | src/ui/ribbon.rs | 1056 | ||||
| -rw-r--r-- | src/ui/suppliers.rs | 802 | ||||
| -rw-r--r-- | src/ui/templates.rs | 1113 | ||||
| -rw-r--r-- | src/ui/zones.rs | 990 |
15 files changed, 13563 insertions, 0 deletions
diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..d970d37 --- /dev/null +++ b/src/ui/app.rs @@ -0,0 +1,1268 @@ +use eframe::egui; +use std::collections::HashMap; +use std::sync::{mpsc, Arc}; +use tokio::sync::Mutex; + +use super::audits::AuditsView; +use super::borrowing::BorrowingView; +use super::categories::CategoriesView; +use super::dashboard::DashboardView; +use super::inventory::InventoryView; +use super::issues::IssuesView; +use super::label_templates::LabelTemplatesView; +use super::login::LoginScreen; +use super::printers::PrintersView; +use super::ribbon::RibbonUI; +use super::suppliers::SuppliersView; +use super::templates::TemplatesView; +use super::zones::ZonesView; +use crate::api::ApiClient; +use crate::config::AppConfig; +use crate::models::{LoginResponse, UserInfo}; +use crate::session::{SessionData, SessionManager}; + +pub struct BeepZoneApp { + // Session management + session_manager: Arc<Mutex<SessionManager>>, + api_client: Option<ApiClient>, + + // Current view state + current_view: AppView, + previous_view: Option<AppView>, + current_user: Option<UserInfo>, + + // Per-view filter state storage + view_filter_states: HashMap<AppView, crate::core::components::filter_builder::FilterGroup>, + + // UI components + login_screen: LoginScreen, + dashboard: DashboardView, + inventory: InventoryView, + categories: CategoriesView, + zones: ZonesView, + borrowing: BorrowingView, + audits: AuditsView, + templates: TemplatesView, + suppliers: SuppliersView, + issues: IssuesView, + printers: PrintersView, + label_templates: LabelTemplatesView, + ribbon_ui: Option<RibbonUI>, + + // Configuration + #[allow(dead_code)] + app_config: Option<AppConfig>, + + // State + login_success: Option<(String, LoginResponse)>, + show_about: bool, + + // Status bar state + server_status: ServerStatus, + last_health_check: std::time::Instant, + health_check_in_progress: bool, + health_check_rx: Option<mpsc::Receiver<HealthCheckResult>>, + // Re-authentication prompt state + reauth_needed: bool, + reauth_password: String, + + // Database outage tracking + db_offline_latch: bool, + last_timeout_at: Option<std::time::Instant>, + consecutive_healthy_checks: u8, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ServerStatus { + Unknown, + Connected, + Disconnected, + Checking, +} + +#[derive(Debug, Clone, Copy)] +struct HealthCheckResult { + status: ServerStatus, + reauth_required: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AppView { + Login, + Dashboard, + Inventory, + Categories, + Zones, + Borrowing, + Audits, + Templates, + Suppliers, + IssueTracker, + Printers, + LabelTemplates, +} + +impl BeepZoneApp { + pub fn new( + _cc: &eframe::CreationContext<'_>, + session_manager: Arc<Mutex<SessionManager>>, + ) -> Self { + let session_manager_blocking = session_manager.blocking_lock(); + let login_screen = LoginScreen::new(&session_manager_blocking); + + // Try to restore session on startup + let (api_client, current_view, current_user) = + if let Some(session) = session_manager_blocking.get_session() { + log::info!("Found saved session, attempting to restore..."); + + // Create API client with saved token + match ApiClient::new(session.server_url.clone()) { + Ok(mut client) => { + client.set_token(session.token.clone()); + + // Verify session is still valid (tolerant) + match client.check_session_valid() { + Ok(true) => { + log::info!( + "Session restored successfully for user: {}", + session.user.username + ); + (Some(client), AppView::Dashboard, Some(session.user.clone())) + } + Ok(false) => { + log::warn!("Saved session check returned invalid"); + (None, AppView::Login, None) + } + Err(e) => { + log::warn!("Saved session validity check error: {}", e); + // Be forgiving on startup: keep client and let periodic checks refine + (Some(client), AppView::Dashboard, Some(session.user.clone())) + } + } + } + Err(e) => { + log::error!("Failed to create API client: {}", e); + (None, AppView::Login, None) + } + } + } else { + log::info!("No saved session found"); + (None, AppView::Login, None) + }; + + drop(session_manager_blocking); + + // Load configuration and initialize ribbon UI + let ribbon_ui = Some(RibbonUI::default()); + let app_config = None; + + let mut app = Self { + session_manager, + api_client, + current_view, + previous_view: None, + view_filter_states: HashMap::new(), + current_user, + login_screen, + dashboard: DashboardView::new(), + inventory: InventoryView::new(), + categories: CategoriesView::new(), + zones: ZonesView::new(), + borrowing: BorrowingView::new(), + audits: AuditsView::new(), + templates: TemplatesView::new(), + suppliers: SuppliersView::new(), + issues: IssuesView::new(), + printers: PrintersView::new(), + label_templates: LabelTemplatesView::new(), + ribbon_ui, + app_config, + login_success: None, + show_about: false, + server_status: ServerStatus::Unknown, + last_health_check: std::time::Instant::now(), + health_check_in_progress: false, + health_check_rx: None, + reauth_needed: false, + reauth_password: String::new(), + db_offline_latch: false, + last_timeout_at: None, + consecutive_healthy_checks: 0, + }; + + // Do initial health check if we have an API client + if app.api_client.is_some() { + app.request_health_check(); + } + + app + } + + fn handle_login_success(&mut self, server_url: String, response: LoginResponse) { + // Capture username for logging before moving fields out of response + let username = response.user.username.clone(); + log::info!("Login successful for user: {}", username); + + // Create API client with token + let mut api_client = match ApiClient::new(server_url.clone()) { + Ok(client) => client, + Err(e) => { + log::error!("Failed to create API client: {}", e); + // This shouldn't happen in normal operation, so just log and continue without client + return; + } + }; + api_client.set_token(response.token.clone()); + + self.api_client = Some(api_client); + self.current_user = Some(response.user.clone()); + + // Save session (blocking is fine here, it's just writing a small JSON file) + let session_data = SessionData { + server_url, + token: response.token, + user: response.user, + remember_server: true, + remember_username: true, + saved_username: self.current_user.as_ref().map(|u| u.username.clone()), + default_printer_id: None, + last_printer_id: None, + }; + + let mut session_manager = self.session_manager.blocking_lock(); + if let Err(e) = session_manager.save_session(session_data) { + log::error!("Failed to save session: {}", e); + } + + // Switch to dashboard + self.current_view = AppView::Dashboard; + self.reauth_needed = false; + self.reauth_password.clear(); + + // Load dashboard data + if let Some(client) = self.api_client.as_ref() { + self.dashboard.refresh_data(client); + } + } + + fn handle_reauth_success(&mut self, server_url: String, response: LoginResponse) { + // Preserve current view but refresh token and user + let mut new_client = match ApiClient::new(server_url.clone()) { + Ok(client) => client, + Err(e) => { + log::error!("Failed to create API client during reauth: {}", e); + self.reauth_needed = true; + return; + } + }; + new_client.set_token(response.token.clone()); + + // Replace client and user + self.api_client = Some(new_client); + self.current_user = Some(response.user.clone()); + + // Save updated session + let session_data = SessionData { + server_url, + token: response.token, + user: response.user, + remember_server: true, + remember_username: true, + saved_username: self.current_user.as_ref().map(|u| u.username.clone()), + default_printer_id: None, + last_printer_id: None, + }; + + let mut session_manager = self.session_manager.blocking_lock(); + if let Err(e) = session_manager.save_session(session_data) { + log::error!("Failed to save session after reauth: {}", e); + } + } + + fn show_top_bar(&mut self, ctx: &egui::Context, disable_actions: bool) { + egui::TopBottomPanel::top("top_bar") + .exact_height(45.0) + .show_separator_line(false) + .frame( + egui::Frame::new() + .fill(ctx.style().visuals.window_fill) + .stroke(egui::Stroke::NONE) + .inner_margin(egui::vec2(16.0, 5.0)), + ) + .show(ctx, |ui| { + // Horizontal layout for title and controls + ui.horizontal(|ui| { + ui.heading("BeepZone"); + + ui.separator(); + + // User info + if let Some(user) = &self.current_user { + ui.label(format!("User: {} ({})", user.username, user.role)); + ui.label(format!("Powah: {}", user.power)); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_enabled_ui(!disable_actions, |ui| { + if ui.button("About").clicked() { + self.show_about = true; + } + + if ui.button("Bye").clicked() { + self.handle_logout(); + } + }); + }); + }); + }); + } + + fn show_ribbon(&mut self, ctx: &egui::Context) -> Option<String> { + let mut action_triggered = None; + + if let Some(ribbon_ui) = &mut self.ribbon_ui { + let min_height = ribbon_ui.preferred_height(); + + // Outer container panel with normal background + egui::TopBottomPanel::top("ribbon_container") + .min_height(min_height + 16.0) + .max_height(min_height + 96.0) + .show_separator_line(false) + .frame(egui::Frame::new().fill(if ctx.style().visuals.dark_mode { + ctx.style().visuals.panel_fill + } else { + // Darker background in light mode + egui::Color32::from_rgb(210, 210, 210) + })) + .show(ctx, |ui| { + ui.add_space(0.0); + + let side_margin: f32 = 16.0; + let inner_pad: f32 = 8.0; + + ui.horizontal(|ui| { + // Left margin + ui.add_space(side_margin); + + // Remaining width after left margin + let remaining = ui.available_width(); + // Leave room for right margin and inner padding on both sides of the frame + let content_width = (remaining - side_margin - inner_pad * 2.0).max(0.0); + + // Custom ribbon background color based on theme + let is_dark_mode = ctx.style().visuals.dark_mode; + let ribbon_bg_color = if is_dark_mode { + // Lighter gray for dark mode - more visible contrast + egui::Color32::from_rgb(45, 45, 45) + } else { + // Lighter/white ribbon in light mode + egui::Color32::from_rgb(248, 248, 248) + }; + + egui::Frame::new() + .fill(ribbon_bg_color) + .inner_margin(inner_pad) + .corner_radius(6.0) + .show(ui, |ui| { + // Constrain to the computed content width so right margin remains + ui.set_width(content_width); + ui.scope(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0); + action_triggered = ribbon_ui.show(ctx, ui); + }); + + // Update current view based on active ribbon tab + if let Some(view_name) = ribbon_ui.get_active_view() { + self.current_view = match view_name.to_lowercase().as_str() { + "dashboard" => AppView::Dashboard, + "inventory" => AppView::Inventory, + "categories" => AppView::Categories, + "zones" => AppView::Zones, + "borrowing" => AppView::Borrowing, + "audits" => AppView::Audits, + "item templates" => AppView::Templates, + "templates" => AppView::Templates, // Backwards compat + "suppliers" => AppView::Suppliers, + "issues" | "issue_tracker" => AppView::IssueTracker, + "printers" => AppView::Printers, + "label templates" => AppView::LabelTemplates, + _ => self.current_view, + }; + } + }); + + // Right margin + ui.add_space(side_margin); + }); + + ui.add_space(8.0); + }); + } else { + // Fallback to simple ribbon if config failed to load + egui::TopBottomPanel::top("ribbon") + .exact_height(38.0) + .show_separator_line(false) + .show(ctx, |ui| { + ui.add_space(2.0); + ui.horizontal_wrapped(|ui| { + ui.selectable_value( + &mut self.current_view, + AppView::Dashboard, + "Dashboard", + ); + ui.selectable_value( + &mut self.current_view, + AppView::Inventory, + "Inventory", + ); + ui.selectable_value( + &mut self.current_view, + AppView::Categories, + "Categories", + ); + ui.selectable_value(&mut self.current_view, AppView::Zones, "Zones"); + ui.selectable_value( + &mut self.current_view, + AppView::Borrowing, + "Borrowing", + ); + ui.selectable_value(&mut self.current_view, AppView::Audits, "Audits"); + ui.selectable_value( + &mut self.current_view, + AppView::Templates, + "Templates", + ); + ui.selectable_value( + &mut self.current_view, + AppView::Suppliers, + "Suppliers", + ); + ui.selectable_value( + &mut self.current_view, + AppView::IssueTracker, + "Issues", + ); + }); + }); + } + + action_triggered + } + + fn handle_logout(&mut self) { + log::info!("Taking myself out"); + + // Logout from API + if let Some(api_client) = &self.api_client { + let _ = api_client.logout(); + } + + // Clear session and reset login screen (do both while holding the lock once) + { + let mut session_manager = self.session_manager.blocking_lock(); + if let Err(e) = session_manager.clear_session() { + log::error!("Failed to clear session: {}", e); + } + + // Reset login screen while we still have the lock + self.login_screen = LoginScreen::new(&session_manager); + } // Lock is dropped here + + // Reset state + self.api_client = None; + self.current_user = None; + self.current_view = AppView::Login; + self.server_status = ServerStatus::Unknown; + } + + /// Force an immediate health check (used when timeout errors detected) + pub fn force_health_check(&mut self) { + self.last_health_check = std::time::Instant::now() - std::time::Duration::from_secs(10); + self.request_health_check(); + } + + fn request_health_check(&mut self) { + if self.api_client.is_none() || self.health_check_in_progress { + return; + } + + if let Some(client) = &self.api_client { + let api_client = client.clone(); + let reauth_needed = self.reauth_needed; + let (tx, rx) = mpsc::channel(); + self.health_check_rx = Some(rx); + self.health_check_in_progress = true; + self.server_status = ServerStatus::Checking; + self.last_health_check = std::time::Instant::now(); + + std::thread::spawn(move || { + let result = Self::run_health_check(api_client, reauth_needed); + let _ = tx.send(result); + }); + } + } + + fn desired_health_interval(&self, predicted_block: bool) -> f32 { + if predicted_block || self.db_offline_latch { + 0.75 + } else if matches!(self.server_status, ServerStatus::Connected) { + 1.5 + } else { + 2.5 + } + } + + fn poll_health_check(&mut self) { + if let Some(rx) = &self.health_check_rx { + match rx.try_recv() { + Ok(result) => { + self.apply_health_result(result); + self.health_check_rx = None; + self.health_check_in_progress = false; + self.last_health_check = std::time::Instant::now(); + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + log::warn!("Health check worker disconnected unexpectedly"); + self.health_check_rx = None; + self.health_check_in_progress = false; + } + } + } + } + + fn apply_health_result(&mut self, result: HealthCheckResult) { + if self.reauth_needed != result.reauth_required { + if self.reauth_needed && !result.reauth_required { + log::info!("Session valid again; clearing re-auth requirement"); + } else if !self.reauth_needed && result.reauth_required { + log::info!("Session invalid/expired; prompting re-auth"); + } + self.reauth_needed = result.reauth_required; + } + + match result.status { + ServerStatus::Disconnected => { + self.db_offline_latch = true; + self.last_timeout_at = Some(std::time::Instant::now()); + self.consecutive_healthy_checks = 0; + } + ServerStatus::Connected => { + self.consecutive_healthy_checks = self.consecutive_healthy_checks.saturating_add(1); + + if self.db_offline_latch { + let timeout_cleared = self + .last_timeout_at + .map(|t| t.elapsed() > std::time::Duration::from_secs(2)) + .unwrap_or(true); + + if timeout_cleared && self.consecutive_healthy_checks >= 2 { + log::info!("Health checks stable; clearing database offline latch"); + self.db_offline_latch = false; + } + } + } + _ => { + self.consecutive_healthy_checks = 0; + } + } + + if self.db_offline_latch { + self.server_status = ServerStatus::Disconnected; + } else { + self.server_status = result.status; + } + } + + fn run_health_check(api_client: ApiClient, mut reauth_needed: bool) -> HealthCheckResult { + let connected = match api_client.check_session_valid() { + Ok(true) => { + reauth_needed = false; + true + } + Ok(false) => { + reauth_needed = true; + true + } + Err(e) => { + log::warn!("Session status check error: {}", e); + false + } + }; + + if connected { + let mut db_disconnected = false; + if let Ok(true) = api_client.health_check() { + if let Ok(info_opt) = api_client.health_info() { + if let Some(info) = info_opt { + let db_down = info.get("database").and_then(|v| v.as_str()) + .map(|s| s.eq_ignore_ascii_case("disconnected")) + .unwrap_or(false) + || info.get("database_connected").and_then(|v| v.as_bool()) + == Some(false) + || info.get("db_connected").and_then(|v| v.as_bool()) + == Some(false) + || info + .get("db") + .and_then(|v| v.as_str()) + .map(|s| s.eq_ignore_ascii_case("down")) + .unwrap_or(false) + || info + .get("database") + .and_then(|v| v.as_str()) + .map(|s| s.eq_ignore_ascii_case("down")) + .unwrap_or(false); + if db_down { + db_disconnected = true; + } + } + } + } + + if db_disconnected { + log::warn!("Database disconnected; treating as offline"); + HealthCheckResult { + status: ServerStatus::Disconnected, + reauth_required: reauth_needed, + } + } else { + HealthCheckResult { + status: ServerStatus::Connected, + reauth_required: reauth_needed, + } + } + } else { + HealthCheckResult { + status: ServerStatus::Disconnected, + reauth_required: reauth_needed, + } + } + } + + fn handle_ribbon_action(&mut self, action: String) { + log::info!("Ribbon action triggered: {}", action); + + // Handle different action types + if action.starts_with("search:") { + let search_query = action.strip_prefix("search:").unwrap_or(""); + log::info!("Search action: {}", search_query); + // TODO: Implement search functionality + } else { + match action.as_str() { + // Dashboard actions + "refresh_dashboard" => { + if let Some(api_client) = &self.api_client { + self.dashboard.refresh_data(api_client); + } + } + "customize_dashboard" => { + log::info!("Customize dashboard - TODO"); + } + + // Inventory actions + "add_item" => { + log::info!("Add item - TODO"); + } + "edit_item" => { + log::info!("Edit item - TODO"); + } + "delete_item" => { + log::info!("Delete item - TODO"); + } + "print_label" => { + log::info!("Print label - TODO"); + } + + // Quick actions + "inventarize_quick" => { + log::info!("Quick inventarize - TODO"); + } + "checkout_checkin" => { + log::info!("Check-out/in - TODO"); + } + "start_room_audit" => { + log::info!("Start room audit - TODO"); + } + "start_spot_check" => { + log::info!("Start spot-check - TODO"); + } + + _ => { + log::info!("Unhandled action: {}", action); + } + } + } + } + + fn show_status_bar(&self, ctx: &egui::Context) { + egui::TopBottomPanel::bottom("status_bar") + .exact_height(24.0) + .show_separator_line(false) + .frame( + egui::Frame::new() + .fill(ctx.style().visuals.window_fill) + .stroke(egui::Stroke::NONE), + ) + .show(ctx, |ui| { + ui.horizontal(|ui| { + // Seqkel inikator + let (icon, text, color) = match self.server_status { + ServerStatus::Connected => ( + "-", + if self.reauth_needed { + "Server Connected • Re-auth required" + } else { + "Server Connected" + }, + egui::Color32::from_rgb(76, 175, 80), + ), + ServerStatus::Disconnected => { + // Check if we detected database timeout recently + let timeout_detected = self.dashboard.has_timeout_error(); + let text = if timeout_detected { + "Database Timeout - Retrying..." + } else { + "Server Disconnected" + }; + ("x", text, egui::Color32::from_rgb(244, 67, 54)) + }, + ServerStatus::Checking => { + ("~", "Checking...", egui::Color32::from_rgb(255, 152, 0)) + } + ServerStatus::Unknown => ( + "??????????? -", + "I don't know maybe connected maybe not ???", + egui::Color32::GRAY, + ), + }; + + ui.label(egui::RichText::new(icon).color(color).size(16.0)); + ui.label(egui::RichText::new(text).color(color).size(12.0)); + + ui.separator(); + + // Server URL + if let Some(client) = &self.api_client { + ui.label( + egui::RichText::new(format!("Server: {}", client.base_url())) + .size(11.0) + .color(egui::Color32::GRAY), + ); + } + + // User info on the right + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(user) = &self.current_user { + ui.label( + egui::RichText::new(format!("User: {}", user.username)) + .size(11.0) + .color(egui::Color32::GRAY), + ); + } + }); + }); + }); + } + + fn show_reconnect_overlay(&self, ctx: &egui::Context) { + let screen_rect = ctx.viewport_rect(); + let visuals = ctx.style().visuals.clone(); + + let dim_color = if visuals.dark_mode { + egui::Color32::from_black_alpha(180) + } else { + egui::Color32::from_white_alpha(200) + }; + + // Dim the entire interface + let layer_id = egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("reconnect_overlay_bg"), + ); + ctx.layer_painter(layer_id) + .rect_filled(screen_rect, 0.0, dim_color); + + // Capture input so underlying widgets don't receive clicks or keypresses + egui::Area::new(egui::Id::new("reconnect_overlay_blocker")) + .order(egui::Order::Foreground) + .movable(false) + .interactable(true) + .fixed_pos(screen_rect.left_top()) + .show(ctx, |ui| { + ui.set_min_size(screen_rect.size()); + ui.allocate_rect(ui.max_rect(), egui::Sense::click_and_drag()); + }); + + let timeout_detected = self.dashboard.has_timeout_error(); + let message = if timeout_detected { + "Database temporarily unavailable. Waiting for heartbeat…" + } else { + "Connection to the backend was lost. Retrying…" + }; + + // Foreground card with spinner and message + egui::Area::new(egui::Id::new("reconnect_overlay_card")) + .order(egui::Order::Foreground) + .movable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0); + ui.set_min_size(egui::vec2(360.0, 200.0)); + egui::Frame::default() + .fill(visuals.panel_fill) + .stroke(egui::Stroke::new(1.0, visuals.weak_text_color())) + .corner_radius(12.0) + .inner_margin(egui::Margin::symmetric(32, 24)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.heading( + egui::RichText::new("Reconnecting…") + .color(visuals.strong_text_color()) + .size(20.0), + ); + ui.add_space(8.0); + ui.spinner(); + ui.label( + egui::RichText::new(message) + .color(visuals.text_color()) + .size(15.0), + ); + ui.label( + egui::RichText::new( + "All actions are paused until the backend recovers.", + ) + .color(visuals.weak_text_color()) + .size(13.0), + ); + }); + }); + }); + + // Keep spinner animating while offline + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + + fn should_block_interaction(&self) -> bool { + self.api_client.is_some() + && self.current_view != AppView::Login + && (matches!(self.server_status, ServerStatus::Disconnected) + || self.db_offline_latch) + } + + /// Save current filter state before switching views + fn save_filter_state_for_view(&mut self, view: AppView) { + if let Some(ribbon) = &self.ribbon_ui { + // Only save filter state for views that use filters + if matches!( + view, + AppView::Inventory | AppView::Zones | AppView::Borrowing + ) { + self.view_filter_states + .insert(view, ribbon.filter_builder.filter_group.clone()); + } + } + } + + /// Restore filter state when switching to a view + fn restore_filter_state_for_view(&mut self, view: AppView) { + if let Some(ribbon) = &mut self.ribbon_ui { + // Check if we have saved state for this view + if let Some(saved_state) = self.view_filter_states.get(&view) { + ribbon.filter_builder.filter_group = saved_state.clone(); + } else { + // No saved state - clear filters for this view (fresh start) + ribbon.filter_builder.filter_group = + crate::core::components::filter_builder::FilterGroup::new(); + } + } + } +} + +impl eframe::App for BeepZoneApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Detect view changes and save/restore filter state + if let Some(prev_view) = self.previous_view { + if prev_view != self.current_view { + // Save filter state for the view we're leaving + self.save_filter_state_for_view(prev_view); + // Restore filter state for the view we're entering + self.restore_filter_state_for_view(self.current_view); + + // Update available columns for the new view + if let Some(ribbon) = &mut self.ribbon_ui { + match self.current_view { + AppView::Inventory => { + // Ensure Inventory uses asset columns in the FilterBuilder + ribbon.filter_builder.set_columns_for_context("assets"); + } + AppView::Zones => { + ribbon.filter_builder.available_columns = vec![ + ("Any".to_string(), "Any".to_string()), + ("Zone Code".to_string(), "zones.zone_code".to_string()), + ("Zone Name".to_string(), "zones.zone_name".to_string()), + ]; + } + AppView::Borrowing => { + ribbon.filter_builder.available_columns = + crate::ui::borrowing::BorrowingView::get_filter_columns(); + } + _ => {} + } + } + } + } + + // Update previous view for next frame + self.previous_view = Some(self.current_view); + + // Customize background color for light mode + if !ctx.style().visuals.dark_mode { + let mut style = (*ctx.style()).clone(); + style.visuals.panel_fill = egui::Color32::from_rgb(210, 210, 210); + style.visuals.window_fill = egui::Color32::from_rgb(210, 210, 210); + ctx.set_style(style); + } + + // Check for login success + if let Some((server_url, response)) = self.login_success.take() { + self.handle_login_success(server_url, response); + } + + // Process any completed health checks and schedule new ones + self.poll_health_check(); + let predicted_block = self.should_block_interaction(); + let health_interval = self.desired_health_interval(predicted_block); + if self.api_client.is_some() + && !self.health_check_in_progress + && self.last_health_check.elapsed().as_secs_f32() > health_interval + { + self.request_health_check(); + } + + // Show appropriate view + if self.current_view == AppView::Login { + self.login_screen.show(ctx, &mut self.login_success); + } else { + let mut block_interaction = self.should_block_interaction(); + + if let Some(client) = &self.api_client { + if client.take_timeout_signal() { + log::warn!("Backend timeout detected via API client; entering reconnect mode"); + self.server_status = ServerStatus::Disconnected; + self.db_offline_latch = true; + self.last_timeout_at = Some(std::time::Instant::now()); + self.consecutive_healthy_checks = 0; + block_interaction = true; + // Force an immediate health re-check + self.last_health_check = std::time::Instant::now() + - std::time::Duration::from_secs(10); + if !self.health_check_in_progress { + self.request_health_check(); + } + } + } + + // When we're blocked, ensure a health check is queued so we recover ASAP + if block_interaction + && !self.health_check_in_progress + && self.last_health_check.elapsed().as_secs_f32() > 1.0 + { + self.request_health_check(); + } + + self.show_top_bar(ctx, block_interaction); + let ribbon_action = if block_interaction { + None + } else { + self.show_ribbon(ctx) + }; + self.show_status_bar(ctx); + + if !block_interaction { + if let Some(action) = ribbon_action { + self.handle_ribbon_action(action); + } + + egui::CentralPanel::default().show(ctx, |ui| match self.current_view { + AppView::Dashboard => { + self.dashboard.show(ui, self.api_client.as_ref()); + + // Check if dashboard has timeout error and trigger health check + if self.dashboard.has_timeout_error() { + self.force_health_check(); + } + } + AppView::Inventory => { + // Handle FilterBuilder popup BEFORE showing inventory + // This ensures filter changes are processed in the current frame + if let Some(ribbon) = &mut self.ribbon_ui { + let filter_changed = ribbon.filter_builder.show_popup(ctx); + if filter_changed { + ribbon + .checkboxes + .insert("inventory_filter_changed".to_string(), true); + } + } + + self.inventory.show( + ui, + self.api_client.as_ref(), + self.ribbon_ui.as_mut(), + &self.session_manager, + ); + } + AppView::Categories => { + self.categories + .show(ui, self.api_client.as_ref(), self.ribbon_ui.as_mut()); + } + AppView::Zones => { + if let Some(ribbon) = self.ribbon_ui.as_mut() { + // Handle FilterBuilder popup BEFORE showing zones view so changes are applied in the same frame + let filter_changed = ribbon.filter_builder.show_popup(ctx); + if filter_changed { + ribbon + .checkboxes + .insert("zones_filter_changed".to_string(), true); + } + + self.zones.show(ui, self.api_client.as_ref(), ribbon); + + // Handle zone navigation request to inventory + if let Some(zone_code) = self.zones.switch_to_inventory_with_zone.take() { + log::info!("Switching to inventory with zone filter: {}", zone_code); + + // Save current Zones filter state + let zones_filter_state = ribbon.filter_builder.filter_group.clone(); + self.view_filter_states + .insert(AppView::Zones, zones_filter_state); + + // Set zone filter using the correct column name from the JOIN + ribbon.filter_builder.set_single_filter( + "zones.zone_code".to_string(), + crate::core::components::filter_builder::FilterOperator::Is, + zone_code, + ); + + // Switch to inventory view + self.current_view = AppView::Inventory; + ribbon.active_tab = "Inventory".to_string(); + ribbon + .checkboxes + .insert("inventory_filter_changed".to_string(), true); + + // Update previous_view to match so next frame doesn't restore old inventory filters + self.previous_view = Some(AppView::Inventory); + + // Request repaint to ensure the filter is applied on the next frame + ctx.request_repaint(); + } + } else { + // Fallback if no ribbon (shouldn't happen) + log::warn!("No ribbon available for zones view"); + } + } + AppView::Borrowing => { + if let Some(ribbon) = self.ribbon_ui.as_mut() { + // Handle FilterBuilder popup + let filter_changed = ribbon.filter_builder.show_popup(ctx); + if filter_changed { + ribbon + .checkboxes + .insert("borrowing_filter_changed".to_string(), true); + } + + self.borrowing + .show(ctx, ui, self.api_client.as_ref(), ribbon); + + // Handle borrower navigation request to inventory + if let Some(borrower_id) = + self.borrowing.switch_to_inventory_with_borrower.take() + { + log::info!( + "Switching to inventory with borrower filter: {}", + borrower_id + ); + + // Save current Borrowing filter state + let borrowing_filter_state = ribbon.filter_builder.filter_group.clone(); + self.view_filter_states + .insert(AppView::Borrowing, borrowing_filter_state); + + // Set borrower filter using the current_borrower_id from assets table + ribbon.filter_builder.set_single_filter( + "assets.current_borrower_id".to_string(), + crate::core::components::filter_builder::FilterOperator::Is, + borrower_id.to_string(), + ); + + // Switch to inventory view + self.current_view = AppView::Inventory; + ribbon.active_tab = "Inventory".to_string(); + ribbon + .checkboxes + .insert("inventory_filter_changed".to_string(), true); + + // Update previous_view to match so next frame doesn't restore old inventory filters + self.previous_view = Some(AppView::Inventory); + + // Request repaint to ensure the filter is applied on the next frame + ctx.request_repaint(); + } + } else { + // Fallback if no ribbon (shouldn't happen) + log::warn!("No ribbon available for borrowing view"); + } + } + AppView::Audits => { + let user_id = self.current_user.as_ref().map(|u| u.id); + self.audits.show(ctx, ui, self.api_client.as_ref(), user_id); + } + AppView::Templates => { + if let Some(ribbon) = self.ribbon_ui.as_mut() { + // Handle FilterBuilder popup BEFORE showing templates view + let filter_changed = ribbon.filter_builder.show_popup(ctx); + if filter_changed { + ribbon + .checkboxes + .insert("templates_filter_changed".to_string(), true); + } + + let flags = self + .templates + .show(ui, self.api_client.as_ref(), Some(ribbon)); + for flag in flags { + ribbon.checkboxes.insert(flag, false); + } + } else { + self.templates.show(ui, self.api_client.as_ref(), None); + } + } + AppView::Suppliers => { + if let Some(ribbon_ui) = self.ribbon_ui.as_mut() { + let flags = self.suppliers.show( + ui, + self.api_client.as_ref(), + Some(&mut *ribbon_ui), + ); + for flag in flags { + ribbon_ui.checkboxes.insert(flag, false); + } + } else { + let _ = self.suppliers.show(ui, self.api_client.as_ref(), None); + } + } + AppView::IssueTracker => { + self.issues.show(ui, self.api_client.as_ref()); + } + AppView::Printers => { + // Render printers dropdown in ribbon if we're on printers tab + if let Some(ribbon) = self.ribbon_ui.as_mut() { + if ribbon.active_tab == "Printers" { + self.printers + .inject_dropdown_into_ribbon(ribbon, &self.session_manager); + } + } + self.printers.render( + ui, + self.api_client.as_ref(), + self.ribbon_ui.as_mut(), + &self.session_manager, + ); + } + AppView::LabelTemplates => { + self.label_templates.render( + ui, + self.api_client.as_ref(), + self.ribbon_ui.as_mut(), + ); + } + AppView::Login => unreachable!(), + }); + } else { + self.show_reconnect_overlay(ctx); + } + } + + // Re-authentication modal when needed (only outside of Login view) + if self.reauth_needed && self.current_view != AppView::Login { + let mut keep_open = true; + egui::Window::new("Session expired") + .collapsible(false) + .resizable(false) + .movable(true) + .open(&mut keep_open) + .show(ctx, |ui| { + ui.label("Your session has expired or is invalid. Reenter your password to continue."); + ui.add_space(8.0); + let mut pw = std::mem::take(&mut self.reauth_password); + let response = ui.add( + egui::TextEdit::singleline(&mut pw) + .password(true) + .hint_text("Password") + .desired_width(260.0), + ); + self.reauth_password = pw; + ui.add_space(8.0); + ui.horizontal(|ui| { + let mut try_login = ui.button("Reauthenticate").clicked(); + // Allow Enter to submit + try_login |= response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if try_login { + if let (Some(client), Some(user)) = (self.api_client.as_mut(), self.current_user.as_ref()) { + // Attempt password login to refresh token + match client.login_password(&user.username, &self.reauth_password) { + Ok(resp) => { + let server_url = client.base_url().to_string(); + self.handle_reauth_success(server_url, resp); + self.reauth_needed = false; + self.reauth_password.clear(); + // Avoid immediate re-flagging by pushing out the next health check + self.last_health_check = std::time::Instant::now(); + } + Err(e) => { + log::error!("Reauth failed: {}", e); + } + } + } + } + if ui.button("Go to Login").clicked() { + self.handle_logout(); + self.reauth_needed = false; + self.reauth_password.clear(); + } + }); + }); + if !keep_open { + // Close button pressed: just dismiss (will reappear on next check if still invalid) + self.reauth_needed = false; + self.reauth_password.clear(); + self.last_health_check = std::time::Instant::now(); + } + } + + // About dialog + if self.show_about { + egui::Window::new("About BeepZone") + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.heading("BeepZone Desktop Client"); + ui.heading("- eGUI EMO Edition"); + ui.label(format!("Version: {}", env!("CARGO_PKG_VERSION"))); + ui.separator(); + ui.label("A crude inventory system meant to run on any potato!"); + ui.label("- Fueled by peanut butter and caffeine"); + ui.label("- Backed by Spaghetti codebase supreme pro plus ultra"); + ui.label("- Running at all thanks to vibe coding and sheer willpower"); + ui.label("- Oles Approved"); + ui.label("- Atleast tries to be a good fucking inventory system!"); + ui.separator(); + ui.label("Made with love (and some hatred) by crt "); + ui.separator(); + if ui.button("Close this goofy ah panel").clicked() { + self.show_about = false; + } + }); + } + } +} diff --git a/src/ui/audits.rs b/src/ui/audits.rs new file mode 100644 index 0000000..b6773f6 --- /dev/null +++ b/src/ui/audits.rs @@ -0,0 +1,898 @@ +use eframe::egui; +use serde_json::{json, Value}; + +use crate::api::ApiClient; +use crate::core::components::interactions::ConfirmDialog; +use crate::core::tables::{get_audit_tasks, get_recent_audit_logs, get_recent_audits}; +use crate::core::workflows::AuditWorkflow; +use crate::core::{ColumnConfig, TableRenderer}; + +pub struct AuditsView { + audits: Vec<Value>, + logs: Vec<Value>, + tasks: Vec<Value>, + is_loading: bool, + last_error: Option<String>, + init_loaded: bool, + workflow: AuditWorkflow, + zone_code_input: String, + start_error: Option<String>, + start_success: Option<String>, + audits_table: TableRenderer, + logs_table: TableRenderer, + tasks_table: TableRenderer, + tasks_loading: bool, + task_error: Option<String>, + task_success: Option<String>, + task_delete_dialog: ConfirmDialog, + pending_task_delete_id: Option<i64>, + pending_task_delete_name: Option<String>, + task_editor: AuditTaskEditor, +} + +impl AuditsView { + pub fn new() -> Self { + let audit_columns = Self::build_audit_columns(); + let log_columns = Self::build_log_columns(); + + Self { + audits: vec![], + logs: vec![], + tasks: vec![], + is_loading: false, + last_error: None, + init_loaded: false, + workflow: AuditWorkflow::new(), + zone_code_input: String::new(), + start_error: None, + start_success: None, + audits_table: TableRenderer::new() + .with_columns(audit_columns) + .with_default_sort("completed_at", false), + logs_table: TableRenderer::new() + .with_columns(log_columns) + .with_default_sort("audit_date", false), + tasks_table: TableRenderer::new() + .with_columns(Self::build_task_columns()) + .with_default_sort("updated_at", false) + .with_search_fields(vec!["task_name".into(), "sequence_preview".into()]), + tasks_loading: false, + task_error: None, + task_success: None, + task_delete_dialog: ConfirmDialog::new( + "Delete Audit Task", + "Are you sure you want to delete this audit task? This cannot be undone.", + ) + .confirm_text("Delete Task") + .dangerous(true), + pending_task_delete_id: None, + pending_task_delete_name: None, + task_editor: AuditTaskEditor::new(), + } + } + + fn load(&mut self, api: &ApiClient) { + if self.is_loading { + return; + } + + self.is_loading = true; + self.tasks_loading = true; + self.last_error = None; + + match get_recent_audits(api, Some(50)) { + Ok(rows) => { + self.audits = rows; + self.audits_table.selection.clear_selection(); + } + Err(err) => { + self.last_error = Some(err.to_string()); + } + } + + if self.last_error.is_none() { + match get_recent_audit_logs(api, Some(200)) { + Ok(rows) => { + self.logs = rows; + self.logs_table.selection.clear_selection(); + } + Err(err) => { + self.last_error = Some(err.to_string()); + } + } + } + + if self.last_error.is_none() { + match get_audit_tasks(api, Some(200)) { + Ok(rows) => { + self.tasks = rows; + self.tasks_table.selection.clear_selection(); + } + Err(err) => { + self.last_error = Some(err.to_string()); + } + } + } + + self.is_loading = false; + self.tasks_loading = false; + self.init_loaded = true; + } + + fn render_launch_controls( + &mut self, + ui: &mut egui::Ui, + api: &ApiClient, + current_user_id: Option<i32>, + ) { + egui::Frame::group(ui.style()) + .fill(ui.style().visuals.extreme_bg_color) + .inner_margin(egui::Margin { + left: 12, + right: 12, + top: 2, + bottom: 2, + }) + .corner_radius(8.0) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + let control_height = ui.spacing().interact_size.y; + let needs_error_margin = self.start_error.is_some(); + let needs_progress_msg = self.workflow.is_active(); + + if !needs_error_margin { + let extra = if needs_progress_msg { 16.0 } else { 8.0 }; + ui.set_max_height(control_height + extra); + } + + if self.workflow.is_active() { + ui.colored_label( + egui::Color32::from_rgb(66, 133, 244), + "Audit in progress. Continue in the workflow window.", + ); + } + + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + let btn_w: f32 = 140.0; + + ui.label("Zone Code:"); + + // Compute input width based on remaining space after two fixed-width buttons + let spacing = ui.spacing().item_spacing.x; + let remaining = ui.available_width(); + let reserve_for_buttons = btn_w * 2.0 + spacing * 2.0; + let input_w = (remaining - reserve_for_buttons).max(200.0); + + let text_resp = ui.add( + egui::TextEdit::singleline(&mut self.zone_code_input) + .hint_text("ZONE-ABC") + .desired_width(input_w), + ); + + let disable_new = self.workflow.is_active(); + let start_zone_clicked_button = ui + .add_enabled( + !disable_new, + egui::Button::new("Start Zone Audit").min_size(egui::vec2(btn_w, 0.0)), + ) + .clicked(); + + let start_spot_clicked = ui + .add_enabled( + !disable_new, + egui::Button::new("Start Spot Check").min_size(egui::vec2(btn_w, 0.0)), + ) + .clicked(); + + let start_zone_pressed_enter = + text_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let start_zone_clicked = start_zone_clicked_button || start_zone_pressed_enter; + + if start_zone_clicked { + if let Some(user_id) = current_user_id { + let code = self.zone_code_input.trim(); + if code.is_empty() { + self.start_error = + Some("Enter a zone code to start an audit".to_string()); + self.start_success = None; + } else { + match self.workflow.start_zone_audit(api, code, user_id as i64) { + Ok(()) => { + self.start_error = None; + self.start_success = + Some(format!("Zone audit started for {}", code)); + self.zone_code_input.clear(); + } + Err(err) => { + self.start_error = Some(err.to_string()); + self.start_success = None; + } + } + } + } else { + self.start_error = + Some("You must be logged in to start an audit".to_string()); + self.start_success = None; + } + } + + if start_spot_clicked { + if let Some(user_id) = current_user_id { + self.workflow.start_spot_check(user_id as i64); + self.start_error = None; + self.start_success = Some("Spot check started".to_string()); + } else { + self.start_error = + Some("You must be logged in to start a spot check".to_string()); + self.start_success = None; + } + } + }); + + if let Some(err) = &self.start_error { + ui.add_space(4.0); + ui.colored_label(egui::Color32::from_rgb(244, 67, 54), err); + } + + if self.workflow.is_active() { + if let Some(msg) = &self.start_success { + ui.add_space(4.0); + ui.colored_label(egui::Color32::from_rgb(76, 175, 80), msg); + } + } + }); + } + + fn build_audit_columns() -> Vec<ColumnConfig> { + vec![ + ColumnConfig::new("ID", "id").with_width(60.0), + ColumnConfig::new("Type", "audit_type").with_width(90.0), + ColumnConfig::new("Zone", "zone_display").with_width(140.0), + ColumnConfig::new("Audit Name", "audit_name").with_width(160.0), + ColumnConfig::new("Started By", "started_by_name").with_width(140.0), + ColumnConfig::new("Started At", "started_at").with_width(150.0), + ColumnConfig::new("Completed At", "completed_at").with_width(150.0), + ColumnConfig::new("Status", "status").with_width(110.0), + ColumnConfig::new("Timeout (min)", "timeout_minutes").with_width(110.0), + ColumnConfig::new("Issues", "issues_summary").with_width(220.0), + ColumnConfig::new("Expected", "assets_expected").with_width(90.0), + ColumnConfig::new("Found", "assets_found").with_width(90.0), + ColumnConfig::new("Notes", "notes").with_width(200.0), + ColumnConfig::new("Cancelled Reason", "cancelled_reason").with_width(220.0), + ] + } + + fn build_log_columns() -> Vec<ColumnConfig> { + vec![ + ColumnConfig::new("ID", "id").with_width(60.0), + ColumnConfig::new("Audit ID", "physical_audit_id").with_width(80.0), + ColumnConfig::new("Asset", "asset_display").with_width(160.0), + ColumnConfig::new("Audit Date", "audit_date").with_width(140.0), + ColumnConfig::new("Audited By", "audited_by_name").with_width(140.0), + ColumnConfig::new("Status Found", "status_found").with_width(110.0), + ColumnConfig::new("Task ID", "audit_task_id").with_width(80.0), + ColumnConfig::new("Task Responses", "task_responses_text").with_width(240.0), + ColumnConfig::new("Exception", "exception_type").with_width(120.0), + ColumnConfig::new("Details", "exception_details").with_width(220.0), + ColumnConfig::new("Found Zone", "found_zone_display").with_width(160.0), + ColumnConfig::new("Action", "auditor_action").with_width(140.0), + ColumnConfig::new("Notes", "notes").with_width(200.0), + ] + } + + fn build_task_columns() -> Vec<ColumnConfig> { + vec![ + ColumnConfig::new("ID", "id").with_width(60.0), + ColumnConfig::new("Task Name", "task_name").with_width(180.0), + ColumnConfig::new("Step Count", "step_count").with_width(90.0), + ColumnConfig::new("Sequence Preview", "sequence_preview").with_width(280.0), + ColumnConfig::new("Created", "created_at").with_width(150.0), + ColumnConfig::new("Updated", "updated_at").with_width(150.0), + ] + } + pub fn show( + &mut self, + ctx: &egui::Context, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + current_user_id: Option<i32>, + ) { + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + + ui.horizontal(|ui| { + ui.heading("Audits"); + if self.is_loading { + ui.spinner(); + ui.label("Loading..."); + } + if let Some(err) = &self.last_error { + ui.colored_label(egui::Color32::RED, err); + if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + } else if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + if let Some(msg) = &self.start_success { + if !self.workflow.is_active() { + ui.colored_label(egui::Color32::from_rgb(76, 175, 80), msg); + } + } + }); + ui.separator(); + + if let Some(api) = api_client { + self.render_launch_controls(ui, api, current_user_id); + } + + if !self.init_loaded { + if let Some(api) = api_client { + self.load(api); + } + } + + if let Some(msg) = &self.start_success { + if !self.workflow.is_active() { + ui.add_space(6.0); + ui.colored_label(egui::Color32::from_rgb(76, 175, 80), msg); + } + } + + self.render_summary(ui); + + egui::CollapsingHeader::new("Recent Audits") + .default_open(true) + .show(ui, |ui| { + self.render_audits_table(ui); + }); + + ui.add_space(10.0); + + egui::CollapsingHeader::new("Audit Logs") + .default_open(true) + .show(ui, |ui| { + self.render_logs_table(ui); + }); + + ui.add_space(10.0); + + egui::CollapsingHeader::new("Audit Task Library") + .default_open(false) + .show(ui, |ui| { + self.render_tasks_section(ui, api_client); + }); + }); + + if let Some(result) = self.task_editor.show(ctx) { + if let Some(api) = api_client { + self.save_task(api, result); + } else { + self.task_error = + Some("No API client available; cannot save audit task changes.".to_string()); + } + } + + if let Some(decision) = self.task_delete_dialog.show_dialog(ctx) { + if decision { + if let (Some(api), Some(id)) = (api_client, self.pending_task_delete_id) { + self.delete_task(api, id); + } else { + self.task_error = + Some("No API client available; cannot delete audit tasks.".to_string()); + } + } else { + self.pending_task_delete_id = None; + self.pending_task_delete_name = None; + } + } + + if let Some(api) = api_client { + if self.workflow.show(ctx, api) { + // Window stays open, nothing else to do here. + } + if let Some(completion) = self.workflow.take_recent_completion() { + self.load(api); + let banner = match completion.status.as_str() { + "cancelled" => "Audit cancelled".to_string(), + "all-good" => "Audit completed successfully".to_string(), + other => format!("Audit finished with status: {}", other), + }; + self.start_success = Some(banner); + } + } + } + + fn render_summary(&self, ui: &mut egui::Ui) { + // derive counts from loaded audits + let total = self.audits.len() as i64; + let mut in_progress = 0; + let mut attention = 0; + let mut timeout = 0; + let mut cancelled = 0; + let mut all_good = 0; + for a in &self.audits { + match a.get("status").and_then(|v| v.as_str()).unwrap_or("") { + "in-progress" => in_progress += 1, + "attention" => attention += 1, + "timeout" => timeout += 1, + "cancelled" => cancelled += 1, + "all-good" => all_good += 1, + _ => {} + } + } + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new(format!("Total: {}", total)).strong()); + ui.separator(); + chip( + ui, + format!("In progress: {}", in_progress), + egui::Color32::from_rgb(66, 133, 244), + ); + chip( + ui, + format!("Attention: {}", attention), + egui::Color32::from_rgb(255, 152, 0), + ); + chip( + ui, + format!("Timeout: {}", timeout), + egui::Color32::from_rgb(244, 67, 54), + ); + chip( + ui, + format!("Cancelled: {}", cancelled), + egui::Color32::from_rgb(158, 158, 158), + ); + chip( + ui, + format!("All good: {}", all_good), + egui::Color32::from_rgb(76, 175, 80), + ); + }); + ui.add_space(6.0); + + fn chip(ui: &mut egui::Ui, text: String, color: egui::Color32) { + egui::Frame::new() + .fill(color.linear_multiply(0.14)) + .corner_radius(6.0) + .inner_margin(egui::Margin { + left: 8, + right: 8, + top: 4, + bottom: 4, + }) + .show(ui, |ui| { + ui.label(egui::RichText::new(text).color(color)); + }); + } + } + + fn render_audits_table(&mut self, ui: &mut egui::Ui) { + if self.audits.is_empty() { + ui.label("No recent audits found."); + return; + } + + let prepared = self.audits_table.prepare_json_data(&self.audits); + self.audits_table.render_json_table(ui, &prepared, None); + } + + fn render_logs_table(&mut self, ui: &mut egui::Ui) { + if self.logs.is_empty() { + ui.label("No audit logs found."); + return; + } + + let prepared = self.logs_table.prepare_json_data(&self.logs); + self.logs_table.render_json_table(ui, &prepared, None); + } + + fn render_tasks_section(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(&mut self.tasks_table.search_query); + ui.separator(); + + let has_api = api_client.is_some(); + if ui + .add_enabled(has_api, egui::Button::new("New Task")) + .clicked() + { + self.task_error = None; + self.task_success = None; + self.task_editor.open_new(); + } + + if ui + .add_enabled(has_api, egui::Button::new("Refresh")) + .clicked() + { + if let Some(api) = api_client { + self.task_error = None; + self.task_success = None; + self.refresh_tasks(api); + } + } + }); + + if let Some(err) = &self.task_error { + ui.add_space(6.0); + ui.colored_label(egui::Color32::from_rgb(244, 67, 54), err); + } + + if let Some(msg) = &self.task_success { + ui.add_space(6.0); + ui.colored_label(egui::Color32::from_rgb(76, 175, 80), msg); + } + + ui.add_space(6.0); + + if self.tasks_loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Loading audit tasks..."); + }); + return; + } + + if self.tasks.is_empty() { + ui.label("No audit tasks found."); + return; + } + + let prepared = self.tasks_table.prepare_json_data(&self.tasks); + + let mut edit_task: Option<Value> = None; + let mut clone_task: Option<Value> = None; + let mut delete_task: Option<Value> = None; + + struct TaskEventHandler<'a> { + edit_action: &'a mut Option<Value>, + clone_action: &'a mut Option<Value>, + delete_action: &'a mut Option<Value>, + } + + impl<'a> crate::core::table_renderer::TableEventHandler<Value> for TaskEventHandler<'a> { + fn on_double_click(&mut self, item: &Value, _row_index: usize) { + *self.edit_action = Some(item.clone()); + } + + fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) { + if ui + .button(format!("{} Edit Task", egui_phosphor::regular::PENCIL)) + .clicked() + { + *self.edit_action = Some(item.clone()); + ui.close(); + } + + if ui + .button(format!("{} Clone Task", egui_phosphor::regular::COPY)) + .clicked() + { + *self.clone_action = Some(item.clone()); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Delete Task", egui_phosphor::regular::TRASH)) + .clicked() + { + *self.delete_action = Some(item.clone()); + ui.close(); + } + } + + fn on_selection_changed(&mut self, _selected_indices: &[usize]) {} + } + + let mut handler = TaskEventHandler { + edit_action: &mut edit_task, + clone_action: &mut clone_task, + delete_action: &mut delete_task, + }; + + self.tasks_table + .render_json_table(ui, &prepared, Some(&mut handler)); + + if let Some(task) = edit_task { + self.task_error = None; + self.task_success = None; + self.task_editor.open_edit(&task); + } + + if let Some(task) = clone_task { + self.task_error = None; + self.task_success = None; + self.task_editor.open_clone(&task); + } + + if let Some(task) = delete_task { + if let Some(id) = task.get("id").and_then(|v| v.as_i64()) { + let name = task + .get("task_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unnamed Task") + .to_string(); + self.pending_task_delete_id = Some(id); + self.pending_task_delete_name = Some(name.clone()); + self.task_delete_dialog.open(name, id.to_string()); + } + } + } + + fn refresh_tasks(&mut self, api: &ApiClient) { + self.tasks_loading = true; + match get_audit_tasks(api, Some(200)) { + Ok(rows) => { + self.tasks = rows; + self.tasks_table.selection.clear_selection(); + self.task_error = None; + } + Err(err) => { + self.task_error = Some(err.to_string()); + } + } + self.tasks_loading = false; + } + + fn save_task(&mut self, api: &ApiClient, result: AuditTaskEditorResult) { + self.task_error = None; + self.task_success = None; + + let AuditTaskEditorResult { + id, + name, + sequence, + is_new, + } = result; + + let mut payload = serde_json::Map::new(); + payload.insert("task_name".into(), Value::String(name.clone())); + payload.insert("json_sequence".into(), sequence); + let payload_value = Value::Object(payload); + + if is_new { + match api.insert("audit_tasks", payload_value) { + Ok(resp) if resp.success => { + self.task_success = Some(format!("Created audit task \"{}\".", name)); + self.refresh_tasks(api); + } + Ok(resp) => { + self.task_error = Some(format!("Insert failed: {:?}", resp.error)); + } + Err(err) => { + self.task_error = Some(format!("Insert error: {}", err)); + } + } + } else if let Some(task_id) = id { + let where_clause = json!({ "id": task_id }); + match api.update("audit_tasks", payload_value, where_clause) { + Ok(resp) if resp.success => { + self.task_success = Some(format!("Updated audit task \"{}\".", name)); + self.refresh_tasks(api); + } + Ok(resp) => { + self.task_error = Some(format!("Update failed: {:?}", resp.error)); + } + Err(err) => { + self.task_error = Some(format!("Update error: {}", err)); + } + } + } else { + self.task_error = Some("Missing task identifier; cannot update.".to_string()); + } + } + + fn delete_task(&mut self, api: &ApiClient, id: i64) { + let where_clause = json!({ "id": id }); + match api.delete("audit_tasks", where_clause) { + Ok(resp) if resp.success => { + let name = self + .pending_task_delete_name + .take() + .unwrap_or_else(|| format!("Task #{id}")); + self.task_success = Some(format!("Deleted audit task \"{}\".", name)); + self.refresh_tasks(api); + } + Ok(resp) => { + self.task_error = Some(format!("Delete failed: {:?}", resp.error)); + } + Err(err) => { + self.task_error = Some(format!("Delete error: {}", err)); + } + } + self.pending_task_delete_id = None; + self.pending_task_delete_name = None; + } +} + +struct AuditTaskEditor { + open: bool, + is_new: bool, + current_id: Option<i64>, + task_name: String, + sequence_text: String, + error: Option<String>, +} + +impl AuditTaskEditor { + fn new() -> Self { + Self { + open: false, + is_new: true, + current_id: None, + task_name: String::new(), + sequence_text: "[]".to_string(), + error: None, + } + } + + fn open_new(&mut self) { + self.open = true; + self.is_new = true; + self.current_id = None; + self.task_name.clear(); + self.sequence_text = "[]".to_string(); + self.error = None; + } + + fn open_edit(&mut self, task: &Value) { + self.open = true; + self.is_new = false; + self.current_id = task.get("id").and_then(|v| v.as_i64()); + self.task_name = task + .get("task_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + self.sequence_text = task + .get("json_sequence") + .map(|seq| serde_json::to_string_pretty(seq).unwrap_or_else(|_| seq.to_string())) + .unwrap_or_else(|| "[]".to_string()); + self.error = None; + } + + fn open_clone(&mut self, task: &Value) { + self.open_edit(task); + self.is_new = true; + self.current_id = None; + if self.task_name.is_empty() { + self.task_name = "Copied Task".to_string(); + } else { + self.task_name = format!("{} (Copy)", self.task_name); + } + } + + fn show(&mut self, ctx: &egui::Context) -> Option<AuditTaskEditorResult> { + if !self.open { + return None; + } + + let mut window_open = true; + let mut close_requested = false; + let mut outcome: Option<AuditTaskEditorResult> = None; + let title = if self.is_new { + "New Audit Task" + } else { + "Edit Audit Task" + }; + + let root_bounds = ctx.available_rect(); + let screen_bounds = ctx.input(|i| { + i.viewport().inner_rect.unwrap_or(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(800.0, 600.0), + )) + }); + let horizontal_margin = 24.0_f32; + let vertical_margin = 24.0_f32; + + let available_max_w = (root_bounds.width() - horizontal_margin).max(420.0_f32); + let screen_max_w = (screen_bounds.width() - horizontal_margin).max(420.0_f32); + let max_w = available_max_w.min(screen_max_w); + + let available_max_h = (root_bounds.height() - vertical_margin).max(360.0_f32); + let screen_max_h = (screen_bounds.height() - vertical_margin).max(360.0_f32); + let max_h = available_max_h.min(screen_max_h); + + let default_w = 520.0_f32.clamp(360.0_f32.min(max_w), max_w); + let default_h = (root_bounds.height() * 0.6_f32) + .max(360.0_f32) + .clamp(320.0_f32.min(max_h), max_h); + let min_w = max_w.min(380.0_f32).max(320.0_f32.min(max_w)); + let min_h = max_h.min(340.0_f32).max(300.0_f32.min(max_h)); + + egui::Window::new(title) + .collapsible(false) + .resizable(true) + .movable(true) + .default_size(egui::vec2(default_w, default_h)) + .min_size(egui::vec2(min_w, min_h)) + .max_size(egui::vec2(max_w, max_h)) + .constrain_to(screen_bounds.shrink2(egui::vec2(12.0_f32, 12.0_f32))) + .open(&mut window_open) + .show(ctx, |ui| { + if let Some(err) = &self.error { + ui.colored_label(egui::Color32::from_rgb(244, 67, 54), err); + ui.add_space(8.0); + } + + let reserved_footer = 72.0_f32; + let scroll_height = (ui.available_height() - reserved_footer).max(160.0_f32); + + egui::ScrollArea::vertical() + .max_height(scroll_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.label("Task Name"); + ui.text_edit_singleline(&mut self.task_name); + ui.add_space(8.0); + + ui.label("JSON Sequence"); + ui.add( + egui::TextEdit::multiline(&mut self.sequence_text) + .desired_rows(14) + .desired_width(f32::INFINITY), + ); + }); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Save Task").clicked() { + let name = self.task_name.trim(); + if name.is_empty() { + self.error = Some("Task name cannot be empty.".to_string()); + } else { + match serde_json::from_str::<Value>(&self.sequence_text) { + Ok(sequence) => { + self.error = None; + outcome = Some(AuditTaskEditorResult { + id: self.current_id, + name: name.to_string(), + sequence, + is_new: self.is_new, + }); + close_requested = true; + } + Err(err) => { + self.error = Some(format!("Invalid JSON: {}", err)); + } + } + } + } + + ui.add_space(12.0); + + if ui.button("Cancel").clicked() { + close_requested = true; + } + }); + }); + + if !window_open || close_requested { + self.open = false; + } + + outcome + } +} + +struct AuditTaskEditorResult { + id: Option<i64>, + name: String, + sequence: Value, + is_new: bool, +} diff --git a/src/ui/borrowing.rs b/src/ui/borrowing.rs new file mode 100644 index 0000000..8b1fc93 --- /dev/null +++ b/src/ui/borrowing.rs @@ -0,0 +1,1618 @@ +use eframe::egui; + +use crate::api::ApiClient; +use crate::core::components::form_builder::FormBuilder; +use crate::core::tables::{get_all_loans, get_borrowers_summary}; +use crate::core::workflows::borrow_flow::BorrowFlow; +use crate::core::workflows::return_flow::ReturnFlow; +use crate::core::{ColumnConfig, TableRenderer}; +use crate::core::{EditorField, FieldType}; + +pub struct BorrowingView { + // data + loans: Vec<serde_json::Value>, + borrowers: Vec<serde_json::Value>, + is_loading: bool, + last_error: Option<String>, + + // UI + init_loaded: bool, + show_loans_column_selector: bool, + show_borrowers_column_selector: bool, + + // Table renderers + loans_table: TableRenderer, + borrowers_table: TableRenderer, + + // Workflows + borrow_flow: BorrowFlow, + return_flow: ReturnFlow, + + // Register borrower dialog + show_register_dialog: bool, + new_borrower_name: String, + new_borrower_email: String, + new_borrower_phone: String, + new_borrower_class: String, + new_borrower_role: String, + register_error: Option<String>, + + // Edit borrower dialog (using FormBuilder) + borrower_editor: FormBuilder, + + // Ban/Unban borrower dialog + show_ban_dialog: bool, + show_unban_dialog: bool, + ban_borrower_data: Option<serde_json::Value>, + ban_fine_amount: String, + ban_reason: String, + + // Return item confirm dialog + show_return_confirm_dialog: bool, + return_loan_data: Option<serde_json::Value>, + + // Delete borrower confirm dialog + show_delete_borrower_dialog: bool, + delete_borrower_data: Option<serde_json::Value>, + + // Search and filtering + loans_search: String, + borrowers_search: String, + + // Navigation + pub switch_to_inventory_with_borrower: Option<i64>, // borrower_id to filter by +} + +impl BorrowingView { + pub fn new() -> Self { + // Define columns for loans table - ALL columns from the query + let loans_columns = vec![ + ColumnConfig::new("Loan ID", "id").with_width(60.0).hidden(), + ColumnConfig::new("Asset ID", "asset_id") + .with_width(70.0) + .hidden(), + ColumnConfig::new("Borrower ID", "borrower_id") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Tag", "asset_tag").with_width(80.0), + ColumnConfig::new("Name", "name").with_width(200.0), + ColumnConfig::new("Borrower", "borrower_name").with_width(120.0), + ColumnConfig::new("Class", "class_name").with_width(80.0), + ColumnConfig::new("Status", "lending_status").with_width(80.0), + ColumnConfig::new("Checked Out", "checkout_date").with_width(100.0), + ColumnConfig::new("Due", "due_date").with_width(90.0), + ColumnConfig::new("Returned", "return_date").with_width(100.0), + ColumnConfig::new("Notes", "notes") + .with_width(150.0) + .hidden(), + ]; + + // Define columns for borrowers table - with all backend fields + let borrowers_columns = vec![ + ColumnConfig::new("ID", "borrower_id").with_width(60.0), + ColumnConfig::new("Name", "borrower_name").with_width(150.0), + ColumnConfig::new("Email", "email") + .with_width(150.0) + .hidden(), + ColumnConfig::new("Phone", "phone_number") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Class", "class_name").with_width(80.0), + ColumnConfig::new("Role", "role").with_width(80.0).hidden(), + ColumnConfig::new("Active", "active_loans").with_width(60.0), + ColumnConfig::new("Overdue", "overdue_loans").with_width(60.0), + ColumnConfig::new("Banned", "banned").with_width(60.0), + ]; + + Self { + loans: vec![], + borrowers: vec![], + is_loading: false, + last_error: None, + init_loaded: false, + show_loans_column_selector: false, + show_borrowers_column_selector: false, + loans_table: TableRenderer::new() + .with_columns(loans_columns) + .with_default_sort("checkout_date", false), // Sort by checkout date DESC (most recent first) + borrowers_table: TableRenderer::new().with_columns(borrowers_columns), + borrow_flow: BorrowFlow::new(), + return_flow: ReturnFlow::new(), + show_register_dialog: false, + new_borrower_name: String::new(), + new_borrower_email: String::new(), + new_borrower_phone: String::new(), + new_borrower_class: String::new(), + new_borrower_role: String::new(), + register_error: None, + borrower_editor: { + let fields = vec![ + EditorField { + name: "borrower_id".to_string(), + label: "ID".to_string(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "name".to_string(), + label: "Name".to_string(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "email".to_string(), + label: "Email".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "phone_number".to_string(), + label: "Phone".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "class_name".to_string(), + label: "Class/Department".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "role".to_string(), + label: "Role/Type".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "notes".to_string(), + label: "Notes".to_string(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "banned".to_string(), + label: "Banned".to_string(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "unban_fine".to_string(), + label: "Unban Fine".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + ]; + FormBuilder::new("Edit Borrower", fields) + }, + show_ban_dialog: false, + show_unban_dialog: false, + ban_borrower_data: None, + ban_fine_amount: String::new(), + ban_reason: String::new(), + show_return_confirm_dialog: false, + return_loan_data: None, + show_delete_borrower_dialog: false, + delete_borrower_data: None, + loans_search: String::new(), + borrowers_search: String::new(), + switch_to_inventory_with_borrower: None, + } + } + + pub fn get_filter_columns() -> Vec<(String, String)> { + vec![ + ("Any".to_string(), "Any".to_string()), + ("Asset Tag".to_string(), "assets.asset_tag".to_string()), + ("Asset Name".to_string(), "assets.name".to_string()), + ("Borrower Name".to_string(), "borrowers.name".to_string()), + ("Class".to_string(), "borrowers.class_name".to_string()), + ("Status".to_string(), "assets.lending_status".to_string()), + ( + "Checkout Date".to_string(), + "lending_history.checkout_date".to_string(), + ), + ( + "Due Date".to_string(), + "lending_history.due_date".to_string(), + ), + ( + "Return Date".to_string(), + "lending_history.return_date".to_string(), + ), + ] + } + + fn load(&mut self, api: &ApiClient) { + if self.is_loading { + return; + } + self.is_loading = true; + self.last_error = None; + match get_all_loans(api, None) { + Ok(list) => { + self.loans = list; + } + Err(e) => { + self.last_error = Some(e.to_string()); + } + } + if self.last_error.is_none() { + match get_borrowers_summary(api) { + Ok(list) => { + self.borrowers = list; + } + Err(e) => { + self.last_error = Some(e.to_string()); + } + } + } + self.is_loading = false; + self.init_loaded = true; + } + + pub fn show( + &mut self, + ctx: &egui::Context, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon: &mut crate::ui::ribbon::RibbonUI, + ) { + ui.horizontal(|ui| { + ui.heading("Borrowing"); + if self.is_loading { + ui.spinner(); + ui.label("Loading..."); + } + if let Some(err) = &self.last_error { + ui.colored_label(egui::Color32::RED, err); + if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + } else if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + }); + ui.separator(); + + // Check for filter changes + if ribbon + .checkboxes + .get("borrowing_filter_changed") + .copied() + .unwrap_or(false) + { + ribbon + .checkboxes + .insert("borrowing_filter_changed".to_string(), false); + // For now just note that filters changed - we'll apply them client-side in render + // In the future we could reload with server-side filtering + } + + // Check for ribbon actions + if let Some(api) = api_client { + if ribbon + .checkboxes + .get("borrowing_action_checkout") + .copied() + .unwrap_or(false) + { + self.borrow_flow.open(api); + } + + if ribbon + .checkboxes + .get("borrowing_action_return") + .copied() + .unwrap_or(false) + { + self.return_flow.open(api); + } + + if ribbon + .checkboxes + .get("borrowing_action_register") + .copied() + .unwrap_or(false) + { + self.show_register_dialog = true; + self.register_error = None; + } + + if ribbon + .checkboxes + .get("borrowing_action_refresh") + .copied() + .unwrap_or(false) + { + self.load(api); + } + } + + if !self.init_loaded { + if let Some(api) = api_client { + self.load(api); + } + } + + // Show borrow flow if open + if let Some(api) = api_client { + self.borrow_flow.show(ctx, api); + if self.borrow_flow.take_recent_success() { + self.load(api); + } + } + + // Show return flow if open + if let Some(api) = api_client { + self.return_flow.show(ctx, api); + if self.return_flow.take_recent_success() { + self.load(api); + } + } + + // Show register dialog if open + if self.show_register_dialog { + if let Some(api) = api_client { + self.show_register_borrower_dialog(ctx, api); + } + } + + // Show borrower editor if open + if let Some(api) = api_client { + if let Some(result) = self.borrower_editor.show_editor(ctx) { + if let Some(data) = result { + // Editor returned data - save it + if let Err(e) = self.save_borrower_changes(api, &data) { + log::error!("Failed to save borrower changes: {}", e); + } else { + self.load(api); + } + } + // else: user cancelled + } + } + + // Show ban dialog if open + if self.show_ban_dialog { + if let Some(api) = api_client { + self.show_ban_dialog(ctx, api); + } + } + + // Show unban dialog if open + if self.show_unban_dialog { + if let Some(api) = api_client { + self.show_unban_dialog(ctx, api); + } + } + + // Show return confirm dialog if open + if self.show_return_confirm_dialog { + if let Some(api) = api_client { + self.show_return_confirm_dialog(ctx, api); + } + } + + // Show delete borrower confirm dialog if open + if self.show_delete_borrower_dialog { + if let Some(api) = api_client { + self.show_delete_borrower_dialog(ctx, api); + } + } + + // Wrap entire content in ScrollArea + egui::ScrollArea::vertical().show(ui, |ui| { + // Section 1: Lending history + egui::CollapsingHeader::new("Lending History") + .default_open(true) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.heading("Loans"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Columns").clicked() { + self.show_loans_column_selector = !self.show_loans_column_selector; + } + }); + }); + + // Search and filter controls + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(&mut self.loans_search); + + ui.separator(); + + // Status filters from ribbon + ui.label("Show:"); + ui.checkbox( + ribbon + .checkboxes + .entry("borrowing_show_normal".to_string()) + .or_insert(true), + "Normal", + ); + ui.checkbox( + ribbon + .checkboxes + .entry("borrowing_show_overdue".to_string()) + .or_insert(true), + "Overdue", + ); + ui.checkbox( + ribbon + .checkboxes + .entry("borrowing_show_stolen".to_string()) + .or_insert(true), + "Stolen", + ); + ui.checkbox( + ribbon + .checkboxes + .entry("borrowing_show_returned".to_string()) + .or_insert(false), + "Returned", + ); + }); + + ui.separator(); + self.render_active_loans(ui, ribbon); + }); + + ui.add_space(10.0); + + // Section 2: Borrowers summary + egui::CollapsingHeader::new("Borrowers") + .default_open(true) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.heading("Borrowers"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Columns").clicked() { + self.show_borrowers_column_selector = + !self.show_borrowers_column_selector; + } + }); + }); + + // Search control + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(&mut self.borrowers_search); + }); + + ui.separator(); + self.render_borrowers_table(ui); + }); + }); // End ScrollArea + + // Show column selector windows + if self.show_loans_column_selector { + egui::Window::new("Loans Columns") + .open(&mut self.show_loans_column_selector) + .resizable(true) + .default_width(250.0) + .show(ctx, |ui| { + self.loans_table.show_column_selector(ui, "loans"); + }); + } + + if self.show_borrowers_column_selector { + egui::Window::new("Borrowers Columns") + .open(&mut self.show_borrowers_column_selector) + .resizable(true) + .default_width(250.0) + .show(ctx, |ui| { + self.borrowers_table.show_column_selector(ui, "borrowers"); + }); + } + } + + fn render_active_loans(&mut self, ui: &mut egui::Ui, ribbon: &crate::ui::ribbon::RibbonUI) { + // Get checkbox states + let show_returned = ribbon + .checkboxes + .get("borrowing_show_returned") + .copied() + .unwrap_or(false); + let show_normal = ribbon + .checkboxes + .get("borrowing_show_normal") + .copied() + .unwrap_or(true); + let show_overdue = ribbon + .checkboxes + .get("borrowing_show_overdue") + .copied() + .unwrap_or(true); + let show_stolen = ribbon + .checkboxes + .get("borrowing_show_stolen") + .copied() + .unwrap_or(true); + + // Apply filters + let filtered_loans: Vec<serde_json::Value> = self + .loans + .iter() + .filter(|loan| { + // First apply search filter + if !self.loans_search.is_empty() { + let search_lower = self.loans_search.to_lowercase(); + let asset_tag = loan.get("asset_tag").and_then(|v| v.as_str()).unwrap_or(""); + let asset_name = loan.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let borrower_name = loan + .get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let class_name = loan + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if !(asset_tag.to_lowercase().contains(&search_lower) + || asset_name.to_lowercase().contains(&search_lower) + || borrower_name.to_lowercase().contains(&search_lower) + || class_name.to_lowercase().contains(&search_lower)) + { + return false; + } + } + + // Apply filter builder filters + if !Self::matches_filter_builder(loan, &ribbon.filter_builder) { + return false; + } + + // Check if this loan has been returned + let has_return_date = loan + .get("return_date") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .is_some(); + + // If returned, check the show_returned checkbox + if has_return_date { + return show_returned; + } + + // For active loans, check the lending_status from assets table + let lending_status = loan + .get("lending_status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Check if stolen + if lending_status == "Stolen" || lending_status == "Illegally Handed Out" { + return show_stolen; + } + + // Check if overdue + if let Some(due_date_str) = loan.get("due_date").and_then(|v| v.as_str()) { + let now = chrono::Local::now().format("%Y-%m-%d").to_string(); + if due_date_str < now.as_str() { + return show_overdue; + } + } + + // Otherwise it's a normal active loan (not overdue, not stolen) + show_normal + }) + .cloned() + .collect(); + + // Derive a display status per loan to avoid confusion: + // If a loan has a return_date, always show "Returned" regardless of the current asset status. + // Otherwise, use the existing lending_status value (Overdue, etc. handled by DB). + let mut display_loans: Vec<serde_json::Value> = Vec::with_capacity(filtered_loans.len()); + for loan in &filtered_loans { + let mut row = loan.clone(); + let has_return = row + .get("return_date") + .and_then(|v| v.as_str()) + .map(|s| !s.is_empty()) + .unwrap_or(false); + + if has_return { + row["lending_status"] = serde_json::Value::String("Returned".to_string()); + } + display_loans.push(row); + } + + let prepared_data = self.loans_table.prepare_json_data(&display_loans); + + // Handle loan table events (return item) + let mut return_loan: Option<serde_json::Value> = None; + + struct LoanEventHandler<'a> { + return_action: &'a mut Option<serde_json::Value>, + } + + impl<'a> crate::core::table_renderer::TableEventHandler<serde_json::Value> + for LoanEventHandler<'a> + { + fn on_double_click(&mut self, _item: &serde_json::Value, _row_index: usize) { + // Not used for loans + } + + fn on_context_menu( + &mut self, + ui: &mut egui::Ui, + item: &serde_json::Value, + _row_index: usize, + ) { + // Only show "Return Item" if the loan is active (no return_date) + let has_return_date = item.get("return_date").and_then(|v| v.as_str()).is_some(); + + if !has_return_date { + if ui + .button(format!( + "{} Return Item", + egui_phosphor::regular::ARROW_RIGHT + )) + .clicked() + { + *self.return_action = Some(item.clone()); + ui.close(); + } + } + } + + fn on_selection_changed(&mut self, _selected_indices: &[usize]) { + // Not used for now + } + } + + let mut handler = LoanEventHandler { + return_action: &mut return_loan, + }; + + self.loans_table + .render_json_table(ui, &prepared_data, Some(&mut handler)); + + // Store return action for processing after all rendering + if let Some(loan) = return_loan { + self.return_loan_data = Some(loan); + self.show_return_confirm_dialog = true; + } + } + + /// Client-side filter matching for filter builder conditions + fn matches_filter_builder( + loan: &serde_json::Value, + filter_builder: &crate::core::components::filter_builder::FilterBuilder, + ) -> bool { + use crate::core::components::filter_builder::FilterOperator; + + // If no valid conditions, don't filter + if !filter_builder.filter_group.is_valid() { + return true; + } + + // Check each condition + for condition in &filter_builder.filter_group.conditions { + if !condition.is_valid() { + continue; + } + + // Map the filter column to the actual JSON field name + let field_name = match condition.column.as_str() { + "assets.asset_tag" => "asset_tag", + "assets.name" => "name", + "borrowers.name" => "borrower_name", + "borrowers.class_name" => "class_name", + "assets.lending_status" => "lending_status", + "lending_history.checkout_date" => "checkout_date", + "lending_history.due_date" => "due_date", + "lending_history.return_date" => "return_date", + _ => { + // Fallback: strip table prefix if present + if condition.column.contains('.') { + condition + .column + .split('.') + .last() + .unwrap_or(&condition.column) + } else { + &condition.column + } + } + }; + + let field_value = loan.get(field_name).and_then(|v| v.as_str()).unwrap_or(""); + + // Apply the operator + let matches = match &condition.operator { + FilterOperator::Is => field_value == condition.value, + FilterOperator::IsNot => field_value != condition.value, + FilterOperator::Contains => field_value + .to_lowercase() + .contains(&condition.value.to_lowercase()), + FilterOperator::DoesntContain => !field_value + .to_lowercase() + .contains(&condition.value.to_lowercase()), + FilterOperator::IsNull => field_value.is_empty(), + FilterOperator::IsNotNull => !field_value.is_empty(), + }; + + if !matches { + return false; // For now, treat as AND logic + } + } + + true + } + + fn render_borrowers_table(&mut self, ui: &mut egui::Ui) { + // Apply search filter if set + let filtered_borrowers: Vec<serde_json::Value> = if self.borrowers_search.is_empty() { + self.borrowers.clone() + } else { + let search_lower = self.borrowers_search.to_lowercase(); + self.borrowers + .iter() + .filter(|borrower| { + let name = borrower + .get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let class = borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + name.to_lowercase().contains(&search_lower) + || class.to_lowercase().contains(&search_lower) + }) + .cloned() + .collect() + }; + + let prepared_data = self.borrowers_table.prepare_json_data(&filtered_borrowers); + + // Store actions to perform after rendering (to avoid borrow checker issues) + let mut edit_borrower: Option<serde_json::Value> = None; + let mut ban_borrower: Option<serde_json::Value> = None; + let mut unban_borrower: Option<serde_json::Value> = None; + let mut delete_borrower: Option<serde_json::Value> = None; + let mut show_items_for_borrower: Option<i64> = None; + + // Create event handler for context menu + struct BorrowerEventHandler<'a> { + edit_action: &'a mut Option<serde_json::Value>, + ban_action: &'a mut Option<serde_json::Value>, + unban_action: &'a mut Option<serde_json::Value>, + delete_action: &'a mut Option<serde_json::Value>, + show_items_action: &'a mut Option<i64>, + } + + impl<'a> crate::core::TableEventHandler<serde_json::Value> for BorrowerEventHandler<'a> { + fn on_double_click(&mut self, item: &serde_json::Value, _row_index: usize) { + // Open edit dialog on double-click + *self.edit_action = Some(item.clone()); + } + + fn on_context_menu( + &mut self, + ui: &mut egui::Ui, + item: &serde_json::Value, + _row_index: usize, + ) { + let is_banned = item + .get("banned") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let borrower_id = item.get("borrower_id").and_then(|v| v.as_i64()); + + if ui + .button(format!("{} Edit Borrower", egui_phosphor::regular::PENCIL)) + .clicked() + { + *self.edit_action = Some(item.clone()); + ui.close(); + } + + if let Some(id) = borrower_id { + if ui + .button(format!( + "{} Show Items Borrowed to this User", + egui_phosphor::regular::PACKAGE + )) + .clicked() + { + *self.show_items_action = Some(id); + ui.close(); + } + } + + ui.separator(); + + if is_banned { + if ui + .button(format!( + "{} Unban Borrower", + egui_phosphor::regular::CHECK_CIRCLE + )) + .clicked() + { + *self.unban_action = Some(item.clone()); + ui.close(); + } + } else { + if ui + .button(format!("{} Ban Borrower", egui_phosphor::regular::PROHIBIT)) + .clicked() + { + *self.ban_action = Some(item.clone()); + ui.close(); + } + } + + ui.separator(); + + if ui + .button(format!("{} Delete Borrower", egui_phosphor::regular::TRASH)) + .clicked() + { + *self.delete_action = Some(item.clone()); + ui.close(); + } + } + + fn on_selection_changed(&mut self, _selected_indices: &[usize]) { + // Not used for now + } + } + + let mut handler = BorrowerEventHandler { + edit_action: &mut edit_borrower, + ban_action: &mut ban_borrower, + unban_action: &mut unban_borrower, + delete_action: &mut delete_borrower, + show_items_action: &mut show_items_for_borrower, + }; + + self.borrowers_table + .render_json_table(ui, &prepared_data, Some(&mut handler)); + + // Process actions after rendering + if let Some(borrower) = edit_borrower { + self.open_edit_borrower_dialog(borrower); + } + if let Some(borrower) = ban_borrower { + self.open_ban_dialog(borrower); + } + if let Some(borrower) = unban_borrower { + self.open_unban_dialog(borrower); + } + if let Some(borrower) = delete_borrower { + self.delete_borrower_data = Some(borrower); + self.show_delete_borrower_dialog = true; + } + if let Some(borrower_id) = show_items_for_borrower { + // Set the flag to switch to inventory with this borrower filter + self.switch_to_inventory_with_borrower = Some(borrower_id); + } + } + + fn show_register_borrower_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) { + egui::Window::new("Register New Borrower") + .collapsible(false) + .resizable(false) + .default_width(400.0) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + + if let Some(err) = &self.register_error { + ui.colored_label(egui::Color32::RED, err); + ui.separator(); + } + + ui.horizontal(|ui| { + ui.label("Name:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_name) + .hint_text("Full name"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Email:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_email) + .hint_text("email@example.com"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Phone:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_phone) + .hint_text("Phone number"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Class:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_class) + .hint_text("Class or department"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Role:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_role) + .hint_text("Student, Staff, etc."), + ); + }); + + ui.add_space(10.0); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Register").clicked() { + if self.new_borrower_name.trim().is_empty() { + self.register_error = Some("Name is required".to_string()); + } else { + match self.register_borrower(api_client) { + Ok(_) => { + // Success - close dialog and reload data + self.show_register_dialog = false; + self.clear_register_form(); + self.load(api_client); + } + Err(e) => { + self.register_error = Some(e.to_string()); + } + } + } + } + + if ui.button("Cancel").clicked() { + self.show_register_dialog = false; + self.clear_register_form(); + } + }); + }); + }); + } + + fn register_borrower(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let mut borrower_data = serde_json::json!({ + "name": self.new_borrower_name.trim(), + "banned": false, + }); + + if !self.new_borrower_email.is_empty() { + borrower_data["email"] = + serde_json::Value::String(self.new_borrower_email.trim().to_string()); + } + + if !self.new_borrower_phone.is_empty() { + borrower_data["phone_number"] = + serde_json::Value::String(self.new_borrower_phone.trim().to_string()); + } + + if !self.new_borrower_class.is_empty() { + borrower_data["class_name"] = + serde_json::Value::String(self.new_borrower_class.trim().to_string()); + } + + if !self.new_borrower_role.is_empty() { + borrower_data["role"] = + serde_json::Value::String(self.new_borrower_role.trim().to_string()); + } + + let request = QueryRequest { + action: "insert".to_string(), + table: "borrowers".to_string(), + columns: None, + r#where: None, + data: Some(borrower_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to register borrower".to_string()))); + } + + Ok(()) + } + + fn clear_register_form(&mut self) { + self.new_borrower_name.clear(); + self.new_borrower_email.clear(); + self.new_borrower_phone.clear(); + self.new_borrower_class.clear(); + self.new_borrower_role.clear(); + self.register_error = None; + } + + // Edit borrower dialog methods + fn open_edit_borrower_dialog(&mut self, borrower: serde_json::Value) { + // The summary doesn't have all fields, so we'll populate what we have + // and the editor will show empty fields for missing data + let mut editor_data = serde_json::Map::new(); + + // Map the summary fields to editor fields + if let Some(id) = borrower.get("borrower_id") { + editor_data.insert("borrower_id".to_string(), id.clone()); + editor_data.insert("id".to_string(), id.clone()); // Also set 'id' for WHERE clause + } + if let Some(name) = borrower.get("borrower_name") { + editor_data.insert("name".to_string(), name.clone()); + } + if let Some(email) = borrower.get("email") { + if !email.is_null() && email.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("email".to_string(), email.clone()); + } + } + if let Some(phone) = borrower.get("phone_number") { + if !phone.is_null() && phone.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("phone_number".to_string(), phone.clone()); + } + } + if let Some(class) = borrower.get("class_name") { + if !class.is_null() && class.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("class_name".to_string(), class.clone()); + } + } + if let Some(role) = borrower.get("role") { + if !role.is_null() && role.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("role".to_string(), role.clone()); + } + } + if let Some(notes) = borrower.get("notes") { + if !notes.is_null() && notes.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("notes".to_string(), notes.clone()); + } + } + if let Some(banned) = borrower.get("banned") { + editor_data.insert("banned".to_string(), banned.clone()); + } + if let Some(unban_fine) = borrower.get("unban_fine") { + if !unban_fine.is_null() { + editor_data.insert("unban_fine".to_string(), unban_fine.clone()); + } + } + + // Open the editor with the borrower data + let value = serde_json::Value::Object(editor_data); + self.borrower_editor.open(&value); + } + + fn save_borrower_changes( + &self, + api_client: &ApiClient, + diff: &serde_json::Map<String, serde_json::Value>, + ) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + // Extract borrower ID from the diff (editor includes it as __editor_item_id) + let borrower_id = diff + .get("__editor_item_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::<i64>().ok()) + .or_else(|| diff.get("borrower_id").and_then(|v| v.as_i64())) + .or_else(|| diff.get("id").and_then(|v| v.as_i64())) + .ok_or_else(|| anyhow::anyhow!("Invalid borrower ID"))?; + + // Build update data from the diff (exclude editor metadata) + let mut update_data = serde_json::Map::new(); + for (key, value) in diff.iter() { + if !key.starts_with("__editor_") && key != "borrower_id" && key != "id" { + update_data.insert(key.clone(), value.clone()); + } + } + + if update_data.is_empty() { + return Ok(()); // Nothing to update + } + + let request = QueryRequest { + action: "update".to_string(), + table: "borrowers".to_string(), + data: Some(serde_json::Value::Object(update_data)), + r#where: Some(serde_json::json!({"id": borrower_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to update borrower".to_string()))); + } + + Ok(()) + } + + // Ban/Unban dialog methods + fn open_ban_dialog(&mut self, borrower: serde_json::Value) { + self.ban_borrower_data = Some(borrower); + self.show_ban_dialog = true; + self.ban_fine_amount.clear(); + self.ban_reason.clear(); + } + + fn open_unban_dialog(&mut self, borrower: serde_json::Value) { + self.ban_borrower_data = Some(borrower); + self.show_unban_dialog = true; + } + + fn show_ban_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) { + let mut keep_open = true; + let mut confirmed = false; + let mut cancelled = false; + + let borrower_name = self + .ban_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + egui::Window::new("Ban Borrower") + .collapsible(false) + .resizable(false) + .default_width(400.0) + .open(&mut keep_open) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + + ui.label( + egui::RichText::new(format!( + "⚠ Are you sure you want to ban '{}'?", + borrower_name + )) + .color(egui::Color32::from_rgb(255, 152, 0)) + .strong(), + ); + + ui.add_space(10.0); + + ui.horizontal(|ui| { + ui.label("Fine Amount ($):"); + ui.text_edit_singleline(&mut self.ban_fine_amount); + }); + ui.label( + egui::RichText::new("(Optional: leave empty for no fine)") + .small() + .color(ui.visuals().weak_text_color()), + ); + + ui.add_space(5.0); + + ui.label("Reason:"); + ui.text_edit_multiline(&mut self.ban_reason); + ui.label( + egui::RichText::new("(Optional: reason for banning)") + .small() + .color(ui.visuals().weak_text_color()), + ); + + ui.add_space(10.0); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Confirm Ban").clicked() { + confirmed = true; + } + + if ui.button("Cancel").clicked() { + cancelled = true; + } + }); + }); + }); + + if confirmed { + match self.ban_borrower(api_client) { + Ok(_) => { + self.show_ban_dialog = false; + self.ban_borrower_data = None; + self.load(api_client); + } + Err(e) => { + log::error!("Failed to ban borrower: {}", e); + } + } + } + + if cancelled || !keep_open { + self.show_ban_dialog = false; + self.ban_borrower_data = None; + } + } + + fn show_unban_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) { + let mut keep_open = true; + let mut confirmed = false; + let mut cancelled = false; + + let borrower_name = self + .ban_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + egui::Window::new("Unban Borrower") + .collapsible(false) + .resizable(false) + .default_width(400.0) + .open(&mut keep_open) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + + ui.label( + egui::RichText::new(format!( + "Are you sure you want to unban '{}'?", + borrower_name + )) + .color(egui::Color32::from_rgb(76, 175, 80)) + .strong(), + ); + + ui.add_space(10.0); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Confirm Unban").clicked() { + confirmed = true; + } + + if ui.button("Cancel").clicked() { + cancelled = true; + } + }); + }); + }); + + if confirmed { + match self.unban_borrower(api_client) { + Ok(_) => { + self.show_unban_dialog = false; + self.ban_borrower_data = None; + self.load(api_client); + } + Err(e) => { + log::error!("Failed to unban borrower: {}", e); + } + } + } + + if cancelled || !keep_open { + self.show_unban_dialog = false; + self.ban_borrower_data = None; + } + } + + fn show_return_confirm_dialog(&mut self, _ctx: &egui::Context, api_client: &ApiClient) { + // Replace the basic confirm dialog with the full Return Flow, pre-selecting the loan + if let Some(loan) = self.return_loan_data.clone() { + // Open the full-featured return flow and jump to confirmation + self.return_flow.open(api_client); + self.return_flow.selected_loan = Some(loan); + self.return_flow.current_step = + crate::core::workflows::return_flow::ReturnStep::Confirm; + } + // Close the legacy confirm dialog path + self.show_return_confirm_dialog = false; + self.return_loan_data = None; + } + + fn show_delete_borrower_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) { + let mut keep_open = true; + let mut confirmed = false; + let mut cancelled = false; + + let borrower_name = self + .delete_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + egui::Window::new("Delete Borrower") + .collapsible(false) + .resizable(false) + .default_width(400.0) + .open(&mut keep_open) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + + ui.label( + egui::RichText::new(format!( + "Are you sure you want to delete '{}'?", + borrower_name + )) + .color(egui::Color32::RED) + .strong(), + ); + + ui.add_space(10.0); + + ui.label( + egui::RichText::new("This action cannot be undone!") + .color(egui::Color32::RED) + .small(), + ); + + ui.add_space(10.0); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Confirm Delete").clicked() { + confirmed = true; + } + + if ui.button("Cancel").clicked() { + cancelled = true; + } + }); + }); + }); + + if confirmed { + match self.delete_borrower(api_client) { + Ok(_) => { + self.show_delete_borrower_dialog = false; + self.delete_borrower_data = None; + self.load(api_client); + } + Err(e) => { + log::error!("Failed to delete borrower: {}", e); + self.last_error = Some(format!("Delete failed: {}", e)); + } + } + } + + if cancelled || !keep_open { + self.show_delete_borrower_dialog = false; + self.delete_borrower_data = None; + } + } + + fn ban_borrower(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let borrower_id = self + .ban_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid borrower ID"))?; + + let mut update_data = serde_json::json!({ + "banned": true, + }); + + // Add unban fine amount if provided + if !self.ban_fine_amount.trim().is_empty() { + if let Ok(fine) = self.ban_fine_amount.trim().parse::<f64>() { + update_data["unban_fine"] = serde_json::Value::Number( + serde_json::Number::from_f64(fine).unwrap_or(serde_json::Number::from(0)), + ); + } + } + + // Add reason to notes if provided + if !self.ban_reason.trim().is_empty() { + update_data["notes"] = serde_json::Value::String(self.ban_reason.trim().to_string()); + } + + let request = QueryRequest { + action: "update".to_string(), + table: "borrowers".to_string(), + data: Some(update_data), + r#where: Some(serde_json::json!({"id": borrower_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to ban borrower".to_string()))); + } + + Ok(()) + } + + fn unban_borrower(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let borrower_id = self + .ban_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid borrower ID"))?; + + let update_data = serde_json::json!({ + "banned": false, + "unban_fine": 0.0, + }); + + let request = QueryRequest { + action: "update".to_string(), + table: "borrowers".to_string(), + data: Some(update_data), + r#where: Some(serde_json::json!({"id": borrower_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to unban borrower".to_string()))); + } + + Ok(()) + } + + #[allow(dead_code)] + fn process_return(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let loan_id = self + .return_loan_data + .as_ref() + .and_then(|l| l.get("id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid loan ID"))?; + + let asset_id = self + .return_loan_data + .as_ref() + .and_then(|l| l.get("asset_id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid asset ID"))?; + + let return_date = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + // Update lending_history to set return_date + let update_data = serde_json::json!({ + "return_date": return_date + }); + + let request = QueryRequest { + action: "update".to_string(), + table: "lending_history".to_string(), + data: Some(update_data), + r#where: Some(serde_json::json!({"id": loan_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to update loan record".to_string()))); + } + + // Update asset status to "Available" + let asset_update = serde_json::json!({ + "lending_status": "Available" + }); + + let asset_request = QueryRequest { + action: "update".to_string(), + table: "assets".to_string(), + data: Some(asset_update), + r#where: Some(serde_json::json!({"id": asset_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let asset_response = api_client.query(&asset_request)?; + + if !asset_response.success { + return Err(anyhow::anyhow!(asset_response + .error + .unwrap_or_else(|| "Failed to update asset status".to_string()))); + } + + Ok(()) + } + + fn delete_borrower(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let borrower_id = self + .delete_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid borrower ID"))?; + + let request = QueryRequest { + action: "delete".to_string(), + table: "borrowers".to_string(), + data: None, + r#where: Some(serde_json::json!({"id": borrower_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to delete borrower".to_string()))); + } + + Ok(()) + } +} 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 + } +} diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs new file mode 100644 index 0000000..3a40c97 --- /dev/null +++ b/src/ui/dashboard.rs @@ -0,0 +1,384 @@ +use eframe::egui; +use egui_extras::{Column, TableBuilder}; + +use crate::api::ApiClient; +use crate::core::{fetch_dashboard_stats, get_asset_changes, get_issue_changes}; +use crate::models::DashboardStats; + +fn format_date_short(date_str: &str) -> String { + // Parse ISO format like "2024-10-17T01:05:14Z" and return "01:05 17/10/24" + if let Some(parts) = date_str.split('T').next() { + if let Some(time_part) = date_str.split('T').nth(1) { + let time = &time_part[..5]; // HH:MM + if let Some((y, rest)) = parts.split_once('-') { + if let Some((m, d)) = rest.split_once('-') { + let year_short = y.chars().skip(2).collect::<String>(); + return format!("{} {}/{}/{}", time, d, m, year_short); + } + } + } + } + date_str.to_string() +} + +pub struct DashboardView { + stats: DashboardStats, + is_loading: bool, + last_error: Option<String>, + data_loaded: bool, + asset_changes: Vec<serde_json::Value>, + issue_changes: Vec<serde_json::Value>, +} + +impl DashboardView { + pub fn new() -> Self { + Self { + stats: DashboardStats::default(), + is_loading: false, + last_error: None, + data_loaded: false, + asset_changes: Vec::new(), + issue_changes: Vec::new(), + } + } + + pub fn refresh_data(&mut self, api_client: &ApiClient) { + self.is_loading = true; + self.last_error = None; + + // Fetch dashboard stats using core module + log::info!("Refreshing dashboard data..."); + match fetch_dashboard_stats(api_client) { + Ok(stats) => { + log::info!( + "Dashboard stats loaded: {} total assets", + stats.total_assets + ); + self.stats = stats; + + // Load recent changes using core module + self.asset_changes = get_asset_changes(api_client, 15).unwrap_or_default(); + self.issue_changes = get_issue_changes(api_client, 12).unwrap_or_default(); + + self.is_loading = false; + self.data_loaded = true; + } + Err(err) => { + log::error!("Failed to load dashboard stats: {}", err); + self.last_error = Some(format!("Failed to load stats: {}", err)); + self.is_loading = false; + } + } + } + + /// Check if the last error was a database timeout + pub fn has_timeout_error(&self) -> bool { + if let Some(error) = &self.last_error { + error.contains("Database temporarily unavailable") + } else { + false + } + } + + pub fn show(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + // Auto-load data on first show + if !self.data_loaded && !self.is_loading { + if let Some(client) = api_client { + self.refresh_data(client); + } + } + + ui.heading("Dashboard"); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Refresh").clicked() { + if let Some(client) = api_client { + self.refresh_data(client); + } + } + + if self.is_loading { + ui.spinner(); + ui.label("Loading..."); + } + }); + + ui.add_space(12.0); + + // Error display + if let Some(error) = &self.last_error { + ui.label(format!("Error: {}", error)); + ui.add_space(8.0); + } + + // Stats cards - using horizontal layout with equal widths and padding + ui.horizontal(|ui| { + let available_width = ui.available_width(); + let side_padding = 20.0; // Equal padding on both sides + let spacing = 16.0; + let frame_margin = 16.0 * 2.0; // inner_margin on both sides + let stroke_width = 1.0 * 2.0; // stroke on both sides + let total_card_overhead = frame_margin + stroke_width; + + // Calculate card content width accounting for frame overhead and side padding + let usable_width = available_width - (side_padding * 2.0); + let card_width = ((usable_width - (spacing * 2.0)) / 3.0) - total_card_overhead; + + // Add left padding + ui.add_space(side_padding); + + self.show_stat_card( + ui, + "Total Assets", + self.stats.total_assets, + egui::Color32::from_rgb(33, 150, 243), + card_width, + ); + ui.add_space(spacing); + self.show_stat_card( + ui, + "Okay Items", + self.stats.okay_items, + egui::Color32::from_rgb(76, 175, 80), + card_width, + ); + ui.add_space(spacing); + self.show_stat_card( + ui, + "Attention", + self.stats.attention_items, + egui::Color32::from_rgb(244, 67, 54), + card_width, + ); + + // Add right padding (this will naturally happen with the remaining space) + ui.add_space(side_padding); + }); + + ui.add_space(24.0); + + // Recent changes tables side-by-side, fill remaining height + let full_h = ui.available_height(); + ui.horizontal(|ui| { + let spacing = 16.0; + let available = ui.available_width(); + let half = (available - spacing) / 2.0; + + // Left column: Asset changes + ui.allocate_ui_with_layout( + egui::vec2(half, full_h), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.set_width(half); + let col_w = ui.available_width(); + ui.set_max_width(col_w); + + ui.heading("Recent Asset Changes"); + ui.separator(); + ui.add_space(8.0); + + if self.asset_changes.is_empty() { + ui.label("No recent asset changes"); + } else { + ui.push_id("asset_changes_table", |ui| { + let col_w = ui.available_width(); + ui.set_width(col_w); + + // Set table body height based on remaining space + let body_h = (ui.available_height() - 36.0).max(180.0); + let table = TableBuilder::new(ui) + .striped(true) + .resizable(false) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::remainder()) + .column(Column::exact(140.0)) + .column(Column::exact(120.0)) + .column(Column::exact(120.0)) + .min_scrolled_height(body_h); + + table + .header(20.0, |mut header| { + header.col(|ui| { + ui.strong("Asset"); + }); + header.col(|ui| { + ui.strong("Change"); + }); + header.col(|ui| { + ui.strong("Date"); + }); + header.col(|ui| { + ui.strong("User"); + }); + }) + .body(|mut body| { + for change in &self.asset_changes { + let asset = change + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let summary = change + .get("changes") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let date_raw = change + .get("date") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let date = format_date_short(date_raw); + let user = change + .get("user") + .and_then(|v| v.as_str()) + .unwrap_or("System"); + + body.row(24.0, |mut row| { + row.col(|ui| { + ui.add(egui::Label::new(asset).truncate()); + }); + row.col(|ui| { + let label = egui::Label::new(summary).truncate(); + ui.add(label).on_hover_text(summary); + }); + row.col(|ui| { + ui.add(egui::Label::new(&date).truncate()); + }); + row.col(|ui| { + ui.add(egui::Label::new(user).truncate()); + }); + }); + } + }); + }); + } + }, + ); + + ui.add_space(spacing); + + // Right column: Issue changes + ui.allocate_ui_with_layout( + egui::vec2(half, full_h), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.set_width(half); + let col_w = ui.available_width(); + ui.set_max_width(col_w); + + ui.heading("Recent Issue Updates"); + ui.separator(); + ui.add_space(8.0); + + if self.issue_changes.is_empty() { + ui.label("No recent issue updates"); + } else { + ui.push_id("issue_changes_table", |ui| { + let col_w = ui.available_width(); + ui.set_width(col_w); + + let body_h = (ui.available_height() - 36.0).max(180.0); + let table = TableBuilder::new(ui) + .striped(true) + .resizable(false) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::remainder()) + .column(Column::exact(140.0)) + .column(Column::exact(120.0)) + .column(Column::exact(120.0)) + .min_scrolled_height(body_h); + + table + .header(20.0, |mut header| { + header.col(|ui| { + ui.strong("Issue"); + }); + header.col(|ui| { + ui.strong("Change"); + }); + header.col(|ui| { + ui.strong("Date"); + }); + header.col(|ui| { + ui.strong("User"); + }); + }) + .body(|mut body| { + for change in &self.issue_changes { + let issue = change + .get("issue") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let summary = change + .get("changes") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let date_raw = change + .get("date") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let date = format_date_short(date_raw); + let user = change + .get("user") + .and_then(|v| v.as_str()) + .unwrap_or("System"); + + body.row(24.0, |mut row| { + row.col(|ui| { + ui.add(egui::Label::new(issue).truncate()); + }); + row.col(|ui| { + let label = egui::Label::new(summary).truncate(); + ui.add(label).on_hover_text(summary); + }); + row.col(|ui| { + ui.add(egui::Label::new(&date).truncate()); + }); + row.col(|ui| { + ui.add(egui::Label::new(user).truncate()); + }); + }); + } + }); + }); + } + }, + ); + }); + } + + fn show_stat_card<T: std::fmt::Display>( + &self, + ui: &mut egui::Ui, + label: &str, + value: T, + color: egui::Color32, + width: f32, + ) { + // Use default widget background - adapts to light/dark mode automatically + egui::Frame::default() + .corner_radius(8.0) + .inner_margin(16.0) + .fill(ui.visuals().widgets.noninteractive.weak_bg_fill) + .stroke(egui::Stroke::new(1.5, color)) + .show(ui, |ui| { + ui.set_min_width(width); + ui.set_max_width(width); + ui.set_min_height(100.0); + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new(label).size(14.0).color(color)); + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!("{}", value)) + .size(32.0) + .strong(), + ); + }); + }); + } +} + +impl Default for DashboardView { + fn default() -> Self { + Self::new() + } +} diff --git a/src/ui/inventory.rs b/src/ui/inventory.rs new file mode 100644 index 0000000..67fac93 --- /dev/null +++ b/src/ui/inventory.rs @@ -0,0 +1,1933 @@ +use crate::api::ApiClient; +use crate::core::components::help::{show_help_window, HelpWindowOptions}; +use crate::core::workflows::borrow_flow::{BorrowFlow, BorrowStep}; +use crate::core::workflows::return_flow::{ReturnFlow, ReturnStep}; +use crate::core::{ + components::form_builder::FormBuilder, components::interactions::ConfirmDialog, + workflows::AddFromTemplateWorkflow, AssetFieldBuilder, AssetOperations, ColumnConfig, + DataLoader, LoadingState, TableEventHandler, TableRenderer, +}; +use eframe::egui; +use egui_commonmark::CommonMarkCache; +use serde_json::Value; +use std::collections::HashMap; + +pub struct InventoryView { + // Data + assets: Vec<Value>, + loading_state: LoadingState, + + // Table and UI components + table_renderer: TableRenderer, + show_column_panel: bool, + + // Filter state tracking + last_show_retired_state: bool, + last_item_lookup: String, + + // Dialogs + delete_dialog: ConfirmDialog, + edit_dialog: FormBuilder, + add_dialog: FormBuilder, + advanced_edit_dialog: FormBuilder, + print_dialog: Option<crate::core::print::PrintDialog>, + show_print_dialog: bool, + + // Bulk action state + pending_delete_ids: Vec<i64>, + pending_edit_ids: Vec<i64>, + is_bulk_edit: bool, + + // Workflows + add_from_template_workflow: AddFromTemplateWorkflow, + borrow_flow: BorrowFlow, + return_flow: ReturnFlow, + + // Help + show_help: bool, + help_cache: CommonMarkCache, +} + +impl InventoryView { + pub fn new() -> Self { + // Define all available columns from the assets table schema + let columns = vec![ + ColumnConfig::new("ID", "id").with_width(60.0).hidden(), + ColumnConfig::new("Asset Tag", "asset_tag").with_width(120.0), + ColumnConfig::new("Numeric ID", "asset_numeric_id") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Type", "asset_type").with_width(60.0), + ColumnConfig::new("Name", "name").with_width(180.0), + ColumnConfig::new("Category", "category_name").with_width(90.0), + ColumnConfig::new("Manufacturer", "manufacturer").with_width(100.0), + ColumnConfig::new("Model", "model").with_width(100.0), + ColumnConfig::new("Serial Number", "serial_number") + .with_width(130.0) + .hidden(), + ColumnConfig::new("Zone", "zone_code").with_width(80.0), + ColumnConfig::new("Label Template", "label_template_name") + .with_width(160.0) + .hidden(), + ColumnConfig::new("Label Template ID", "label_template_id") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Zone Plus", "zone_plus") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Zone Note", "zone_note") + .with_width(150.0) + .hidden(), + ColumnConfig::new("Status", "status").with_width(80.0), + ColumnConfig::new("Last Audit", "last_audit") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Last Audit Status", "last_audit_status") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Price", "price") + .with_width(90.0) + .hidden(), + ColumnConfig::new("Purchase Date", "purchase_date") + .with_width(110.0) + .hidden(), + ColumnConfig::new("Warranty Until", "warranty_until") + .with_width(110.0) + .hidden(), + ColumnConfig::new("Expiry Date", "expiry_date") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Qty Available", "quantity_available") + .with_width(90.0) + .hidden(), + ColumnConfig::new("Qty Total", "quantity_total") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Qty Used", "quantity_used") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Supplier", "supplier_name") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Lendable", "lendable") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Min Role", "minimum_role_for_lending") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Lending Status", "lending_status").with_width(70.0), + ColumnConfig::new("Current Borrower", "current_borrower_name") + .with_width(130.0) + .hidden(), + ColumnConfig::new("Due Date", "due_date") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Previous Borrower", "previous_borrower_name") + .with_width(130.0) + .hidden(), + ColumnConfig::new("No Scan", "no_scan") + .with_width(70.0) + .hidden(), + ColumnConfig::new("Notes", "notes") + .with_width(200.0) + .hidden(), + ColumnConfig::new("Created Date", "created_date") + .with_width(140.0) + .hidden(), + ColumnConfig::new("Created By", "created_by_username") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Last Modified", "last_modified_date").with_width(70.0), // Visible by default + ColumnConfig::new("Modified By", "last_modified_by_username") + .with_width(100.0) + .hidden(), + ]; + + Self { + assets: Vec::new(), + loading_state: LoadingState::new(), + table_renderer: TableRenderer::new() + .with_columns(columns) + .with_default_sort("last_modified_date", false), // Sort by last modified, newest first + show_column_panel: false, + last_show_retired_state: true, // Default to showing retired items (matches ribbon default) + last_item_lookup: String::new(), + delete_dialog: ConfirmDialog::new( + "Delete Asset", + "Are you sure you want to delete this asset?", + ), + edit_dialog: FormBuilder::new("Edit Asset", vec![]), + add_dialog: FormBuilder::new("Add Asset", vec![]), + advanced_edit_dialog: FormBuilder::new("Advanced Edit Asset", vec![]), + print_dialog: None, + show_print_dialog: false, + pending_delete_ids: Vec::new(), + pending_edit_ids: Vec::new(), + is_bulk_edit: false, + add_from_template_workflow: AddFromTemplateWorkflow::new(), + borrow_flow: BorrowFlow::new(), + return_flow: ReturnFlow::new(), + show_help: false, + help_cache: CommonMarkCache::default(), + } + } + + /// Load assets from the API + fn load_assets( + &mut self, + api_client: &ApiClient, + limit: Option<u32>, + where_clause: Option<Value>, + filter: Option<Value>, + ) { + self.loading_state.start_loading(); + + match DataLoader::load_assets(api_client, limit, where_clause, filter) { + Ok(assets) => { + self.assets = assets; + // Enrich borrower/due_date columns from lending_history for accuracy + self.enrich_loans_for_visible_assets(api_client); + self.loading_state.finish_success(); + } + Err(e) => { + self.loading_state.finish_error(e); + } + } + } + + /// Load assets with retired filter applied + fn load_assets_with_filter( + &mut self, + api_client: &ApiClient, + limit: Option<u32>, + show_retired: bool, + ) { + let filter = if show_retired { + None // Show all items including retired + } else { + // Filter out retired items: WHERE status != 'Retired' + Some(serde_json::json!({ + "and": [ + { + "column": "assets.status", + "op": "!=", + "value": "Retired" + } + ] + })) + }; + + self.load_assets(api_client, limit, None, filter); + } + + /// Enrich current/previous borrower and due_date using active and recent loans + fn enrich_loans_for_visible_assets(&mut self, api_client: &ApiClient) { + // Collect visible asset IDs + let mut ids: Vec<i64> = Vec::new(); + for asset in &self.assets { + if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) { + ids.push(id); + } + } + if ids.is_empty() { + return; + } + + // Build active loans map: asset_id -> (borrower_name, due_date) + let mut active_map: HashMap<i64, (Option<String>, Option<String>)> = HashMap::new(); + if let Ok(active_loans) = crate::core::get_active_loans(api_client, None) { + for row in active_loans { + let aid = row.get("asset_id").and_then(|v| v.as_i64()); + if let Some(asset_id) = aid { + let borrower_name = row + .get("borrower_name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let due = row + .get("due_date") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + active_map.insert(asset_id, (borrower_name, due)); + } + } + } + + // Build recent returns map: asset_id -> borrower_name (most recent return) + let mut recent_return_map: HashMap<i64, String> = HashMap::new(); + if let Ok(recent_returns) = + crate::core::get_recent_returns_for_assets(api_client, &ids, Some(1), None) + { + for row in recent_returns { + if let Some(asset_id) = row.get("asset_id").and_then(|v| v.as_i64()) { + if let Some(name) = row.get("borrower_name").and_then(|v| v.as_str()) { + // Only set if not already set (keep most recent as we sorted desc server-side) + recent_return_map + .entry(asset_id) + .or_insert_with(|| name.to_string()); + } + } + } + } + + // Apply enrichment to assets + for asset in &mut self.assets { + let aid = asset.get("id").and_then(|v| v.as_i64()); + if let Some(asset_id) = aid { + // Current borrower and due_date from active loan (authoritative) + if let Some((borrower_name_opt, due_opt)) = active_map.get(&asset_id) { + if let Some(obj) = asset.as_object_mut() { + if let Some(bname) = borrower_name_opt { + obj.insert( + "current_borrower_name".to_string(), + Value::String(bname.clone()), + ); + } + if let Some(due) = due_opt { + obj.insert("due_date".to_string(), Value::String(due.clone())); + } + } + } + + // Previous borrower from most recent returned loan (only when not currently borrowed) + if !active_map.contains_key(&asset_id) { + if let Some(prev_name) = recent_return_map.get(&asset_id) { + if let Some(obj) = asset.as_object_mut() { + obj.insert( + "previous_borrower_name".to_string(), + Value::String(prev_name.clone()), + ); + } + } + } + } + } + } + + /// Get selected asset IDs for bulk operations (works with filtered view) + fn get_selected_ids(&self) -> Vec<i64> { + let filtered_data = self.table_renderer.prepare_json_data(&self.assets); + let mut ids = Vec::new(); + for &row_idx in &self.table_renderer.selection.selected_rows { + if let Some((_, asset)) = filtered_data.get(row_idx) { + if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) { + ids.push(id); + } else if let Some(s) = asset.get("id").and_then(|v| v.as_str()) { + if let Ok(n) = s.parse::<i64>() { + ids.push(n); + } + } + } + } + ids + } + + /// Find an asset by ID + fn find_asset_by_id(&self, id: i64) -> Option<Value> { + AssetOperations::find_by_id(&self.assets, id, |asset| { + asset.get("id").and_then(|v| v.as_i64()) + }) + } + + /// Prepare field configurations for different dialog types + fn prepare_advanced_edit_fields(&mut self, api_client: &ApiClient) { + self.advanced_edit_dialog = AssetFieldBuilder::create_advanced_edit_dialog(api_client); + } + + fn prepare_easy_edit_fields(&mut self, api_client: &ApiClient) { + self.edit_dialog = AssetFieldBuilder::create_easy_edit_dialog(api_client); + } + + fn prepare_add_asset_editor(&mut self, api_client: &ApiClient) { + self.add_dialog = AssetFieldBuilder::create_add_dialog_with_preset(api_client); + } + + /// Helper method to open easy edit dialog with specific asset + fn open_easy_edit_with(&mut self, item: &serde_json::Value, api_client: Option<&ApiClient>) { + log::info!("=== OPENING EASY EDIT ==="); + log::info!("Asset data: {:?}", item); + let asset_id = item.get("id").and_then(|v| v.as_i64()).or_else(|| { + item.get("id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + }); + log::info!("Extracted asset ID: {:?}", asset_id); + + if let Some(client) = api_client { + self.prepare_easy_edit_fields(client); + } + self.edit_dialog.title = "Easy Edit Asset".to_string(); + self.edit_dialog.open(item); + + log::info!( + "After opening, dialog item_id: {:?}", + self.edit_dialog.item_id + ); + } + + /// Perform item lookup based on asset tag or numeric ID + fn perform_item_lookup( + &mut self, + lookup_text: &str, + api_client: Option<&ApiClient>, + limit: Option<u32>, + ) { + if lookup_text != self.last_item_lookup { + self.last_item_lookup = lookup_text.to_string(); + + if !lookup_text.is_empty() { + if let Some(client) = api_client { + // Build filter with OR and LIKE for asset_tag OR asset_numeric_id + let filter = serde_json::json!({ + "or": [ + { + "column": "assets.asset_tag", + "op": "like", + "value": format!("%{}%", lookup_text) + }, + { + "column": "assets.asset_numeric_id", + "op": "like", + "value": format!("%{}%", lookup_text) + } + ] + }); + self.load_assets(client, limit, None, Some(filter)); + } + } else { + // Clear search when lookup is empty + if let Some(client) = api_client { + self.load_assets(client, limit, None, None); + } + } + } + } + + /// Apply updates using the extracted operations module + fn apply_updates( + &mut self, + api: &ApiClient, + updated: serde_json::Map<String, Value>, + limit: Option<u32>, + ) { + let assets = self.assets.clone(); + let easy_id = self.edit_dialog.item_id.clone(); + let advanced_id = self.advanced_edit_dialog.item_id.clone(); + + AssetOperations::apply_updates( + api, + updated, + &mut self.pending_edit_ids, + easy_id.as_deref(), + advanced_id.as_deref(), + |id| { + AssetOperations::find_by_id(&assets, id, |asset| { + asset.get("id").and_then(|v| v.as_i64()) + }) + }, + limit, + |api_client, limit| { + match DataLoader::load_assets(api_client, limit, None, None) { + Ok(_) => { /* Assets will be reloaded after this call */ } + Err(e) => log::error!("Failed to reload assets: {}", e), + } + }, + ); + + // Reload assets after update + match DataLoader::load_assets(api, limit, None, None) { + Ok(assets) => { + self.assets = assets; + } + Err(e) => log::error!("Failed to reload assets after update: {}", e), + } + + // Reset selection state after updates + self.is_bulk_edit = false; + self.table_renderer.selection.clear_selection(); + } + + fn render_table_with_events( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>, + ) { + // We need to work around Rust's borrowing rules here + // First, get the data we need + let assets_clone = self.assets.clone(); + let prepared_data = self.table_renderer.prepare_json_data(&assets_clone); + + // Create a temporary event handler that stores actions for later processing + let mut deferred_actions: Vec<DeferredAction> = Vec::new(); + let mut temp_handler = TempInventoryEventHandler { + deferred_actions: &mut deferred_actions, + }; + + // Render table with the temporary event handler + self.table_renderer + .render_json_table(ui, &prepared_data, Some(&mut temp_handler)); + + // Process the deferred actions + self.process_temp_deferred_actions(deferred_actions, api_client, session_manager); + } + + fn process_temp_deferred_actions( + &mut self, + actions: Vec<DeferredAction>, + api_client: Option<&ApiClient>, + session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>, + ) { + for action in actions { + match action { + DeferredAction::DoubleClick(asset) => { + log::info!( + "Processing double-click edit for asset: {:?}", + asset.get("name") + ); + self.open_easy_edit_with(&asset, api_client); + } + DeferredAction::ContextClone(asset) => { + log::info!( + "Processing context menu clone for asset: {:?}", + asset.get("name") + ); + if let Some(client) = api_client { + // Use full add dialog so all fields are available when cloning + self.add_dialog = + crate::core::asset_fields::AssetFieldBuilder::create_full_add_dialog( + client, + ); + } + // Prepare cloned payload using shared helper + let cloned = crate::core::components::prepare_cloned_value( + &asset, + &[ + "id", + "asset_numeric_id", + "created_date", + "created_at", + "last_modified_date", + "last_modified", + "last_modified_by", + "last_modified_by_username", + "current_borrower_name", + "previous_borrower_name", + "last_audit", + "last_audit_status", + "due_date", + ], + Some("name"), + Some(""), + ); + self.add_dialog.title = "Add Asset".to_string(); + if let Some(obj) = cloned.as_object() { + // Use open_new so all preset fields are treated as new values and will be saved + self.add_dialog.open_new(Some(obj)); + } else { + self.add_dialog.open(&cloned); + } + } + DeferredAction::ContextEdit(asset) => { + log::info!( + "Processing context menu edit for asset: {:?}", + asset.get("name") + ); + self.open_easy_edit_with(&asset, api_client); + } + DeferredAction::ContextAdvancedEdit(asset) => { + log::info!( + "Processing context menu advanced edit for asset: {:?}", + asset.get("name") + ); + if let Some(client) = api_client { + self.prepare_advanced_edit_fields(client); + } + self.advanced_edit_dialog.open(&asset); + } + DeferredAction::ContextDelete(asset) => { + if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) { + log::info!("Processing context menu delete for asset ID: {}", id); + self.pending_delete_ids = vec![id]; + let name = asset + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + self.delete_dialog.open(name, id.to_string()); + } + } + DeferredAction::ContextLend(asset) => { + log::info!( + "Opening borrow flow from inventory for asset: {:?}", + asset.get("name") + ); + if let Some(client) = api_client { + // Open flow, then preselect this asset and skip to borrower step + self.borrow_flow.open(client); + self.borrow_flow.selected_asset = Some(asset.clone()); + self.borrow_flow.current_step = BorrowStep::SelectBorrower; + } + } + DeferredAction::ContextReturn(asset) => { + log::info!( + "Opening return flow from inventory for asset: {:?}", + asset.get("name") + ); + if let Some(client) = api_client { + self.return_flow.open(client); + // Try to preselect the matching active loan by asset_id or asset_tag + let asset_id = asset.get("id").and_then(|v| v.as_i64()); + let asset_tag = asset.get("asset_tag").and_then(|v| v.as_str()); + if let Some(loan) = self + .return_flow + .active_loans + .iter() + .find(|loan| { + let loan_asset_id = loan.get("asset_id").and_then(|v| v.as_i64()); + let loan_tag = loan.get("asset_tag").and_then(|v| v.as_str()); + (asset_id.is_some() && loan_asset_id == asset_id) + || (asset_tag.is_some() && loan_tag == asset_tag) + }) + .cloned() + { + self.return_flow.selected_loan = Some(loan); + self.return_flow.current_step = ReturnStep::Confirm; + } + } + } + DeferredAction::ContextPrintLabel(asset) => { + log::info!("Processing print label for asset: {:?}", asset.get("name")); + self.open_print_dialog(&asset, api_client, false, session_manager); + } + DeferredAction::ContextAdvancedPrint(asset) => { + log::info!( + "Processing advanced print for asset: {:?}", + asset.get("name") + ); + self.open_print_dialog(&asset, api_client, true, session_manager); + } + } + } + } + + /// Insert new asset and return its DB id if available + fn insert_new_asset( + &mut self, + client: &ApiClient, + limit: Option<u32>, + mut data: serde_json::Map<String, Value>, + ) -> Option<i64> { + AssetOperations::preprocess_quick_adds(client, &mut data); + AssetOperations::insert_new_asset(client, data, limit, |api_client, limit| { + self.load_assets(api_client, limit, None, None) + }) + } + + /// Open print dialog for an asset + fn open_print_dialog( + &mut self, + asset: &Value, + api_client: Option<&ApiClient>, + force_advanced: bool, + session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>, + ) { + use std::collections::HashMap; + + // Extract asset data as strings for template rendering + let mut asset_data = HashMap::new(); + if let Some(obj) = asset.as_object() { + for (key, value) in obj { + let value_str = match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + _ => serde_json::to_string(value).unwrap_or_default(), + }; + asset_data.insert(key.clone(), value_str); + } + } + + // Get label template ID from asset + let label_template_id = asset.get("label_template_id").and_then(|v| v.as_i64()); + + // Get default and last-used printer from session + let (default_printer_id, last_printer_id) = { + let guard = session_manager.blocking_lock(); + let default_id = guard.get_default_printer_id(); + let last_printer = guard.get_last_print_preferences(); + (default_id, last_printer) + }; + + // Smart logic: if not forcing advanced AND both default printer and template are set, print directly + if !force_advanced && default_printer_id.is_some() && label_template_id.is_some() { + // Print directly without dialog + log::info!("Printing directly with default printer and template"); + if let Some(client) = api_client { + if let (Some(printer_id), Some(template_id)) = + (default_printer_id, label_template_id) + { + self.execute_print(client, printer_id, template_id, &asset_data); + } + } + } else { + // Show dialog + let mut dialog = crate::core::print::PrintDialog::new(asset_data); + dialog = dialog.with_defaults(default_printer_id, label_template_id, last_printer_id); + + if let Some(client) = api_client { + if let Err(e) = dialog.load_data(client) { + log::error!("Failed to load print dialog data: {}", e); + } + } + + self.print_dialog = Some(dialog); + self.show_print_dialog = true; + } + } + + /// Execute print job via print dialog + fn execute_print( + &mut self, + api_client: &ApiClient, + printer_id: i64, + template_id: i64, + asset_data: &HashMap<String, String>, + ) { + log::info!( + "Executing print: printer_id={}, template_id={}", + printer_id, + template_id + ); + + // Create a print dialog with the options and execute + // The dialog handles all printer settings loading, JSON parsing, and printing + let mut dialog = crate::core::print::PrintDialog::new(asset_data.clone()).with_defaults( + Some(printer_id), + Some(template_id), + None, + ); + + match dialog.execute_print(api_client) { + Ok(_) => { + log::info!("Successfully printed label"); + self.log_print_history(api_client, asset_data, printer_id, template_id, "Success"); + } + Err(e) => { + log::error!("Failed to print label: {}", e); + self.log_print_history( + api_client, + asset_data, + printer_id, + template_id, + &format!("Error: {}", e), + ); + } + } + } + + fn log_print_history( + &self, + _api_client: &ApiClient, + _asset_data: &HashMap<String, String>, + _printer_id: i64, + _template_id: i64, + status: &str, + ) { + // Print history logging disabled - backend doesn't support raw SQL queries + // and using insert() requires __editor_item_id column which is a log table issue + // TODO: Either add raw SQL support to backend or create a dedicated /print-history endpoint + log::debug!("Print job status: {}", status); + } + + pub fn show( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon_ui: Option<&mut crate::ui::ribbon::RibbonUI>, + session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>, + ) { + // Handle initial load if needed - but ONLY if we've never loaded before + // Don't auto-load if assets are empty due to filters returning 0 results + // Also skip auto-load if there's a pending filter change (which will load filtered results) + let has_pending_filter = ribbon_ui + .as_ref() + .map(|r| { + *r.checkboxes + .get("inventory_filter_changed") + .unwrap_or(&false) + }) + .unwrap_or(false); + + if self.assets.is_empty() + && !self.loading_state.is_loading + && self.loading_state.last_error.is_none() + && self.loading_state.last_load_time.is_none() // Only auto-load if we've never attempted a load + && !has_pending_filter + // Don't auto-load if a filter is about to be applied + { + if let Some(client) = api_client { + log::info!("Inventory view never loaded, triggering initial auto-load"); + // Respect retired filter state from ribbon during auto-load + let show_retired = ribbon_ui + .as_ref() + .map(|r| *r.checkboxes.get("show_retired").unwrap_or(&true)) + .unwrap_or(true); + self.load_assets_with_filter(client, Some(100), show_retired); + } + } + + // Render content and get flags to clear + let flags_to_clear = self.render_content( + ui, + api_client, + Some(100), + ribbon_ui.as_ref().map(|r| &**r), + session_manager, + ); + + // Clear the flags after processing + if let Some(ribbon) = ribbon_ui { + for flag in flags_to_clear { + ribbon.checkboxes.insert(flag, false); + } + } + } + + fn render_content( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + limit: Option<u32>, + ribbon_ui: Option<&crate::ui::ribbon::RibbonUI>, + session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>, + ) -> Vec<String> { + // Handle ribbon actions first + let flags_to_clear = if let Some(ribbon) = ribbon_ui { + self.handle_ribbon_actions(ribbon, api_client, session_manager) + } else { + Vec::new() + }; + + // Top toolbar with search and actions + ui.horizontal(|ui| { + ui.label("Search:"); + let mut search_changed = false; + ui.add_enabled_ui(!self.loading_state.is_loading, |ui| { + search_changed = ui + .text_edit_singleline(&mut self.table_renderer.search_query) + .changed(); + }); + + ui.separator(); + + // Action buttons + if let Some(client) = api_client { + if ui.button("Refresh").clicked() { + // Clear any previous error state when user explicitly refreshes + self.loading_state.last_error = None; + + // Respect current filters when refreshing + if let Some(ribbon) = ribbon_ui { + let show_retired = ribbon + .checkboxes + .get("show_retired") + .copied() + .unwrap_or(true); + let user_filter = ribbon.filter_builder.get_filter_json("assets"); + let combined_filter = self.combine_filters(show_retired, user_filter); + self.load_assets(client, limit, None, combined_filter); + } else { + self.load_assets(client, limit, None, None); + } + } + + if ui.button("➕ Add Asset").clicked() { + self.prepare_add_asset_editor(client); + } + + // Show selection count but no buttons (use right-click instead) + let selected_count = self.table_renderer.selection.get_selected_count(); + if selected_count > 0 { + ui.label(format!("{} selected", selected_count)); + } + } + + ui.separator(); + + if ui.button("⚙ Columns").clicked() { + self.show_column_panel = !self.show_column_panel; + } + }); + + ui.separator(); + + // Show loading state + if self.loading_state.is_loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Loading assets..."); + }); + } + + // Show errors + if let Some(error) = self.loading_state.get_error() { + ui.colored_label(egui::Color32::RED, format!("Error: {}", error)); + } + + // Column configuration panel + if self.show_column_panel { + egui::Window::new("Column Configuration") + .open(&mut self.show_column_panel) + .resizable(true) + .movable(true) + .default_width(350.0) + .min_width(300.0) + .max_width(500.0) + .max_height(600.0) + .default_pos([200.0, 150.0]) + .show(ui.ctx(), |ui| { + ui.label("Show/Hide Columns:"); + ui.separator(); + + // Scrollable area for columns + egui::ScrollArea::vertical() + .max_height(450.0) + .show(ui, |ui| { + // Use columns layout to make better use of width while keeping groups intact + ui.columns(2, |columns| { + // Left column + columns[0].group(|ui| { + ui.strong("Basic Information"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "name" | "asset_tag" | "asset_type" | "status" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[0].add_space(5.0); + + columns[0].group(|ui| { + ui.strong("Location & Status"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "zone_code" + | "zone_plus" + | "zone_note" + | "lending_status" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[0].add_space(5.0); + + columns[0].group(|ui| { + ui.strong("Quantities & Lending"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "quantity_available" + | "quantity_total" + | "quantity_used" + | "lendable" + | "minimum_role_for_lending" + | "current_borrower_name" + | "due_date" + | "previous_borrower_name" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + // Right column + columns[1].group(|ui| { + ui.strong("Classification"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "category_name" + | "manufacturer" + | "model" + | "serial_number" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[1].add_space(5.0); + + columns[1].group(|ui| { + ui.strong("Financial & Dates"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "price" + | "purchase_date" + | "warranty_until" + | "expiry_date" + | "last_audit" + | "last_audit_status" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[1].add_space(5.0); + + columns[1].group(|ui| { + ui.strong("Metadata & Other"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "id" | "asset_numeric_id" + | "supplier_name" + | "no_scan" + | "notes" + | "created_date" + | "created_by_username" + | "last_modified_date" + | "last_modified_by_username" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + }); + }); + + ui.separator(); + ui.columns(3, |columns| { + if columns[0].button("Show All").clicked() { + for column in &mut self.table_renderer.columns { + column.visible = true; + } + } + if columns[1].button("Hide All").clicked() { + for column in &mut self.table_renderer.columns { + column.visible = false; + } + } + if columns[2].button("Reset to Default").clicked() { + // Reset to default visibility + for column in &mut self.table_renderer.columns { + column.visible = matches!( + column.field.as_str(), + "asset_tag" + | "asset_type" + | "name" + | "category_name" + | "manufacturer" + | "model" + | "zone_code" + | "status" + | "lending_status" + | "last_modified_date" + ); + } + } + }); + }); + } + + // Render table with event handling + self.render_table_with_events(ui, api_client, session_manager); + + // Handle dialogs + self.handle_dialogs(ui, api_client, limit, session_manager); + + // Process deferred actions from table events + self.process_deferred_actions(ui, api_client, limit, session_manager); + + flags_to_clear + } + + fn handle_dialogs( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + limit: Option<u32>, + session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>, + ) { + // Delete confirmation dialog + if let Some(confirmed) = self.delete_dialog.show_dialog(ui.ctx()) { + log::info!( + "Delete dialog result: confirmed={}, pending_delete_ids={:?}", + confirmed, + self.pending_delete_ids + ); + if confirmed && !self.pending_delete_ids.is_empty() { + if let Some(client) = api_client { + for id in &self.pending_delete_ids { + let where_clause = serde_json::json!({"id": id}); + log::info!( + "Sending DELETE for asset id {} with where {:?}", + id, + where_clause + ); + match client.delete("assets", where_clause) { + Ok(resp) => { + if resp.success { + let deleted = resp.data.unwrap_or(0); + log::info!( + "Delete success for asset {} ({} row(s) affected)", + id, + deleted + ); + } else { + log::error!( + "Server rejected delete for asset {}: {:?}", + id, + resp.error + ); + } + } + Err(e) => { + log::error!("Failed to delete asset {}: {}", id, e); + } + } + } + // Reload after attempting deletes + self.load_assets(client, limit, None, None); + } else { + log::error!("No API client available for delete operation"); + } + self.pending_delete_ids.clear(); + } + } + + // Edit dialogs + if let Some(Some(updated)) = self.edit_dialog.show_editor(ui.ctx()) { + if let Some(client) = api_client { + self.apply_updates(client, updated, limit); + } + } + + if let Some(Some(updated)) = self.advanced_edit_dialog.show_editor(ui.ctx()) { + if let Some(client) = api_client { + self.apply_updates(client, updated, limit); + } + } + + if let Some(Some(mut new_data)) = self.add_dialog.show_editor(ui.ctx()) { + if let Some(client) = api_client { + // Check if user requested label printing after add + let print_after_add = new_data + .get("print_label") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Preflight: enforce asset_tag uniqueness if provided + if let Some(tag) = new_data.get("asset_tag").and_then(|v| v.as_str()) { + let tag = tag.trim(); + if !tag.is_empty() { + let where_clause = serde_json::json!({ "asset_tag": tag }); + if let Ok(resp) = client.select( + "assets", + Some(vec!["id".into()]), + Some(where_clause), + None, + Some(1), + ) { + if resp.success { + if let Some(rows) = resp.data { + if !rows.is_empty() { + // Tag already exists; reopen editor and require change + log::warn!("Asset tag '{}' already exists; prompting user to change it", tag); + // Put back the print flag and reopen the dialog with current values + self.add_dialog.title = "Add Asset".to_string(); + self.add_dialog + .open(&serde_json::Value::Object(new_data.clone())); + return; // Don't proceed to insert or print + } + } + } else { + log::error!("Tag uniqueness check failed: {:?}", resp.error); + } + } + } + } + + // Prepare asset data snapshot for printing (before filtering removes fields) + let mut print_snapshot = new_data.clone(); + // Remove the UI-only flag so it doesn't get sent to the server + new_data.remove("print_label"); + + // Insert the asset and capture DB id + let inserted_id = self.insert_new_asset(client, limit, new_data); + + // If requested, trigger printing with smart defaults: + // - If default printer and label_template_id are set -> print directly + // - Otherwise open the print dialog to ask what to do + if print_after_add { + if let Some(id) = inserted_id { + let id_val = Value::Number(id.into()); + // Expose id under both `id` and `asset_id` so label templates can pick it up + print_snapshot.insert("id".to_string(), id_val.clone()); + print_snapshot.insert("asset_id".to_string(), id_val); + // Try to also include asset_numeric_id from the freshly reloaded assets + if let Some(row) = self + .assets + .iter() + .find(|r| r.get("id").and_then(|v| v.as_i64()) == Some(id)) + { + if let Some(n) = row.get("asset_numeric_id").and_then(|v| v.as_i64()) { + print_snapshot.insert( + "asset_numeric_id".to_string(), + Value::Number(n.into()), + ); + } + } + } + let asset_val = Value::Object(print_snapshot); + self.open_print_dialog(&asset_val, api_client, false, session_manager); + } + } + } + + // Workflow handling + if let Some(client) = api_client { + // Handle add from template workflow + if let Some(asset_data) = self.add_from_template_workflow.show(ui, client) { + if let Value::Object(mut map) = asset_data { + // Extract optional print flag before insert + let print_after_add = map + .get("print_label") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Keep a snapshot for printing (includes label_template_id etc.) + let mut print_snapshot = map.clone(); + // Remove UI-only field so it doesn't get sent to server + map.remove("print_label"); + + // Insert the asset and capture DB id + let inserted_id = self.insert_new_asset(client, limit, map); + + // If requested, perform printing via smart defaults + if print_after_add { + if let Some(id) = inserted_id { + let id_val = Value::Number(id.into()); + print_snapshot.insert("id".to_string(), id_val.clone()); + print_snapshot.insert("asset_id".to_string(), id_val); + // Try to also include asset_numeric_id from the freshly reloaded assets + if let Some(row) = self + .assets + .iter() + .find(|r| r.get("id").and_then(|v| v.as_i64()) == Some(id)) + { + if let Some(n) = + row.get("asset_numeric_id").and_then(|v| v.as_i64()) + { + print_snapshot.insert( + "asset_numeric_id".to_string(), + Value::Number(n.into()), + ); + } + } + } + let asset_val = Value::Object(print_snapshot); + self.open_print_dialog(&asset_val, api_client, false, session_manager); + } + } + } + + // Show help window if requested + if self.show_help { + const HELP_TEXT: &str = r#"# Inventory Management + +## Quick Actions +- **Add Asset**: Create new assets from templates or blank forms +- **Edit Asset**: Modify existing asset details +- **Delete Asset**: Remove assets (requires confirmation) +- **Print Label**: Generate and print asset labels + +## Borrowing/Lending +- **Borrow**: Check out assets to users +- **Return**: Check assets back in + +## Filtering +- Use **Show Retired** to include/exclude retired assets +- **Item Lookup** searches across multiple fields +- Click **Filters** to build advanced queries + +## Tips +- Double-click a row to quick-edit +- Right-click for context menu options +- Use Ctrl+Click to select multiple items +"#; + show_help_window( + ui.ctx(), + &mut self.help_cache, + "inventory_help", + "Inventory Help", + HELP_TEXT, + &mut self.show_help, + HelpWindowOptions::default(), + ); + } + + // Show borrow/return flows if open and reload when they complete successfully + self.borrow_flow.show(ui.ctx(), client); + if self.borrow_flow.take_recent_success() { + self.load_assets(client, limit, None, None); + } + + if self.return_flow.show(ui.ctx(), client) { + // still open + } else if self.return_flow.success_message.is_some() { + self.load_assets(client, limit, None, None); + } + } + + // Print dialog + if self.show_print_dialog { + let mut should_clear_dialog = false; + let mut completed_options: Option<crate::core::print::PrintOptions> = None; + let mut completed_asset_data: Option<HashMap<String, String>> = None; + + if let Some(dialog) = self.print_dialog.as_mut() { + let mut open = self.show_print_dialog; + let completed = dialog.show(ui.ctx(), &mut open, api_client); + self.show_print_dialog = open; + + if completed { + completed_options = Some(dialog.options().clone()); + completed_asset_data = Some(dialog.asset_data().clone()); + should_clear_dialog = true; + } else if !self.show_print_dialog { + // Dialog was closed without completing the print job + should_clear_dialog = true; + } + } else { + self.show_print_dialog = false; + } + + if should_clear_dialog { + self.print_dialog = None; + } + + if let (Some(options), Some(asset_data)) = (completed_options, completed_asset_data) { + if let (Some(printer_id), Some(template_id)) = + (options.printer_id, options.label_template_id) + { + if let Some(client) = api_client { + self.log_print_history( + client, + &asset_data, + printer_id, + template_id, + "Success", + ); + } + } + } + } + } + + fn process_deferred_actions( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + _limit: Option<u32>, + session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>, + ) { + // Handle double-click edit + if let Some(asset) = ui + .ctx() + .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("double_click_edit"))) + { + log::info!( + "Processing double-click edit for asset: {:?}", + asset.get("name") + ); + self.open_easy_edit_with(&asset, api_client); + } + + // Handle context menu actions + if let Some(asset) = ui + .ctx() + .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("context_menu_edit"))) + { + log::info!( + "Processing context menu edit for asset: {:?}", + asset.get("name") + ); + self.open_easy_edit_with(&asset, api_client); + } + + if let Some(asset) = ui + .ctx() + .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("context_menu_edit_adv"))) + { + log::info!( + "Processing context menu advanced edit for asset: {:?}", + asset.get("name") + ); + if let Some(client) = api_client { + self.prepare_advanced_edit_fields(client); + } + self.advanced_edit_dialog.open(&asset); + } + + if let Some(asset) = ui + .ctx() + .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("context_menu_delete"))) + { + if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) { + log::info!("Processing context menu delete for asset ID: {}", id); + self.pending_delete_ids = vec![id]; + self.delete_dialog.show = true; + } + } + + // Handle print label actions + if let Some(asset) = ui + .ctx() + .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("context_menu_print"))) + { + log::info!( + "Processing context menu print for asset: {:?}", + asset.get("name") + ); + self.open_print_dialog(&asset, api_client, false, session_manager); + } + + if let Some(asset) = ui + .ctx() + .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("context_menu_print_adv"))) + { + log::info!( + "Processing context menu advanced print for asset: {:?}", + asset.get("name") + ); + self.open_print_dialog(&asset, api_client, true, session_manager); + } + } + + /// Handle ribbon checkbox actions (main integration point) + /// Returns a list of flags that should be cleared after processing + fn handle_ribbon_actions( + &mut self, + ribbon: &crate::ui::ribbon::RibbonUI, + api_client: Option<&ApiClient>, + session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>, + ) -> Vec<String> { + let mut flags_to_clear = Vec::new(); + + // Handle help button + if *ribbon + .checkboxes + .get("inventory_show_help") + .unwrap_or(&false) + { + flags_to_clear.push("inventory_show_help".to_string()); + self.show_help = true; + } + + // Handle limit settings first + let limit = if *ribbon + .checkboxes + .get("inventory_no_limit") + .unwrap_or(&false) + { + None + } else { + Some(*ribbon.number_fields.get("inventory_limit").unwrap_or(&100)) + }; + + // Check if retired filter state changed and trigger refresh if needed + let show_retired = *ribbon.checkboxes.get("show_retired").unwrap_or(&true); + if self.last_show_retired_state != show_retired { + self.last_show_retired_state = show_retired; + if let Some(client) = api_client { + self.loading_state.last_error = None; + self.load_assets_with_filter(client, limit, show_retired); + return flags_to_clear; // Early return to avoid duplicate loading + } + } + + // Handle filter builder changes + if *ribbon + .checkboxes + .get("inventory_filter_changed") + .unwrap_or(&false) + { + flags_to_clear.push("inventory_filter_changed".to_string()); + if let Some(client) = api_client { + self.loading_state.last_error = None; + + // Get current show_retired setting + let show_retired = ribbon + .checkboxes + .get("show_retired") + .copied() + .unwrap_or(true); + + // Get user-defined filters from FilterBuilder + let user_filter = ribbon.filter_builder.get_filter_json("assets"); + + // Combine retired filter with user filters + let combined_filter = self.combine_filters(show_retired, user_filter); + + // Debug: Log the filter to see what we're getting + if let Some(ref cf) = combined_filter { + log::info!("Combined filter: {:?}", cf); + } else { + log::info!("No filter conditions (showing all assets)"); + } + + self.load_assets(client, limit, None, combined_filter); + return flags_to_clear; // Early return to avoid duplicate loading + } + } + + let selected_ids = self.get_selected_ids(); + + if *ribbon + .checkboxes + .get("inventory_action_add") + .unwrap_or(&false) + { + if let Some(client) = api_client { + self.prepare_add_asset_editor(client); + } else { + let mut preset = serde_json::Map::new(); + preset.insert( + "asset_type".to_string(), + serde_json::Value::String("N".to_string()), + ); + preset.insert( + "status".to_string(), + serde_json::Value::String("Good".to_string()), + ); + self.add_dialog.title = "Add Asset".to_string(); + self.add_dialog.open_new(Some(&preset)); + } + } + + if *ribbon + .checkboxes + .get("inventory_action_delete") + .unwrap_or(&false) + { + if !selected_ids.is_empty() { + self.pending_delete_ids = selected_ids.clone(); + if selected_ids.len() == 1 { + if let Some(asset) = self.find_asset_by_id(selected_ids[0]) { + let name = asset + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + self.delete_dialog.open(name, selected_ids[0].to_string()); + } + } else { + self.delete_dialog.title = "Delete Assets".to_string(); + self.delete_dialog.message = format!( + "Are you sure you want to delete {} selected assets?", + selected_ids.len() + ); + self.delete_dialog.open( + format!("Multiple items ({} selected)", selected_ids.len()), + "multiple".to_string(), + ); + } + } + } + + if *ribbon + .checkboxes + .get("inventory_action_edit_easy") + .unwrap_or(&false) + { + if !selected_ids.is_empty() { + self.pending_edit_ids = selected_ids.clone(); + self.is_bulk_edit = selected_ids.len() > 1; + if let Some(client) = api_client { + self.prepare_easy_edit_fields(client); + } + if selected_ids.len() == 1 { + if let Some(asset) = self.find_asset_by_id(selected_ids[0]) { + self.edit_dialog.open(&asset); + } + } else { + self.edit_dialog.open_new(None); + } + } + } + + if *ribbon + .checkboxes + .get("inventory_action_edit_adv") + .unwrap_or(&false) + { + if !selected_ids.is_empty() { + self.pending_edit_ids = selected_ids.clone(); + self.is_bulk_edit = selected_ids.len() > 1; + if let Some(client) = api_client { + self.prepare_advanced_edit_fields(client); + } + if selected_ids.len() == 1 { + if let Some(asset) = self.find_asset_by_id(selected_ids[0]) { + self.advanced_edit_dialog.open(&asset); + } + } else { + self.advanced_edit_dialog.open_new(None); + } + } + } + + if *ribbon + .checkboxes + .get("inventory_action_print_label") + .unwrap_or(&false) + { + if !selected_ids.is_empty() && selected_ids.len() == 1 { + if let Some(asset) = self.find_asset_by_id(selected_ids[0]) { + log::info!("Print label requested for asset: {:?}", asset.get("name")); + // Check if Alt is held for advanced print (ctx not available here, so just default) + self.open_print_dialog(&asset, api_client, false, session_manager); + } + } else if !selected_ids.is_empty() { + log::warn!("Bulk label printing not yet implemented"); + } + } + + // Handle item lookup + if *ribbon + .checkboxes + .get("item_lookup_trigger") + .unwrap_or(&false) + { + if let Some(lookup_text) = ribbon.search_texts.get("item_lookup") { + self.perform_item_lookup(lookup_text, api_client, limit); + } + } + + // Handle limit refresh trigger (when limit changes or refresh is needed) + if *ribbon + .checkboxes + .get("inventory_limit_refresh_trigger") + .unwrap_or(&false) + { + if let Some(client) = api_client { + // Clear any previous error state when refreshing via ribbon + self.loading_state.last_error = None; + self.load_assets(client, limit, None, None); + } + } + + // Handle workflow actions + if *ribbon + .checkboxes + .get("inventory_add_from_template_single") + .unwrap_or(&false) + { + flags_to_clear.push("inventory_add_from_template_single".to_string()); + if let Some(client) = api_client { + self.add_from_template_workflow.start_single_mode(client); + } + } + + if *ribbon + .checkboxes + .get("inventory_add_from_template_multiple") + .unwrap_or(&false) + { + flags_to_clear.push("inventory_add_from_template_multiple".to_string()); + if let Some(client) = api_client { + self.add_from_template_workflow.start_multiple_mode(client); + } + } + + flags_to_clear + } + + /// Combine retired filter with user-defined filters + fn combine_filters(&self, show_retired: bool, user_filter: Option<Value>) -> Option<Value> { + if show_retired && user_filter.is_none() { + // No filtering needed + return None; + } + + let mut conditions = Vec::new(); + + // Add retired filter if needed + if !show_retired { + conditions.push(serde_json::json!({ + "column": "assets.status", + "op": "!=", + "value": "Retired" + })); + } + + // Add user filter conditions (sanitized for inventory joins) + if let Some(mut filter) = user_filter { + // Map columns from other views (e.g., borrowers.*) to inventory's JOIN aliases + self.sanitize_filter_for_inventory(&mut filter); + if let Some(and_array) = filter.get("and").and_then(|v| v.as_array()) { + conditions.extend(and_array.iter().cloned()); + } else if let Some(_or_array) = filter.get("or").and_then(|v| v.as_array()) { + // Wrap OR conditions to maintain precedence + conditions.push(filter); + } else { + // Single condition + conditions.push(filter); + } + } + + // Return appropriate filter structure + match conditions.len() { + 0 => None, + 1 => { + if let Some(mut only) = conditions.into_iter().next() { + // Final pass to sanitize single-condition filters + self.sanitize_filter_for_inventory(&mut only); + Some(only) + } else { + None + } + } + _ => { + let mut combined = serde_json::json!({ + "and": conditions + }); + self.sanitize_filter_for_inventory(&mut combined); + Some(combined) + } + } + } + + /// Rewrite filter column names to match inventory JOIN aliases + fn sanitize_filter_for_inventory(&self, filter: &mut Value) { + fn rewrite_column(col: &str) -> String { + match col { + // Borrowing view columns → inventory aliases + "borrowers.name" => "current_borrower.name".to_string(), + "borrowers.class_name" => "current_borrower.class_name".to_string(), + // Fallback: leave unchanged + _ => col.to_string(), + } + } + + match filter { + Value::Object(map) => { + // If this object has a `column`, rewrite it + if let Some(Value::String(col)) = map.get_mut("column") { + let new_col = rewrite_column(col); + *col = new_col; + } + // Recurse into possible logical groups + if let Some(Value::Array(arr)) = map.get_mut("and") { + for v in arr.iter_mut() { + self.sanitize_filter_for_inventory(v); + } + } + if let Some(Value::Array(arr)) = map.get_mut("or") { + for v in arr.iter_mut() { + self.sanitize_filter_for_inventory(v); + } + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + self.sanitize_filter_for_inventory(v); + } + } + _ => {} + } + } +} + +impl Default for InventoryView { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone)] +enum DeferredAction { + DoubleClick(Value), + ContextEdit(Value), + ContextAdvancedEdit(Value), + ContextDelete(Value), + ContextLend(Value), + ContextReturn(Value), + ContextPrintLabel(Value), + ContextAdvancedPrint(Value), + ContextClone(Value), +} + +// Temporary event handler that collects actions for later processing +struct TempInventoryEventHandler<'a> { + deferred_actions: &'a mut Vec<DeferredAction>, +} + +impl<'a> TableEventHandler<Value> for TempInventoryEventHandler<'a> { + fn on_double_click(&mut self, item: &Value, _row_index: usize) { + log::info!("Double-click detected on asset: {:?}", item.get("name")); + self.deferred_actions + .push(DeferredAction::DoubleClick(item.clone())); + } + + fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) { + if ui + .button(format!("{} Edit", egui_phosphor::regular::PENCIL)) + .clicked() + { + log::info!( + "Context menu edit clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextEdit(item.clone())); + ui.close(); + } + + if ui + .button(format!("{} Clone Asset", egui_phosphor::regular::COPY)) + .clicked() + { + log::info!( + "Context menu clone clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextClone(item.clone())); + ui.close(); + } + + if ui + .button(format!("{} Advanced Edit", egui_phosphor::regular::GEAR)) + .clicked() + { + log::info!( + "Context menu advanced edit clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextAdvancedEdit(item.clone())); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Print Label", egui_phosphor::regular::PRINTER)) + .clicked() + { + log::info!( + "Context menu print label clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextPrintLabel(item.clone())); + ui.close(); + } + + if ui + .button(format!( + "{} Advanced Print", + egui_phosphor::regular::PRINTER + )) + .clicked() + { + log::info!( + "Context menu advanced print clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextAdvancedPrint(item.clone())); + ui.close(); + } + + ui.separator(); + + // Lend/Return options for lendable assets + let lendable = match item.get("lendable") { + Some(serde_json::Value::Bool(b)) => *b, + Some(serde_json::Value::Number(n)) => n.as_i64() == Some(1) || n.as_u64() == Some(1), + Some(serde_json::Value::String(s)) => { + let s = s.to_lowercase(); + s == "true" || s == "1" || s == "yes" || s == "y" + } + _ => false, + }; + // Only act when we have an explicit lending_status; blank usually means non-lendable or unmanaged + let status = item + .get("lending_status") + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .unwrap_or(""); + + if lendable && !status.is_empty() { + if status == "Available" { + if ui + .button(format!("{} Lend Item", egui_phosphor::regular::ARROW_LEFT)) + .clicked() + { + log::info!( + "Context menu lend clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextLend(item.clone())); + ui.close(); + } + } else if matches!( + status, + "Borrowed" | "Overdue" | "Stolen" | "Illegally Handed Out" | "Deployed" + ) { + if ui + .button(format!( + "{} Return Item", + egui_phosphor::regular::ARROW_RIGHT + )) + .clicked() + { + log::info!( + "Context menu return clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextReturn(item.clone())); + ui.close(); + } + } + ui.separator(); + } + + if ui + .button(format!("{} Delete", egui_phosphor::regular::TRASH)) + .clicked() + { + log::info!( + "Context menu delete clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextDelete(item.clone())); + ui.close(); + } + } + + fn on_selection_changed(&mut self, selected_indices: &[usize]) { + log::debug!("Selection changed: {:?}", selected_indices); + } +} diff --git a/src/ui/issues.rs b/src/ui/issues.rs new file mode 100644 index 0000000..163a500 --- /dev/null +++ b/src/ui/issues.rs @@ -0,0 +1,773 @@ +use eframe::egui; + +use crate::api::ApiClient; +use crate::core::components::interactions::ConfirmDialog; +use crate::core::tables::get_issues; +use crate::core::{EditorField, FieldType, FormBuilder}; +use std::collections::HashSet; + +#[derive(Clone)] +struct ColumnConfig { + name: String, + field: String, + visible: bool, +} + +pub struct IssuesView { + rows: Vec<serde_json::Value>, + is_loading: bool, + last_error: Option<String>, + init_loaded: bool, + // Cached summary stats (avoid recomputing every frame) + summary_by_status: Vec<(String, i32)>, + summary_by_severity: Vec<(String, i32)>, + // Columns & selector + columns: Vec<ColumnConfig>, + show_column_panel: bool, + // Selection & interactions + selected_row: Option<usize>, + last_click_time: Option<std::time::Instant>, + last_click_row: Option<usize>, + selected_rows: HashSet<usize>, + selection_anchor: Option<usize>, + // Dialogs + delete_dialog: ConfirmDialog, + edit_dialog: FormBuilder, + // Track ids for operations + edit_current_id: Option<i64>, + delete_current_id: Option<i64>, +} + +impl IssuesView { + pub fn new() -> Self { + let columns = vec![ + ColumnConfig { + name: "Title".into(), + field: "title".into(), + visible: true, + }, + ColumnConfig { + name: "Status".into(), + field: "status".into(), + visible: true, + }, + ColumnConfig { + name: "Severity".into(), + field: "severity".into(), + visible: true, + }, + ColumnConfig { + name: "Priority".into(), + field: "priority".into(), + visible: true, + }, + ColumnConfig { + name: "Asset".into(), + field: "asset_label".into(), + visible: true, + }, + ColumnConfig { + name: "Borrower".into(), + field: "borrower_name".into(), + visible: false, + }, + ColumnConfig { + name: "Assigned To".into(), + field: "assigned_to_name".into(), + visible: false, + }, + ColumnConfig { + name: "Auto".into(), + field: "auto_detected".into(), + visible: false, + }, + ColumnConfig { + name: "Trigger".into(), + field: "detection_trigger".into(), + visible: false, + }, + ColumnConfig { + name: "Updated".into(), + field: "updated_at".into(), + visible: true, + }, + ColumnConfig { + name: "Created".into(), + field: "created_at".into(), + visible: false, + }, + ColumnConfig { + name: "Resolved".into(), + field: "resolved_date".into(), + visible: false, + }, + ColumnConfig { + name: "Description".into(), + field: "description".into(), + visible: false, + }, + ColumnConfig { + name: "Solution".into(), + field: "solution".into(), + visible: false, + }, + ColumnConfig { + name: "Solution+".into(), + field: "solution_plus".into(), + visible: false, + }, + ColumnConfig { + name: "Replacement".into(), + field: "replacement_asset_id".into(), + visible: false, + }, + ColumnConfig { + name: "Cost".into(), + field: "cost".into(), + visible: false, + }, + ColumnConfig { + name: "Notes".into(), + field: "notes".into(), + visible: false, + }, + ColumnConfig { + name: "ID".into(), + field: "id".into(), + visible: false, + }, + ]; + Self { + rows: vec![], + is_loading: false, + last_error: None, + init_loaded: false, + summary_by_status: Vec::new(), + summary_by_severity: Vec::new(), + columns, + show_column_panel: false, + selected_row: None, + last_click_time: None, + last_click_row: None, + selected_rows: HashSet::new(), + selection_anchor: None, + delete_dialog: ConfirmDialog::new( + "Delete Issue", + "Are you sure you want to delete this issue?", + ), + edit_dialog: FormBuilder::new( + "Edit Issue", + vec![ + EditorField { + name: "title".into(), + label: "Title".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "status".into(), + label: "Status".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "severity".into(), + label: "Severity".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "priority".into(), + label: "Priority".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "description".into(), + label: "Description".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "solution".into(), + label: "Solution".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ], + ), + edit_current_id: None, + delete_current_id: None, + } + } + + fn load(&mut self, api: &ApiClient) { + if self.is_loading { + return; + } + self.is_loading = true; + self.last_error = None; + match get_issues(api, Some(200)) { + Ok(mut list) => { + // Build asset label + for row in &mut list { + if let Some(obj) = row.as_object_mut() { + let asset = match ( + obj.get("asset_tag").and_then(|v| v.as_str()), + obj.get("asset_name").and_then(|v| v.as_str()), + ) { + (Some(tag), Some(name)) if !tag.is_empty() => { + format!("{} ({})", name, tag) + } + (_, Some(name)) => name.to_string(), + _ => "-".to_string(), + }; + obj.insert("asset_label".into(), serde_json::json!(asset)); + } + } + self.rows = list; + } + Err(e) => self.last_error = Some(e.to_string()), + } + self.is_loading = false; + self.init_loaded = true; + self.recompute_summary(); + } + + pub fn show(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + ui.horizontal(|ui| { + ui.heading("Issues"); + if self.is_loading { + ui.spinner(); + ui.label("Loading..."); + } + if let Some(err) = &self.last_error { + ui.colored_label(egui::Color32::RED, err); + if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + } else if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + ui.separator(); + let button_text = if self.show_column_panel { + "Hide Column Selector" + } else { + "Show Column Selector" + }; + if ui.button(button_text).clicked() { + self.show_column_panel = !self.show_column_panel; + } + }); + ui.separator(); + + if !self.init_loaded { + if let Some(api) = api_client { + self.load(api); + } + } + + // Column selector + if self.show_column_panel { + self.show_columns_window(ui); + } + + // Summary chips (cached) + self.render_summary(ui); + + let visible_columns: Vec<ColumnConfig> = + self.columns.iter().filter(|c| c.visible).cloned().collect(); + self.render_table(ui, &visible_columns); + + // Process selection/dialog events + let ctx = ui.ctx(); + // inline selection now, nothing to fetch here + if let Some(row_idx) = + ctx.data_mut(|d| d.remove_temp::<usize>(egui::Id::new("iss_double_click_idx"))) + { + self.selected_row = Some(row_idx); + self.last_click_row = None; + self.last_click_time = None; + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::<serde_json::Value>(egui::Id::new("iss_double_click_edit")) + }) { + self.open_editor_with(&item); + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::<serde_json::Value>(egui::Id::new("iss_context_menu_edit")) + }) { + self.open_editor_with(&item); + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::<serde_json::Value>(egui::Id::new("iss_context_menu_delete")) + }) { + let title = item + .get("title") + .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.delete_current_id = Some(id); + self.delete_dialog.open(title, id.to_string()); + ctx.request_repaint(); + } + + if let Some(confirmed) = self.delete_dialog.show_dialog(ctx) { + if confirmed { + if let (Some(api), Some(id)) = (api_client, self.delete_current_id) { + let where_clause = serde_json::json!({"id": id}); + match api.delete("issue_tracker", where_clause) { + Ok(resp) if resp.success => { + self.load(api); + } + Ok(resp) => { + self.last_error = Some(format!("Delete failed: {:?}", resp.error)); + } + Err(e) => { + self.last_error = Some(format!("Delete error: {}", e)); + } + } + } + } + } + + if let Some(result) = self.edit_dialog.show_editor(ctx) { + if let Some(updated) = result { + if let (Some(api), Some(id)) = (api_client, self.edit_current_id) { + let values = serde_json::Value::Object(updated); + let where_clause = serde_json::json!({"id": id}); + match api.update("issue_tracker", values, where_clause) { + Ok(resp) if resp.success => { + self.load(api); + } + Ok(resp) => { + self.last_error = Some(format!("Update failed: {:?}", resp.error)); + } + Err(e) => { + self.last_error = Some(format!("Update error: {}", e)); + } + } + } + } + } + } + + fn render_summary(&self, ui: &mut egui::Ui) { + ui.horizontal_wrapped(|ui| { + for (status, n) in &self.summary_by_status { + issues_chip(ui, format!("{}: {}", status, n), color_for_status(status)); + } + ui.separator(); + for (sev, n) in &self.summary_by_severity { + issues_chip(ui, format!("{}: {}", sev, n), color_for_severity(sev)); + } + }); + ui.add_space(6.0); + } + + fn recompute_summary(&mut self) { + use std::collections::HashMap; + let mut by_status: HashMap<String, i32> = HashMap::new(); + let mut by_sev: HashMap<String, i32> = HashMap::new(); + for r in &self.rows { + let status = r + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let sev = r + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + *by_status.entry(status).or_insert(0) += 1; + *by_sev.entry(sev).or_insert(0) += 1; + } + // Stable order for status + let status_order = [ + "Open", + "In Progress", + "On Hold", + "Resolved", + "Closed", + "Unknown", + ]; + let mut status_vec: Vec<(String, i32)> = by_status.into_iter().collect(); + status_vec.sort_by(|a, b| { + let ia = status_order + .iter() + .position(|s| s.eq_ignore_ascii_case(&a.0)) + .unwrap_or(status_order.len()); + let ib = status_order + .iter() + .position(|s| s.eq_ignore_ascii_case(&b.0)) + .unwrap_or(status_order.len()); + ia.cmp(&ib) + .then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())) + }); + // Stable order for severity + let sev_order = ["Critical", "High", "Medium", "Low", "Unknown"]; + let mut sev_vec: Vec<(String, i32)> = by_sev.into_iter().collect(); + sev_vec.sort_by(|a, b| { + let ia = sev_order + .iter() + .position(|s| s.eq_ignore_ascii_case(&a.0)) + .unwrap_or(sev_order.len()); + let ib = sev_order + .iter() + .position(|s| s.eq_ignore_ascii_case(&b.0)) + .unwrap_or(sev_order.len()); + ia.cmp(&ib) + .then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())) + }); + self.summary_by_status = status_vec; + self.summary_by_severity = sev_vec; + } + + fn show_columns_window(&mut self, ui: &egui::Ui) { + let ctx = ui.ctx(); + let screen_rect = ctx.input(|i| { + i.viewport().inner_rect.unwrap_or(egui::Rect::from_min_max( + egui::pos2(0.0, 0.0), + egui::pos2(1920.0, 1080.0), + )) + }); + let max_w = (screen_rect.width() - 20.0).max(220.0); + let max_h = (screen_rect.height() - 100.0).max(200.0); + + egui::Window::new("Column Selector") + .collapsible(false) + .resizable(true) + .default_width(260.0) + .default_height(360.0) + .anchor(egui::Align2::RIGHT_TOP, [-10.0, 90.0]) + .open(&mut self.show_column_panel) + .min_size(egui::vec2(220.0, 200.0)) + .max_size(egui::vec2(max_w, max_h)) + .frame(egui::Frame { + fill: egui::Color32::from_rgb(30, 30, 30), + stroke: egui::Stroke::new(1.0, egui::Color32::from_gray(60)), + inner_margin: egui::Margin::from(10.0), + outer_margin: egui::Margin::ZERO, + corner_radius: 6.0.into(), + shadow: egui::epaint::Shadow::NONE, + }) + .show(ctx, |ui| { + ui.heading("Columns"); + ui.separator(); + ui.add_space(8.0); + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + for column in &mut self.columns { + ui.checkbox(&mut column.visible, &column.name); + } + }); + ui.add_space(8.0); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Show All").clicked() { + for col in &mut self.columns { + col.visible = true; + } + } + if ui.button("Hide All").clicked() { + for col in &mut self.columns { + col.visible = false; + } + } + }); + }); + } + + fn render_table(&mut self, ui: &mut egui::Ui, visible_columns: &Vec<ColumnConfig>) { + use egui_extras::{Column, TableBuilder}; + let mut table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)); + // Add checkbox column first, then the rest + table = table.column(Column::initial(28.0)); + for _ in 0..visible_columns.len() { + table = table.column(Column::remainder()); + } + table + .header(22.0, |mut header| { + // Select-all checkbox + header.col(|ui| { + let all_selected = self + .rows + .iter() + .enumerate() + .all(|(i, _)| self.selected_rows.contains(&i)); + let mut chk = all_selected; + if ui + .checkbox(&mut chk, "") + .on_hover_text("Select All") + .clicked() + { + if chk { + self.selected_rows = (0..self.rows.len()).collect(); + } else { + self.selected_rows.clear(); + } + } + }); + for col in visible_columns { + header.col(|ui| { + ui.strong(&col.name); + }); + } + }) + .body(|mut body| { + for (idx, r) in self.rows.iter().enumerate() { + let r_clone = r.clone(); + let is_selected = self.selected_rows.contains(&idx); + body.row(20.0, |mut row| { + if is_selected { + row.set_selected(true); + } + // Checkbox cell + row.col(|ui| { + let mut checked = self.selected_rows.contains(&idx); + let resp = ui.checkbox(&mut checked, ""); + if resp.changed() { + let mods = ui.input(|i| i.modifiers); + if mods.shift { + let anchor = self.selection_anchor.unwrap_or(idx); + let (a, b) = if anchor <= idx { + (anchor, idx) + } else { + (idx, anchor) + }; + for i in a..=b { + if checked { + self.selected_rows.insert(i); + } else { + self.selected_rows.remove(&i); + } + } + } else if mods.command || mods.ctrl { + if checked { + self.selected_rows.insert(idx); + } else { + self.selected_rows.remove(&idx); + } + self.selection_anchor = Some(idx); + } else { + self.selected_rows.clear(); + if checked { + self.selected_rows.insert(idx); + self.selection_anchor = Some(idx); + } + } + } + }); + // Data columns + let mut combined: Option<egui::Response> = None; + for col in visible_columns { + row.col(|ui| { + let resp = render_issue_cell(ui, &r_clone, &col.field); + combined = Some(match combined.take() { + Some(p) => p.union(resp), + None => resp, + }); + }); + } + let mut row_resp = row.response(); + if let Some(c) = combined { + row_resp = row_resp.union(c); + } + if row_resp.clicked() { + let now = std::time::Instant::now(); + let dbl = if let (Some(t), Some(rw)) = + (self.last_click_time, self.last_click_row) + { + rw == idx && now.duration_since(t).as_millis() < 500 + } else { + false + }; + if dbl { + row_resp.ctx.data_mut(|d| { + d.insert_temp(egui::Id::new("iss_double_click_idx"), idx); + d.insert_temp( + egui::Id::new("iss_double_click_edit"), + r_clone.clone(), + ); + }); + } else { + // Multi-select on row click + let mods = row_resp.ctx.input(|i| i.modifiers); + if mods.shift { + let anchor = self.selection_anchor.unwrap_or(idx); + let (a, b) = if anchor <= idx { + (anchor, idx) + } else { + (idx, anchor) + }; + for i in a..=b { + self.selected_rows.insert(i); + } + } else if mods.command || mods.ctrl { + if self.selected_rows.contains(&idx) { + self.selected_rows.remove(&idx); + } else { + self.selected_rows.insert(idx); + } + self.selection_anchor = Some(idx); + } else { + self.selected_rows.clear(); + self.selected_rows.insert(idx); + self.selection_anchor = Some(idx); + } + self.last_click_time = Some(now); + self.last_click_row = Some(idx); + } + } + row_resp.context_menu(|ui| { + if ui + .button(format!("{} Edit", egui_phosphor::regular::PENCIL)) + .clicked() + { + ui.ctx().data_mut(|d| { + d.insert_temp( + egui::Id::new("iss_context_menu_edit"), + r_clone.clone(), + ) + }); + ui.close(); + } + ui.separator(); + if ui + .button(format!("{} Delete", egui_phosphor::regular::TRASH)) + .clicked() + { + ui.ctx().data_mut(|d| { + d.insert_temp( + egui::Id::new("iss_context_menu_delete"), + r_clone.clone(), + ) + }); + ui.close(); + } + }); + }); + } + }); + } + + fn open_editor_with(&mut self, item: &serde_json::Value) { + self.edit_current_id = item.get("id").and_then(|v| v.as_i64()); + self.edit_dialog.open(item); + } +} + +fn issues_chip(ui: &mut egui::Ui, text: String, color: egui::Color32) { + egui::Frame::new() + .fill(color.linear_multiply(0.14)) + .corner_radius(6.0) + .inner_margin(egui::Margin { + left: 8_i8, + right: 8_i8, + top: 4_i8, + bottom: 4_i8, + }) + .show(ui, |ui| { + ui.label(egui::RichText::new(text).color(color)); + }); +} + +fn color_for_status(status: &str) -> egui::Color32 { + match status.to_lowercase().as_str() { + "open" => egui::Color32::from_rgb(244, 67, 54), + "in progress" => egui::Color32::from_rgb(255, 152, 0), + "on hold" => egui::Color32::from_rgb(121, 85, 72), + "resolved" => egui::Color32::from_rgb(76, 175, 80), + "closed" => egui::Color32::from_rgb(96, 125, 139), + _ => egui::Color32::GRAY, + } +} + +fn color_for_severity(sev: &str) -> egui::Color32 { + match sev.to_lowercase().as_str() { + "critical" => egui::Color32::from_rgb(244, 67, 54), + "high" => egui::Color32::from_rgb(255, 152, 0), + "medium" => egui::Color32::from_rgb(66, 165, 245), + "low" => egui::Color32::from_rgb(158, 158, 158), + _ => egui::Color32::GRAY, + } +} + +fn label_trunc(ui: &mut egui::Ui, text: &str, max: usize) -> egui::Response { + if text.len() > max { + let short = format!("{}…", &text[..max]); + ui.label(short).on_hover_ui(|ui| { + ui.label(text); + }) + } else { + ui.label(text) + } +} + +fn render_issue_cell(ui: &mut egui::Ui, row: &serde_json::Value, field: &str) -> egui::Response { + let t = |k: &str| row.get(k).and_then(|v| v.as_str()).unwrap_or(""); + match field { + "id" => ui.label( + row.get("id") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string(), + ), + "title" => label_trunc(ui, t("title"), 60), + "status" => ui.label(t("status")), + "severity" => ui.label(t("severity")), + "priority" => ui.label(t("priority")), + "asset_label" => ui.label(t("asset_label")), + "borrower_name" => ui.label(t("borrower_name")), + "assigned_to_name" => ui.label(t("assigned_to_name")), + "auto_detected" => ui.label( + if row + .get("auto_detected") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + "Yes" + } else { + "No" + }, + ), + "detection_trigger" => label_trunc(ui, t("detection_trigger"), 40), + "updated_at" => ui.label(t("updated_at")), + "created_at" => ui.label(t("created_at")), + "resolved_date" => ui.label(t("resolved_date")), + "description" => label_trunc(ui, t("description"), 80), + "solution" => label_trunc(ui, t("solution"), 80), + "solution_plus" => label_trunc(ui, t("solution_plus"), 80), + "replacement_asset_id" => ui.label( + row.get("replacement_asset_id") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string(), + ), + "cost" => ui.label(match row.get("cost") { + Some(serde_json::Value::Number(n)) => n.to_string(), + Some(serde_json::Value::String(s)) => s.clone(), + _ => String::new(), + }), + "notes" => label_trunc(ui, t("notes"), 80), + other => ui.label(format!("{}", other)), + } +} diff --git a/src/ui/label_templates.rs b/src/ui/label_templates.rs new file mode 100644 index 0000000..fbe373e --- /dev/null +++ b/src/ui/label_templates.rs @@ -0,0 +1,607 @@ +use crate::api::ApiClient; +use crate::core::components::form_builder::FormBuilder; +use crate::core::components::interactions::ConfirmDialog; +use crate::core::{ColumnConfig, TableEventHandler, TableRenderer}; +use crate::core::{EditorField, FieldType}; +use crate::ui::ribbon::RibbonUI; +use eframe::egui; +use serde_json::Value; + +pub struct LabelTemplatesView { + templates: Vec<serde_json::Value>, + is_loading: bool, + last_error: Option<String>, + initial_load_done: bool, + + // Table renderer + table_renderer: TableRenderer, + + // Editor dialogs + edit_dialog: FormBuilder, + add_dialog: FormBuilder, + delete_dialog: ConfirmDialog, + + // Pending operations + pending_delete_id: Option<i64>, + pending_edit_id: Option<i64>, +} + +impl LabelTemplatesView { + pub fn new() -> Self { + let edit_dialog = Self::create_edit_dialog(); + let add_dialog = Self::create_add_dialog(); + + // Define columns for label_templates table + let columns = vec![ + ColumnConfig::new("ID", "id").with_width(60.0).hidden(), + ColumnConfig::new("Template Code", "template_code").with_width(150.0), + ColumnConfig::new("Template Name", "template_name").with_width(200.0), + ColumnConfig::new("Layout JSON", "layout_json") + .with_width(250.0) + .hidden(), + ]; + + Self { + templates: Vec::new(), + is_loading: false, + last_error: None, + initial_load_done: false, + table_renderer: TableRenderer::new() + .with_columns(columns) + .with_default_sort("template_name", true) + .with_search_fields(vec![ + "template_code".to_string(), + "template_name".to_string(), + ]), + edit_dialog, + add_dialog, + delete_dialog: ConfirmDialog::new( + "Delete Label Template", + "Are you sure you want to delete this label template?", + ), + pending_delete_id: None, + pending_edit_id: None, + } + } + + fn create_edit_dialog() -> FormBuilder { + FormBuilder::new( + "Edit Label Template", + vec![ + EditorField { + name: "id".into(), + label: "ID".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "template_code".into(), + label: "Template Code".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "template_name".into(), + label: "Template Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "layout_json".into(), + label: "Layout JSON".into(), + field_type: FieldType::MultilineText, + required: true, + read_only: false, + }, + ], + ) + } + + fn create_add_dialog() -> FormBuilder { + FormBuilder::new( + "Add Label Template", + vec![ + EditorField { + name: "template_code".into(), + label: "Template Code".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "template_name".into(), + label: "Template Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "layout_json".into(), + label: "Layout JSON".into(), + field_type: FieldType::MultilineText, + required: true, + read_only: false, + }, + ], + ) + } + + 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_templates(client); + } + } + + fn load_templates(&mut self, api_client: &ApiClient) { + use crate::core::tables::get_label_templates; + + self.is_loading = true; + self.last_error = None; + match get_label_templates(api_client) { + Ok(list) => { + self.templates = list; + self.is_loading = false; + self.initial_load_done = true; + } + Err(e) => { + self.last_error = Some(e.to_string()); + self.is_loading = false; + self.initial_load_done = true; + } + } + } + + pub fn render( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon_ui: Option<&mut RibbonUI>, + ) { + self.ensure_loaded(api_client); + + // Get search query from ribbon first (before mutable borrow) + let search_query = ribbon_ui + .as_ref() + .and_then(|r| r.search_texts.get("labels_search")) + .map(|s| s.clone()) + .unwrap_or_default(); + + // Apply search to table renderer + self.table_renderer.search_query = search_query; + + // Handle ribbon actions + if let Some(ribbon) = ribbon_ui { + if ribbon + .checkboxes + .get("labels_action_add") + .copied() + .unwrap_or(false) + { + // Provide helpful default layout JSON template matching database schema + let layout_json = r##"{ + "version": "1.0", + "background": "#FFFFFF", + "elements": [ + { + "type": "text", + "field": "{{asset_tag}}", + "x": 5, + "y": 10, + "fontSize": 14, + "fontWeight": "bold", + "fontFamily": "Arial" + }, + { + "type": "text", + "field": "{{name}}", + "x": 5, + "y": 28, + "fontSize": 10, + "fontFamily": "Arial" + }, + { + "type": "qrcode", + "field": "{{asset_tag}}", + "x": 5, + "y": 50, + "size": 40 + } + ] +}"##; + let default_data = serde_json::json!({ + "layout_json": layout_json + }); + self.add_dialog.open(&default_data); + } + if ribbon + .checkboxes + .get("labels_action_refresh") + .copied() + .unwrap_or(false) + { + if let Some(client) = api_client { + self.load_templates(client); + } + } + } + + // Error message + let mut clear_error = false; + if let Some(err) = &self.last_error { + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::RED, format!("Error: {}", err)); + if ui.button("Close").clicked() { + clear_error = true; + } + }); + ui.separator(); + } + if clear_error { + self.last_error = None; + } + + // Loading indicator + if self.is_loading { + ui.spinner(); + ui.label("Loading label templates..."); + return; + } + + // Render table with event handling + self.render_table_with_events(ui, api_client); + + // Handle dialogs + self.handle_dialogs(ui, api_client); + + // Process deferred actions from context menus + self.process_deferred_actions(ui, api_client); + } + + fn render_table_with_events(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + let templates_clone = self.templates.clone(); + let prepared_data = self.table_renderer.prepare_json_data(&templates_clone); + + let mut deferred_actions: Vec<DeferredAction> = Vec::new(); + let mut temp_handler = TempTemplatesEventHandler { + api_client, + deferred_actions: &mut deferred_actions, + }; + + self.table_renderer + .render_json_table(ui, &prepared_data, Some(&mut temp_handler)); + + self.process_temp_deferred_actions(deferred_actions, api_client); + } + + fn process_temp_deferred_actions( + &mut self, + actions: Vec<DeferredAction>, + _api_client: Option<&ApiClient>, + ) { + for action in actions { + match action { + DeferredAction::DoubleClick(template) => { + log::info!( + "Processing double-click edit for template: {:?}", + template.get("template_name") + ); + self.edit_dialog.open(&template); + if let Some(id) = template.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + DeferredAction::ContextEdit(template) => { + log::info!( + "Processing context menu edit for template: {:?}", + template.get("template_name") + ); + self.edit_dialog.open(&template); + if let Some(id) = template.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + DeferredAction::ContextDelete(template) => { + let name = template + .get("template_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + let id = template.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); + log::info!("Processing context menu delete for template: {}", name); + self.pending_delete_id = Some(id); + self.delete_dialog.open(name.to_string(), id.to_string()); + } + DeferredAction::ContextClone(template) => { + log::info!( + "Processing context menu clone for template: {:?}", + template.get("template_name") + ); + // Build payload for Add dialog using shared helper + let mut cloned = crate::core::components::prepare_cloned_value( + &template, + &["id", "template_code"], + Some("template_name"), + Some(""), + ); + // Ensure layout_json is a string for the editor + if let Some(obj) = cloned.as_object_mut() { + if let Some(v) = template.get("layout_json") { + let as_string = if let Some(s) = v.as_str() { + s.to_string() + } else { + serde_json::to_string_pretty(v).unwrap_or_else(|_| "{}".to_string()) + }; + obj.insert( + "layout_json".to_string(), + serde_json::Value::String(as_string), + ); + } + } + self.add_dialog.title = "Add Label Template".to_string(); + self.add_dialog.open(&cloned); + } + } + } + } + + fn handle_dialogs(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + // Delete confirmation dialog + if let Some(confirmed) = self.delete_dialog.show_dialog(ui.ctx()) { + if confirmed { + if let (Some(id), Some(client)) = (self.pending_delete_id, api_client) { + let where_clause = serde_json::json!({"id": id}); + match client.delete("label_templates", where_clause) { + Ok(resp) => { + if resp.success { + log::info!("Label template {} deleted successfully", id); + self.load_templates(client); + } else { + self.last_error = Some(format!("Delete failed: {:?}", resp.error)); + log::error!("Delete failed: {:?}", resp.error); + } + } + Err(e) => { + self.last_error = Some(format!("Failed to delete template: {}", e)); + log::error!("Failed to delete template: {}", e); + } + } + } + self.pending_delete_id = None; + } + } + + // Edit dialog + if let Some(Some(updated)) = self.edit_dialog.show_editor(ui.ctx()) { + if let (Some(id), Some(client)) = (self.pending_edit_id, api_client) { + let where_clause = serde_json::json!({"id": id}); + let mut to_update = updated; + // Remove editor metadata + let mut meta_keys: Vec<String> = to_update + .keys() + .filter(|k| k.starts_with("__editor_")) + .cloned() + .collect(); + // Also remove __editor_item_id specifically + if to_update.contains_key("__editor_item_id") { + meta_keys.push("__editor_item_id".to_string()); + } + for k in meta_keys { + to_update.remove(&k); + } + // Send layout_json as actual JSON object + if let Some(val) = to_update.get_mut("layout_json") { + if let Some(s) = val.as_str() { + match serde_json::from_str::<serde_json::Value>(s) { + Ok(json_val) => { + // Send as actual JSON object, not string + *val = json_val; + } + Err(e) => { + self.last_error = Some(format!("Layout JSON is invalid: {}", e)); + return; + } + } + } + } + match client.update( + "label_templates", + serde_json::Value::Object(to_update.clone()), + where_clause, + ) { + Ok(resp) => { + if resp.success { + log::info!("Label template {} updated successfully", id); + self.load_templates(client); + } else { + self.last_error = Some(format!("Update failed: {:?}", resp.error)); + log::error!("Update failed: {:?}", resp.error); + } + } + Err(e) => { + self.last_error = Some(format!("Failed to update template: {}", e)); + log::error!("Failed to update template: {}", e); + } + } + self.pending_edit_id = None; + } + } + + // Add dialog + if let Some(Some(new_data)) = self.add_dialog.show_editor(ui.ctx()) { + if let Some(client) = api_client { + let mut payload = new_data; + // Strip any editor metadata that may have leaked in + let meta_strip: Vec<String> = payload + .keys() + .filter(|k| k.starts_with("__editor_")) + .cloned() + .collect(); + for k in meta_strip { + payload.remove(&k); + } + // Send layout_json as actual JSON object + if let Some(val) = payload.get_mut("layout_json") { + if let Some(s) = val.as_str() { + match serde_json::from_str::<serde_json::Value>(s) { + Ok(json_val) => { + // Send as actual JSON object, not string + *val = json_val; + } + Err(e) => { + self.last_error = Some(format!("Layout JSON is invalid: {}", e)); + return; + } + } + } + } + match client.insert("label_templates", serde_json::Value::Object(payload)) { + Ok(resp) => { + if resp.success { + log::info!("Label template added successfully"); + self.load_templates(client); + } else { + self.last_error = Some(format!("Insert failed: {:?}", resp.error)); + log::error!("Insert failed: {:?}", resp.error); + } + } + Err(e) => { + self.last_error = Some(format!("Failed to add template: {}", e)); + log::error!("Failed to add template: {}", e); + } + } + } + } + } + + fn process_deferred_actions(&mut self, ui: &mut egui::Ui, _api_client: Option<&ApiClient>) { + // Handle double-click edit + if let Some(template) = ui + .ctx() + .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("label_double_click_edit"))) + { + log::info!( + "Processing double-click edit for template: {:?}", + template.get("template_name") + ); + self.edit_dialog.open(&template); + if let Some(id) = template.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + + // Handle context menu actions + if let Some(template) = ui + .ctx() + .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("label_context_menu_edit"))) + { + log::info!( + "Processing context menu edit for template: {:?}", + template.get("template_name") + ); + self.edit_dialog.open(&template); + if let Some(id) = template.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + + if let Some(template) = ui + .ctx() + .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("label_context_menu_delete"))) + { + let name = template + .get("template_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + let id = template.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); + log::info!("Processing context menu delete for template: {}", name); + self.pending_delete_id = Some(id); + self.delete_dialog.open(name.to_string(), id.to_string()); + } + } +} + +impl Default for LabelTemplatesView { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone)] +enum DeferredAction { + DoubleClick(Value), + ContextEdit(Value), + ContextDelete(Value), + ContextClone(Value), +} + +// Temporary event handler that collects actions for later processing +struct TempTemplatesEventHandler<'a> { + #[allow(dead_code)] + api_client: Option<&'a ApiClient>, + deferred_actions: &'a mut Vec<DeferredAction>, +} + +impl<'a> TableEventHandler<Value> for TempTemplatesEventHandler<'a> { + fn on_double_click(&mut self, item: &Value, _row_index: usize) { + log::info!( + "Double-click detected on template: {:?}", + item.get("template_name") + ); + self.deferred_actions + .push(DeferredAction::DoubleClick(item.clone())); + } + + fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) { + if ui + .button(format!("{} Edit Template", egui_phosphor::regular::PENCIL)) + .clicked() + { + log::info!( + "Context menu edit clicked for template: {:?}", + item.get("template_name") + ); + self.deferred_actions + .push(DeferredAction::ContextEdit(item.clone())); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Clone Template", egui_phosphor::regular::COPY)) + .clicked() + { + log::info!( + "Context menu clone clicked for template: {:?}", + item.get("template_name") + ); + self.deferred_actions + .push(DeferredAction::ContextClone(item.clone())); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Delete Template", egui_phosphor::regular::TRASH)) + .clicked() + { + log::info!( + "Context menu delete clicked for template: {:?}", + item.get("template_name") + ); + self.deferred_actions + .push(DeferredAction::ContextDelete(item.clone())); + ui.close(); + } + } + + fn on_selection_changed(&mut self, selected_indices: &[usize]) { + log::debug!("Template selection changed: {:?}", selected_indices); + } +} diff --git a/src/ui/login.rs b/src/ui/login.rs new file mode 100644 index 0000000..8a85418 --- /dev/null +++ b/src/ui/login.rs @@ -0,0 +1,272 @@ +use eframe::egui; +use std::sync::mpsc::Receiver; + +use crate::api::ApiClient; +use crate::models::LoginResponse; +use crate::session::SessionManager; + +pub struct LoginScreen { + server_url: String, + username: String, + password: String, + + remember_server: bool, + remember_username: bool, + + error_message: Option<String>, + is_logging_in: bool, + + // For async operations + login_receiver: Option<Receiver<Result<(String, LoginResponse), String>>>, +} + +impl LoginScreen { + pub fn new(session_manager: &SessionManager) -> Self { + let server_url = session_manager + .get_saved_server_url() + .unwrap_or_else(|| "http://localhost:5777".to_string()); // Reminder to myself : Fucking remove this before release + + let username = session_manager.get_saved_username().unwrap_or_default(); + + let remember_username = !username.is_empty(); + + Self { + server_url, + username, + password: String::new(), + remember_server: true, + remember_username, + error_message: None, + is_logging_in: false, + login_receiver: None, + } + } + + pub fn show(&mut self, ctx: &egui::Context, on_success: &mut Option<(String, LoginResponse)>) { + // Check if we have a login result from async operation + if let Some(receiver) = &self.login_receiver { + match receiver.try_recv() { + Ok(result) => { + log::info!("UI thread: Received login result!"); + self.is_logging_in = false; + self.login_receiver = None; + + match result { + Ok((server_url, login_response)) => { + log::info!("UI thread: Login successful, setting on_success"); + *on_success = Some((server_url, login_response)); + } + Err(err) => { + log::error!("UI thread: Login failed: {} tried suicide yet maybe that actually works?", err); + self.error_message = Some(err); + } + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + // Still waiting, request repaint to check again + ctx.request_repaint(); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + log::error!("UI thread: Channel disconnected!"); + self.is_logging_in = false; + self.login_receiver = None; + self.error_message = Some("Connection error".to_string()); + } + } + } + + egui::CentralPanel::default().show(ctx, |ui| { + // Center the login form both horizontally and vertically bruh + let available_size = ui.available_size(); + let panel_width = available_size.x.min(500.0); + + ui.allocate_ui_with_layout( + available_size, + egui::Layout::top_down(egui::Align::Center), + |ui| { + // Add vertical spacing to center + ui.add_space(available_size.y * 0.15); + + // Logo/Title + ui.heading(egui::RichText::new("BeepZone Login").size(48.0).strong()); + ui.label( + egui::RichText::new(format!( + "BeepZone Desktop Client eGUI EMO Edition - v{}", + env!("CARGO_PKG_VERSION") + )) + .size(18.0), + ); + + ui.add_space(30.0); + + // Login form + egui::Frame::new() + .fill(ctx.style().visuals.window_fill) + .stroke(ctx.style().visuals.window_stroke) + .corner_radius(12.0) + .inner_margin(32.0) + .show(ui, |ui| { + ui.set_width(panel_width * 0.8); + + // Server URL + ui.horizontal(|ui: &mut egui::Ui| { + ui.label("BeepZone Sekel API URL:"); + ui.add_space(10.0); + }); + ui.text_edit_singleline(&mut self.server_url); + ui.add_space(8.0); + + // Username field + ui.label("Username:"); + ui.text_edit_singleline(&mut self.username); + ui.add_space(8.0); + + // Password field + ui.label("Password:"); + let password_response = ui + .add(egui::TextEdit::singleline(&mut self.password).password(true)); + + // Enter key to submit + if password_response.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + { + self.do_login(); + } + + ui.add_space(12.0); + + // Remember options + ui.checkbox(&mut self.remember_server, "Remember Sekel URL"); + ui.checkbox(&mut self.remember_username, "Remember Username"); + + ui.add_space(16.0); + + // Error message + if let Some(error) = &self.error_message { + ui.label(format!("Error: {}", error)); + ui.add_space(12.0); + } + + // Login button + ui.add_enabled_ui(!self.is_logging_in, |ui| { + let button_text = if self.is_logging_in { + "Logging in..." + } else { + "Login" + }; + + if ui + .add_sized( + [ui.available_width(), 40.0], + egui::Button::new(button_text), + ) + .clicked() + { + self.do_login(); + } + }); + + if self.is_logging_in { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Connecting to server..."); + }); + } + }); + + ui.add_space(20.0); + ui.label( + egui::RichText::new("- The only based Sigma Inventory System -") + .size(12.0) + .color(egui::Color32::GRAY), + ); + + // Debug info + if self.is_logging_in { + ui.add_space(10.0); + ui.label( + egui::RichText::new(format!("Connecting to: {}", self.server_url)) + .size(10.0) + .color(egui::Color32::GRAY), + ); + } + }, + ); + }); + } + + fn do_login(&mut self) { + // Validate inputs + if self.server_url.trim().is_empty() { + self.error_message = + Some("Server URL is required!? Perhaps enter it you know?".to_string()); + return; + } + + if self.username.trim().is_empty() || self.password.trim().is_empty() { + self.error_message = Some( + "Username and password are required!? Please enter both you know?".to_string(), + ); + return; + } + + self.error_message = None; + self.is_logging_in = true; + + // Clone data for background thread + let server_url = self.server_url.clone(); + let username = self.username.clone(); + let password = self.password.clone(); + + log::info!("Trying to sign in as : {}", username); + + // Create channel for communication + let (tx, rx) = std::sync::mpsc::channel(); + self.login_receiver = Some(rx); + + // Spawn background thread to perform login + std::thread::spawn(move || { + log::info!("Background thread: Connecting to {}", server_url); + let result = match ApiClient::new(server_url.clone()) { + Ok(client) => { + log::info!("Background thread: API client created, attempting login..."); + match client.login_password(&username, &password) { + Ok(response) => { + log::info!( + "Background thread: Got response, success={}", + response.success + ); + if response.success { + log::info!( + "Login successfulf for user: {}", + response.user.username + ); + Ok((server_url, response)) + } else { + let error = "Login failed gay credentials".to_string(); + log::error!("{}", error); + Err(error) + } + } + Err(e) => { + let error = format!("Notwork error: {}", e); + log::error!("{}", error); + Err(error) + } + } + } + Err(e) => { + let error = format!("Failed at connecting to server somehow: {}", e); + log::error!("{}", error); + Err(error) + } + }; + + log::info!("Background thread: Sending result back to UI"); + if let Err(e) = tx.send(result) { + log::error!("Background thread: Failed to send result: {:?}", e); + } + }); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..f173749 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,14 @@ +pub mod app; +pub mod audits; +pub mod borrowing; +pub mod categories; +pub mod dashboard; +pub mod inventory; +pub mod issues; +pub mod label_templates; +pub mod login; +pub mod printers; +pub mod ribbon; +pub mod suppliers; +pub mod templates; +pub mod zones; diff --git a/src/ui/printers.rs b/src/ui/printers.rs new file mode 100644 index 0000000..bafc445 --- /dev/null +++ b/src/ui/printers.rs @@ -0,0 +1,943 @@ +use crate::api::ApiClient; +use crate::core::components::form_builder::FormBuilder; +use crate::core::components::interactions::ConfirmDialog; +use crate::core::{ColumnConfig, TableEventHandler, TableRenderer}; +use crate::core::{EditorField, FieldType}; +use crate::ui::ribbon::RibbonUI; +use eframe::egui; +use serde_json::Value; + +const SYSTEM_PRINTER_SETTINGS_TEMPLATE: &str = r#"{ + "paper_size": "Custom", + "orientation": "landscape", + "margins": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "color": false, + "quality": "high", + "copies": 1, + "duplex": false, + "center": "both", + "center_disabled": false, + "scale_mode": "fit", + "scale_factor": 1.0, + "custom_width_mm": 50.8, + "custom_height_mm": 76.2, + "printer_name": null, + "show_dialog_if_unfound": true +}"#; + +const PDF_PRINTER_SETTINGS_TEMPLATE: &str = SYSTEM_PRINTER_SETTINGS_TEMPLATE; + +const SYSTEM_PRINTER_JSON_HELP: &str = r#"# System printer JSON + +Use this payload when registering the `System` printer plugin. Leave fields out to fall back to BeepZone's legacy sizing. + +## Core fields +- `paper_size` *(string)* — Named stock such as `A4`, `Letter`, `A5`, or `Custom`. +- `orientation` *(string)* — Either `portrait` or `landscape`. Selecting `landscape` rotates the page 90°; any custom width/height you supply are interpreted in the stock's natural (portrait) orientation and the app flips them automatically while printing. +- `margins` *(object in millimetres)* — Trim space on each edge with `top`, `right`, `bottom`, `left` properties. +- `scale_mode` *(string)* — Scaling behavior: `fit` (proportional fit), `fit-x` (fit width), `fit-y` (fit height), `max-both`, `max-x`, `max-y`, or `manual`. +- `scale_factor` *(number ≥ 0)* — Manual multiplier applied according to scale_mode. +- `duplex`, `color`, `quality` *(optional)* — Mirrors the underlying OS print options. +- `copies` *(number)* — Number of copies to print. +- `custom_width_mm` / `custom_height_mm` *(numbers)* — Provide both to describe bespoke media using the printer's normal portrait orientation. + +## Layout control +- `center` *("none" | "horizontal" | "vertical" | "both" | null)* — Centers content when not disabled. +- `center_disabled` *(bool)* — When `true`, ignores the `center` setting while keeping the last chosen mode for later. + +## Direct print (optional) +- `printer_name` *(string | null)* — If set, the System plugin will attempt to print directly to this OS printer by name. +- `show_dialog_if_unfound` *(bool, default: true)* — When `true` (or omitted) and the named printer can't be resolved, a lightweight popup chooser appears. Set to `false` to skip the chooser and only open the PDF viewer. +- `compatibility_mode` *(bool, default: false)* — When `true`, sends NO CUPS job options at all - only the raw PDF. Use this for severely broken printer filters (e.g., Kyocera network printers with crashing filters). The printer will use its default settings. + +## Examples + +### Custom Label Printer (e.g., ZQ510) +```json +{ + "paper_size": "Custom", + "orientation": "landscape", + "margins": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scale_mode": "fit", + "scale_factor": 1.0, + "color": false, + "quality": "high", + "copies": 1, + "duplex": false, + "custom_width_mm": 50.8, + "custom_height_mm": 76.2, + "center": "both", + "center_disabled": false, + "printer_name": "ZQ510", + "show_dialog_if_unfound": true +} +``` + +### Standard A4 Office Printer +```json +{ + "paper_size": "A4", + "orientation": "portrait", + "margins": { + "top": 12.7, + "right": 12.7, + "bottom": 12.7, + "left": 12.7 + }, + "scale_mode": "fit", + "scale_factor": 1.0, + "color": true, + "quality": "high", + "copies": 1, + "duplex": false, + "center": "both", + "center_disabled": false, + "printer_name": "HP LaserJet Pro", + "show_dialog_if_unfound": true +} +``` +"#; + +const PDF_PRINTER_JSON_HELP: &str = r#"# PDF export JSON + +The PDF plugin understands the same shape as the System printer. Use the optional flags only when you want the enhanced layout controls; otherwise omit them for the classic renderer settings. + +## Typical usage +- Provide `paper_size` / `orientation` or include `custom_width_mm` + `custom_height_mm` for bespoke sheets. Enter the measurements in the stock's natural portrait orientation; landscape output is handled automatically. +- Reuse the `margins` block from your system printers so labels line up identically. +- `scale_mode`, `scale_factor`, `center`, `center_disabled` behave exactly the same as the System plugin. +- The exported file path is still chosen through the PDF save dialog; these settings only influence page geometry. + +## Available scale modes +- `fit` — Proportionally fit the design within the printable area +- `fit-x` — Fit to page width only +- `fit-y` — Fit to page height only +- `max-both` — Maximum size that fits both dimensions +- `max-x` — Maximum width scaling +- `max-y` — Maximum height scaling +- `manual` — Use exact `scale_factor` value + +## Example + +```json +{ + "paper_size": "Letter", + "orientation": "portrait", + "margins": { "top": 5.0, "right": 5.0, "bottom": 5.0, "left": 5.0 }, + "scale_mode": "manual", + "scale_factor": 0.92, + "center": "horizontal", + "center_disabled": false +} +``` +"#; + +pub struct PrintersView { + printers: Vec<serde_json::Value>, + is_loading: bool, + last_error: Option<String>, + initial_load_done: bool, + + // Table renderer + table_renderer: TableRenderer, + + // Editor dialogs + edit_dialog: FormBuilder, + add_dialog: FormBuilder, + delete_dialog: ConfirmDialog, + + // Pending operations + pending_delete_id: Option<i64>, + pending_edit_id: Option<i64>, + + // Navigation + pub switch_to_print_history: bool, + + // Track last selected plugin to detect changes + last_add_dialog_plugin: Option<String>, +} + +impl PrintersView { + pub fn new() -> Self { + let edit_dialog = Self::create_edit_dialog(); + let add_dialog = Self::create_add_dialog(); + + // Define columns for printer_settings table + let columns = vec![ + ColumnConfig::new("ID", "id").with_width(60.0).hidden(), + ColumnConfig::new("Printer Name", "printer_name").with_width(150.0), + ColumnConfig::new("Description", "description").with_width(200.0), + ColumnConfig::new("Plugin", "printer_plugin").with_width(100.0), + ColumnConfig::new("Log Prints", "log").with_width(90.0), + ColumnConfig::new("Use for Reports", "can_be_used_for_reports").with_width(120.0), + ColumnConfig::new("Min Power Level", "min_powerlevel_to_use").with_width(110.0), + ColumnConfig::new("Settings JSON", "printer_settings") + .with_width(150.0) + .hidden(), + ]; + + Self { + printers: Vec::new(), + is_loading: false, + last_error: None, + initial_load_done: false, + table_renderer: TableRenderer::new() + .with_columns(columns) + .with_default_sort("printer_name", true) + .with_search_fields(vec![ + "printer_name".to_string(), + "description".to_string(), + "printer_plugin".to_string(), + ]), + edit_dialog, + add_dialog, + delete_dialog: ConfirmDialog::new( + "Delete Printer", + "Are you sure you want to delete this printer configuration?", + ), + pending_delete_id: None, + pending_edit_id: None, + switch_to_print_history: false, + last_add_dialog_plugin: None, + } + } + + fn plugin_help_text(plugin: &str) -> Option<&'static str> { + match plugin { + "System" => Some(SYSTEM_PRINTER_JSON_HELP), + "PDF" => Some(PDF_PRINTER_JSON_HELP), + _ => None, + } + } + + fn apply_plugin_help(editor: &mut FormBuilder, plugin: Option<&str>) { + if let Some(plugin) = plugin { + if let Some(help) = Self::plugin_help_text(plugin) { + editor.form_help_text = Some(help.to_string()); + return; + } + } + editor.form_help_text = None; + } + + fn create_edit_dialog() -> FormBuilder { + let plugin_options = vec![ + ("Ptouch".to_string(), "Brother P-Touch".to_string()), + ("Brother".to_string(), "Brother (Generic)".to_string()), + ("Zebra".to_string(), "Zebra".to_string()), + ("System".to_string(), "System Printer".to_string()), + ("PDF".to_string(), "PDF Export".to_string()), + ("Network".to_string(), "Network Printer".to_string()), + ("Custom".to_string(), "Custom".to_string()), + ]; + + FormBuilder::new( + "Edit Printer", + vec![ + EditorField { + name: "id".into(), + label: "ID".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "printer_name".into(), + label: "Printer Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "description".into(), + label: "Description".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "printer_plugin".into(), + label: "Printer Plugin".into(), + field_type: FieldType::Dropdown(plugin_options.clone()), + required: true, + read_only: false, + }, + EditorField { + name: "log".into(), + label: "Log Print Jobs".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "can_be_used_for_reports".into(), + label: "Can Print Reports".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "min_powerlevel_to_use".into(), + label: "Minimum Power Level".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "printer_settings".into(), + label: "Printer Settings Required".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ], + ) + } + + fn create_add_dialog() -> FormBuilder { + let plugin_options = vec![ + ("Ptouch".to_string(), "Brother P-Touch".to_string()), + ("Brother".to_string(), "Brother (Generic)".to_string()), + ("Zebra".to_string(), "Zebra".to_string()), + ("System".to_string(), "System Printer".to_string()), + ("PDF".to_string(), "PDF Export".to_string()), + ("Network".to_string(), "Network Printer".to_string()), + ("Custom".to_string(), "Custom".to_string()), + ]; + + FormBuilder::new( + "Add Printer", + vec![ + EditorField { + name: "printer_name".into(), + label: "Printer Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "description".into(), + label: "Description".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "printer_plugin".into(), + label: "Printer Plugin".into(), + field_type: FieldType::Dropdown(plugin_options), + required: true, + read_only: false, + }, + EditorField { + name: "log".into(), + label: "Log Print Jobs".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "can_be_used_for_reports".into(), + label: "Can Print Reports".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "min_powerlevel_to_use".into(), + label: "Minimum Power Level".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "printer_settings".into(), + label: "Printer Settings (JSON)".into(), + field_type: FieldType::MultilineText, + required: true, + read_only: false, + }, + ], + ) + } + + 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_printers(client); + } + } + + fn load_printers(&mut self, api_client: &ApiClient) { + use crate::core::tables::get_printers; + + self.is_loading = true; + self.last_error = None; + match get_printers(api_client) { + Ok(list) => { + self.printers = list; + self.is_loading = false; + self.initial_load_done = true; + } + Err(e) => { + self.last_error = Some(e.to_string()); + self.is_loading = false; + self.initial_load_done = true; + } + } + } + + pub fn render( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon_ui: Option<&mut RibbonUI>, + session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>, + ) { + self.ensure_loaded(api_client); + + // Get search query from ribbon first (before mutable borrow) + let search_query = ribbon_ui + .as_ref() + .and_then(|r| r.search_texts.get("printers_search")) + .map(|s| s.clone()) + .unwrap_or_default(); + + // Apply search to table renderer + self.table_renderer.search_query = search_query; + + // Handle ribbon actions and default printer dropdown + if let Some(ribbon) = ribbon_ui { + if ribbon + .checkboxes + .get("printers_action_add") + .copied() + .unwrap_or(false) + { + // Provide default values - printer_settings will get plugin-specific template + let default_data = serde_json::json!({ + "printer_settings": "{}", + "log": true, + "can_be_used_for_reports": false, + "min_powerlevel_to_use": "0" + }); + self.add_dialog.open(&default_data); + } + if ribbon + .checkboxes + .get("printers_action_refresh") + .copied() + .unwrap_or(false) + { + if let Some(client) = api_client { + self.load_printers(client); + } + } + if ribbon + .checkboxes + .get("printers_view_print_history") + .copied() + .unwrap_or(false) + { + self.switch_to_print_history = true; + } + + // Handle default printer dropdown (will be rendered in Settings group) + // Store selected printer ID change flag + if ribbon + .checkboxes + .get("printers_default_changed") + .copied() + .unwrap_or(false) + { + if let Some(printer_id_str) = ribbon.search_texts.get("printers_default_id") { + if printer_id_str.is_empty() { + // Clear default printer + if let Ok(mut session) = session_manager.try_lock() { + if let Err(e) = session.update_default_printer(None) { + log::error!("Failed to clear default printer: {}", e); + } else { + log::info!("Default printer cleared"); + } + } + } else if let Ok(printer_id) = printer_id_str.parse::<i64>() { + // Set default printer + if let Ok(mut session) = session_manager.try_lock() { + if let Err(e) = session.update_default_printer(Some(printer_id)) { + log::error!("Failed to update default printer: {}", e); + } else { + log::info!("Default printer set to ID: {}", printer_id); + } + } + } + } + } + } + + // Error message + let mut clear_error = false; + if let Some(err) = &self.last_error { + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::RED, format!("Error: {}", err)); + if ui.button("Close").clicked() { + clear_error = true; + } + }); + ui.separator(); + } + if clear_error { + self.last_error = None; + } + + // Loading indicator + if self.is_loading { + ui.spinner(); + ui.label("Loading printers..."); + return; + } + + // Render table with event handling + self.render_table_with_events(ui, api_client); + + // Handle dialogs + self.handle_dialogs(ui, api_client); + + // Process deferred actions from context menus + self.process_deferred_actions(ui, api_client); + } + + /// Called before rendering to inject printer dropdown data into ribbon + pub fn inject_dropdown_into_ribbon( + &self, + ribbon_ui: &mut RibbonUI, + session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>, + ) { + // Try to get current default printer ID without blocking (avoid Tokio panic) + let current_default = session_manager + .try_lock() + .ok() + .and_then(|s| s.get_default_printer_id()); + + // Store current default for ribbon rendering + if let Some(id) = current_default { + ribbon_ui + .search_texts + .insert("_printers_current_default".to_string(), id.to_string()); + } else { + ribbon_ui + .search_texts + .insert("_printers_current_default".to_string(), "".to_string()); + } + + // Store printer list as JSON string for ribbon to parse + let printers_json = serde_json::to_string(&self.printers).unwrap_or_default(); + ribbon_ui + .search_texts + .insert("_printers_list".to_string(), printers_json); + } + + fn render_table_with_events(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + let printers_clone = self.printers.clone(); + let prepared_data = self.table_renderer.prepare_json_data(&printers_clone); + + let mut deferred_actions: Vec<DeferredAction> = Vec::new(); + let mut temp_handler = TempPrintersEventHandler { + api_client, + deferred_actions: &mut deferred_actions, + }; + + self.table_renderer + .render_json_table(ui, &prepared_data, Some(&mut temp_handler)); + + self.process_temp_deferred_actions(deferred_actions, api_client); + } + + fn process_temp_deferred_actions( + &mut self, + actions: Vec<DeferredAction>, + _api_client: Option<&ApiClient>, + ) { + for action in actions { + match action { + DeferredAction::DoubleClick(printer) => { + log::info!( + "Processing double-click edit for printer: {:?}", + printer.get("printer_name") + ); + self.edit_dialog.open(&printer); + if let Some(id) = printer.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + DeferredAction::ContextEdit(printer) => { + log::info!( + "Processing context menu edit for printer: {:?}", + printer.get("printer_name") + ); + self.edit_dialog.open(&printer); + if let Some(id) = printer.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + DeferredAction::ContextDelete(printer) => { + let name = printer + .get("printer_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + let id = printer.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); + log::info!("Processing context menu delete for printer: {}", name); + self.pending_delete_id = Some(id); + self.delete_dialog.open(name.to_string(), id.to_string()); + } + DeferredAction::ContextClone(printer) => { + log::info!( + "Processing context menu clone for printer: {:?}", + printer.get("printer_name") + ); + let mut cloned = crate::core::components::prepare_cloned_value( + &printer, + &["id"], + Some("printer_name"), + Some(""), + ); + if let Some(obj) = cloned.as_object_mut() { + if let Some(ps) = obj.get("printer_settings") { + let as_str = if ps.is_string() { + ps.as_str().unwrap_or("{}").to_string() + } else { + serde_json::to_string_pretty(ps) + .unwrap_or_else(|_| "{}".to_string()) + }; + obj.insert( + "printer_settings".to_string(), + serde_json::Value::String(as_str), + ); + } + self.add_dialog.open_new(Some(obj)); + } + } + } + } + } + + fn handle_dialogs(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + // BEFORE showing add dialog, check if printer_plugin changed and auto-populate printer_settings + if self.add_dialog.show { + let current_plugin = self + .add_dialog + .data + .get("printer_plugin") + .map(|s| s.clone()); + + // Detect if plugin changed to "System" + if current_plugin != self.last_add_dialog_plugin { + if let Some(ref plugin) = current_plugin { + let template = match plugin.as_str() { + "System" => Some(SYSTEM_PRINTER_SETTINGS_TEMPLATE), + "PDF" => Some(PDF_PRINTER_SETTINGS_TEMPLATE), + _ => None, + }; + + if let Some(template) = template { + let current_settings = self + .add_dialog + .data + .get("printer_settings") + .map(|s| s.as_str()) + .unwrap_or("{}"); + + if current_settings.trim().is_empty() || current_settings.trim() == "{}" { + self.add_dialog + .data + .insert("printer_settings".to_string(), template.to_string()); + } + } + } + self.last_add_dialog_plugin = current_plugin.clone(); + } + + Self::apply_plugin_help(&mut self.add_dialog, current_plugin.as_deref()); + } else { + // Reset tracking when dialog closes + self.last_add_dialog_plugin = None; + self.add_dialog.form_help_text = None; + } + + if self.edit_dialog.show { + let edit_plugin = self + .edit_dialog + .data + .get("printer_plugin") + .map(|s| s.clone()); + Self::apply_plugin_help(&mut self.edit_dialog, edit_plugin.as_deref()); + } else { + self.edit_dialog.form_help_text = None; + } + + // Delete confirmation dialog + if let Some(confirmed) = self.delete_dialog.show_dialog(ui.ctx()) { + if confirmed { + if let (Some(id), Some(client)) = (self.pending_delete_id, api_client) { + let where_clause = serde_json::json!({"id": id}); + match client.delete("printer_settings", where_clause) { + Ok(_) => { + log::info!("Printer {} deleted successfully", id); + self.load_printers(client); + } + Err(e) => { + self.last_error = Some(format!("Failed to delete printer: {}", e)); + log::error!("Failed to delete printer: {}", e); + } + } + } + self.pending_delete_id = None; + } + } + + // Edit dialog + if let Some(Some(updated)) = self.edit_dialog.show_editor(ui.ctx()) { + if let (Some(id), Some(client)) = (self.pending_edit_id, api_client) { + let where_clause = serde_json::json!({"id": id}); + // Ensure printer_settings field is valid JSON and send as JSON object + let mut to_update = updated; + // Remove generic editor metadata keys (avoid backend invalid column errors) + let mut meta_keys: Vec<String> = to_update + .keys() + .filter(|k| k.starts_with("__editor_")) + .cloned() + .collect(); + // Also remove __editor_item_id specifically + if to_update.contains_key("__editor_item_id") { + meta_keys.push("__editor_item_id".to_string()); + } + for k in meta_keys { + to_update.remove(&k); + } + if let Some(val) = to_update.get_mut("printer_settings") { + if let Some(s) = val.as_str() { + match serde_json::from_str::<serde_json::Value>(s) { + Ok(json_val) => { + // Send as actual JSON object, not base64 string + *val = json_val; + } + Err(e) => { + self.last_error = + Some(format!("Printer Settings JSON is invalid: {}", e)); + return; + } + } + } + } + match client.update( + "printer_settings", + serde_json::Value::Object(to_update.clone()), + where_clause, + ) { + Ok(resp) => { + if resp.success { + log::info!("Printer {} updated successfully", id); + self.load_printers(client); + } else { + self.last_error = Some(format!("Update failed: {:?}", resp.error)); + log::error!("Update failed: {:?}", resp.error); + } + } + Err(e) => { + self.last_error = Some(format!("Failed to update printer: {}", e)); + log::error!("Failed to update printer: {}", e); + } + } + self.pending_edit_id = None; + } + } + + // Add dialog + if let Some(Some(new_data)) = self.add_dialog.show_editor(ui.ctx()) { + if let Some(client) = api_client { + // Parse printer_settings JSON and send as JSON object + let mut payload = new_data; + // Strip any editor metadata that may have leaked in + let meta_strip: Vec<String> = payload + .keys() + .filter(|k| k.starts_with("__editor_")) + .cloned() + .collect(); + for k in meta_strip { + payload.remove(&k); + } + if let Some(val) = payload.get_mut("printer_settings") { + if let Some(s) = val.as_str() { + match serde_json::from_str::<serde_json::Value>(s) { + Ok(json_val) => { + // Send as actual JSON object, not base64 string + *val = json_val; + } + Err(e) => { + self.last_error = + Some(format!("Printer Settings JSON is invalid: {}", e)); + return; + } + } + } + } + match client.insert( + "printer_settings", + serde_json::Value::Object(payload.clone()), + ) { + Ok(resp) => { + if resp.success { + log::info!("Printer added successfully"); + self.load_printers(client); + } else { + self.last_error = Some(format!("Insert failed: {:?}", resp.error)); + log::error!("Insert failed: {:?}", resp.error); + } + } + Err(e) => { + self.last_error = Some(format!("Failed to add printer: {}", e)); + log::error!("Failed to add printer: {}", e); + } + } + } + } + } + + fn process_deferred_actions(&mut self, ui: &mut egui::Ui, _api_client: Option<&ApiClient>) { + // Handle double-click edit + if let Some(printer) = ui + .ctx() + .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("printer_double_click_edit"))) + { + log::info!( + "Processing double-click edit for printer: {:?}", + printer.get("printer_name") + ); + self.edit_dialog.open(&printer); + if let Some(id) = printer.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + + // Handle context menu actions + if let Some(printer) = ui + .ctx() + .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("printer_context_menu_edit"))) + { + log::info!( + "Processing context menu edit for printer: {:?}", + printer.get("printer_name") + ); + self.edit_dialog.open(&printer); + if let Some(id) = printer.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + + if let Some(printer) = ui + .ctx() + .data_mut(|d| d.remove_temp::<Value>(egui::Id::new("printer_context_menu_delete"))) + { + let name = printer + .get("printer_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + let id = printer.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); + log::info!("Processing context menu delete for printer: {}", name); + self.pending_delete_id = Some(id); + self.delete_dialog.open(name.to_string(), id.to_string()); + } + } +} + +impl Default for PrintersView { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone)] +enum DeferredAction { + DoubleClick(Value), + ContextEdit(Value), + ContextDelete(Value), + ContextClone(Value), +} + +// Temporary event handler that collects actions for later processing +struct TempPrintersEventHandler<'a> { + #[allow(dead_code)] + api_client: Option<&'a ApiClient>, + deferred_actions: &'a mut Vec<DeferredAction>, +} + +impl<'a> TableEventHandler<Value> for TempPrintersEventHandler<'a> { + fn on_double_click(&mut self, item: &Value, _row_index: usize) { + log::info!( + "Double-click detected on printer: {:?}", + item.get("printer_name") + ); + self.deferred_actions + .push(DeferredAction::DoubleClick(item.clone())); + } + + fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) { + if ui + .button(format!("{} Edit Printer", egui_phosphor::regular::PENCIL)) + .clicked() + { + log::info!( + "Context menu edit clicked for printer: {:?}", + item.get("printer_name") + ); + self.deferred_actions + .push(DeferredAction::ContextEdit(item.clone())); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Clone Printer", egui_phosphor::regular::COPY)) + .clicked() + { + log::info!( + "Context menu clone clicked for printer: {:?}", + item.get("printer_name") + ); + self.deferred_actions + .push(DeferredAction::ContextClone(item.clone())); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Delete Printer", egui_phosphor::regular::TRASH)) + .clicked() + { + log::info!( + "Context menu delete clicked for printer: {:?}", + item.get("printer_name") + ); + self.deferred_actions + .push(DeferredAction::ContextDelete(item.clone())); + ui.close(); + } + } + + fn on_selection_changed(&mut self, selected_indices: &[usize]) { + log::debug!("Printer selection changed: {:?}", selected_indices); + } +} diff --git a/src/ui/ribbon.rs b/src/ui/ribbon.rs new file mode 100644 index 0000000..0da355f --- /dev/null +++ b/src/ui/ribbon.rs @@ -0,0 +1,1056 @@ +use crate::core::components::filter_builder::FilterBuilder; +use eframe::egui; +use egui_phosphor::variants::regular as icons; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +pub struct RibbonUI { + pub active_tab: String, + pub search_texts: HashMap<String, String>, + pub checkboxes: HashMap<String, bool>, + pub number_fields: HashMap<String, u32>, + pub filter_builder: FilterBuilder, +} + +impl Default for RibbonUI { + fn default() -> Self { + let mut number_fields = HashMap::new(); + number_fields.insert("inventory_limit".to_string(), 100); + number_fields.insert("templates_limit".to_string(), 200); + + Self { + active_tab: "Dashboard".to_string(), + search_texts: HashMap::new(), + checkboxes: HashMap::new(), + number_fields, + filter_builder: FilterBuilder::new(), + } + } +} + +impl RibbonUI { + pub fn preferred_height(&self) -> f32 { + 135.0 + } + + pub fn get_active_view(&self) -> Option<String> { + Some(self.active_tab.clone()) + } + + pub fn show(&mut self, _ctx: &egui::Context, ui: &mut egui::Ui) -> Option<String> { + // Clear one-shot trigger flags from previous frame so clicks only fire once + // NOTE: inventory_filter_changed and templates_filter_changed are NOT cleared here - they're cleared by their views after processing + for key in [ + "item_lookup_trigger", + "inventory_limit_refresh_trigger", + // Inventory Actions + "inventory_action_add", + "inventory_action_delete", + "inventory_action_edit_easy", + "inventory_action_edit_adv", + "inventory_action_print_label", + // Inventory Quick Actions + "inventory_quick_inventarize_room", + "inventory_quick_add_multiple_from_template", + // Templates + "templates_limit_changed", + "templates_action_new", + "templates_action_edit", + "templates_action_delete", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.vertical(|ui| { + // Tab headers row + ui.horizontal(|ui| { + let tabs = vec![ + "Dashboard", + "Inventory", + "Categories", + "Zones", + "Borrowing", + "Audits", + "Suppliers", + "Issues", + "Printers", + "Label Templates", + "Item Templates", + ]; + for tab in &tabs { + if ui.selectable_label(self.active_tab == *tab, *tab).clicked() { + self.active_tab = tab.to_string(); + // Update filter columns based on the active tab + match *tab { + "Zones" => self.filter_builder.set_columns_for_context("zones"), + "Inventory" => self.filter_builder.set_columns_for_context("assets"), + _ => {} + } + } + } + }); + + ui.separator(); + + // Content area with fixed height I dont even know what this here fucking does tbh + let ribbon_height = 90.0; + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), ribbon_height), + egui::Layout::left_to_right(egui::Align::Min), + |ui| { + egui::ScrollArea::horizontal().show(ui, |ui| { + ui.horizontal(|ui| match self.active_tab.as_str() { + "Dashboard" => self.show_dashboard_tab(ui, ribbon_height), + "Inventory" => self.show_inventory_tab(ui, ribbon_height), + "Categories" => self.show_categories_tab(ui, ribbon_height), + "Zones" => self.show_zones_tab(ui, ribbon_height), + "Borrowing" => self.show_borrowing_tab(ui, ribbon_height), + "Audits" => self.show_audits_tab(ui, ribbon_height), + "Item Templates" => self.show_templates_tab(ui, ribbon_height), + "Suppliers" => self.show_suppliers_tab(ui, ribbon_height), + "Issues" => self.show_issues_tab(ui, ribbon_height), + "Printers" => self.show_printers_tab(ui, ribbon_height), + "Label Templates" => self.show_label_templates_tab(ui, ribbon_height), + _ => {} + }); + }); + }, + ); + }); + None + } + + /// Render a 2-row grid of actions with consistent column widths and set checkbox triggers by key. + fn render_actions_grid_with_keys( + &mut self, + ui: &mut egui::Ui, + grid_id: &str, + items: &[(String, &str)], + ) { + let rows: usize = 2; + if items.is_empty() { + return; + } + let cols: usize = (items.len() + rows - 1) / rows; + + let pad_x = ui.style().spacing.button_padding.x; + let row_height: f32 = 24.0; + let mut col_widths = vec![0.0f32; cols]; + for col in 0..cols { + for row in 0..rows { + let idx = col * rows + row; + if idx < items.len() { + let text = &items[idx].0; + let text_width = 8.0 * text.len() as f32; + col_widths[col] = col_widths[col].max(text_width + pad_x * 2.0); + } + } + } + + egui::Grid::new(grid_id) + .num_columns(cols) + .spacing(egui::vec2(8.0, 8.0)) + .show(ui, |ui| { + for row in 0..rows { + for col in 0..cols { + let idx = col * rows + row; + if idx < items.len() { + let (label, key) = &items[idx]; + let w = col_widths[col]; + if ui + .add_sized([w, row_height], egui::Button::new(label.clone())) + .clicked() + { + self.checkboxes.insert(key.to_string(), true); + } + } else { + ui.allocate_space(egui::vec2(col_widths[col], row_height)); + } + } + ui.end_row(); + } + }); + } + + fn show_dashboard_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("View"); + egui::ScrollArea::vertical() + .id_salt("dashboard_view_scroll") + .show(ui, |ui| if ui.button("Refresh").clicked() {}); + }); + }); + // Stats section removed for now + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Widgets"); + egui::ScrollArea::vertical() + .id_salt("dashboard_widgets_scroll") + .show(ui, |ui| { + ui.checkbox( + self.checkboxes + .entry("show_pie".to_string()) + .or_insert(true), + "Show Pie Chart", + ); + ui.checkbox( + self.checkboxes + .entry("show_timeline".to_string()) + .or_insert(true), + "Show Timeline", + ); + }); + }); + }); + } + + fn show_inventory_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + // 2x2 grid: Add, Edit, Remove, Print Label (Edit supports Alt for Advanced) + let labels = [ + &format!("{} {}", icons::PLUS, "Add Item"), + &format!("{} {}", icons::PENCIL, "Edit"), + &format!("{} {}", icons::TRASH, "Remove"), + &format!("{} {}", icons::PRINTER, "Print Label"), + ]; // 4 labels -> 2x2 grid + let rows: usize = 2; + let cols: usize = 2; + let row_height: f32 = 24.0; + let pad_x = ui.style().spacing.button_padding.x; + // Compute column widths based on text + let mut col_widths = vec![0.0f32; cols]; + for col in 0..cols { + for row in 0..rows { + let idx = col * rows + row; + let text = labels[idx]; + let text_width = 8.0 * text.len() as f32; + col_widths[col] = col_widths[col].max(text_width + pad_x * 2.0); + } + } + egui::Grid::new("inventory_actions_grid") + .num_columns(cols) + .spacing(egui::vec2(8.0, 8.0)) + .show(ui, |ui| { + for row in 0..rows { + for col in 0..cols { + let idx = col * rows + row; + let w = col_widths[col]; + let button = egui::Button::new(labels[idx]); + let clicked = ui.add_sized([w, row_height], button).clicked(); + if clicked { + // If user holds Alt while clicking Edit, trigger Advanced Edit instead of Easy + let alt_held = ui.input(|i| i.modifiers.alt); + match idx { + 0 => { + self.checkboxes + .insert("inventory_action_add".to_string(), true); + } + 1 => { + if alt_held { + self.checkboxes.insert( + "inventory_action_edit_adv".to_string(), + true, + ); + } else { + self.checkboxes.insert( + "inventory_action_edit_easy".to_string(), + true, + ); + } + } + 2 => { + self.checkboxes.insert( + "inventory_action_delete".to_string(), + true, + ); + } + 3 => { + self.checkboxes.insert( + "inventory_action_print_label".to_string(), + true, + ); + } + _ => {} + } + } + } + ui.end_row(); + } + }); + }); + }); + // Import/Export hidden for now (planned-only) + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.set_width(200.0); // Reduced width for compact design + ui.vertical(|ui| { + ui.label("Filter"); + egui::ScrollArea::vertical() + .id_salt("inventory_filter_scroll") + .show(ui, |ui| { + // Compact FilterBuilder with popup + let filter_changed = self.filter_builder.show_compact(ui); + if filter_changed { + // Set trigger for filter application + self.checkboxes + .insert("inventory_filter_changed".to_string(), true); + } + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("View"); + egui::ScrollArea::vertical() + .id_salt("inventory_view_scroll") + .show(ui, |ui| { + let old_show_retired = + self.checkboxes.get("show_retired").copied().unwrap_or(true); + let mut show_retired = old_show_retired; + ui.checkbox(&mut show_retired, "Show Retired"); + if show_retired != old_show_retired { + self.checkboxes + .insert("show_retired".to_string(), show_retired); + self.checkboxes + .insert("inventory_filter_changed".to_string(), true); + } + if ui.button("❓ Help").clicked() { + self.checkboxes + .insert("inventory_show_help".to_string(), true); + } + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Quick Actions"); + egui::ScrollArea::vertical() + .id_salt("inventory_quick_actions_scroll") + .show(ui, |ui| { + // Ensure both controls share the same visual width while stacking vertically + let pad_x = ui.style().spacing.button_padding.x; + let w1 = 8.0 * "Inventarize Room".len() as f32 + pad_x * 2.0; + let w2 = 8.0 * "Add from...".len() as f32 + pad_x * 2.0; + let w = w1.max(w2).max(130.0); + + if ui + .add_sized([w, 24.0], egui::Button::new("Inventarize Room")) + .clicked() + { + self.checkboxes + .insert("inventory_quick_inventarize_room".to_string(), true); + } + + // Use a fixed-width ComboBox as a dropdown to ensure equal width + egui::ComboBox::from_id_salt("inventory_add_from_combo") + .width(w) + .selected_text("Add from...") + .show_ui(ui, |ui| { + if ui.selectable_label(false, "Add from Template").clicked() { + self.checkboxes.insert( + "inventory_add_from_template_single".to_string(), + true, + ); + } + if ui + .selectable_label(false, "Add Multiple from Template") + .clicked() + { + self.checkboxes.insert( + "inventory_add_from_template_multiple".to_string(), + true, + ); + } + if ui + .selectable_label(false, "Add using Another Item") + .clicked() + { + self.checkboxes + .insert("inventory_add_from_item_single".to_string(), true); + } + if ui + .selectable_label(false, "Add Multiple from Another Item") + .clicked() + { + self.checkboxes.insert( + "inventory_add_from_item_multiple".to_string(), + true, + ); + } + }); + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Query"); + egui::ScrollArea::vertical() + .id_salt("inventory_query_scroll") + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Limit:"); + let limit = self + .number_fields + .entry("inventory_limit".to_string()) + .or_insert(100); + let mut limit_str = limit.to_string(); + let response = ui.add_sized( + [60.0, 20.0], + egui::TextEdit::singleline(&mut limit_str), + ); + + // Trigger refresh on Enter or when value changes + let enter_pressed = response.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if response.changed() || enter_pressed { + if let Ok(val) = limit_str.parse::<u32>() { + *limit = val; + if enter_pressed { + // Set trigger for inventory refresh + let trigger = self + .checkboxes + .entry("inventory_limit_refresh_trigger".to_string()) + .or_insert(false); + *trigger = true; + } + } + } + }); + + let no_limit_changed = ui + .checkbox( + self.checkboxes + .entry("inventory_no_limit".to_string()) + .or_insert(false), + "Maximum", + ) + .changed(); + if no_limit_changed { + // Trigger refresh when no limit checkbox changes + let trigger = self + .checkboxes + .entry("inventory_limit_refresh_trigger".to_string()) + .or_insert(false); + *trigger = true; + } + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Lookup"); + egui::ScrollArea::vertical() + .id_salt("inventory_lookup_scroll") + .show(ui, |ui| { + ui.label("Item Lookup:"); + ui.horizontal(|ui| { + let lookup_text = self + .search_texts + .entry("item_lookup".to_string()) + .or_insert_with(String::new); + let response = ui.add_sized( + [120.0, 30.0], + egui::TextEdit::singleline(lookup_text).hint_text("Tag or ID"), + ); + + // Trigger search on Enter key + let enter_pressed = response.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)); + + if ui.button("Search").clicked() || enter_pressed { + // Set trigger flag for inventory view to detect + let trigger = self + .checkboxes + .entry("item_lookup_trigger".to_string()) + .or_insert(false); + *trigger = true; + } + }); + }); + }); + }); + } + + fn show_categories_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + // Clear one-shot action triggers from previous frame + for key in [ + "categories_refresh", + "categories_add", + "categories_edit", + "categories_delete", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + // Match Inventory layout: [Add, Edit, Delete, Refresh] (Refresh where Inventory has Print) + let items = vec![ + ( + format!("{} {}", icons::PLUS, "Add Category"), + "categories_add", + ), + ( + format!("{} {}", icons::PENCIL, "Edit Category"), + "categories_edit", + ), + ( + format!("{} {}", icons::TRASH, "Delete Category"), + "categories_delete", + ), + ( + format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"), + "categories_refresh", + ), + ]; + self.render_actions_grid_with_keys(ui, "categories_actions_grid", &items); + }); + }); + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Search"); + egui::ScrollArea::vertical() + .id_salt("categories_filter_scroll") + .show(ui, |ui| { + let search_text = self + .search_texts + .entry("categories_search".to_string()) + .or_insert_with(String::new); + ui.text_edit_singleline(search_text); + }); + }); + }); + } + + fn show_zones_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + // Clear one-shot action triggers from previous frame + for key in [ + "zones_action_add", + "zones_action_edit", + "zones_action_remove", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ( + format!("{} {}", icons::PLUS, "Add Zone"), + "zones_action_add", + ), + ( + format!("{} {}", icons::PENCIL, "Edit Zone"), + "zones_action_edit", + ), + ]; + self.render_actions_grid_with_keys(ui, "zones_actions_grid", &items); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.set_width(200.0); // Reduced width for compact design + ui.vertical(|ui| { + ui.label("Filter"); + egui::ScrollArea::vertical() + .id_salt("zones_filter_scroll") + .show(ui, |ui| { + // Compact FilterBuilder with popup (same as Inventory) + let filter_changed = self.filter_builder.show_compact(ui); + if filter_changed { + // Set trigger for filter application + self.checkboxes + .insert("zones_filter_changed".to_string(), true); + } + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("View"); + egui::ScrollArea::vertical() + .id_salt("zones_view_scroll") + .show(ui, |ui| { + // Use new key `zones_show_empty` but mirror to legacy key for compatibility + let show_empty = self + .checkboxes + .get("zones_show_empty") + .copied() + .unwrap_or(true); + let mut local = show_empty; + if ui.checkbox(&mut local, "Show Empty").changed() { + self.checkboxes + .insert("zones_show_empty".to_string(), local); + self.checkboxes + .insert("show_empty_zones".to_string(), local); + } + ui.checkbox( + self.checkboxes + .entry("zones_show_items".to_string()) + .or_insert(true), + "Show Items in Zones", + ); + }); + }); + }); + } + + fn show_borrowing_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + // Clear one-shot action triggers from previous frame + for key in [ + "borrowing_action_checkout", + "borrowing_action_return", + "borrowing_action_register", + "borrowing_action_refresh", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ( + format!("{} {}", icons::ARROW_LEFT, "Check Out"), + "borrowing_action_checkout", + ), + ( + format!("{} {}", icons::ARROW_RIGHT, "Return"), + "borrowing_action_return", + ), + ( + format!("{} {}", icons::PENCIL, "Register"), + "borrowing_action_register", + ), + ( + format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"), + "borrowing_action_refresh", + ), + ]; + self.render_actions_grid_with_keys(ui, "borrowing_actions_grid", &items); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.set_width(200.0); + ui.vertical(|ui| { + ui.label("Filter"); + egui::ScrollArea::vertical() + .id_salt("borrowing_filter_scroll") + .show(ui, |ui| { + // Compact FilterBuilder with popup + let filter_changed = self.filter_builder.show_compact(ui); + if filter_changed { + // Set trigger for filter application + self.checkboxes + .insert("borrowing_filter_changed".to_string(), true); + } + }); + }); + }); + } + + fn show_audits_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ("New Audit".to_string(), "audits_action_new"), + ("View Audit".to_string(), "audits_action_view"), + ("Export Report".to_string(), "audits_action_export"), + ]; + self.render_actions_grid_with_keys(ui, "audits_actions_grid", &items); + }); + }); + } + + fn show_templates_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + // Clear one-shot action triggers from previous frame + for key in [ + "templates_action_new", + "templates_action_edit", + "templates_action_delete", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ( + format!("{} {}", icons::PLUS, "Add Template"), + "templates_action_new", + ), + ( + format!("{} {}", icons::PENCIL, "Edit Template"), + "templates_action_edit", + ), + ]; + self.render_actions_grid_with_keys(ui, "templates_actions_grid", &items); + }); + }); + // Import/Export removed for now + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.set_width(200.0); // Reduced width for compact design + ui.vertical(|ui| { + ui.label("Filter"); + egui::ScrollArea::vertical() + .id_salt("templates_filter_scroll") + .show(ui, |ui| { + // Compact FilterBuilder with popup + let filter_changed = self.filter_builder.show_compact(ui); + if filter_changed { + // Set trigger for filter application + self.checkboxes + .insert("templates_filter_changed".to_string(), true); + } + }); + }); + }); + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Query"); + egui::ScrollArea::vertical() + .id_salt("templates_query_scroll") + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Limit:"); + let limit = self + .number_fields + .entry("templates_limit".to_string()) + .or_insert(200); + let mut limit_str = limit.to_string(); + let response = ui.add_sized( + [60.0, 20.0], + egui::TextEdit::singleline(&mut limit_str), + ); + + // Trigger refresh on Enter or when value changes + let enter_pressed = response.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if response.changed() || enter_pressed { + if let Ok(val) = limit_str.parse::<u32>() { + *limit = val; + self.checkboxes + .insert("templates_limit_changed".to_string(), true); + } + } + }); + }); + }); + }); + } + + fn show_issues_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ("Report Issue".to_string(), "issues_action_report"), + ("View Issue".to_string(), "issues_action_view"), + ("Resolve Issue".to_string(), "issues_action_resolve"), + ]; + self.render_actions_grid_with_keys(ui, "issues_actions_grid", &items); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Filter"); + egui::ScrollArea::vertical() + .id_salt("issues_filter_scroll") + .show(ui, |ui| { + let search_text = self + .search_texts + .entry("issue_search".to_string()) + .or_insert_with(String::new); + ui.text_edit_singleline(search_text); + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("View"); + egui::ScrollArea::vertical() + .id_salt("issues_view_scroll") + .show(ui, |ui| { + ui.checkbox( + self.checkboxes + .entry("show_resolved".to_string()) + .or_insert(false), + "Show Resolved", + ); + ui.checkbox( + self.checkboxes + .entry("show_high_priority".to_string()) + .or_insert(true), + "High Priority Only", + ); + }); + }); + }); + } + + fn show_suppliers_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + for key in [ + "suppliers_action_new", + "suppliers_action_edit", + "suppliers_action_delete", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ( + format!("{} {}", icons::PLUS, "New Supplier"), + "suppliers_action_new", + ), + ( + format!("{} {}", icons::PENCIL, "Edit Supplier"), + "suppliers_action_edit", + ), + ]; + self.render_actions_grid_with_keys(ui, "suppliers_actions_grid", &items); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Search"); + egui::ScrollArea::vertical() + .id_salt("suppliers_filter_scroll") + .show(ui, |ui| { + let search_text = self + .search_texts + .entry("supplier_search".to_string()) + .or_insert_with(String::new); + ui.text_edit_singleline(search_text); + }); + }); + }); + } + + pub fn show_printers_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + // Clear one-shot action triggers from previous frame + for key in [ + "printers_action_add", + "printers_action_refresh", + "printers_view_print_history", + "printers_default_changed", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ( + format!("{} {}", icons::PLUS, "Add Printer"), + "printers_action_add", + ), + ( + format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"), + "printers_action_refresh", + ), + ]; + self.render_actions_grid_with_keys(ui, "printers_actions_grid", &items); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Settings"); + egui::ScrollArea::vertical() + .id_salt("printers_settings_scroll") + .show(ui, |ui| { + ui.label("Default Printer:"); + + // Get printer data injected by PrintersView + let printers_json = self + .search_texts + .get("_printers_list") + .cloned() + .unwrap_or_default(); + let current_default_str = self + .search_texts + .get("_printers_current_default") + .cloned() + .unwrap_or_default(); + let current_default = if current_default_str.is_empty() { + None + } else { + current_default_str.parse::<i64>().ok() + }; + + if printers_json.is_empty() { + ui.label("(Loading...)"); + } else { + // Parse printer list + let printers: Vec<serde_json::Value> = + serde_json::from_str(&printers_json).unwrap_or_default(); + + let current_name = if let Some(id) = current_default { + printers + .iter() + .find(|p| p.get("id").and_then(|v| v.as_i64()) == Some(id)) + .and_then(|p| p.get("printer_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string() + } else { + "None".to_string() + }; + + egui::ComboBox::from_id_salt("ribbon_default_printer_selector") + .selected_text(¤t_name) + .show_ui(ui, |ui| { + // None option + if ui + .selectable_label(current_default.is_none(), "None") + .clicked() + { + self.search_texts.insert( + "printers_default_id".to_string(), + "".to_string(), + ); + self.checkboxes + .insert("printers_default_changed".to_string(), true); + } + + // Printer options + for printer in &printers { + if let (Some(id), Some(name)) = ( + printer.get("id").and_then(|v| v.as_i64()), + printer.get("printer_name").and_then(|v| v.as_str()), + ) { + let is_selected = current_default == Some(id); + if ui.selectable_label(is_selected, name).clicked() { + self.search_texts.insert( + "printers_default_id".to_string(), + id.to_string(), + ); + self.checkboxes.insert( + "printers_default_changed".to_string(), + true, + ); + } + } + } + }); + } + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("View"); + egui::ScrollArea::vertical() + .id_salt("printers_view_scroll") + .show(ui, |ui| { + if ui + .button(format!( + "{} {}", + icons::ARROWS_CLOCKWISE, + "View Print History" + )) + .clicked() + { + self.checkboxes + .insert("printers_view_print_history".to_string(), true); + } + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Search"); + egui::ScrollArea::vertical() + .id_salt("printers_search_scroll") + .show(ui, |ui| { + let search_text = self + .search_texts + .entry("printers_search".to_string()) + .or_insert_with(String::new); + ui.text_edit_singleline(search_text); + }); + }); + }); + } + + fn show_label_templates_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + // Clear one-shot action triggers from previous frame + for key in ["labels_action_add", "labels_action_refresh"] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ( + format!("{} {}", icons::PLUS, "Add Template"), + "labels_action_add", + ), + ( + format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"), + "labels_action_refresh", + ), + ]; + self.render_actions_grid_with_keys(ui, "labels_actions_grid", &items); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Search"); + egui::ScrollArea::vertical() + .id_salt("labels_search_scroll") + .show(ui, |ui| { + let search_text = self + .search_texts + .entry("labels_search".to_string()) + .or_insert_with(String::new); + ui.text_edit_singleline(search_text); + }); + }); + }); + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RibbonConfig {} diff --git a/src/ui/suppliers.rs b/src/ui/suppliers.rs new file mode 100644 index 0000000..ce7679f --- /dev/null +++ b/src/ui/suppliers.rs @@ -0,0 +1,802 @@ +use eframe::egui; +use std::collections::HashSet; + +use crate::api::ApiClient; +use crate::core::components::form_builder::FormBuilder; +use crate::core::components::interactions::ConfirmDialog; +use crate::core::counters::count_entities; +use crate::core::tables::get_suppliers; +use crate::core::{EditorField, FieldType}; +use crate::ui::ribbon::RibbonUI; + +#[derive(Clone)] +struct ColumnConfig { + name: String, + field: String, + visible: bool, +} + +pub struct SuppliersView { + rows: Vec<serde_json::Value>, + display_rows: Vec<serde_json::Value>, + is_loading: bool, + last_error: Option<String>, + init_loaded: bool, + // Columns & selector + columns: Vec<ColumnConfig>, + show_column_panel: bool, + // Selection & interactions + selected_row: Option<usize>, + last_click_time: Option<std::time::Instant>, + last_click_row: Option<usize>, + selected_rows: HashSet<usize>, + selection_anchor: Option<usize>, + // Dialogs + delete_dialog: ConfirmDialog, + edit_dialog: FormBuilder, + add_dialog: FormBuilder, + // Track ids for operations + edit_current_id: Option<i64>, + delete_current_id: Option<i64>, +} + +impl SuppliersView { + pub fn new() -> Self { + let columns = vec![ + ColumnConfig { + name: "Name".into(), + field: "name".into(), + visible: true, + }, + ColumnConfig { + name: "Contact".into(), + field: "contact".into(), + visible: true, + }, + ColumnConfig { + name: "Email".into(), + field: "email".into(), + visible: true, + }, + ColumnConfig { + name: "Phone".into(), + field: "phone".into(), + visible: true, + }, + ColumnConfig { + name: "Website".into(), + field: "website".into(), + visible: true, + }, + ColumnConfig { + name: "Items".into(), + field: "items_count".into(), + visible: true, + }, + // Hidden by default + ColumnConfig { + name: "Notes".into(), + field: "notes".into(), + visible: false, + }, + ColumnConfig { + name: "Created".into(), + field: "created_at".into(), + visible: false, + }, + ColumnConfig { + name: "ID".into(), + field: "id".into(), + visible: false, + }, + ]; + Self { + rows: vec![], + display_rows: vec![], + is_loading: false, + last_error: None, + init_loaded: false, + columns, + show_column_panel: false, + selected_row: None, + last_click_time: None, + last_click_row: None, + selected_rows: HashSet::new(), + selection_anchor: None, + delete_dialog: ConfirmDialog::new( + "Delete Supplier", + "Are you sure you want to delete this supplier?", + ), + edit_dialog: FormBuilder::new( + "Edit Supplier", + vec![ + EditorField { + name: "name".into(), + label: "Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "contact".into(), + label: "Contact".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "email".into(), + label: "Email".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "phone".into(), + label: "Phone".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "website".into(), + label: "Website".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "notes".into(), + label: "Notes".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ], + ), + add_dialog: FormBuilder::new( + "Add Supplier", + vec![ + EditorField { + name: "name".into(), + label: "Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "contact".into(), + label: "Contact".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "email".into(), + label: "Email".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "phone".into(), + label: "Phone".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "website".into(), + label: "Website".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "notes".into(), + label: "Notes".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ], + ), + edit_current_id: None, + delete_current_id: None, + } + } + + fn load(&mut self, api: &ApiClient) { + if self.is_loading { + return; + } + self.is_loading = true; + self.last_error = None; + match get_suppliers(api, Some(200)) { + Ok(mut list) => { + // Compute items_count per supplier + for row in &mut list { + if let Some(id) = row.get("id").and_then(|v| v.as_i64()) { + let where_clause = serde_json::json!({"supplier_id": id}); + let count = count_entities(api, "assets", Some(where_clause)).unwrap_or(0); + row.as_object_mut().map(|o| { + o.insert("items_count".into(), serde_json::json!(count)); + }); + } + } + self.rows = list; + self.display_rows = self.rows.clone(); + } + Err(e) => self.last_error = Some(e.to_string()), + } + self.is_loading = false; + self.init_loaded = true; + } + + 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(); + + // Apply simple search from ribbon (by name/code/contact/email/phone/website) + if let Some(ribbon) = ribbon.as_ref() { + let term = ribbon + .search_texts + .get("supplier_search") + .cloned() + .unwrap_or_default(); + let tl = term.to_lowercase(); + if tl.is_empty() { + self.display_rows = self.rows.clone(); + } else { + self.display_rows = self + .rows + .iter() + .filter(|row| { + let fields = ["name", "contact", "email", "phone", "website"]; + fields.iter().any(|f| { + row.get(*f) + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase().contains(&tl)) + .unwrap_or(false) + }) + }) + .cloned() + .collect(); + } + } + + if let Some(ribbon) = ribbon.as_ref() { + // Handle ribbon actions before rendering + if ribbon + .checkboxes + .get("suppliers_action_new") + .copied() + .unwrap_or(false) + { + self.add_dialog.open_new(None); + flags_to_clear.push("suppliers_action_new".to_string()); + } + + if ribbon + .checkboxes + .get("suppliers_action_edit") + .copied() + .unwrap_or(false) + { + if let Some(selected) = self.first_selected_supplier() { + self.open_editor_with(&selected); + } else { + log::warn!("Ribbon edit triggered but no supplier selected"); + } + flags_to_clear.push("suppliers_action_edit".to_string()); + } + + if ribbon + .checkboxes + .get("suppliers_action_delete") + .copied() + .unwrap_or(false) + { + if let Some((name, id)) = self.first_selected_supplier_id_name() { + self.delete_current_id = Some(id); + self.delete_dialog.open(name, id.to_string()); + } else { + log::warn!("Ribbon delete triggered but no supplier selected"); + } + flags_to_clear.push("suppliers_action_delete".to_string()); + } + } + + ui.horizontal(|ui| { + ui.heading("Suppliers"); + if self.is_loading { + ui.spinner(); + ui.label("Loading..."); + } + if let Some(err) = &self.last_error { + ui.colored_label(egui::Color32::RED, err); + if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + } else if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + + ui.separator(); + let button_text = if self.show_column_panel { + "Hide Column Selector" + } else { + "Show Column Selector" + }; + if ui.button(button_text).clicked() { + self.show_column_panel = !self.show_column_panel; + } + }); + ui.separator(); + + if !self.init_loaded { + if let Some(api) = api_client { + self.load(api); + } + } + + // Column selector window + if self.show_column_panel { + let ctx = ui.ctx(); + let screen_rect = ctx.input(|i| { + i.viewport().inner_rect.unwrap_or(egui::Rect::from_min_max( + egui::pos2(0.0, 0.0), + egui::pos2(1920.0, 1080.0), + )) + }); + let max_w = (screen_rect.width() - 20.0).max(220.0); + let max_h = (screen_rect.height() - 100.0).max(200.0); + + egui::Window::new("Column Selector") + .collapsible(false) + .resizable(true) + .default_width(260.0) + .default_height(360.0) + .anchor(egui::Align2::RIGHT_TOP, [-10.0, 90.0]) + .open(&mut self.show_column_panel) + .min_size(egui::vec2(220.0, 200.0)) + .max_size(egui::vec2(max_w, max_h)) + .frame(egui::Frame { + fill: egui::Color32::from_rgb(30, 30, 30), + stroke: egui::Stroke::new(1.0, egui::Color32::from_gray(60)), + inner_margin: egui::Margin::from(10.0), + outer_margin: egui::Margin::ZERO, + corner_radius: 6.0.into(), + shadow: egui::epaint::Shadow::NONE, + }) + .show(ctx, |ui| { + ui.heading("Columns"); + ui.separator(); + ui.add_space(8.0); + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + for column in &mut self.columns { + ui.checkbox(&mut column.visible, &column.name); + } + }); + ui.add_space(8.0); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Show All").clicked() { + for col in &mut self.columns { + col.visible = true; + } + } + if ui.button("Hide All").clicked() { + for col in &mut self.columns { + col.visible = false; + } + } + }); + }); + } + + // compute visible columns and render table + let visible_columns: Vec<ColumnConfig> = + self.columns.iter().filter(|c| c.visible).cloned().collect(); + self.render_table(ui, &visible_columns); + + // Process selection and dialog events + let ctx = ui.ctx(); + // selection handled inline via checkbox/row clicks + if let Some(row_idx) = + ctx.data_mut(|d| d.remove_temp::<usize>(egui::Id::new("sup_double_click_idx"))) + { + self.selected_row = Some(row_idx); + self.last_click_row = None; + self.last_click_time = None; + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::<serde_json::Value>(egui::Id::new("sup_double_click_edit")) + }) { + self.open_editor_with(&item); + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::<serde_json::Value>(egui::Id::new("sup_context_menu_edit")) + }) { + self.open_editor_with(&item); + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::<serde_json::Value>(egui::Id::new("sup_context_menu_clone")) + }) { + // Prepare cloned payload: clear id, suffix name, keep other fields + let cloned = crate::core::components::prepare_cloned_value( + &item, + &["id"], + Some("name"), + Some(""), + ); + if let Some(obj) = cloned.as_object() { + self.add_dialog.open_new(Some(obj)); + } + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::<serde_json::Value>(egui::Id::new("sup_context_menu_delete")) + }) { + let name = item + .get("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.delete_current_id = Some(id); + self.delete_dialog.open(name, id.to_string()); + ctx.request_repaint(); + } + + // Handle delete confirmation dialog + if let Some(confirmed) = self.delete_dialog.show_dialog(ctx) { + if confirmed { + if let (Some(api), Some(id)) = (api_client, self.delete_current_id) { + let where_clause = serde_json::json!({"id": id}); + match api.delete("suppliers", where_clause) { + Ok(resp) if resp.success => { + self.load(api); + } + Ok(resp) => { + self.last_error = Some(format!("Delete failed: {:?}", resp.error)); + } + Err(e) => { + self.last_error = Some(format!("Delete error: {}", e)); + } + } + } + } + } + + // Handle edit dialog save + if let Some(result) = self.edit_dialog.show_editor(ctx) { + if let Some(updated) = result { + if let (Some(api), Some(id)) = (api_client, self.edit_current_id) { + let mut updated = updated; + strip_editor_metadata(&mut updated); + let values = serde_json::Value::Object(updated); + let where_clause = serde_json::json!({"id": id}); + match api.update("suppliers", values, where_clause) { + Ok(resp) if resp.success => { + self.load(api); + } + Ok(resp) => { + self.last_error = Some(format!("Update failed: {:?}", resp.error)); + } + Err(e) => { + self.last_error = Some(format!("Update error: {}", e)); + } + } + } + } + } + + // Handle add dialog (clone -> insert) + self.handle_add_dialog(ui.ctx(), api_client); + + flags_to_clear + } + + fn render_table(&mut self, ui: &mut egui::Ui, visible_columns: &Vec<ColumnConfig>) { + use egui_extras::{Column, TableBuilder}; + let mut table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)); + // Add selection checkbox column first + table = table.column(Column::initial(28.0)); + for _ in 0..visible_columns.len() { + table = table.column(Column::remainder()); + } + table + .header(22.0, |mut header| { + // Select-all checkbox + header.col(|ui| { + let all_selected = self + .display_rows + .iter() + .enumerate() + .all(|(i, _)| self.selected_rows.contains(&i)); + let mut chk = all_selected; + if ui + .checkbox(&mut chk, "") + .on_hover_text("Select All") + .clicked() + { + if chk { + self.selected_rows = (0..self.display_rows.len()).collect(); + } else { + self.selected_rows.clear(); + } + } + }); + for col in visible_columns { + header.col(|ui| { + ui.strong(&col.name); + }); + } + }) + .body(|mut body| { + for (idx, r) in self.display_rows.iter().enumerate() { + let r_clone = r.clone(); + let is_selected = self.selected_rows.contains(&idx); + body.row(20.0, |mut row| { + if is_selected { + row.set_selected(true); + } + // Checkbox column + row.col(|ui| { + let mut checked = self.selected_rows.contains(&idx); + let resp = ui.checkbox(&mut checked, ""); + if resp.changed() { + let mods = ui.input(|i| i.modifiers); + if mods.shift { + let anchor = self.selection_anchor.unwrap_or(idx); + let (a, b) = if anchor <= idx { + (anchor, idx) + } else { + (idx, anchor) + }; + for i in a..=b { + if checked { + self.selected_rows.insert(i); + } else { + self.selected_rows.remove(&i); + } + } + } else if mods.command || mods.ctrl { + if checked { + self.selected_rows.insert(idx); + } else { + self.selected_rows.remove(&idx); + } + self.selection_anchor = Some(idx); + } else { + self.selected_rows.clear(); + if checked { + self.selected_rows.insert(idx); + self.selection_anchor = Some(idx); + } + } + } + }); + let mut combined_cell_response: Option<egui::Response> = None; + for col in visible_columns { + row.col(|ui| { + let resp = render_supplier_cell(ui, &r_clone, &col.field); + combined_cell_response = + Some(match combined_cell_response.take() { + Some(prev) => prev.union(resp), + None => resp, + }); + }); + } + let mut row_resp = row.response(); + if let Some(cell_r) = combined_cell_response { + row_resp = row_resp.union(cell_r); + } + if row_resp.clicked() { + let now = std::time::Instant::now(); + let is_double_click = if let (Some(last_time), Some(last_row)) = + (self.last_click_time, self.last_click_row) + { + last_row == idx && now.duration_since(last_time).as_millis() < 500 + } else { + false + }; + if is_double_click { + row_resp.ctx.data_mut(|d| { + d.insert_temp(egui::Id::new("sup_double_click_idx"), idx); + d.insert_temp( + egui::Id::new("sup_double_click_edit"), + r_clone.clone(), + ); + }); + } else { + // Multi-select on row click + let mods = row_resp.ctx.input(|i| i.modifiers); + if mods.shift { + let anchor = self.selection_anchor.unwrap_or(idx); + let (a, b) = if anchor <= idx { + (anchor, idx) + } else { + (idx, anchor) + }; + for i in a..=b { + self.selected_rows.insert(i); + } + } else if mods.command || mods.ctrl { + if self.selected_rows.contains(&idx) { + self.selected_rows.remove(&idx); + } else { + self.selected_rows.insert(idx); + } + self.selection_anchor = Some(idx); + } else { + self.selected_rows.clear(); + self.selected_rows.insert(idx); + self.selection_anchor = Some(idx); + } + self.last_click_time = Some(now); + self.last_click_row = Some(idx); + } + } + row_resp.context_menu(|ui| { + if ui + .button(format!("{} Edit", egui_phosphor::regular::PENCIL)) + .clicked() + { + ui.ctx().data_mut(|d| { + d.insert_temp( + egui::Id::new("sup_context_menu_edit"), + r_clone.clone(), + ) + }); + ui.close(); + } + if ui + .button(format!("{} Clone", egui_phosphor::regular::COPY)) + .clicked() + { + ui.ctx().data_mut(|d| { + d.insert_temp( + egui::Id::new("sup_context_menu_clone"), + r_clone.clone(), + ) + }); + ui.close(); + } + ui.separator(); + if ui + .button(format!("{} Delete", egui_phosphor::regular::TRASH)) + .clicked() + { + ui.ctx().data_mut(|d| { + d.insert_temp( + egui::Id::new("sup_context_menu_delete"), + r_clone.clone(), + ) + }); + ui.close(); + } + }); + }); + } + }); + } + + fn open_editor_with(&mut self, item: &serde_json::Value) { + self.edit_current_id = item.get("id").and_then(|v| v.as_i64()); + self.edit_dialog.open(item); + } + + fn first_selected_supplier(&self) -> Option<serde_json::Value> { + self.first_selected_index() + .and_then(|idx| self.display_rows.get(idx).cloned()) + } + + fn first_selected_supplier_id_name(&self) -> Option<(String, i64)> { + self.first_selected_index().and_then(|idx| { + self.display_rows.get(idx).and_then(|row| { + let name = row + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let id = row.get("id").and_then(|v| v.as_i64())?; + Some((name, id)) + }) + }) + } + + fn first_selected_index(&self) -> Option<usize> { + if self.selected_rows.is_empty() { + None + } else { + self.selected_rows.iter().copied().min() + } + } +} + +fn label_trunc(ui: &mut egui::Ui, text: &str, max: usize) -> egui::Response { + if text.len() > max { + let short = format!("{}…", &text[..max]); + ui.label(short).on_hover_ui(|ui| { + ui.label(text); + }) + } else { + ui.label(text) + } +} + +fn render_supplier_cell(ui: &mut egui::Ui, row: &serde_json::Value, field: &str) -> egui::Response { + let t = |k: &str| row.get(k).and_then(|v| v.as_str()).unwrap_or(""); + match field { + "id" => ui.label( + row.get("id") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string(), + ), + "name" => ui.label(t("name")), + "contact" => ui.label(t("contact")), + "email" => ui.label(t("email")), + "phone" => ui.label(t("phone")), + "website" => ui.label(t("website")), + "notes" => label_trunc(ui, t("notes"), 60), + "created_at" => ui.label(t("created_at")), + "items_count" => ui.label( + row.get("items_count") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string(), + ), + other => ui.label(format!("{}", other)), + } +} + +// Handle add dialog save and insert +impl SuppliersView { + fn handle_add_dialog(&mut self, ctx: &egui::Context, api_client: Option<&ApiClient>) { + if let Some(Some(mut new_data)) = self.add_dialog.show_editor(ctx) { + if let Some(api) = api_client { + strip_editor_metadata(&mut new_data); + match api.insert("suppliers", serde_json::Value::Object(new_data)) { + Ok(resp) if resp.success => { + self.load(api); + } + Ok(resp) => { + self.last_error = Some(format!("Insert failed: {:?}", resp.error)); + } + Err(e) => { + self.last_error = Some(format!("Insert error: {}", e)); + } + } + } + } + } +} + +fn strip_editor_metadata(map: &mut serde_json::Map<String, serde_json::Value>) { + let meta_keys: Vec<String> = map + .keys() + .filter(|k| k.starts_with("__editor_")) + .cloned() + .collect(); + for key in meta_keys { + map.remove(&key); + } +} diff --git a/src/ui/templates.rs b/src/ui/templates.rs new file mode 100644 index 0000000..9fd46a5 --- /dev/null +++ b/src/ui/templates.rs @@ -0,0 +1,1113 @@ +use crate::api::ApiClient; +use crate::core::asset_fields::AssetDropdownOptions; +use crate::core::components::form_builder::FormBuilder; +use crate::core::tables::get_templates; +use crate::core::{ColumnConfig, LoadingState, TableRenderer}; +use crate::core::{EditorField, FieldType}; +use eframe::egui; + +pub struct TemplatesView { + templates: Vec<serde_json::Value>, + loading_state: LoadingState, + table_renderer: TableRenderer, + show_column_panel: bool, + edit_dialog: FormBuilder, + pending_delete_ids: Vec<i64>, +} + +impl TemplatesView { + pub fn new() -> Self { + let columns = vec![ + ColumnConfig::new("ID", "id").with_width(60.0).hidden(), + ColumnConfig::new("Template Code", "template_code").with_width(120.0), + ColumnConfig::new("Name", "name").with_width(200.0), + ColumnConfig::new("Asset Type", "asset_type").with_width(80.0), + ColumnConfig::new("Description", "description").with_width(250.0), + ColumnConfig::new("Asset Tag Generation String", "asset_tag_generation_string") + .with_width(200.0), + ColumnConfig::new("Label Template", "label_template_name") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Label Template ID", "label_template_id") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Audit Task", "audit_task_name") + .with_width(140.0) + .hidden(), + ColumnConfig::new("Audit Task ID", "audit_task_id") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Category", "category_name").with_width(120.0), + ColumnConfig::new("Manufacturer", "manufacturer") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Model", "model") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Zone", "zone_name") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Zone Code", "zone_code") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Zone+", "zone_plus") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Zone Note", "zone_note") + .with_width(150.0) + .hidden(), + ColumnConfig::new("Status", "status") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Price", "price") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Purchase Date", "purchase_date") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Purchase Now?", "purchase_date_now") + .with_width(110.0) + .hidden(), + ColumnConfig::new("Warranty Until", "warranty_until") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Warranty Auto?", "warranty_auto") + .with_width(110.0) + .hidden(), + ColumnConfig::new("Warranty Amount", "warranty_auto_amount") + .with_width(110.0) + .hidden(), + ColumnConfig::new("Warranty Unit", "warranty_auto_unit") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Expiry Date", "expiry_date") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Expiry Auto?", "expiry_auto") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Expiry Amount", "expiry_auto_amount") + .with_width(110.0) + .hidden(), + ColumnConfig::new("Expiry Unit", "expiry_auto_unit") + .with_width(90.0) + .hidden(), + ColumnConfig::new("Qty Total", "quantity_total") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Qty Used", "quantity_used") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Supplier", "supplier_name") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Lendable", "lendable") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Lending Status", "lending_status") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Min Role", "minimum_role_for_lending") + .with_width(80.0) + .hidden(), + ColumnConfig::new("No Scan", "no_scan") + .with_width(70.0) + .hidden(), + ColumnConfig::new("Notes", "notes") + .with_width(200.0) + .hidden(), + ColumnConfig::new("Active", "active") + .with_width(70.0) + .hidden(), + ColumnConfig::new("Created Date", "created_at") + .with_width(140.0) + .hidden(), + ]; + Self { + templates: Vec::new(), + loading_state: LoadingState::new(), + table_renderer: TableRenderer::new() + .with_columns(columns) + .with_default_sort("created_at", false), + show_column_panel: false, + edit_dialog: FormBuilder::new("Template Editor", vec![]), + pending_delete_ids: Vec::new(), + } + } + + fn prepare_template_edit_fields(&mut self, api_client: &ApiClient) { + let options = AssetDropdownOptions::new(api_client); + + let fields: Vec<EditorField> = vec![ + // Basic identifiers + EditorField { + name: "template_code".into(), + label: "Template Code".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "name".into(), + label: "Template Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + // Asset tag generation + EditorField { + name: "asset_tag_generation_string".into(), + label: "Asset Tag Generation String".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + // Type / status + EditorField { + name: "asset_type".into(), + label: "Asset Type".into(), + field_type: FieldType::Dropdown({ + let mut asset_type_opts = vec![("".to_string(), "-- None --".to_string())]; + asset_type_opts.extend(options.asset_types.clone()); + asset_type_opts + }), + required: false, + read_only: false, + }, + EditorField { + name: "status".into(), + label: "Default Status".into(), + field_type: FieldType::Dropdown({ + let mut status_opts = vec![("".to_string(), "-- None --".to_string())]; + status_opts.extend(options.status_options.clone()); + status_opts + }), + required: false, + read_only: false, + }, + // Zone and zone-plus + EditorField { + name: "zone_id".into(), + label: "Default Zone".into(), + field_type: FieldType::Dropdown({ + let mut zone_opts = vec![("".to_string(), "-- None --".to_string())]; + zone_opts.extend(options.zone_options.clone()); + zone_opts + }), + required: false, + read_only: false, + }, + EditorField { + name: "zone_plus".into(), + label: "Zone+".into(), + field_type: FieldType::Dropdown({ + let mut zone_plus_opts = vec![("".to_string(), "-- None --".to_string())]; + zone_plus_opts.extend(options.zone_plus_options.clone()); + zone_plus_opts + }), + required: false, + read_only: false, + }, + // No-scan option + EditorField { + name: "no_scan".into(), + label: "No Scan".into(), + field_type: FieldType::Dropdown(options.no_scan_options.clone()), + required: false, + read_only: false, + }, + // Purchase / warranty / expiry + EditorField { + name: "purchase_date".into(), + label: "Purchase Date".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + EditorField { + name: "purchase_date_now".into(), + label: "Use current date (Purchase)".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "warranty_until".into(), + label: "Warranty Until".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + EditorField { + name: "warranty_auto".into(), + label: "Auto-calc Warranty".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "warranty_auto_amount".into(), + label: "Warranty Auto Amount".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "warranty_auto_unit".into(), + label: "Warranty Auto Unit".into(), + field_type: FieldType::Dropdown(vec![ + ("days".to_string(), "Days".to_string()), + ("years".to_string(), "Years".to_string()), + ]), + required: false, + read_only: false, + }, + EditorField { + name: "expiry_date".into(), + label: "Expiry Date".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + EditorField { + name: "expiry_auto".into(), + label: "Auto-calc Expiry".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "expiry_auto_amount".into(), + label: "Expiry Auto Amount".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "expiry_auto_unit".into(), + label: "Expiry Auto Unit".into(), + field_type: FieldType::Dropdown(vec![ + ("days".to_string(), "Days".to_string()), + ("years".to_string(), "Years".to_string()), + ]), + required: false, + read_only: false, + }, + // Financial / lending / supplier + EditorField { + name: "price".into(), + label: "Price".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "lendable".into(), + label: "Lendable".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "lending_status".into(), + label: "Lending Status".into(), + field_type: FieldType::Dropdown({ + let mut lending_status_opts = vec![("".to_string(), "-- None --".to_string())]; + lending_status_opts.extend(options.lending_status_options.clone()); + lending_status_opts + }), + required: false, + read_only: false, + }, + EditorField { + name: "supplier_id".into(), + label: "Supplier".into(), + field_type: FieldType::Dropdown({ + let mut supplier_opts = vec![("".to_string(), "-- None --".to_string())]; + supplier_opts.extend(options.supplier_options.clone()); + supplier_opts + }), + required: false, + read_only: false, + }, + // Label template + EditorField { + name: "label_template_id".into(), + label: "Label Template".into(), + field_type: FieldType::Dropdown({ + let mut label_template_opts = vec![("".to_string(), "-- None --".to_string())]; + label_template_opts.extend(options.label_template_options.clone()); + label_template_opts + }), + required: false, + read_only: false, + }, + EditorField { + name: "audit_task_id".into(), + label: "Default Audit Task".into(), + field_type: FieldType::Dropdown(options.audit_task_options.clone()), + required: false, + read_only: false, + }, + // Defaults for created assets + EditorField { + name: "category_id".into(), + label: "Default Category".into(), + field_type: FieldType::Dropdown({ + let mut category_opts = vec![("".to_string(), "-- None --".to_string())]; + category_opts.extend(options.category_options.clone()); + category_opts + }), + required: false, + read_only: false, + }, + EditorField { + name: "manufacturer".into(), + label: "Default Manufacturer".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "model".into(), + label: "Default Model".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "description".into(), + label: "Description".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "additional_fields_json".into(), + label: "Additional Fields (JSON)".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ]; + + self.edit_dialog = FormBuilder::new("Template Editor", fields); + } + + fn parse_additional_fields_input( + raw: Option<serde_json::Value>, + ) -> Result<Option<serde_json::Value>, String> { + match raw { + Some(serde_json::Value::String(s)) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + Ok(Some(serde_json::Value::Null)) + } else { + serde_json::from_str::<serde_json::Value>(trimmed) + .map(Some) + .map_err(|e| e.to_string()) + } + } + Some(serde_json::Value::Null) => Ok(Some(serde_json::Value::Null)), + Some(other) => Ok(Some(other)), + None => Ok(None), + } + } + + fn load_templates(&mut self, api_client: &ApiClient, limit: Option<u32>) { + self.loading_state.start_loading(); + + match get_templates(api_client, limit) { + Ok(templates) => { + self.templates = templates; + self.loading_state.finish_success(); + } + Err(e) => { + self.loading_state.finish_error(e.to_string()); + } + } + } + + pub fn show( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon: Option<&mut crate::ui::ribbon::RibbonUI>, + ) -> Vec<String> { + let mut flags_to_clear = Vec::new(); + + // Get limit from ribbon + let limit = ribbon + .as_ref() + .and_then(|r| r.number_fields.get("templates_limit")) + .copied() + .or(Some(200)); + + // Top toolbar + ui.horizontal(|ui| { + ui.heading("Templates"); + + if self.loading_state.is_loading { + ui.spinner(); + ui.label("Loading..."); + } + + if let Some(err) = &self.loading_state.last_error { + ui.colored_label(egui::Color32::RED, err); + if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.loading_state.last_error = None; + self.load_templates(api, limit); + } + } + } else if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load_templates(api, limit); + } + } + + ui.separator(); + + if ui.button("Columns").clicked() { + self.show_column_panel = !self.show_column_panel; + } + }); + + ui.separator(); + + // Auto-load on first view + if self.templates.is_empty() + && !self.loading_state.is_loading + && self.loading_state.last_error.is_none() + && self.loading_state.last_load_time.is_none() + { + if let Some(api) = api_client { + log::info!("Templates view never loaded, triggering initial auto-load"); + self.load_templates(api, limit); + } + } + + // Handle ribbon events + if let Some(ribbon) = ribbon.as_ref() { + // Handle filter changes + if *ribbon + .checkboxes + .get("templates_filter_changed") + .unwrap_or(&false) + { + flags_to_clear.push("templates_filter_changed".to_string()); + if let Some(client) = api_client { + self.loading_state.last_error = None; + + // Get user-defined filters from FilterBuilder + let user_filter = ribbon.filter_builder.get_filter_json("templates"); + + // Debug: Log the filter to see what we're getting + if let Some(ref cf) = user_filter { + log::info!("Template filter: {:?}", cf); + } else { + log::info!("No filter conditions (showing all templates)"); + } + + self.load_templates(client, limit); + return flags_to_clear; // Early return to avoid duplicate loading + } + } + + // Handle limit changes + if *ribbon + .checkboxes + .get("templates_limit_changed") + .unwrap_or(&false) + { + flags_to_clear.push("templates_limit_changed".to_string()); + if let Some(client) = api_client { + self.loading_state.last_error = None; + self.load_templates(client, limit); + } + } + + // Handle ribbon actions + if *ribbon + .checkboxes + .get("templates_action_new") + .unwrap_or(&false) + { + flags_to_clear.push("templates_action_new".to_string()); + + // Prepare dynamic dropdown fields before opening dialog + if let Some(client) = api_client { + self.prepare_template_edit_fields(client); + } + + // Open new template dialog with empty data (comprehensive fields for templates) + let empty_template = serde_json::json!({ + "id": "", + "template_code": "", + "name": "", + "asset_type": "", + "asset_tag_generation_string": "", + "description": "", + "additional_fields": null, + "additional_fields_json": "{}", + "category_id": "", + "manufacturer": "", + "model": "", + "zone_id": "", + "zone_plus": "", + "status": "", + "price": "", + "warranty_until": "", + "expiry_date": "", + "quantity_total": "", + "quantity_used": "", + "supplier_id": "", + "label_template_id": "", + "audit_task_id": "", + "lendable": false, + "minimum_role_for_lending": "", + "no_scan": "", + "notes": "", + "active": false + }); + self.edit_dialog.title = "Add New Template".to_string(); + self.open_edit_template_dialog(empty_template); + } + + if *ribbon + .checkboxes + .get("templates_action_edit") + .unwrap_or(&false) + { + flags_to_clear.push("templates_action_edit".to_string()); + // TODO: Implement edit selected template (requires selection tracking) + log::info!( + "Edit Template clicked (requires table selection - use double-click for now)" + ); + } + + if *ribbon + .checkboxes + .get("templates_action_delete") + .unwrap_or(&false) + { + flags_to_clear.push("templates_action_delete".to_string()); + // TODO: Implement delete selected templates (requires selection tracking) + log::info!( + "Delete Template clicked (requires table selection - use right-click for now)" + ); + } + } + + // Render table with event handler + let mut edit_template: Option<serde_json::Value> = None; + let mut delete_template: Option<serde_json::Value> = None; + let mut clone_template: Option<serde_json::Value> = None; + + struct TemplateEventHandler<'a> { + edit_action: &'a mut Option<serde_json::Value>, + delete_action: &'a mut Option<serde_json::Value>, + clone_action: &'a mut Option<serde_json::Value>, + } + + impl<'a> crate::core::table_renderer::TableEventHandler<serde_json::Value> + for TemplateEventHandler<'a> + { + fn on_double_click(&mut self, item: &serde_json::Value, _row_index: usize) { + *self.edit_action = Some(item.clone()); + } + + fn on_context_menu( + &mut self, + ui: &mut egui::Ui, + item: &serde_json::Value, + _row_index: usize, + ) { + if ui + .button(format!("{} Edit Template", egui_phosphor::regular::PENCIL)) + .clicked() + { + *self.edit_action = Some(item.clone()); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Clone Template", egui_phosphor::regular::COPY)) + .clicked() + { + *self.clone_action = Some(item.clone()); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Delete Template", egui_phosphor::regular::TRASH)) + .clicked() + { + *self.delete_action = Some(item.clone()); + ui.close(); + } + } + + fn on_selection_changed(&mut self, _selected_indices: &[usize]) { + // Not used for now + } + } + + let mut handler = TemplateEventHandler { + edit_action: &mut edit_template, + delete_action: &mut delete_template, + clone_action: &mut clone_template, + }; + + let prepared_data = self.table_renderer.prepare_json_data(&self.templates); + self.table_renderer + .render_json_table(ui, &prepared_data, Some(&mut handler)); + + // Process actions after rendering + if let Some(template) = edit_template { + // Prepare dynamic dropdown fields before opening dialog + if let Some(client) = api_client { + self.prepare_template_edit_fields(client); + } + self.open_edit_template_dialog(template); + } + if let Some(template) = delete_template { + if let Some(id) = template.get("id").and_then(|v| v.as_i64()) { + self.pending_delete_ids.push(id); + } + } + + // Handle clone action: open Add New dialog pre-filled with selected template values + if let Some(template) = clone_template { + // Prepare dynamic dropdown fields before opening dialog + if let Some(client) = api_client { + self.prepare_template_edit_fields(client); + } + + // Use shared clone helper to prepare new-item payload + let cloned = crate::core::components::prepare_cloned_value( + &template, + &["id", "template_code"], + Some("name"), + Some(""), + ); + + self.edit_dialog.title = "Add New Template".to_string(); + self.open_edit_template_dialog(cloned); + } + + // Show column selector if open + if self.show_column_panel { + egui::Window::new("Column Configuration") + .open(&mut self.show_column_panel) + .resizable(true) + .movable(true) + .default_width(350.0) + .min_width(300.0) + .max_width(500.0) + .max_height(600.0) + .default_pos([200.0, 150.0]) + .show(ui.ctx(), |ui| { + ui.label("Show/Hide Columns:"); + ui.separator(); + + // Scrollable area for columns + egui::ScrollArea::vertical() + .max_height(450.0) + .show(ui, |ui| { + // Use columns layout to make better use of width while keeping groups intact + ui.columns(2, |columns| { + // Left column + columns[0].group(|ui| { + ui.strong("Basic Information"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "template_code" + | "name" + | "asset_type" + | "description" + | "asset_tag_generation_string" + | "label_template_name" + | "label_template_id" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[0].add_space(5.0); + + columns[0].group(|ui| { + ui.strong("Classification"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "category_name" | "manufacturer" | "model" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[0].add_space(5.0); + + columns[0].group(|ui| { + ui.strong("Location & Status"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "zone_name" + | "zone_code" + | "zone_plus" + | "zone_note" + | "status" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + // Right column + columns[1].group(|ui| { + ui.strong("Financial, Dates & Auto-Calc"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "price" + | "purchase_date" + | "purchase_date_now" + | "warranty_until" + | "warranty_auto" + | "warranty_auto_amount" + | "warranty_auto_unit" + | "expiry_date" + | "expiry_auto" + | "expiry_auto_amount" + | "expiry_auto_unit" + | "created_at" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[1].add_space(5.0); + + columns[1].group(|ui| { + ui.strong("Quantities & Lending"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "quantity_total" + | "quantity_used" + | "lendable" + | "lending_status" + | "minimum_role_for_lending" + | "no_scan" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[1].add_space(5.0); + + columns[1].group(|ui| { + ui.strong("Metadata & Other"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "id" | "supplier_name" | "notes" | "active" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + }); + }); + + ui.separator(); + ui.columns(3, |columns| { + if columns[0].button("Show All").clicked() { + for column in &mut self.table_renderer.columns { + column.visible = true; + } + } + if columns[1].button("Hide All").clicked() { + for column in &mut self.table_renderer.columns { + column.visible = false; + } + } + if columns[2].button("Reset to Default").clicked() { + // Reset to default visibility (matching the initial setup) + for column in &mut self.table_renderer.columns { + column.visible = matches!( + column.field.as_str(), + "template_code" + | "name" + | "asset_type" + | "description" + | "category_name" + ); + } + } + }); + }); + } + + // Handle pending deletes + if !self.pending_delete_ids.is_empty() { + if let Some(api) = api_client { + let ids_to_delete = self.pending_delete_ids.clone(); + self.pending_delete_ids.clear(); + + for id in ids_to_delete { + let where_clause = serde_json::json!({"id": id}); + match api.delete("templates", where_clause) { + Ok(resp) if resp.success => { + log::info!("Template {} deleted successfully", id); + } + Ok(resp) => { + self.loading_state.last_error = + Some(format!("Delete failed: {:?}", resp.error)); + } + Err(e) => { + self.loading_state.last_error = Some(format!("Delete error: {}", e)); + } + } + } + + // Reload after deletes + self.load_templates(api, limit); + } + } + + // Handle edit dialog save + let ctx = ui.ctx(); + if let Some(result) = self.edit_dialog.show_editor(ctx) { + log::info!( + "🎯 Templates received editor result: {:?}", + result.is_some() + ); + if let Some(updated) = result { + log::info!( + "🎯 Processing template save with data keys: {:?}", + updated.keys().collect::<Vec<_>>() + ); + if let Some(api) = api_client { + let mut id_from_updated: Option<i64> = None; + if let Some(id_val) = updated.get("id") { + log::info!("Raw id_val for template save: {:?}", id_val); + id_from_updated = if let Some(s) = id_val.as_str() { + if s.trim().is_empty() { + None + } else { + s.trim().parse::<i64>().ok() + } + } else { + id_val.as_i64() + }; + } else if let Some(meta_id_val) = updated.get("__editor_item_id") { + log::info!( + "No 'id' in diff; checking __editor_item_id: {:?}", + meta_id_val + ); + id_from_updated = match meta_id_val { + serde_json::Value::String(s) => { + let s = s.trim(); + if s.is_empty() { + None + } else { + s.parse::<i64>().ok() + } + } + serde_json::Value::Number(n) => n.as_i64(), + _ => None, + }; + } + if let Some(id) = id_from_updated { + log::info!("Entering UPDATE template path for id {}", id); + let mut cleaned = updated.clone(); + cleaned.remove("__editor_item_id"); + + let additional_fields_update = match Self::parse_additional_fields_input( + cleaned.remove("additional_fields_json"), + ) { + Ok(val) => val, + Err(err) => { + let msg = format!("Additional Fields JSON is invalid: {}", err); + log::error!("{}", msg); + self.loading_state.last_error = Some(msg); + return flags_to_clear; + } + }; + + // Filter empty strings to NULL for UPDATE too + let mut filtered_values = serde_json::Map::new(); + for (key, value) in cleaned.iter() { + if key.starts_with("__editor_") { + continue; + } + match value { + serde_json::Value::String(s) if s.trim().is_empty() => { + filtered_values.insert(key.clone(), serde_json::Value::Null); + } + _ => { + filtered_values.insert(key.clone(), value.clone()); + } + } + } + if let Some(val) = additional_fields_update { + filtered_values.insert("additional_fields".to_string(), val); + } + let values = serde_json::Value::Object(filtered_values); + let where_clause = serde_json::json!({"id": id}); + log::info!( + "Sending UPDATE request: values={:?} where={:?}", + values, + where_clause + ); + match api.update("templates", values, where_clause) { + Ok(resp) if resp.success => { + log::info!("Template {} updated successfully", id); + self.load_templates(api, limit); + } + Ok(resp) => { + let err = format!("Update failed: {:?}", resp.error); + log::error!("{}", err); + self.loading_state.last_error = Some(err); + } + Err(e) => { + let err = format!("Update error: {}", e); + log::error!("{}", err); + self.loading_state.last_error = Some(err); + } + } + } else { + log::info!("🆕 Entering INSERT template path (no valid ID detected)"); + let mut values = updated.clone(); + values.remove("id"); + values.remove("__editor_item_id"); + + let additional_fields_insert = match Self::parse_additional_fields_input( + values.remove("additional_fields_json"), + ) { + Ok(val) => val, + Err(err) => { + let msg = format!("Additional Fields JSON is invalid: {}", err); + log::error!("{}", msg); + self.loading_state.last_error = Some(msg); + return flags_to_clear; + } + }; + let mut filtered_values = serde_json::Map::new(); + for (key, value) in values.iter() { + if key.starts_with("__editor_") { + continue; + } + match value { + serde_json::Value::String(s) if s.trim().is_empty() => { + filtered_values.insert(key.clone(), serde_json::Value::Null); + } + _ => { + filtered_values.insert(key.clone(), value.clone()); + } + } + } + if let Some(val) = additional_fields_insert { + filtered_values.insert("additional_fields".to_string(), val); + } + let values = serde_json::Value::Object(filtered_values); + log::info!("➡️ Sending INSERT request for template: {:?}", values); + match api.insert("templates", values) { + Ok(resp) if resp.success => { + log::info!( + "✅ New template created successfully (id={:?})", + resp.data + ); + self.load_templates(api, limit); + } + Ok(resp) => { + let error_msg = format!("Insert failed: {:?}", resp.error); + log::error!("Template insert failed: {}", error_msg); + self.loading_state.last_error = Some(error_msg); + } + Err(e) => { + let error_msg = format!("Insert error: {}", e); + log::error!("Template insert error: {}", error_msg); + self.loading_state.last_error = Some(error_msg); + } + } + } + } + } + } + + flags_to_clear + } + + fn open_edit_template_dialog(&mut self, mut template: serde_json::Value) { + // Determine whether we are creating a new template (no ID or empty/zero ID) + let is_new = match template.get("id") { + Some(serde_json::Value::String(s)) => s.trim().is_empty(), + Some(serde_json::Value::Number(n)) => n.as_i64().map(|id| id <= 0).unwrap_or(true), + Some(serde_json::Value::Null) | None => true, + _ => false, + }; + + self.edit_dialog.title = if is_new { + "Add New Template".to_string() + } else { + "Edit Template".to_string() + }; + + if let Some(obj) = template.as_object_mut() { + let pretty_json = if let Some(existing) = + obj.get("additional_fields_json").and_then(|v| v.as_str()) + { + existing.to_string() + } else { + match obj.get("additional_fields") { + Some(serde_json::Value::Null) | None => String::new(), + Some(serde_json::Value::String(s)) => s.clone(), + Some(value) => { + serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()) + } + } + }; + obj.insert( + "additional_fields_json".to_string(), + serde_json::Value::String(pretty_json), + ); + } + + // Debug log to check the template data being passed to editor + log::info!( + "Template data for editor: {}", + serde_json::to_string_pretty(&template) + .unwrap_or_else(|_| "failed to serialize".to_string()) + ); + + if is_new { + // Use open_new so cloned templates keep their preset values when saved + if let Some(obj) = template.as_object() { + self.edit_dialog.open_new(Some(obj)); + } else { + self.edit_dialog.open_new(None); + } + } else { + self.edit_dialog.open(&template); + } + } +} 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(); + } +} |
