dashboard.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. use eframe::egui;
  2. use egui_extras::{Column, TableBuilder};
  3. use crate::api::ApiClient;
  4. use crate::core::{fetch_dashboard_stats, get_asset_changes, get_issue_changes};
  5. use crate::models::DashboardStats;
  6. fn format_date_short(date_str: &str) -> String {
  7. // Parse ISO format like "2024-10-17T01:05:14Z" and return "01:05 17/10/24"
  8. if let Some(parts) = date_str.split('T').next() {
  9. if let Some(time_part) = date_str.split('T').nth(1) {
  10. let time = &time_part[..5]; // HH:MM
  11. if let Some((y, rest)) = parts.split_once('-') {
  12. if let Some((m, d)) = rest.split_once('-') {
  13. let year_short = y.chars().skip(2).collect::<String>();
  14. return format!("{} {}/{}/{}", time, d, m, year_short);
  15. }
  16. }
  17. }
  18. }
  19. date_str.to_string()
  20. }
  21. pub struct DashboardView {
  22. stats: DashboardStats,
  23. is_loading: bool,
  24. last_error: Option<String>,
  25. data_loaded: bool,
  26. asset_changes: Vec<serde_json::Value>,
  27. issue_changes: Vec<serde_json::Value>,
  28. }
  29. impl DashboardView {
  30. pub fn new() -> Self {
  31. Self {
  32. stats: DashboardStats::default(),
  33. is_loading: false,
  34. last_error: None,
  35. data_loaded: false,
  36. asset_changes: Vec::new(),
  37. issue_changes: Vec::new(),
  38. }
  39. }
  40. pub fn refresh_data(&mut self, api_client: &ApiClient) {
  41. self.is_loading = true;
  42. self.last_error = None;
  43. // Fetch dashboard stats using core module
  44. log::info!("Refreshing dashboard data...");
  45. match fetch_dashboard_stats(api_client) {
  46. Ok(stats) => {
  47. log::info!(
  48. "Dashboard stats loaded: {} total assets",
  49. stats.total_assets
  50. );
  51. self.stats = stats;
  52. // Load recent changes using core module
  53. self.asset_changes = get_asset_changes(api_client, 15).unwrap_or_default();
  54. self.issue_changes = get_issue_changes(api_client, 12).unwrap_or_default();
  55. self.is_loading = false;
  56. self.data_loaded = true;
  57. }
  58. Err(err) => {
  59. log::error!("Failed to load dashboard stats: {}", err);
  60. self.last_error = Some(format!("Failed to load stats: {}", err));
  61. self.is_loading = false;
  62. }
  63. }
  64. }
  65. /// Check if the last error was a database timeout
  66. pub fn has_timeout_error(&self) -> bool {
  67. if let Some(error) = &self.last_error {
  68. error.contains("Database temporarily unavailable")
  69. } else {
  70. false
  71. }
  72. }
  73. pub fn show(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) {
  74. // Auto-load data on first show
  75. if !self.data_loaded && !self.is_loading {
  76. if let Some(client) = api_client {
  77. self.refresh_data(client);
  78. }
  79. }
  80. ui.heading("Dashboard");
  81. ui.separator();
  82. ui.horizontal(|ui| {
  83. if ui.button("Refresh").clicked() {
  84. if let Some(client) = api_client {
  85. self.refresh_data(client);
  86. }
  87. }
  88. if self.is_loading {
  89. ui.spinner();
  90. ui.label("Loading...");
  91. }
  92. });
  93. ui.add_space(12.0);
  94. // Error display
  95. if let Some(error) = &self.last_error {
  96. ui.label(format!("Error: {}", error));
  97. ui.add_space(8.0);
  98. }
  99. // Stats cards - using horizontal layout with equal widths and padding
  100. ui.horizontal(|ui| {
  101. let available_width = ui.available_width();
  102. let side_padding = 20.0; // Equal padding on both sides
  103. let spacing = 16.0;
  104. let frame_margin = 16.0 * 2.0; // inner_margin on both sides
  105. let stroke_width = 1.0 * 2.0; // stroke on both sides
  106. let total_card_overhead = frame_margin + stroke_width;
  107. // Calculate card content width accounting for frame overhead and side padding
  108. let usable_width = available_width - (side_padding * 2.0);
  109. let card_width = ((usable_width - (spacing * 2.0)) / 3.0) - total_card_overhead;
  110. // Add left padding
  111. ui.add_space(side_padding);
  112. self.show_stat_card(
  113. ui,
  114. "Total Assets",
  115. self.stats.total_assets,
  116. egui::Color32::from_rgb(33, 150, 243),
  117. card_width,
  118. );
  119. ui.add_space(spacing);
  120. self.show_stat_card(
  121. ui,
  122. "Okay Items",
  123. self.stats.okay_items,
  124. egui::Color32::from_rgb(76, 175, 80),
  125. card_width,
  126. );
  127. ui.add_space(spacing);
  128. self.show_stat_card(
  129. ui,
  130. "Attention",
  131. self.stats.attention_items,
  132. egui::Color32::from_rgb(244, 67, 54),
  133. card_width,
  134. );
  135. // Add right padding (this will naturally happen with the remaining space)
  136. ui.add_space(side_padding);
  137. });
  138. ui.add_space(24.0);
  139. // Recent changes tables side-by-side, fill remaining height
  140. let full_h = ui.available_height();
  141. ui.horizontal(|ui| {
  142. let spacing = 16.0;
  143. let available = ui.available_width();
  144. let half = (available - spacing) / 2.0;
  145. // Left column: Asset changes
  146. ui.allocate_ui_with_layout(
  147. egui::vec2(half, full_h),
  148. egui::Layout::top_down(egui::Align::Min),
  149. |ui| {
  150. ui.set_width(half);
  151. let col_w = ui.available_width();
  152. ui.set_max_width(col_w);
  153. ui.heading("Recent Asset Changes");
  154. ui.separator();
  155. ui.add_space(8.0);
  156. if self.asset_changes.is_empty() {
  157. ui.label("No recent asset changes");
  158. } else {
  159. ui.push_id("asset_changes_table", |ui| {
  160. let col_w = ui.available_width();
  161. ui.set_width(col_w);
  162. // Set table body height based on remaining space
  163. let body_h = (ui.available_height() - 36.0).max(180.0);
  164. let table = TableBuilder::new(ui)
  165. .striped(true)
  166. .resizable(false)
  167. .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
  168. .column(Column::remainder())
  169. .column(Column::exact(140.0))
  170. .column(Column::exact(120.0))
  171. .column(Column::exact(120.0))
  172. .min_scrolled_height(body_h);
  173. table
  174. .header(20.0, |mut header| {
  175. header.col(|ui| {
  176. ui.strong("Asset");
  177. });
  178. header.col(|ui| {
  179. ui.strong("Change");
  180. });
  181. header.col(|ui| {
  182. ui.strong("Date");
  183. });
  184. header.col(|ui| {
  185. ui.strong("User");
  186. });
  187. })
  188. .body(|mut body| {
  189. for change in &self.asset_changes {
  190. let asset = change
  191. .get("asset_tag")
  192. .and_then(|v| v.as_str())
  193. .unwrap_or("N/A");
  194. let summary = change
  195. .get("changes")
  196. .and_then(|v| v.as_str())
  197. .unwrap_or("N/A");
  198. let date_raw = change
  199. .get("date")
  200. .and_then(|v| v.as_str())
  201. .unwrap_or("N/A");
  202. let date = format_date_short(date_raw);
  203. let user = change
  204. .get("user")
  205. .and_then(|v| v.as_str())
  206. .unwrap_or("System");
  207. body.row(24.0, |mut row| {
  208. row.col(|ui| {
  209. ui.add(egui::Label::new(asset).truncate());
  210. });
  211. row.col(|ui| {
  212. let label = egui::Label::new(summary).truncate();
  213. ui.add(label).on_hover_text(summary);
  214. });
  215. row.col(|ui| {
  216. ui.add(egui::Label::new(&date).truncate());
  217. });
  218. row.col(|ui| {
  219. ui.add(egui::Label::new(user).truncate());
  220. });
  221. });
  222. }
  223. });
  224. });
  225. }
  226. },
  227. );
  228. ui.add_space(spacing);
  229. // Right column: Issue changes
  230. ui.allocate_ui_with_layout(
  231. egui::vec2(half, full_h),
  232. egui::Layout::top_down(egui::Align::Min),
  233. |ui| {
  234. ui.set_width(half);
  235. let col_w = ui.available_width();
  236. ui.set_max_width(col_w);
  237. ui.heading("Recent Issue Updates");
  238. ui.separator();
  239. ui.add_space(8.0);
  240. if self.issue_changes.is_empty() {
  241. ui.label("No recent issue updates");
  242. } else {
  243. ui.push_id("issue_changes_table", |ui| {
  244. let col_w = ui.available_width();
  245. ui.set_width(col_w);
  246. let body_h = (ui.available_height() - 36.0).max(180.0);
  247. let table = TableBuilder::new(ui)
  248. .striped(true)
  249. .resizable(false)
  250. .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
  251. .column(Column::remainder())
  252. .column(Column::exact(140.0))
  253. .column(Column::exact(120.0))
  254. .column(Column::exact(120.0))
  255. .min_scrolled_height(body_h);
  256. table
  257. .header(20.0, |mut header| {
  258. header.col(|ui| {
  259. ui.strong("Issue");
  260. });
  261. header.col(|ui| {
  262. ui.strong("Change");
  263. });
  264. header.col(|ui| {
  265. ui.strong("Date");
  266. });
  267. header.col(|ui| {
  268. ui.strong("User");
  269. });
  270. })
  271. .body(|mut body| {
  272. for change in &self.issue_changes {
  273. let issue = change
  274. .get("issue")
  275. .and_then(|v| v.as_str())
  276. .unwrap_or("N/A");
  277. let summary = change
  278. .get("changes")
  279. .and_then(|v| v.as_str())
  280. .unwrap_or("N/A");
  281. let date_raw = change
  282. .get("date")
  283. .and_then(|v| v.as_str())
  284. .unwrap_or("N/A");
  285. let date = format_date_short(date_raw);
  286. let user = change
  287. .get("user")
  288. .and_then(|v| v.as_str())
  289. .unwrap_or("System");
  290. body.row(24.0, |mut row| {
  291. row.col(|ui| {
  292. ui.add(egui::Label::new(issue).truncate());
  293. });
  294. row.col(|ui| {
  295. let label = egui::Label::new(summary).truncate();
  296. ui.add(label).on_hover_text(summary);
  297. });
  298. row.col(|ui| {
  299. ui.add(egui::Label::new(&date).truncate());
  300. });
  301. row.col(|ui| {
  302. ui.add(egui::Label::new(user).truncate());
  303. });
  304. });
  305. }
  306. });
  307. });
  308. }
  309. },
  310. );
  311. });
  312. }
  313. fn show_stat_card<T: std::fmt::Display>(
  314. &self,
  315. ui: &mut egui::Ui,
  316. label: &str,
  317. value: T,
  318. color: egui::Color32,
  319. width: f32,
  320. ) {
  321. // Use default widget background - adapts to light/dark mode automatically
  322. egui::Frame::default()
  323. .corner_radius(8.0)
  324. .inner_margin(16.0)
  325. .fill(ui.visuals().widgets.noninteractive.weak_bg_fill)
  326. .stroke(egui::Stroke::new(1.5, color))
  327. .show(ui, |ui| {
  328. ui.set_min_width(width);
  329. ui.set_max_width(width);
  330. ui.set_min_height(100.0);
  331. ui.vertical_centered(|ui| {
  332. ui.label(egui::RichText::new(label).size(14.0).color(color));
  333. ui.add_space(8.0);
  334. ui.label(
  335. egui::RichText::new(format!("{}", value))
  336. .size(32.0)
  337. .strong(),
  338. );
  339. });
  340. });
  341. }
  342. }
  343. impl Default for DashboardView {
  344. fn default() -> Self {
  345. Self::new()
  346. }
  347. }