aboutsummaryrefslogtreecommitdiff
path: root/src/ui/audits.rs
diff options
context:
space:
mode:
authorUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
committerUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
commit8323fdd73272a2882781aba3c499ba0be3dff2a6 (patch)
treeffbf86473933e69cfaeef30d5c6ea7e5b494856c /src/ui/audits.rs
committing to insanityHEADmaster
Diffstat (limited to 'src/ui/audits.rs')
-rw-r--r--src/ui/audits.rs898
1 files changed, 898 insertions, 0 deletions
diff --git a/src/ui/audits.rs b/src/ui/audits.rs
new file mode 100644
index 0000000..b6773f6
--- /dev/null
+++ b/src/ui/audits.rs
@@ -0,0 +1,898 @@
+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<Value>,
+ logs: Vec<Value>,
+ tasks: Vec<Value>,
+ is_loading: bool,
+ last_error: Option<String>,
+ init_loaded: bool,
+ workflow: AuditWorkflow,
+ zone_code_input: String,
+ start_error: Option<String>,
+ start_success: Option<String>,
+ audits_table: TableRenderer,
+ logs_table: TableRenderer,
+ tasks_table: TableRenderer,
+ tasks_loading: bool,
+ task_error: Option<String>,
+ task_success: Option<String>,
+ task_delete_dialog: ConfirmDialog,
+ pending_task_delete_id: Option<i64>,
+ pending_task_delete_name: Option<String>,
+ 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<i32>,
+ ) {
+ 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<ColumnConfig> {
+ 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<ColumnConfig> {
+ 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<ColumnConfig> {
+ 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<i32>,
+ ) {
+ 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<Value> = None;
+ let mut clone_task: Option<Value> = None;
+ let mut delete_task: Option<Value> = None;
+
+ struct TaskEventHandler<'a> {
+ edit_action: &'a mut Option<Value>,
+ clone_action: &'a mut Option<Value>,
+ delete_action: &'a mut Option<Value>,
+ }
+
+ impl<'a> crate::core::table_renderer::TableEventHandler<Value> 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<i64>,
+ task_name: String,
+ sequence_text: String,
+ error: Option<String>,
+}
+
+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<AuditTaskEditorResult> {
+ if !self.open {
+ return None;
+ }
+
+ let mut window_open = true;
+ let mut close_requested = false;
+ let mut outcome: Option<AuditTaskEditorResult> = 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::<Value>(&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<i64>,
+ name: String,
+ sequence: Value,
+ is_new: bool,
+}