use eframe::egui; use crate::api::ApiClient; use crate::core::components::interactions::ConfirmDialog; use crate::core::tables::get_issues; use crate::core::{EditorField, FieldType, FormBuilder}; use std::collections::HashSet; #[derive(Clone)] struct ColumnConfig { name: String, field: String, visible: bool, } pub struct IssuesView { rows: Vec, is_loading: bool, last_error: Option, init_loaded: bool, // Cached summary stats (avoid recomputing every frame) summary_by_status: Vec<(String, i32)>, summary_by_severity: Vec<(String, i32)>, // Columns & selector columns: Vec, show_column_panel: bool, // Selection & interactions selected_row: Option, last_click_time: Option, last_click_row: Option, selected_rows: HashSet, selection_anchor: Option, // Dialogs delete_dialog: ConfirmDialog, edit_dialog: FormBuilder, // Track ids for operations edit_current_id: Option, delete_current_id: Option, } impl IssuesView { pub fn new() -> Self { let columns = vec![ ColumnConfig { name: "Title".into(), field: "title".into(), visible: true, }, ColumnConfig { name: "Status".into(), field: "status".into(), visible: true, }, ColumnConfig { name: "Severity".into(), field: "severity".into(), visible: true, }, ColumnConfig { name: "Priority".into(), field: "priority".into(), visible: true, }, ColumnConfig { name: "Asset".into(), field: "asset_label".into(), visible: true, }, ColumnConfig { name: "Borrower".into(), field: "borrower_name".into(), visible: false, }, ColumnConfig { name: "Assigned To".into(), field: "assigned_to_name".into(), visible: false, }, ColumnConfig { name: "Auto".into(), field: "auto_detected".into(), visible: false, }, ColumnConfig { name: "Trigger".into(), field: "detection_trigger".into(), visible: false, }, ColumnConfig { name: "Updated".into(), field: "updated_at".into(), visible: true, }, ColumnConfig { name: "Created".into(), field: "created_at".into(), visible: false, }, ColumnConfig { name: "Resolved".into(), field: "resolved_date".into(), visible: false, }, ColumnConfig { name: "Description".into(), field: "description".into(), visible: false, }, ColumnConfig { name: "Solution".into(), field: "solution".into(), visible: false, }, ColumnConfig { name: "Solution+".into(), field: "solution_plus".into(), visible: false, }, ColumnConfig { name: "Replacement".into(), field: "replacement_asset_id".into(), visible: false, }, ColumnConfig { name: "Cost".into(), field: "cost".into(), visible: false, }, ColumnConfig { name: "Notes".into(), field: "notes".into(), visible: false, }, ColumnConfig { name: "ID".into(), field: "id".into(), visible: false, }, ]; Self { rows: vec![], is_loading: false, last_error: None, init_loaded: false, summary_by_status: Vec::new(), summary_by_severity: Vec::new(), columns, show_column_panel: false, selected_row: None, last_click_time: None, last_click_row: None, selected_rows: HashSet::new(), selection_anchor: None, delete_dialog: ConfirmDialog::new( "Delete Issue", "Are you sure you want to delete this issue?", ), edit_dialog: FormBuilder::new( "Edit Issue", vec![ EditorField { name: "title".into(), label: "Title".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "status".into(), label: "Status".into(), field_type: FieldType::Text, required: true, read_only: false, }, EditorField { name: "severity".into(), label: "Severity".into(), field_type: FieldType::Text, required: false, read_only: false, }, EditorField { name: "priority".into(), label: "Priority".into(), field_type: FieldType::Text, required: false, read_only: false, }, EditorField { name: "description".into(), label: "Description".into(), field_type: FieldType::MultilineText, required: false, read_only: false, }, EditorField { name: "solution".into(), label: "Solution".into(), field_type: FieldType::MultilineText, required: false, read_only: false, }, ], ), edit_current_id: None, delete_current_id: None, } } fn load(&mut self, api: &ApiClient) { if self.is_loading { return; } self.is_loading = true; self.last_error = None; match get_issues(api, Some(200)) { Ok(mut list) => { // Build asset label for row in &mut list { if let Some(obj) = row.as_object_mut() { let asset = match ( obj.get("asset_tag").and_then(|v| v.as_str()), obj.get("asset_name").and_then(|v| v.as_str()), ) { (Some(tag), Some(name)) if !tag.is_empty() => { format!("{} ({})", name, tag) } (_, Some(name)) => name.to_string(), _ => "-".to_string(), }; obj.insert("asset_label".into(), serde_json::json!(asset)); } } self.rows = list; } Err(e) => self.last_error = Some(e.to_string()), } self.is_loading = false; self.init_loaded = true; self.recompute_summary(); } pub fn show(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { ui.horizontal(|ui| { ui.heading("Issues"); if self.is_loading { ui.spinner(); ui.label("Loading..."); } if let Some(err) = &self.last_error { ui.colored_label(egui::Color32::RED, err); if ui.button("Refresh").clicked() { if let Some(api) = api_client { self.load(api); } } } else if ui.button("Refresh").clicked() { if let Some(api) = api_client { self.load(api); } } ui.separator(); let button_text = if self.show_column_panel { "Hide Column Selector" } else { "Show Column Selector" }; if ui.button(button_text).clicked() { self.show_column_panel = !self.show_column_panel; } }); ui.separator(); if !self.init_loaded { if let Some(api) = api_client { self.load(api); } } // Column selector if self.show_column_panel { self.show_columns_window(ui); } // Summary chips (cached) self.render_summary(ui); let visible_columns: Vec = self.columns.iter().filter(|c| c.visible).cloned().collect(); self.render_table(ui, &visible_columns); // Process selection/dialog events let ctx = ui.ctx(); // inline selection now, nothing to fetch here if let Some(row_idx) = ctx.data_mut(|d| d.remove_temp::(egui::Id::new("iss_double_click_idx"))) { self.selected_row = Some(row_idx); self.last_click_row = None; self.last_click_time = None; } if let Some(item) = ctx.data_mut(|d| { d.remove_temp::(egui::Id::new("iss_double_click_edit")) }) { self.open_editor_with(&item); ctx.request_repaint(); } if let Some(item) = ctx.data_mut(|d| { d.remove_temp::(egui::Id::new("iss_context_menu_edit")) }) { self.open_editor_with(&item); ctx.request_repaint(); } if let Some(item) = ctx.data_mut(|d| { d.remove_temp::(egui::Id::new("iss_context_menu_delete")) }) { let title = item .get("title") .and_then(|v| v.as_str()) .unwrap_or("Unknown") .to_string(); let id = item.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); self.delete_current_id = Some(id); self.delete_dialog.open(title, id.to_string()); ctx.request_repaint(); } if let Some(confirmed) = self.delete_dialog.show_dialog(ctx) { if confirmed { if let (Some(api), Some(id)) = (api_client, self.delete_current_id) { let where_clause = serde_json::json!({"id": id}); match api.delete("issue_tracker", where_clause) { Ok(resp) if resp.success => { self.load(api); } Ok(resp) => { self.last_error = Some(format!("Delete failed: {:?}", resp.error)); } Err(e) => { self.last_error = Some(format!("Delete error: {}", e)); } } } } } if let Some(result) = self.edit_dialog.show_editor(ctx) { if let Some(updated) = result { if let (Some(api), Some(id)) = (api_client, self.edit_current_id) { let values = serde_json::Value::Object(updated); let where_clause = serde_json::json!({"id": id}); match api.update("issue_tracker", values, where_clause) { Ok(resp) if resp.success => { self.load(api); } Ok(resp) => { self.last_error = Some(format!("Update failed: {:?}", resp.error)); } Err(e) => { self.last_error = Some(format!("Update error: {}", e)); } } } } } } fn render_summary(&self, ui: &mut egui::Ui) { ui.horizontal_wrapped(|ui| { for (status, n) in &self.summary_by_status { issues_chip(ui, format!("{}: {}", status, n), color_for_status(status)); } ui.separator(); for (sev, n) in &self.summary_by_severity { issues_chip(ui, format!("{}: {}", sev, n), color_for_severity(sev)); } }); ui.add_space(6.0); } fn recompute_summary(&mut self) { use std::collections::HashMap; let mut by_status: HashMap = HashMap::new(); let mut by_sev: HashMap = HashMap::new(); for r in &self.rows { let status = r .get("status") .and_then(|v| v.as_str()) .unwrap_or("Unknown") .to_string(); let sev = r .get("severity") .and_then(|v| v.as_str()) .unwrap_or("Unknown") .to_string(); *by_status.entry(status).or_insert(0) += 1; *by_sev.entry(sev).or_insert(0) += 1; } // Stable order for status let status_order = [ "Open", "In Progress", "On Hold", "Resolved", "Closed", "Unknown", ]; let mut status_vec: Vec<(String, i32)> = by_status.into_iter().collect(); status_vec.sort_by(|a, b| { let ia = status_order .iter() .position(|s| s.eq_ignore_ascii_case(&a.0)) .unwrap_or(status_order.len()); let ib = status_order .iter() .position(|s| s.eq_ignore_ascii_case(&b.0)) .unwrap_or(status_order.len()); ia.cmp(&ib) .then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())) }); // Stable order for severity let sev_order = ["Critical", "High", "Medium", "Low", "Unknown"]; let mut sev_vec: Vec<(String, i32)> = by_sev.into_iter().collect(); sev_vec.sort_by(|a, b| { let ia = sev_order .iter() .position(|s| s.eq_ignore_ascii_case(&a.0)) .unwrap_or(sev_order.len()); let ib = sev_order .iter() .position(|s| s.eq_ignore_ascii_case(&b.0)) .unwrap_or(sev_order.len()); ia.cmp(&ib) .then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())) }); self.summary_by_status = status_vec; self.summary_by_severity = sev_vec; } fn show_columns_window(&mut self, ui: &egui::Ui) { let ctx = ui.ctx(); let screen_rect = ctx.input(|i| { i.viewport().inner_rect.unwrap_or(egui::Rect::from_min_max( egui::pos2(0.0, 0.0), egui::pos2(1920.0, 1080.0), )) }); let max_w = (screen_rect.width() - 20.0).max(220.0); let max_h = (screen_rect.height() - 100.0).max(200.0); egui::Window::new("Column Selector") .collapsible(false) .resizable(true) .default_width(260.0) .default_height(360.0) .anchor(egui::Align2::RIGHT_TOP, [-10.0, 90.0]) .open(&mut self.show_column_panel) .min_size(egui::vec2(220.0, 200.0)) .max_size(egui::vec2(max_w, max_h)) .frame(egui::Frame { fill: egui::Color32::from_rgb(30, 30, 30), stroke: egui::Stroke::new(1.0, egui::Color32::from_gray(60)), inner_margin: egui::Margin::from(10.0), outer_margin: egui::Margin::ZERO, corner_radius: 6.0.into(), shadow: egui::epaint::Shadow::NONE, }) .show(ctx, |ui| { ui.heading("Columns"); ui.separator(); ui.add_space(8.0); egui::ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { for column in &mut self.columns { ui.checkbox(&mut column.visible, &column.name); } }); ui.add_space(8.0); ui.separator(); ui.horizontal(|ui| { if ui.button("Show All").clicked() { for col in &mut self.columns { col.visible = true; } } if ui.button("Hide All").clicked() { for col in &mut self.columns { col.visible = false; } } }); }); } fn render_table(&mut self, ui: &mut egui::Ui, visible_columns: &Vec) { use egui_extras::{Column, TableBuilder}; let mut table = TableBuilder::new(ui) .striped(true) .resizable(true) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)); // Add checkbox column first, then the rest table = table.column(Column::initial(28.0)); for _ in 0..visible_columns.len() { table = table.column(Column::remainder()); } table .header(22.0, |mut header| { // Select-all checkbox header.col(|ui| { let all_selected = self .rows .iter() .enumerate() .all(|(i, _)| self.selected_rows.contains(&i)); let mut chk = all_selected; if ui .checkbox(&mut chk, "") .on_hover_text("Select All") .clicked() { if chk { self.selected_rows = (0..self.rows.len()).collect(); } else { self.selected_rows.clear(); } } }); for col in visible_columns { header.col(|ui| { ui.strong(&col.name); }); } }) .body(|mut body| { for (idx, r) in self.rows.iter().enumerate() { let r_clone = r.clone(); let is_selected = self.selected_rows.contains(&idx); body.row(20.0, |mut row| { if is_selected { row.set_selected(true); } // Checkbox cell row.col(|ui| { let mut checked = self.selected_rows.contains(&idx); let resp = ui.checkbox(&mut checked, ""); if resp.changed() { let mods = ui.input(|i| i.modifiers); if mods.shift { let anchor = self.selection_anchor.unwrap_or(idx); let (a, b) = if anchor <= idx { (anchor, idx) } else { (idx, anchor) }; for i in a..=b { if checked { self.selected_rows.insert(i); } else { self.selected_rows.remove(&i); } } } else if mods.command || mods.ctrl { if checked { self.selected_rows.insert(idx); } else { self.selected_rows.remove(&idx); } self.selection_anchor = Some(idx); } else { self.selected_rows.clear(); if checked { self.selected_rows.insert(idx); self.selection_anchor = Some(idx); } } } }); // Data columns let mut combined: Option = None; for col in visible_columns { row.col(|ui| { let resp = render_issue_cell(ui, &r_clone, &col.field); combined = Some(match combined.take() { Some(p) => p.union(resp), None => resp, }); }); } let mut row_resp = row.response(); if let Some(c) = combined { row_resp = row_resp.union(c); } if row_resp.clicked() { let now = std::time::Instant::now(); let dbl = if let (Some(t), Some(rw)) = (self.last_click_time, self.last_click_row) { rw == idx && now.duration_since(t).as_millis() < 500 } else { false }; if dbl { row_resp.ctx.data_mut(|d| { d.insert_temp(egui::Id::new("iss_double_click_idx"), idx); d.insert_temp( egui::Id::new("iss_double_click_edit"), r_clone.clone(), ); }); } else { // Multi-select on row click let mods = row_resp.ctx.input(|i| i.modifiers); if mods.shift { let anchor = self.selection_anchor.unwrap_or(idx); let (a, b) = if anchor <= idx { (anchor, idx) } else { (idx, anchor) }; for i in a..=b { self.selected_rows.insert(i); } } else if mods.command || mods.ctrl { if self.selected_rows.contains(&idx) { self.selected_rows.remove(&idx); } else { self.selected_rows.insert(idx); } self.selection_anchor = Some(idx); } else { self.selected_rows.clear(); self.selected_rows.insert(idx); self.selection_anchor = Some(idx); } self.last_click_time = Some(now); self.last_click_row = Some(idx); } } row_resp.context_menu(|ui| { if ui .button(format!("{} Edit", egui_phosphor::regular::PENCIL)) .clicked() { ui.ctx().data_mut(|d| { d.insert_temp( egui::Id::new("iss_context_menu_edit"), r_clone.clone(), ) }); ui.close(); } ui.separator(); if ui .button(format!("{} Delete", egui_phosphor::regular::TRASH)) .clicked() { ui.ctx().data_mut(|d| { d.insert_temp( egui::Id::new("iss_context_menu_delete"), r_clone.clone(), ) }); ui.close(); } }); }); } }); } fn open_editor_with(&mut self, item: &serde_json::Value) { self.edit_current_id = item.get("id").and_then(|v| v.as_i64()); self.edit_dialog.open(item); } } fn issues_chip(ui: &mut egui::Ui, text: String, color: egui::Color32) { egui::Frame::new() .fill(color.linear_multiply(0.14)) .corner_radius(6.0) .inner_margin(egui::Margin { left: 8_i8, right: 8_i8, top: 4_i8, bottom: 4_i8, }) .show(ui, |ui| { ui.label(egui::RichText::new(text).color(color)); }); } fn color_for_status(status: &str) -> egui::Color32 { match status.to_lowercase().as_str() { "open" => egui::Color32::from_rgb(244, 67, 54), "in progress" => egui::Color32::from_rgb(255, 152, 0), "on hold" => egui::Color32::from_rgb(121, 85, 72), "resolved" => egui::Color32::from_rgb(76, 175, 80), "closed" => egui::Color32::from_rgb(96, 125, 139), _ => egui::Color32::GRAY, } } fn color_for_severity(sev: &str) -> egui::Color32 { match sev.to_lowercase().as_str() { "critical" => egui::Color32::from_rgb(244, 67, 54), "high" => egui::Color32::from_rgb(255, 152, 0), "medium" => egui::Color32::from_rgb(66, 165, 245), "low" => egui::Color32::from_rgb(158, 158, 158), _ => egui::Color32::GRAY, } } fn label_trunc(ui: &mut egui::Ui, text: &str, max: usize) -> egui::Response { if text.len() > max { let short = format!("{}…", &text[..max]); ui.label(short).on_hover_ui(|ui| { ui.label(text); }) } else { ui.label(text) } } fn render_issue_cell(ui: &mut egui::Ui, row: &serde_json::Value, field: &str) -> egui::Response { let t = |k: &str| row.get(k).and_then(|v| v.as_str()).unwrap_or(""); match field { "id" => ui.label( row.get("id") .and_then(|v| v.as_i64()) .unwrap_or(0) .to_string(), ), "title" => label_trunc(ui, t("title"), 60), "status" => ui.label(t("status")), "severity" => ui.label(t("severity")), "priority" => ui.label(t("priority")), "asset_label" => ui.label(t("asset_label")), "borrower_name" => ui.label(t("borrower_name")), "assigned_to_name" => ui.label(t("assigned_to_name")), "auto_detected" => ui.label( if row .get("auto_detected") .and_then(|v| v.as_bool()) .unwrap_or(false) { "Yes" } else { "No" }, ), "detection_trigger" => label_trunc(ui, t("detection_trigger"), 40), "updated_at" => ui.label(t("updated_at")), "created_at" => ui.label(t("created_at")), "resolved_date" => ui.label(t("resolved_date")), "description" => label_trunc(ui, t("description"), 80), "solution" => label_trunc(ui, t("solution"), 80), "solution_plus" => label_trunc(ui, t("solution_plus"), 80), "replacement_asset_id" => ui.label( row.get("replacement_asset_id") .and_then(|v| v.as_i64()) .unwrap_or(0) .to_string(), ), "cost" => ui.label(match row.get("cost") { Some(serde_json::Value::Number(n)) => n.to_string(), Some(serde_json::Value::String(s)) => s.clone(), _ => String::new(), }), "notes" => label_trunc(ui, t("notes"), 80), other => ui.label(format!("{}", other)), } }