use eframe::egui; use serde_json::{json, Value}; use crate::api::ApiClient; use crate::core::components::interactions::ConfirmDialog; use crate::core::tables::{get_audit_tasks, get_recent_audit_logs, get_recent_audits}; use crate::core::workflows::AuditWorkflow; use crate::core::{ColumnConfig, TableRenderer}; pub struct AuditsView { audits: Vec, logs: Vec, tasks: Vec, is_loading: bool, last_error: Option, init_loaded: bool, workflow: AuditWorkflow, zone_code_input: String, start_error: Option, start_success: Option, audits_table: TableRenderer, logs_table: TableRenderer, tasks_table: TableRenderer, tasks_loading: bool, task_error: Option, task_success: Option, task_delete_dialog: ConfirmDialog, pending_task_delete_id: Option, pending_task_delete_name: Option, task_editor: AuditTaskEditor, } impl AuditsView { pub fn new() -> Self { let audit_columns = Self::build_audit_columns(); let log_columns = Self::build_log_columns(); Self { audits: vec![], logs: vec![], tasks: vec![], is_loading: false, last_error: None, init_loaded: false, workflow: AuditWorkflow::new(), zone_code_input: String::new(), start_error: None, start_success: None, audits_table: TableRenderer::new() .with_columns(audit_columns) .with_default_sort("completed_at", false), logs_table: TableRenderer::new() .with_columns(log_columns) .with_default_sort("audit_date", false), tasks_table: TableRenderer::new() .with_columns(Self::build_task_columns()) .with_default_sort("updated_at", false) .with_search_fields(vec!["task_name".into(), "sequence_preview".into()]), tasks_loading: false, task_error: None, task_success: None, task_delete_dialog: ConfirmDialog::new( "Delete Audit Task", "Are you sure you want to delete this audit task? This cannot be undone.", ) .confirm_text("Delete Task") .dangerous(true), pending_task_delete_id: None, pending_task_delete_name: None, task_editor: AuditTaskEditor::new(), } } fn load(&mut self, api: &ApiClient) { if self.is_loading { return; } self.is_loading = true; self.tasks_loading = true; self.last_error = None; match get_recent_audits(api, Some(50)) { Ok(rows) => { self.audits = rows; self.audits_table.selection.clear_selection(); } Err(err) => { self.last_error = Some(err.to_string()); } } if self.last_error.is_none() { match get_recent_audit_logs(api, Some(200)) { Ok(rows) => { self.logs = rows; self.logs_table.selection.clear_selection(); } Err(err) => { self.last_error = Some(err.to_string()); } } } if self.last_error.is_none() { match get_audit_tasks(api, Some(200)) { Ok(rows) => { self.tasks = rows; self.tasks_table.selection.clear_selection(); } Err(err) => { self.last_error = Some(err.to_string()); } } } self.is_loading = false; self.tasks_loading = false; self.init_loaded = true; } fn render_launch_controls( &mut self, ui: &mut egui::Ui, api: &ApiClient, current_user_id: Option, ) { egui::Frame::group(ui.style()) .fill(ui.style().visuals.extreme_bg_color) .inner_margin(egui::Margin { left: 12, right: 12, top: 2, bottom: 2, }) .corner_radius(8.0) .show(ui, |ui| { ui.set_width(ui.available_width()); let control_height = ui.spacing().interact_size.y; let needs_error_margin = self.start_error.is_some(); let needs_progress_msg = self.workflow.is_active(); if !needs_error_margin { let extra = if needs_progress_msg { 16.0 } else { 8.0 }; ui.set_max_height(control_height + extra); } if self.workflow.is_active() { ui.colored_label( egui::Color32::from_rgb(66, 133, 244), "Audit in progress. Continue in the workflow window.", ); } ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { let btn_w: f32 = 140.0; ui.label("Zone Code:"); // Compute input width based on remaining space after two fixed-width buttons let spacing = ui.spacing().item_spacing.x; let remaining = ui.available_width(); let reserve_for_buttons = btn_w * 2.0 + spacing * 2.0; let input_w = (remaining - reserve_for_buttons).max(200.0); let text_resp = ui.add( egui::TextEdit::singleline(&mut self.zone_code_input) .hint_text("ZONE-ABC") .desired_width(input_w), ); let disable_new = self.workflow.is_active(); let start_zone_clicked_button = ui .add_enabled( !disable_new, egui::Button::new("Start Zone Audit").min_size(egui::vec2(btn_w, 0.0)), ) .clicked(); let start_spot_clicked = ui .add_enabled( !disable_new, egui::Button::new("Start Spot Check").min_size(egui::vec2(btn_w, 0.0)), ) .clicked(); let start_zone_pressed_enter = text_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); let start_zone_clicked = start_zone_clicked_button || start_zone_pressed_enter; if start_zone_clicked { if let Some(user_id) = current_user_id { let code = self.zone_code_input.trim(); if code.is_empty() { self.start_error = Some("Enter a zone code to start an audit".to_string()); self.start_success = None; } else { match self.workflow.start_zone_audit(api, code, user_id as i64) { Ok(()) => { self.start_error = None; self.start_success = Some(format!("Zone audit started for {}", code)); self.zone_code_input.clear(); } Err(err) => { self.start_error = Some(err.to_string()); self.start_success = None; } } } } else { self.start_error = Some("You must be logged in to start an audit".to_string()); self.start_success = None; } } if start_spot_clicked { if let Some(user_id) = current_user_id { self.workflow.start_spot_check(user_id as i64); self.start_error = None; self.start_success = Some("Spot check started".to_string()); } else { self.start_error = Some("You must be logged in to start a spot check".to_string()); self.start_success = None; } } }); if let Some(err) = &self.start_error { ui.add_space(4.0); ui.colored_label(egui::Color32::from_rgb(244, 67, 54), err); } if self.workflow.is_active() { if let Some(msg) = &self.start_success { ui.add_space(4.0); ui.colored_label(egui::Color32::from_rgb(76, 175, 80), msg); } } }); } fn build_audit_columns() -> Vec { vec![ ColumnConfig::new("ID", "id").with_width(60.0), ColumnConfig::new("Type", "audit_type").with_width(90.0), ColumnConfig::new("Zone", "zone_display").with_width(140.0), ColumnConfig::new("Audit Name", "audit_name").with_width(160.0), ColumnConfig::new("Started By", "started_by_name").with_width(140.0), ColumnConfig::new("Started At", "started_at").with_width(150.0), ColumnConfig::new("Completed At", "completed_at").with_width(150.0), ColumnConfig::new("Status", "status").with_width(110.0), ColumnConfig::new("Timeout (min)", "timeout_minutes").with_width(110.0), ColumnConfig::new("Issues", "issues_summary").with_width(220.0), ColumnConfig::new("Expected", "assets_expected").with_width(90.0), ColumnConfig::new("Found", "assets_found").with_width(90.0), ColumnConfig::new("Notes", "notes").with_width(200.0), ColumnConfig::new("Cancelled Reason", "cancelled_reason").with_width(220.0), ] } fn build_log_columns() -> Vec { vec![ ColumnConfig::new("ID", "id").with_width(60.0), ColumnConfig::new("Audit ID", "physical_audit_id").with_width(80.0), ColumnConfig::new("Asset", "asset_display").with_width(160.0), ColumnConfig::new("Audit Date", "audit_date").with_width(140.0), ColumnConfig::new("Audited By", "audited_by_name").with_width(140.0), ColumnConfig::new("Status Found", "status_found").with_width(110.0), ColumnConfig::new("Task ID", "audit_task_id").with_width(80.0), ColumnConfig::new("Task Responses", "task_responses_text").with_width(240.0), ColumnConfig::new("Exception", "exception_type").with_width(120.0), ColumnConfig::new("Details", "exception_details").with_width(220.0), ColumnConfig::new("Found Zone", "found_zone_display").with_width(160.0), ColumnConfig::new("Action", "auditor_action").with_width(140.0), ColumnConfig::new("Notes", "notes").with_width(200.0), ] } fn build_task_columns() -> Vec { vec![ ColumnConfig::new("ID", "id").with_width(60.0), ColumnConfig::new("Task Name", "task_name").with_width(180.0), ColumnConfig::new("Step Count", "step_count").with_width(90.0), ColumnConfig::new("Sequence Preview", "sequence_preview").with_width(280.0), ColumnConfig::new("Created", "created_at").with_width(150.0), ColumnConfig::new("Updated", "updated_at").with_width(150.0), ] } pub fn show( &mut self, ctx: &egui::Context, ui: &mut egui::Ui, api_client: Option<&ApiClient>, current_user_id: Option, ) { egui::ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { ui.set_width(ui.available_width()); ui.horizontal(|ui| { ui.heading("Audits"); 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); } } if let Some(msg) = &self.start_success { if !self.workflow.is_active() { ui.colored_label(egui::Color32::from_rgb(76, 175, 80), msg); } } }); ui.separator(); if let Some(api) = api_client { self.render_launch_controls(ui, api, current_user_id); } if !self.init_loaded { if let Some(api) = api_client { self.load(api); } } if let Some(msg) = &self.start_success { if !self.workflow.is_active() { ui.add_space(6.0); ui.colored_label(egui::Color32::from_rgb(76, 175, 80), msg); } } self.render_summary(ui); egui::CollapsingHeader::new("Recent Audits") .default_open(true) .show(ui, |ui| { self.render_audits_table(ui); }); ui.add_space(10.0); egui::CollapsingHeader::new("Audit Logs") .default_open(true) .show(ui, |ui| { self.render_logs_table(ui); }); ui.add_space(10.0); egui::CollapsingHeader::new("Audit Task Library") .default_open(false) .show(ui, |ui| { self.render_tasks_section(ui, api_client); }); }); if let Some(result) = self.task_editor.show(ctx) { if let Some(api) = api_client { self.save_task(api, result); } else { self.task_error = Some("No API client available; cannot save audit task changes.".to_string()); } } if let Some(decision) = self.task_delete_dialog.show_dialog(ctx) { if decision { if let (Some(api), Some(id)) = (api_client, self.pending_task_delete_id) { self.delete_task(api, id); } else { self.task_error = Some("No API client available; cannot delete audit tasks.".to_string()); } } else { self.pending_task_delete_id = None; self.pending_task_delete_name = None; } } if let Some(api) = api_client { if self.workflow.show(ctx, api) { // Window stays open, nothing else to do here. } if let Some(completion) = self.workflow.take_recent_completion() { self.load(api); let banner = match completion.status.as_str() { "cancelled" => "Audit cancelled".to_string(), "all-good" => "Audit completed successfully".to_string(), other => format!("Audit finished with status: {}", other), }; self.start_success = Some(banner); } } } fn render_summary(&self, ui: &mut egui::Ui) { // derive counts from loaded audits let total = self.audits.len() as i64; let mut in_progress = 0; let mut attention = 0; let mut timeout = 0; let mut cancelled = 0; let mut all_good = 0; for a in &self.audits { match a.get("status").and_then(|v| v.as_str()).unwrap_or("") { "in-progress" => in_progress += 1, "attention" => attention += 1, "timeout" => timeout += 1, "cancelled" => cancelled += 1, "all-good" => all_good += 1, _ => {} } } ui.horizontal_wrapped(|ui| { ui.label(egui::RichText::new(format!("Total: {}", total)).strong()); ui.separator(); chip( ui, format!("In progress: {}", in_progress), egui::Color32::from_rgb(66, 133, 244), ); chip( ui, format!("Attention: {}", attention), egui::Color32::from_rgb(255, 152, 0), ); chip( ui, format!("Timeout: {}", timeout), egui::Color32::from_rgb(244, 67, 54), ); chip( ui, format!("Cancelled: {}", cancelled), egui::Color32::from_rgb(158, 158, 158), ); chip( ui, format!("All good: {}", all_good), egui::Color32::from_rgb(76, 175, 80), ); }); ui.add_space(6.0); fn 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, right: 8, top: 4, bottom: 4, }) .show(ui, |ui| { ui.label(egui::RichText::new(text).color(color)); }); } } fn render_audits_table(&mut self, ui: &mut egui::Ui) { if self.audits.is_empty() { ui.label("No recent audits found."); return; } let prepared = self.audits_table.prepare_json_data(&self.audits); self.audits_table.render_json_table(ui, &prepared, None); } fn render_logs_table(&mut self, ui: &mut egui::Ui) { if self.logs.is_empty() { ui.label("No audit logs found."); return; } let prepared = self.logs_table.prepare_json_data(&self.logs); self.logs_table.render_json_table(ui, &prepared, None); } fn render_tasks_section(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { ui.horizontal(|ui| { ui.label("Search:"); ui.text_edit_singleline(&mut self.tasks_table.search_query); ui.separator(); let has_api = api_client.is_some(); if ui .add_enabled(has_api, egui::Button::new("New Task")) .clicked() { self.task_error = None; self.task_success = None; self.task_editor.open_new(); } if ui .add_enabled(has_api, egui::Button::new("Refresh")) .clicked() { if let Some(api) = api_client { self.task_error = None; self.task_success = None; self.refresh_tasks(api); } } }); if let Some(err) = &self.task_error { ui.add_space(6.0); ui.colored_label(egui::Color32::from_rgb(244, 67, 54), err); } if let Some(msg) = &self.task_success { ui.add_space(6.0); ui.colored_label(egui::Color32::from_rgb(76, 175, 80), msg); } ui.add_space(6.0); if self.tasks_loading { ui.horizontal(|ui| { ui.spinner(); ui.label("Loading audit tasks..."); }); return; } if self.tasks.is_empty() { ui.label("No audit tasks found."); return; } let prepared = self.tasks_table.prepare_json_data(&self.tasks); let mut edit_task: Option = None; let mut clone_task: Option = None; let mut delete_task: Option = None; struct TaskEventHandler<'a> { edit_action: &'a mut Option, clone_action: &'a mut Option, delete_action: &'a mut Option, } impl<'a> crate::core::table_renderer::TableEventHandler for TaskEventHandler<'a> { fn on_double_click(&mut self, item: &Value, _row_index: usize) { *self.edit_action = Some(item.clone()); } fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) { if ui .button(format!("{} Edit Task", egui_phosphor::regular::PENCIL)) .clicked() { *self.edit_action = Some(item.clone()); ui.close(); } if ui .button(format!("{} Clone Task", egui_phosphor::regular::COPY)) .clicked() { *self.clone_action = Some(item.clone()); ui.close(); } ui.separator(); if ui .button(format!("{} Delete Task", egui_phosphor::regular::TRASH)) .clicked() { *self.delete_action = Some(item.clone()); ui.close(); } } fn on_selection_changed(&mut self, _selected_indices: &[usize]) {} } let mut handler = TaskEventHandler { edit_action: &mut edit_task, clone_action: &mut clone_task, delete_action: &mut delete_task, }; self.tasks_table .render_json_table(ui, &prepared, Some(&mut handler)); if let Some(task) = edit_task { self.task_error = None; self.task_success = None; self.task_editor.open_edit(&task); } if let Some(task) = clone_task { self.task_error = None; self.task_success = None; self.task_editor.open_clone(&task); } if let Some(task) = delete_task { if let Some(id) = task.get("id").and_then(|v| v.as_i64()) { let name = task .get("task_name") .and_then(|v| v.as_str()) .unwrap_or("Unnamed Task") .to_string(); self.pending_task_delete_id = Some(id); self.pending_task_delete_name = Some(name.clone()); self.task_delete_dialog.open(name, id.to_string()); } } } fn refresh_tasks(&mut self, api: &ApiClient) { self.tasks_loading = true; match get_audit_tasks(api, Some(200)) { Ok(rows) => { self.tasks = rows; self.tasks_table.selection.clear_selection(); self.task_error = None; } Err(err) => { self.task_error = Some(err.to_string()); } } self.tasks_loading = false; } fn save_task(&mut self, api: &ApiClient, result: AuditTaskEditorResult) { self.task_error = None; self.task_success = None; let AuditTaskEditorResult { id, name, sequence, is_new, } = result; let mut payload = serde_json::Map::new(); payload.insert("task_name".into(), Value::String(name.clone())); payload.insert("json_sequence".into(), sequence); let payload_value = Value::Object(payload); if is_new { match api.insert("audit_tasks", payload_value) { Ok(resp) if resp.success => { self.task_success = Some(format!("Created audit task \"{}\".", name)); self.refresh_tasks(api); } Ok(resp) => { self.task_error = Some(format!("Insert failed: {:?}", resp.error)); } Err(err) => { self.task_error = Some(format!("Insert error: {}", err)); } } } else if let Some(task_id) = id { let where_clause = json!({ "id": task_id }); match api.update("audit_tasks", payload_value, where_clause) { Ok(resp) if resp.success => { self.task_success = Some(format!("Updated audit task \"{}\".", name)); self.refresh_tasks(api); } Ok(resp) => { self.task_error = Some(format!("Update failed: {:?}", resp.error)); } Err(err) => { self.task_error = Some(format!("Update error: {}", err)); } } } else { self.task_error = Some("Missing task identifier; cannot update.".to_string()); } } fn delete_task(&mut self, api: &ApiClient, id: i64) { let where_clause = json!({ "id": id }); match api.delete("audit_tasks", where_clause) { Ok(resp) if resp.success => { let name = self .pending_task_delete_name .take() .unwrap_or_else(|| format!("Task #{id}")); self.task_success = Some(format!("Deleted audit task \"{}\".", name)); self.refresh_tasks(api); } Ok(resp) => { self.task_error = Some(format!("Delete failed: {:?}", resp.error)); } Err(err) => { self.task_error = Some(format!("Delete error: {}", err)); } } self.pending_task_delete_id = None; self.pending_task_delete_name = None; } } struct AuditTaskEditor { open: bool, is_new: bool, current_id: Option, task_name: String, sequence_text: String, error: Option, } impl AuditTaskEditor { fn new() -> Self { Self { open: false, is_new: true, current_id: None, task_name: String::new(), sequence_text: "[]".to_string(), error: None, } } fn open_new(&mut self) { self.open = true; self.is_new = true; self.current_id = None; self.task_name.clear(); self.sequence_text = "[]".to_string(); self.error = None; } fn open_edit(&mut self, task: &Value) { self.open = true; self.is_new = false; self.current_id = task.get("id").and_then(|v| v.as_i64()); self.task_name = task .get("task_name") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); self.sequence_text = task .get("json_sequence") .map(|seq| serde_json::to_string_pretty(seq).unwrap_or_else(|_| seq.to_string())) .unwrap_or_else(|| "[]".to_string()); self.error = None; } fn open_clone(&mut self, task: &Value) { self.open_edit(task); self.is_new = true; self.current_id = None; if self.task_name.is_empty() { self.task_name = "Copied Task".to_string(); } else { self.task_name = format!("{} (Copy)", self.task_name); } } fn show(&mut self, ctx: &egui::Context) -> Option { if !self.open { return None; } let mut window_open = true; let mut close_requested = false; let mut outcome: Option = None; let title = if self.is_new { "New Audit Task" } else { "Edit Audit Task" }; let root_bounds = ctx.available_rect(); let screen_bounds = ctx.input(|i| { i.viewport().inner_rect.unwrap_or(egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(800.0, 600.0), )) }); let horizontal_margin = 24.0_f32; let vertical_margin = 24.0_f32; let available_max_w = (root_bounds.width() - horizontal_margin).max(420.0_f32); let screen_max_w = (screen_bounds.width() - horizontal_margin).max(420.0_f32); let max_w = available_max_w.min(screen_max_w); let available_max_h = (root_bounds.height() - vertical_margin).max(360.0_f32); let screen_max_h = (screen_bounds.height() - vertical_margin).max(360.0_f32); let max_h = available_max_h.min(screen_max_h); let default_w = 520.0_f32.clamp(360.0_f32.min(max_w), max_w); let default_h = (root_bounds.height() * 0.6_f32) .max(360.0_f32) .clamp(320.0_f32.min(max_h), max_h); let min_w = max_w.min(380.0_f32).max(320.0_f32.min(max_w)); let min_h = max_h.min(340.0_f32).max(300.0_f32.min(max_h)); egui::Window::new(title) .collapsible(false) .resizable(true) .movable(true) .default_size(egui::vec2(default_w, default_h)) .min_size(egui::vec2(min_w, min_h)) .max_size(egui::vec2(max_w, max_h)) .constrain_to(screen_bounds.shrink2(egui::vec2(12.0_f32, 12.0_f32))) .open(&mut window_open) .show(ctx, |ui| { if let Some(err) = &self.error { ui.colored_label(egui::Color32::from_rgb(244, 67, 54), err); ui.add_space(8.0); } let reserved_footer = 72.0_f32; let scroll_height = (ui.available_height() - reserved_footer).max(160.0_f32); egui::ScrollArea::vertical() .max_height(scroll_height) .auto_shrink([false, false]) .show(ui, |ui| { ui.label("Task Name"); ui.text_edit_singleline(&mut self.task_name); ui.add_space(8.0); ui.label("JSON Sequence"); ui.add( egui::TextEdit::multiline(&mut self.sequence_text) .desired_rows(14) .desired_width(f32::INFINITY), ); }); ui.add_space(12.0); ui.separator(); ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Save Task").clicked() { let name = self.task_name.trim(); if name.is_empty() { self.error = Some("Task name cannot be empty.".to_string()); } else { match serde_json::from_str::(&self.sequence_text) { Ok(sequence) => { self.error = None; outcome = Some(AuditTaskEditorResult { id: self.current_id, name: name.to_string(), sequence, is_new: self.is_new, }); close_requested = true; } Err(err) => { self.error = Some(format!("Invalid JSON: {}", err)); } } } } ui.add_space(12.0); if ui.button("Cancel").clicked() { close_requested = true; } }); }); if !window_open || close_requested { self.open = false; } outcome } } struct AuditTaskEditorResult { id: Option, name: String, sequence: Value, is_new: bool, }