diff options
Diffstat (limited to 'src/ui/dashboard.rs')
| -rw-r--r-- | src/ui/dashboard.rs | 384 |
1 files changed, 384 insertions, 0 deletions
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() + } +} |
