From 8323fdd73272a2882781aba3c499ba0be3dff2a6 Mon Sep 17 00:00:00 2001 From: UMTS at Teleco Date: Sat, 13 Dec 2025 02:51:15 +0100 Subject: committing to insanity --- src/ui/app.rs | 1268 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1268 insertions(+) create mode 100644 src/ui/app.rs (limited to 'src/ui/app.rs') 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>, + api_client: Option, + + // Current view state + current_view: AppView, + previous_view: Option, + current_user: Option, + + // Per-view filter state storage + view_filter_states: HashMap, + + // 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, + + // Configuration + #[allow(dead_code)] + app_config: Option, + + // 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>, + // Re-authentication prompt state + reauth_needed: bool, + reauth_password: String, + + // Database outage tracking + db_offline_latch: bool, + last_timeout_at: Option, + 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>, + ) -> 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 { + 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; + } + }); + } + } +} -- cgit v1.2.3-70-g09d2