aboutsummaryrefslogtreecommitdiff
path: root/src/ui/app.rs
diff options
context:
space:
mode:
authorUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
committerUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
commit8323fdd73272a2882781aba3c499ba0be3dff2a6 (patch)
treeffbf86473933e69cfaeef30d5c6ea7e5b494856c /src/ui/app.rs
committing to insanityHEADmaster
Diffstat (limited to 'src/ui/app.rs')
-rw-r--r--src/ui/app.rs1268
1 files changed, 1268 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;
+ }
+ });
+ }
+ }
+}