use eframe::egui; use egui_phosphor::variants::regular as icons; use serde_json::Value; use std::collections::HashSet; /// Column configuration for table rendering #[derive(Clone)] pub struct ColumnConfig { pub name: String, pub field: String, pub visible: bool, pub width: f32, #[allow(dead_code)] pub min_width: f32, } impl ColumnConfig { pub fn new(name: impl Into, field: impl Into) -> Self { Self { name: name.into(), field: field.into(), visible: true, width: 100.0, min_width: 50.0, } } pub fn with_width(mut self, width: f32) -> Self { self.width = width; self } #[allow(dead_code)] pub fn with_min_width(mut self, min_width: f32) -> Self { self.min_width = min_width; self } pub fn hidden(mut self) -> Self { self.visible = false; self } } /// Sorting configuration #[derive(Clone)] pub struct SortConfig { pub field: Option, pub ascending: bool, } impl Default for SortConfig { fn default() -> Self { Self { field: None, ascending: true, } } } /// Multi-selection state management pub struct SelectionManager { pub selected_rows: HashSet, pub selection_anchor: Option, pub last_click_time: Option, pub last_click_row: Option, } impl Default for SelectionManager { fn default() -> Self { Self { selected_rows: HashSet::new(), selection_anchor: None, last_click_time: None, last_click_row: None, } } } impl SelectionManager { pub fn new() -> Self { Self::default() } pub fn is_selected(&self, row: usize) -> bool { self.selected_rows.contains(&row) } pub fn select_all(&mut self, row_count: usize) { self.selected_rows = (0..row_count).collect(); } pub fn clear_selection(&mut self) { self.selected_rows.clear(); self.selection_anchor = None; } pub fn toggle_row(&mut self, row: usize, modifier: SelectionModifier) { match modifier { SelectionModifier::None => { self.selected_rows.clear(); self.selected_rows.insert(row); self.selection_anchor = Some(row); } SelectionModifier::Ctrl => { if self.selected_rows.contains(&row) { self.selected_rows.remove(&row); } else { self.selected_rows.insert(row); } self.selection_anchor = Some(row); } SelectionModifier::Shift => { let anchor = self.selection_anchor.unwrap_or(row); let (start, end) = if anchor <= row { (anchor, row) } else { (row, anchor) }; for i in start..=end { self.selected_rows.insert(i); } } } } pub fn get_selected_count(&self) -> usize { self.selected_rows.len() } pub fn get_selected_indices(&self) -> Vec { let mut indices: Vec<_> = self.selected_rows.iter().cloned().collect(); indices.sort(); indices } } pub enum SelectionModifier { None, Ctrl, Shift, } /// Callbacks for table events pub trait TableEventHandler { fn on_double_click(&mut self, item: &T, row_index: usize); fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &T, row_index: usize); fn on_selection_changed(&mut self, selected_indices: &[usize]); } /// Generic table renderer that can display any data with configurable columns pub struct TableRenderer { pub columns: Vec, pub sort_config: SortConfig, pub selection: SelectionManager, pub search_query: String, pub search_fields: Vec, } impl Default for TableRenderer { fn default() -> Self { Self::new() } } impl TableRenderer { pub fn new() -> Self { Self { columns: Vec::new(), sort_config: SortConfig::default(), selection: SelectionManager::new(), search_query: String::new(), search_fields: vec![ // Default search fields for assets/inventory "name".to_string(), "asset_tag".to_string(), "manufacturer".to_string(), "model".to_string(), "serial_number".to_string(), "first_name".to_string(), "last_name".to_string(), "email".to_string(), ], } } pub fn with_columns(mut self, columns: Vec) -> Self { self.columns = columns; self } pub fn with_default_sort(mut self, field: &str, ascending: bool) -> Self { self.sort_config = SortConfig { field: Some(field.to_string()), ascending, }; self } #[allow(dead_code)] pub fn add_column(mut self, column: ColumnConfig) -> Self { self.columns.push(column); self } #[allow(dead_code)] pub fn set_search_query(&mut self, query: String) { self.search_query = query; } pub fn with_search_fields(mut self, fields: Vec) -> Self { self.search_fields = fields; self } /// Filter and sort JSON values based on current configuration pub fn prepare_json_data<'a>(&self, data: &'a [Value]) -> Vec<(usize, &'a Value)> { let mut filtered: Vec<(usize, &Value)> = data .iter() .enumerate() .filter(|(_, item)| { if self.search_query.is_empty() { true } else { // Simple search across configured fields let search_lower = self.search_query.to_lowercase(); self.search_fields.iter().any(|field| { item.get(field) .and_then(|v| v.as_str()) .map(|s| s.to_lowercase().contains(&search_lower)) .unwrap_or(false) }) } }) .collect(); // Sort if configured if let Some(ref field) = self.sort_config.field { let field = field.clone(); let ascending = self.sort_config.ascending; filtered.sort_by(|a, b| { let val_a = a.1.get(&field); let val_b = b.1.get(&field); let cmp = match (val_a, val_b) { (Some(a), Some(b)) => { // Try to compare as strings first match (a.as_str(), b.as_str()) { (Some(s_a), Some(s_b)) => s_a.cmp(s_b), _ => { // Try to compare as numbers match (a.as_i64(), b.as_i64()) { (Some(n_a), Some(n_b)) => n_a.cmp(&n_b), _ => std::cmp::Ordering::Equal, } } } } (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, (None, None) => std::cmp::Ordering::Equal, }; if ascending { cmp } else { cmp.reverse() } }); } filtered } /// Render the table with JSON data pub fn render_json_table<'a>( &mut self, ui: &mut egui::Ui, data: &'a [(usize, &'a Value)], mut event_handler: Option<&mut dyn TableEventHandler>, ) -> egui::Vec2 { use egui_extras::{Column, TableBuilder}; let visible_columns: Vec<_> = self.columns.iter().filter(|c| c.visible).collect(); let mut table = TableBuilder::new(ui) .striped(true) .resizable(true) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) .max_scroll_height(f32::MAX); // Add selection checkbox column first, then remainder columns table = table.column(Column::initial(28.0)); for _column in &visible_columns { table = table.column(Column::remainder().resizable(true).clip(true)); } table .header(24.0, |mut header| { // Select-all checkbox header header.col(|ui| { let all_selected = data.len() > 0 && (0..data.len()).all(|i| self.selection.selected_rows.contains(&i)); let mut chk = all_selected; if ui .checkbox(&mut chk, "") .on_hover_text("Select All") .clicked() { if chk { self.selection.select_all(data.len()); } else { self.selection.clear_selection(); } if let Some(ref mut handler) = event_handler { handler.on_selection_changed(&self.selection.get_selected_indices()); } } }); // Column headers with sorting for column in &visible_columns { header.col(|ui| { let is_sorted = self.sort_config.field.as_ref() == Some(&column.field); let label = if is_sorted { if self.sort_config.ascending { format!("{} {}", column.name, icons::ARROW_UP) } else { format!("{} {}", column.name, icons::ARROW_DOWN) } } else { column.name.clone() }; let button = egui::Button::new(label).frame(false); if ui.add(button).clicked() { if is_sorted { self.sort_config.ascending = !self.sort_config.ascending; } else { self.sort_config.field = Some(column.field.clone()); self.sort_config.ascending = true; } } }); } }) .body(|mut body| { for (idx, (_orig_idx, item)) in data.iter().enumerate() { let _item_clone = (*item).clone(); let is_selected = self.selection.is_selected(idx); body.row(20.0, |mut row| { // Apply selection highlight if is_selected { row.set_selected(true); } // Checkbox column row.col(|ui| { let mut checked = self.selection.is_selected(idx); let resp = ui.checkbox(&mut checked, ""); if resp.changed() { let mods = ui.input(|i| i.modifiers); let modifier = if mods.shift { SelectionModifier::Shift } else if mods.command || mods.ctrl { SelectionModifier::Ctrl } else { SelectionModifier::None }; if checked { self.selection.toggle_row(idx, modifier); } else { self.selection.selected_rows.remove(&idx); } if let Some(ref mut handler) = event_handler { handler.on_selection_changed( &self.selection.get_selected_indices(), ); } } }); // Render data cells and collect their responses let mut combined_cell_response: Option = None; for column in &visible_columns { row.col(|ui| { let resp = JsonCellRenderer::render_cell(ui, item, &column.field); combined_cell_response = Some(match combined_cell_response.take() { Some(prev) => prev.union(resp), None => resp, }); }); } // Handle row interactions let mut row_response = row.response(); if let Some(cell_resp) = combined_cell_response { row_response = row_response.union(cell_resp); } // Handle clicks if row_response.clicked() { // Double-click detection let now = std::time::Instant::now(); let is_double_click = if let (Some(last_time), Some(last_row)) = ( self.selection.last_click_time, self.selection.last_click_row, ) { last_row == idx && now.duration_since(last_time).as_millis() < 500 } else { false }; if is_double_click { if let Some(ref mut handler) = event_handler { handler.on_double_click(item, idx); } self.selection.last_click_row = None; self.selection.last_click_time = None; } else { // Single click selection let mods = row_response.ctx.input(|i| i.modifiers); let modifier = if mods.shift { SelectionModifier::Shift } else if mods.command || mods.ctrl { SelectionModifier::Ctrl } else { SelectionModifier::None }; self.selection.toggle_row(idx, modifier); self.selection.last_click_row = Some(idx); self.selection.last_click_time = Some(now); if let Some(ref mut handler) = event_handler { handler.on_selection_changed( &self.selection.get_selected_indices(), ); } row_response.ctx.request_repaint(); } } // Handle right-click context menu if let Some(ref mut handler) = event_handler { row_response.context_menu(|ui| { handler.on_context_menu(ui, item, idx); }); } }); } }); ui.available_size() } /// Show column selector panel pub fn show_column_selector(&mut self, ui: &mut egui::Ui, _id_suffix: &str) { ui.heading("Column Visibility"); ui.separator(); egui::ScrollArea::vertical() .max_height(400.0) .show(ui, |ui| { for col in &mut self.columns { ui.checkbox(&mut col.visible, &col.name); } }); } } /// JSON-specific cell renderer for asset-like data pub struct JsonCellRenderer; impl JsonCellRenderer { pub fn render_cell(ui: &mut egui::Ui, data: &Value, field: &str) -> egui::Response { let json_value = data.get(field); // Handle null values if json_value.is_none() || json_value.unwrap().is_null() { return ui.add(egui::Label::new("-").sense(egui::Sense::click())); } let json_value = json_value.unwrap(); match field { // Integer fields "id" | "asset_numeric_id" | "category_id" | "zone_id" | "supplier_id" | "current_borrower_id" | "previous_borrower_id" | "created_by" | "last_modified_by" => { let text = json_value .as_i64() .map(|n| n.to_string()) .unwrap_or_else(|| "-".to_string()); ui.add(egui::Label::new(text).sense(egui::Sense::click())) } // Quantity fields "quantity_available" | "quantity_total" | "quantity_used" => { let text = json_value .as_i64() .map(|n| n.to_string()) .unwrap_or_else(|| "0".to_string()); ui.add(egui::Label::new(text).sense(egui::Sense::click())) } // Price field "price" => { let text = if let Some(num) = json_value.as_f64() { format!("${:.2}", num) } else if let Some(num) = json_value.as_i64() { format!("${:.2}", num as f64) } else { "-".to_string() }; ui.add(egui::Label::new(text).sense(egui::Sense::click())) } // Boolean lendable field (normalize bool/number/string) "lendable" => { let is_lendable = match json_value { serde_json::Value::Bool(b) => *b, serde_json::Value::Number(n) => n.as_i64() == Some(1) || n.as_u64() == Some(1), serde_json::Value::String(s) => { let s = s.to_lowercase(); s == "true" || s == "1" || s == "yes" || s == "y" } _ => false, }; let (text, color) = if is_lendable { ("Yes", egui::Color32::from_rgb(76, 175, 80)) } else { ("No", egui::Color32::GRAY) }; ui.add( egui::Label::new(egui::RichText::new(text).color(color)) .sense(egui::Sense::click()), ) } // Boolean banned field "banned" => { let is_banned = json_value.as_bool().unwrap_or(false); let (text, color) = if is_banned { ("YES!", egui::Color32::from_rgb(244, 67, 54)) } else { ("No", egui::Color32::from_rgb(76, 175, 80)) }; ui.add( egui::Label::new(egui::RichText::new(text).color(color)) .sense(egui::Sense::click()), ) } // Asset type enum "asset_type" => { let value = json_value.as_str().unwrap_or(""); let (display, color) = match value { "N" => ("Normal", egui::Color32::from_rgb(33, 150, 243)), "B" => ("Basic", egui::Color32::from_rgb(76, 175, 80)), "L" => ("License", egui::Color32::from_rgb(156, 39, 176)), "C" => ("Consumable", egui::Color32::from_rgb(255, 152, 0)), _ => ("Unknown", ui.visuals().text_color()), }; ui.add( egui::Label::new(egui::RichText::new(display).color(color)) .sense(egui::Sense::click()), ) } // Status enum (supports both asset and audit statuses) "status" => { let value = json_value.as_str().unwrap_or(""); let (display, color) = match value { // Audit status values "in-progress" => ("in-progress", egui::Color32::from_rgb(66, 133, 244)), "attention" => ("attention", egui::Color32::from_rgb(255, 152, 0)), "timeout" => ("timeout", egui::Color32::from_rgb(244, 67, 54)), "cancelled" => ("cancelled", egui::Color32::from_rgb(158, 158, 158)), "all-good" => ("all-good", egui::Color32::from_rgb(76, 175, 80)), // Asset status values "Good" => (value, egui::Color32::from_rgb(76, 175, 80)), "Attention" => (value, egui::Color32::from_rgb(255, 193, 7)), // Faulty should be strong red to indicate severe issues "Faulty" => (value, egui::Color32::from_rgb(244, 67, 54)), "Missing" => (value, egui::Color32::from_rgb(244, 67, 54)), "Retired" => (value, egui::Color32::GRAY), "In Repair" => (value, egui::Color32::from_rgb(156, 39, 176)), "In Transit" => (value, egui::Color32::from_rgb(33, 150, 243)), "Expired" => (value, egui::Color32::from_rgb(183, 28, 28)), "Unmanaged" => (value, egui::Color32::DARK_GRAY), _ => (value, ui.visuals().text_color()), }; ui.add( egui::Label::new(egui::RichText::new(display).color(color)) .sense(egui::Sense::click()), ) } // Audit log specific status field "status_found" => { let value = json_value.as_str().unwrap_or(""); let color = match value { "Good" => egui::Color32::from_rgb(76, 175, 80), "Attention" => egui::Color32::from_rgb(255, 152, 0), "Faulty" | "Missing" => egui::Color32::from_rgb(244, 67, 54), "In Repair" | "In Transit" => egui::Color32::from_rgb(66, 133, 244), _ => ui.visuals().text_color(), }; ui.add( egui::Label::new(egui::RichText::new(value).color(color)) .sense(egui::Sense::click()), ) } // Lending status enum "lending_status" => { let value = json_value.as_str().unwrap_or(""); let color = match value { "Available" => egui::Color32::from_rgb(76, 175, 80), "Borrowed" => egui::Color32::from_rgb(255, 152, 0), "Overdue" => egui::Color32::from_rgb(244, 67, 54), "Deployed" => egui::Color32::from_rgb(33, 150, 243), "Illegally Handed Out" => egui::Color32::from_rgb(183, 28, 28), "Stolen" => egui::Color32::from_rgb(136, 14, 79), _ => egui::Color32::GRAY, }; if !value.is_empty() { ui.add( egui::Label::new(egui::RichText::new(value).color(color)) .sense(egui::Sense::click()), ) } else { ui.add(egui::Label::new("-").sense(egui::Sense::click())) } } // Zone plus enum "zone_plus" => { let value = json_value.as_str().unwrap_or("-"); let color = match value { "Floating Local" => egui::Color32::from_rgb(33, 150, 243), "Floating Global" => egui::Color32::from_rgb(156, 39, 176), "Clarify" => egui::Color32::from_rgb(255, 152, 0), _ => ui.visuals().text_color(), }; ui.add( egui::Label::new(egui::RichText::new(value).color(color)) .sense(egui::Sense::click()), ) } // No scan enum "no_scan" => { let value = json_value.as_str().unwrap_or("No"); let color = match value { "Yes" => egui::Color32::from_rgb(244, 67, 54), "Ask" => egui::Color32::from_rgb(255, 152, 0), "No" => egui::Color32::from_rgb(76, 175, 80), _ => ui.visuals().text_color(), }; ui.add( egui::Label::new(egui::RichText::new(value).color(color)) .sense(egui::Sense::click()), ) } // Date fields "purchase_date" | "warranty_until" | "expiry_date" | "due_date" | "last_audit" | "checkout_date" | "return_date" => { if let Some(date_str) = json_value.as_str() { let text = if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { date.format("%b %d, %Y").to_string() } else { date_str.to_string() }; ui.add(egui::Label::new(text).sense(egui::Sense::click())) } else { ui.add(egui::Label::new("-").sense(egui::Sense::click())) } } // DateTime fields "created_date" | "last_modified_date" => { if let Some(datetime_str) = json_value.as_str() { let text = if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S") { dt.format("%b %d, %Y %H:%M").to_string() } else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(datetime_str) { dt.format("%b %d, %Y %H:%M").to_string() } else { datetime_str.to_string() }; ui.add(egui::Label::new(text).sense(egui::Sense::click())) } else { ui.add(egui::Label::new("-").sense(egui::Sense::click())) } } // Default text/string fields _ => { let (display, hover) = if let Some(text) = json_value.as_str() { if text.is_empty() { ("-".to_string(), None) } else if text.len() > 50 { (format!("{}...", &text[..47]), Some(text.to_string())) } else { (text.to_string(), None) } } else if let Some(num) = json_value.as_i64() { (num.to_string(), None) } else if let Some(num) = json_value.as_f64() { (format!("{:.2}", num), None) } else { ("-".to_string(), None) }; let resp = ui.add(egui::Label::new(display).sense(egui::Sense::click())); if let Some(h) = hover { resp.on_hover_text(h) } else { resp } } } } }