diff options
Diffstat (limited to 'src/ui/issues.rs')
| -rw-r--r-- | src/ui/issues.rs | 773 |
1 files changed, 773 insertions, 0 deletions
diff --git a/src/ui/issues.rs b/src/ui/issues.rs new file mode 100644 index 0000000..163a500 --- /dev/null +++ b/src/ui/issues.rs @@ -0,0 +1,773 @@ +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<serde_json::Value>, + is_loading: bool, + last_error: Option<String>, + 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<ColumnConfig>, + show_column_panel: bool, + // Selection & interactions + selected_row: Option<usize>, + last_click_time: Option<std::time::Instant>, + last_click_row: Option<usize>, + selected_rows: HashSet<usize>, + selection_anchor: Option<usize>, + // Dialogs + delete_dialog: ConfirmDialog, + edit_dialog: FormBuilder, + // Track ids for operations + edit_current_id: Option<i64>, + delete_current_id: Option<i64>, +} + +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<ColumnConfig> = + 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::<usize>(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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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<String, i32> = HashMap::new(); + let mut by_sev: HashMap<String, i32> = 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<ColumnConfig>) { + 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<egui::Response> = 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)), + } +} |
