use std::collections::HashMap; use std::convert::TryFrom; use anyhow::{anyhow, Context, Result}; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use base64::Engine as _; use chrono::{DateTime, Utc}; use eframe::egui; use serde::Deserialize; use serde_json::{json, Map, Value}; use crate::api::ApiClient; use crate::core::components::interactions::ConfirmDialog; use crate::core::tables::{ find_asset_by_tag_or_numeric, find_zone_by_code, get_assets_in_zone, get_audit_task_definition, }; const STATUS_OPTIONS: &[&str] = &[ "Good", "Attention", "Faulty", "Missing", "Retired", "In Repair", "In Transit", "Expired", "Unmanaged", ]; const EXCEPTION_WRONG_ZONE: &str = "wrong-zone"; const EXCEPTION_UNEXPECTED_ASSET: &str = "unexpected-asset"; const EXCEPTION_OTHER: &str = "other"; const DEFAULT_MISSING_DETAIL: &str = "Marked missing during audit"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuditMode { FullZone, SpotCheck, } #[derive(Debug, Clone)] struct ZoneInfo { id: i64, zone_code: Option, zone_name: String, _zone_type: Option, audit_timeout_minutes: Option, } impl ZoneInfo { fn from_value(value: &Value) -> Result { let id = value .get("id") .and_then(|v| v.as_i64()) .ok_or_else(|| anyhow!("Zone record missing id"))?; let zone_name = value .get("zone_name") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); Ok(Self { id, zone_code: value .get("zone_code") .and_then(|v| v.as_str()) .map(|s| s.to_string()), zone_name, _zone_type: value .get("zone_type") .and_then(|v| v.as_str()) .map(|s| s.to_string()), audit_timeout_minutes: value.get("audit_timeout_minutes").and_then(|v| v.as_i64()), }) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AuditScanPolicy { Required, Ask, Skip, } impl AuditScanPolicy { fn from_value(value: Option<&Value>) -> Self { match value.and_then(|v| v.as_str()).map(|s| s.to_lowercase()) { Some(ref s) if s == "yes" => AuditScanPolicy::Skip, Some(ref s) if s == "ask" => AuditScanPolicy::Ask, _ => AuditScanPolicy::Required, } } } #[derive(Debug, Clone)] struct AuditAssetState { asset_id: i64, asset_numeric_id: Option, asset_tag: String, name: String, _status_before: Option, scan_policy: AuditScanPolicy, audit_task_id: Option, expected: bool, _expected_zone_id: Option, _actual_zone_id: Option, scanned: bool, status_found: String, notes: String, task_responses: Option, additional_fields: Map, exception_type: Option, exception_details: Option, completed_at: Option>, } impl AuditAssetState { fn from_value(value: Value, expected_zone_id: Option, expected: bool) -> Result { let asset_id = value .get("id") .and_then(|v| v.as_i64()) .ok_or_else(|| anyhow!("Asset record missing id"))?; let asset_tag = value .get("asset_tag") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let name = value .get("name") .and_then(|v| v.as_str()) .unwrap_or("Unnamed Asset") .to_string(); let status_before = value .get("status") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let scan_policy = AuditScanPolicy::from_value(value.get("no_scan")); let status_found = status_before.clone().unwrap_or_else(|| "Good".to_string()); Ok(Self { asset_id, asset_numeric_id: value.get("asset_numeric_id").and_then(|v| v.as_i64()), asset_tag, name, _status_before: status_before, scan_policy, audit_task_id: value.get("audit_task_id").and_then(|v| v.as_i64()), expected, _expected_zone_id: expected_zone_id, _actual_zone_id: value.get("zone_id").and_then(|v| v.as_i64()), scanned: matches!(scan_policy, AuditScanPolicy::Skip), status_found, notes: String::new(), task_responses: None, additional_fields: Map::new(), exception_type: None, exception_details: None, completed_at: if matches!(scan_policy, AuditScanPolicy::Skip) { Some(Utc::now()) } else { None }, }) } fn requires_scan(&self) -> bool { self.expected && matches!(self.scan_policy, AuditScanPolicy::Required) } fn matches_identifier(&self, identifier: &str) -> bool { let normalized = identifier.trim().to_lowercase(); if normalized.is_empty() { return false; } let tag_match = !self.asset_tag.is_empty() && self.asset_tag.to_lowercase() == normalized; let numeric_match = self .asset_numeric_id .map(|n| n.to_string() == normalized) .unwrap_or(false); tag_match || numeric_match } fn display_label(&self, mode: AuditMode) -> String { let mut label = format!("{} — {}", self.asset_tag, self.name); if !self.expected { label.push_str(match mode { AuditMode::FullZone => " (unexpected)", AuditMode::SpotCheck => " (spot check)", }); } if matches!(self.scan_policy, AuditScanPolicy::Ask) && !self.scanned { label.push_str(" (confirm)"); } else if !self.requires_scan() { label.push_str(" (auto)"); } else if !self.scanned { label.push_str(" (pending)"); } label } fn set_status(&mut self, status: &str, mark_scanned: bool) { self.status_found = status.to_string(); if mark_scanned { self.scanned = true; self.completed_at = Some(Utc::now()); } if status == "Missing" { if matches!(self.scan_policy, AuditScanPolicy::Ask) && !self.scanned { // Leave confirmation-driven assets pending until explicitly handled self.status_found = "Missing".to_string(); self.scanned = false; self.completed_at = None; return; } self.exception_type = Some(EXCEPTION_OTHER.to_string()); if self .exception_details .as_deref() .map(|d| d == DEFAULT_MISSING_DETAIL) .unwrap_or(true) { self.exception_details = Some(DEFAULT_MISSING_DETAIL.to_string()); } } else if self .exception_type .as_deref() .map(|t| t == EXCEPTION_OTHER) .unwrap_or(false) && self .exception_details .as_deref() .map(|d| d == DEFAULT_MISSING_DETAIL) .unwrap_or(false) { self.exception_type = None; self.exception_details = None; } } } #[derive(Debug, Clone)] struct TaskRunnerState { asset_index: usize, runner: AuditTaskRunner, } #[derive(Debug, Clone)] struct AuditTaskOutcome { status_override: Option, additional_fields: Map, responses: Value, } #[derive(Debug, Clone)] struct TaskResponseEntry { step: i64, question: String, answer: Value, } #[allow(dead_code)] #[derive(Debug, Clone)] pub struct AuditCompletion { pub audit_id: i64, pub status: String, } #[derive(Debug, Clone, Copy)] enum PendingFinalizeIntent { FromButton { needs_force: bool }, FromDialog { force_missing: bool }, } pub struct AuditWorkflow { is_open: bool, mode: AuditMode, zone_info: Option, expected_assets: Vec, selected_asset: Option, scan_input: String, notes: String, audit_name: String, started_at: Option>, timeout_minutes: Option, last_error: Option, ask_dialog: ConfirmDialog, pending_ask_index: Option, cancel_dialog: ConfirmDialog, finalize_dialog: ConfirmDialog, current_task_runner: Option, cached_tasks: HashMap, has_recent_completion: bool, completion_snapshot: Option, user_id: Option, pending_finalize: Option, } impl AuditWorkflow { pub fn new() -> Self { Self { is_open: false, mode: AuditMode::FullZone, zone_info: None, expected_assets: Vec::new(), selected_asset: None, scan_input: String::new(), notes: String::new(), audit_name: String::new(), started_at: None, timeout_minutes: None, last_error: None, ask_dialog: ConfirmDialog::new( "Confirm Asset", "This asset is marked as 'Ask'. Confirm to include it in the audit progress.", ) .dangerous(false) .confirm_text("Confirm") .cancel_text("Skip"), pending_ask_index: None, cancel_dialog: ConfirmDialog::new( "Cancel Audit", "Are you sure you want to cancel the current audit? Progress will be lost.", ) .dangerous(true) .confirm_text("Cancel Audit") .cancel_text("Keep Working"), finalize_dialog: ConfirmDialog::new( "Complete Audit", "Some required assets have not been scanned. They will be marked as Missing if you continue.", ) .dangerous(true) .confirm_text("Mark Missing & Complete") .cancel_text("Go Back"), current_task_runner: None, cached_tasks: HashMap::new(), has_recent_completion: false, completion_snapshot: None, user_id: None, pending_finalize: None, } } pub fn is_active(&self) -> bool { self.is_open } pub fn start_zone_audit( &mut self, api_client: &ApiClient, zone_code: &str, user_id: i64, ) -> Result<()> { let zone_value = find_zone_by_code(api_client, zone_code)? .ok_or_else(|| anyhow!("Zone '{}' was not found", zone_code))?; let zone = ZoneInfo::from_value(&zone_value)?; let zone_id = i32::try_from(zone.id).context("Zone identifier exceeds i32 range")?; let raw_assets = get_assets_in_zone(api_client, zone_id, Some(1_000))?; let mut assets = Vec::with_capacity(raw_assets.len()); for value in raw_assets { let mut state = AuditAssetState::from_value(value, Some(zone.id), true)?; if matches!(state.scan_policy, AuditScanPolicy::Skip) { state.completed_at = Some(Utc::now()); } assets.push(state); } self.reset_core_state(); self.has_recent_completion = false; self.completion_snapshot = None; self.is_open = true; self.mode = AuditMode::FullZone; self.zone_info = Some(zone.clone()); self.expected_assets = assets; self.started_at = Some(Utc::now()); self.timeout_minutes = zone.audit_timeout_minutes; self.audit_name = format!("Zone {} Audit", zone.zone_name); self.user_id = Some(user_id); self.last_error = None; self.ensure_skip_assets_recorded(); Ok(()) } pub fn start_spot_check(&mut self, user_id: i64) { self.reset_core_state(); self.has_recent_completion = false; self.completion_snapshot = None; self.is_open = true; self.mode = AuditMode::SpotCheck; self.audit_name = format!("Spot Check {}", Utc::now().format("%Y-%m-%d %H:%M")); self.started_at = Some(Utc::now()); self.user_id = Some(user_id); self.last_error = None; } pub fn show(&mut self, ctx: &egui::Context, api_client: &ApiClient) -> bool { if !self.is_open { return false; } let mut keep_open = self.is_open; let window_title = match self.mode { AuditMode::FullZone => "Zone Audit", AuditMode::SpotCheck => "Spot Check", }; 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 mut max_size = screen_rect.size() - egui::vec2(32.0, 32.0); max_size.x = max_size.x.max(860.0).min(screen_rect.width()); max_size.y = max_size.y.max(520.0).min(screen_rect.height()); let mut default_size = egui::vec2(1040.0, 680.0); default_size.x = default_size.x.min(max_size.x); default_size.y = default_size.y.min(max_size.y); egui::Window::new(window_title) .id(egui::Id::new("audit_workflow_window")) .collapsible(false) .resizable(true) .default_size(default_size) .max_size(max_size) .min_size(egui::vec2(820.0, 520.0)) .open(&mut keep_open) .show(ctx, |ui| { if let Some(zone) = &self.zone_info { ui.horizontal(|ui| { ui.heading(format!( "Auditing {} ({})", zone.zone_name, zone.zone_code.as_deref().unwrap_or("no-code") )); if let Some(timeout) = zone.audit_timeout_minutes { ui.add_space(12.0); ui.label(format!("Timeout: {} min", timeout)); } }); } else { ui.heading(&self.audit_name); } if let Some(err) = &self.last_error { ui.add_space(8.0); ui.colored_label(egui::Color32::RED, err); } ui.add_space(8.0); self.render_scanning(ui, ctx, api_client); }); if !keep_open { self.cancel_without_saving(); } if let Some(result) = self.ask_dialog.show_dialog(ctx) { self.process_ask_dialog(result, api_client); } if let Some(result) = self.cancel_dialog.show_dialog(ctx) { if result { match self.cancel_audit(api_client) { Ok(()) => { keep_open = false; } Err(err) => { self.last_error = Some(err.to_string()); } } } } if let Some(result) = self.finalize_dialog.show_dialog(ctx) { if result { if self.trigger_pending_ask(PendingFinalizeIntent::FromDialog { force_missing: true, }) { // Ask dialog opened; finalize will continue after confirmations. } else if let Err(err) = self.finalize_audit(api_client, true) { self.last_error = Some(err.to_string()); } } } if let Some(mut state) = self.current_task_runner.take() { if let Some(outcome) = state.runner.show(ctx) { self.apply_task_outcome(state.asset_index, outcome); } else if state.runner.is_open() { self.current_task_runner = Some(state); } } if !self.is_open { keep_open = false; } self.is_open = keep_open; keep_open } pub fn take_recent_completion(&mut self) -> Option { if self.has_recent_completion { self.has_recent_completion = false; self.completion_snapshot.take() } else { None } } fn reset_core_state(&mut self) { self.is_open = false; self.zone_info = None; self.expected_assets.clear(); self.selected_asset = None; self.scan_input.clear(); self.notes.clear(); self.audit_name.clear(); self.started_at = None; self.timeout_minutes = None; self.last_error = None; self.pending_ask_index = None; self.current_task_runner = None; self.user_id = None; self.pending_finalize = None; // Preserve cached_tasks so audit tasks are reused between runs } fn cancel_without_saving(&mut self) { self.reset_core_state(); } fn cancel_audit(&mut self, api_client: &ApiClient) -> Result<()> { if !self.is_open { return Ok(()); } if self.started_at.is_none() { self.reset_core_state(); return Ok(()); } let user_id = self .user_id .ok_or_else(|| anyhow!("Missing current user id for audit session"))?; let started_at = self.started_at.unwrap(); let cancelled_at = Utc::now(); let required_total = self.required_total(); let _scanned_total = self.expected_assets.iter().filter(|a| a.scanned).count(); let mut found_count = 0; let mut missing_assets = Vec::new(); let mut attention_assets = Vec::new(); let mut exceptions = Vec::new(); let mut unexpected_assets = Vec::new(); for asset in &self.expected_assets { if !asset.scanned { continue; } if asset.expected && asset.requires_scan() { if asset.status_found != "Missing" { found_count += 1; } else { missing_assets.push(json!({ "asset_id": asset.asset_id, "asset_tag": asset.asset_tag, "name": asset.name, })); } if asset.status_found != "Good" && asset.status_found != "Missing" { attention_assets.push(json!({ "asset_id": asset.asset_id, "asset_tag": asset.asset_tag, "name": asset.name, "status": asset.status_found, })); } } if let Some(ref exception) = asset.exception_type { exceptions.push(json!({ "asset_id": asset.asset_id, "asset_tag": asset.asset_tag, "name": asset.name, "type": exception, "details": asset.exception_details, })); } if !asset.expected { unexpected_assets.push(json!({ "asset_id": asset.asset_id, "asset_tag": asset.asset_tag, "name": asset.name, "status": asset.status_found, })); } } let mut issues = Map::new(); if !missing_assets.is_empty() { issues.insert("missing_assets".into(), Value::Array(missing_assets)); } if !attention_assets.is_empty() { issues.insert("attention_assets".into(), Value::Array(attention_assets)); } if !exceptions.is_empty() { issues.insert("exceptions".into(), Value::Array(exceptions)); } if !unexpected_assets.is_empty() { issues.insert("unexpected_assets".into(), Value::Array(unexpected_assets)); } let mut payload = Map::new(); payload.insert( "audit_type".into(), Value::String(match self.mode { AuditMode::FullZone => "full-zone".to_string(), AuditMode::SpotCheck => "spot-check".to_string(), }), ); if let Some(zone) = &self.zone_info { payload.insert("zone_id".into(), json!(zone.id)); } if !self.audit_name.trim().is_empty() { payload.insert("audit_name".into(), json!(self.audit_name.trim())); } payload.insert("started_by".into(), json!(user_id)); payload.insert( "started_at".into(), json!(started_at.format("%Y-%m-%d %H:%M:%S").to_string()), ); payload.insert( "completed_at".into(), json!(cancelled_at.format("%Y-%m-%d %H:%M:%S").to_string()), ); payload.insert("status".into(), json!("cancelled")); if let Some(timeout) = self.timeout_minutes { payload.insert("timeout_minutes".into(), json!(timeout)); } if issues.is_empty() { payload.insert("issues_found".into(), Value::Null); } else { payload.insert("issues_found".into(), Value::Object(issues)); } payload.insert("assets_expected".into(), json!(required_total as i64)); payload.insert("assets_found".into(), json!(found_count as i64)); if !self.notes.trim().is_empty() { payload.insert("notes".into(), json!(self.notes.trim())); } let cancel_reason = if let Some(zone) = &self.zone_info { format!( "Audit cancelled for zone {} at {}", zone.zone_name, cancelled_at.format("%Y-%m-%d %H:%M:%S") ) } else { format!( "Spot check cancelled at {}", cancelled_at.format("%Y-%m-%d %H:%M:%S") ) }; payload.insert("cancelled_reason".into(), json!(cancel_reason)); let audit_insert = api_client.insert("physical_audits", Value::Object(payload))?; if !audit_insert.success { return Err(anyhow!( "Failed to cancel audit session: {}", audit_insert .error .unwrap_or_else(|| "unknown error".to_string()) )); } let audit_id = audit_insert.data.unwrap_or(0) as i64; for asset in &self.expected_assets { if !asset.scanned { continue; } let mut log_payload = Map::new(); log_payload.insert("physical_audit_id".into(), json!(audit_id)); log_payload.insert("asset_id".into(), json!(asset.asset_id)); log_payload.insert("status_found".into(), json!(asset.status_found)); if let Some(task_id) = asset.audit_task_id { log_payload.insert("audit_task_id".into(), json!(task_id)); } if let Some(responses) = &asset.task_responses { log_payload.insert("audit_task_responses".into(), responses.clone()); } if let Some(exception) = &asset.exception_type { log_payload.insert("exception_type".into(), json!(exception)); } if let Some(details) = &asset.exception_details { log_payload.insert("exception_details".into(), json!(details)); } if let Some(zone) = &self.zone_info { log_payload.insert("found_in_zone_id".into(), json!(zone.id)); } if !asset.notes.trim().is_empty() { log_payload.insert("notes".into(), json!(asset.notes.trim())); } let log_insert = api_client.insert("physical_audit_logs", Value::Object(log_payload))?; if !log_insert.success { return Err(anyhow!( "Failed to record cancellation log for asset {}", asset.asset_tag )); } } let completion = AuditCompletion { audit_id, status: "cancelled".to_string(), }; self.completion_snapshot = Some(completion); self.has_recent_completion = true; self.reset_core_state(); Ok(()) } fn ensure_skip_assets_recorded(&mut self) { for asset in &mut self.expected_assets { if !asset.scanned && matches!(asset.scan_policy, AuditScanPolicy::Skip) { asset.scanned = true; asset.completed_at = Some(Utc::now()); } } } fn render_scanning(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, api_client: &ApiClient) { let required_total = self.required_total(); let completed_total = self.completed_total(); let progress = if required_total > 0 { completed_total as f32 / required_total as f32 } else { 0.0 }; let remaining_required = self.remaining_required(); ui.horizontal(|ui| { ui.vertical(|ui| { if required_total > 0 { ui.add( egui::ProgressBar::new(progress) .text(format!("{}/{} processed", completed_total, required_total)) .desired_width(320.0), ); } else { ui.label("No required assets to scan"); } if self.mode == AuditMode::FullZone && remaining_required > 0 { ui.add_space(4.0); ui.colored_label( egui::Color32::from_rgb(255, 179, 0), format!( "{} required assets pending; finishing now marks them Missing.", remaining_required ), ); } }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut complete_button = ui.add(egui::Button::new("Complete Audit")); if self.mode == AuditMode::FullZone && remaining_required > 0 { complete_button = complete_button.on_hover_text(format!( "{} required assets pending. Completing now will mark them as Missing.", remaining_required )); } if complete_button.clicked() { let needs_force = self.mode == AuditMode::FullZone && remaining_required > 0; if self.trigger_pending_ask(PendingFinalizeIntent::FromButton { needs_force }) { // Ask dialog opened; completion will resume after confirmations. } else if needs_force { let name = format!("{} pending items", remaining_required); let detail = "Unscanned assets will be marked Missing upon completion."; self.finalize_dialog.open(name, detail); } else if let Err(err) = self.finalize_audit(api_client, false) { self.last_error = Some(err.to_string()); } } if ui.button("Cancel Audit").clicked() { if let Some(zone) = &self.zone_info { self.cancel_dialog .open(&zone.zone_name, zone.zone_code.clone().unwrap_or_default()); } else { self.cancel_dialog.open("Spot Check", &self.audit_name); } } }); }); ui.add_space(10.0); ui.horizontal(|ui| { let input = ui.add( egui::TextEdit::singleline(&mut self.scan_input) .hint_text("Scan asset tag or numeric ID") .desired_width(260.0), ); let submitted = input.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); if submitted { if let Err(err) = self.handle_scan(api_client) { self.last_error = Some(err.to_string()); } ctx.request_repaint(); } if ui.button("Submit").clicked() { if let Err(err) = self.handle_scan(api_client) { self.last_error = Some(err.to_string()); } ctx.request_repaint(); } if ui.button("Clear").clicked() { self.scan_input.clear(); } }); ui.add_space(12.0); ui.separator(); ui.add_space(8.0); ui.columns(2, |columns| { let [left, right] = columns else { return; }; left.set_min_width(320.0); left.set_max_width(360.0); left.heading("Assets"); left.add_space(4.0); let mut selection_change = None; egui::ScrollArea::vertical() .id_salt("audit_assets_scroll") .auto_shrink([false; 2]) .show(left, |ui| { for idx in 0..self.expected_assets.len() { let asset = &self.expected_assets[idx]; let selected = self.selected_asset == Some(idx); let label = asset.display_label(self.mode); let response = ui.selectable_label(selected, label); let response = if !asset.scanned && asset.requires_scan() { response.on_hover_text("Pending scan") } else { response }; if response.clicked() { selection_change = Some(idx); } } }); if let Some(idx) = selection_change { self.selected_asset = Some(idx); } right.set_min_width(right.available_width().max(420.0)); right.heading("Details"); right.add_space(4.0); if let Some(idx) = self.selected_asset { let mut run_task_clicked = None; if let Some(asset) = self.expected_assets.get_mut(idx) { right.label(format!("Asset Tag: {}", asset.asset_tag)); right.label(format!("Name: {}", asset.name)); if !asset.expected { right.colored_label( egui::Color32::from_rgb(255, 152, 0), "Unexpected asset", ); } if let Some(policy_text) = match asset.scan_policy { AuditScanPolicy::Required => None, AuditScanPolicy::Ask => Some("Requires confirmation"), AuditScanPolicy::Skip => Some("Auto-completed"), } { right.label(policy_text); } right.add_space(6.0); let mut status_value = asset.status_found.clone(); egui::ComboBox::from_label("Status") .selected_text(&status_value) .show_ui(right, |ui| { for option in STATUS_OPTIONS { ui.selectable_value(&mut status_value, option.to_string(), *option); } }); if status_value != asset.status_found { asset.set_status(&status_value, true); } right.add_space(6.0); right.label("Notes"); right.add( egui::TextEdit::multiline(&mut asset.notes) .desired_rows(3) .desired_width(right.available_width()) .hint_text("Optional notes for this asset"), ); right.add_space(6.0); right.horizontal(|ui| { if ui.button("Mark Good").clicked() { asset.set_status("Good", true); asset.exception_type = None; asset.exception_details = None; } if ui.button("Mark Missing").clicked() { asset.set_status("Missing", true); asset.exception_type = Some(EXCEPTION_OTHER.to_string()); asset.exception_details = Some(DEFAULT_MISSING_DETAIL.to_string()); } }); if let Some(task_id) = asset.audit_task_id { right.add_space(6.0); if right.button("Run Audit Task").clicked() { run_task_clicked = Some(task_id); } } if asset.requires_scan() && !asset.scanned { right.add_space(6.0); if right.button("Mark Scanned").clicked() { let current_status = asset.status_found.clone(); asset.set_status(¤t_status, true); } } } if let Some(task_id) = run_task_clicked { if let Err(err) = self.launch_task_runner(idx, task_id, api_client) { self.last_error = Some(err.to_string()); } } } else { right.label("Select an asset to see details."); } right.add_space(12.0); right.separator(); right.add_space(8.0); right.label("Audit Notes"); right.add( egui::TextEdit::multiline(&mut self.notes) .desired_rows(4) .desired_width(right.available_width()) .hint_text("Optional notes for the entire audit"), ); }); } fn required_total(&self) -> usize { self.expected_assets .iter() .filter(|a| a.requires_scan()) .count() } fn completed_total(&self) -> usize { self.expected_assets .iter() .filter(|a| a.requires_scan() && a.scanned) .count() } fn remaining_required(&self) -> usize { self.expected_assets .iter() .filter(|asset| asset.requires_scan() && !asset.scanned) .count() } fn next_unresolved_ask(&self) -> Option { self.expected_assets .iter() .enumerate() .find(|(_, asset)| matches!(asset.scan_policy, AuditScanPolicy::Ask) && !asset.scanned) .map(|(idx, _)| idx) } fn trigger_pending_ask(&mut self, intent: PendingFinalizeIntent) -> bool { if self.ask_dialog.show || self.pending_ask_index.is_some() { self.pending_finalize = Some(intent); return true; } if let Some(idx) = self.next_unresolved_ask() { if let Some(asset) = self.expected_assets.get(idx) { self.pending_finalize = Some(intent); self.pending_ask_index = Some(idx); self.ask_dialog .open(asset.name.clone(), asset.asset_tag.clone()); return true; } } false } fn handle_scan(&mut self, api_client: &ApiClient) -> Result<()> { let input = self.scan_input.trim(); if input.is_empty() { return Ok(()); } self.last_error = None; if let Some(idx) = self .expected_assets .iter() .position(|asset| asset.matches_identifier(input)) { self.selected_asset = Some(idx); self.process_matched_asset(idx, api_client)?; self.scan_input.clear(); return Ok(()); } // Asset not in current list, try to fetch from the API if let Some(value) = find_asset_by_tag_or_numeric(api_client, input)? { let zone_id = value.get("zone_id").and_then(|v| v.as_i64()); let mut state = AuditAssetState::from_value( value, self.zone_info.as_ref().map(|z| z.id), self.mode == AuditMode::FullZone && self.zone_info.is_some(), )?; if let Some(zone) = &self.zone_info { if zone_id != Some(zone.id) { state.expected = false; state.exception_type = Some(EXCEPTION_WRONG_ZONE.to_string()); state.exception_details = Some(format!( "Asset assigned to zone {:?}, found in {}", zone_id, zone.zone_name )); } else if self.mode == AuditMode::FullZone { state.expected = false; state.exception_type = Some(EXCEPTION_UNEXPECTED_ASSET.to_string()); state.exception_details = Some("Asset not listed on zone roster".to_string()); } } else { state.expected = false; state.exception_type = Some(EXCEPTION_UNEXPECTED_ASSET.to_string()); state.exception_details = Some("Captured during spot check".to_string()); } let idx = self.expected_assets.len(); self.expected_assets.push(state); self.selected_asset = Some(idx); self.process_matched_asset(idx, api_client)?; self.scan_input.clear(); return Ok(()); } self.last_error = Some(format!("No asset found for '{}'.", input)); self.scan_input.clear(); Ok(()) } fn process_matched_asset(&mut self, index: usize, api_client: &ApiClient) -> Result<()> { if index >= self.expected_assets.len() { return Ok(()); } let (policy, already_scanned, task_id, name, tag, status_value) = { let asset = &self.expected_assets[index]; ( asset.scan_policy, asset.scanned, asset.audit_task_id, asset.name.clone(), asset.asset_tag.clone(), asset.status_found.clone(), ) }; if matches!(policy, AuditScanPolicy::Ask) && !already_scanned { self.pending_ask_index = Some(index); self.ask_dialog.open(name, tag); return Ok(()); } if let Some(task_id) = task_id { if !already_scanned { self.launch_task_runner(index, task_id, api_client)?; return Ok(()); } } if !already_scanned { self.expected_assets[index].set_status(&status_value, true); } Ok(()) } fn launch_task_runner( &mut self, index: usize, task_id: i64, api_client: &ApiClient, ) -> Result<()> { if let Some(state) = &self.current_task_runner { if state.asset_index == index { return Ok(()); // already running for this asset } } let definition = if let Some(def) = self.cached_tasks.get(&task_id) { def.clone() } else { let task_value = get_audit_task_definition(api_client, task_id)? .ok_or_else(|| anyhow!("Audit task {} not found", task_id))?; let task_json = task_value .get("json_sequence") .cloned() .unwrap_or(Value::Null); let definition = AuditTaskDefinition::from_value(task_json)?; self.cached_tasks.insert(task_id, definition.clone()); definition }; let asset_label = self.expected_assets[index].name.clone(); let runner = AuditTaskRunner::new(definition, asset_label); self.current_task_runner = Some(TaskRunnerState { asset_index: index, runner, }); Ok(()) } fn process_ask_dialog(&mut self, confirmed: bool, api_client: &ApiClient) { if let Some(idx) = self.pending_ask_index.take() { if idx < self.expected_assets.len() { let task_id = self.expected_assets[idx].audit_task_id; if confirmed { if let Some(task_id) = task_id { if let Err(err) = self.launch_task_runner(idx, task_id, api_client) { self.last_error = Some(err.to_string()); } } else { let status_value = self.expected_assets[idx].status_found.clone(); self.expected_assets[idx].set_status(&status_value, true); } } else { self.expected_assets[idx].set_status("Missing", true); self.expected_assets[idx].exception_type = Some(EXCEPTION_OTHER.to_string()); if self.expected_assets[idx].exception_details.is_none() { self.expected_assets[idx].exception_details = Some(DEFAULT_MISSING_DETAIL.to_string()); } } } } if let Some(intent) = self.pending_finalize.take() { if self.trigger_pending_ask(intent) { return; } match intent { PendingFinalizeIntent::FromButton { needs_force } => { if needs_force { let remaining = self.remaining_required(); if remaining > 0 { let name = format!("{} pending items", remaining); let detail = "Unscanned assets will be marked Missing upon completion."; self.finalize_dialog.open(name, detail); } else if let Err(err) = self.finalize_audit(api_client, false) { self.last_error = Some(err.to_string()); } } else if let Err(err) = self.finalize_audit(api_client, false) { self.last_error = Some(err.to_string()); } } PendingFinalizeIntent::FromDialog { force_missing } => { if let Err(err) = self.finalize_audit(api_client, force_missing) { self.last_error = Some(err.to_string()); } } } } } fn apply_task_outcome(&mut self, index: usize, mut outcome: AuditTaskOutcome) { if let Some(asset) = self.expected_assets.get_mut(index) { if let Some(status) = outcome.status_override.take() { asset.set_status(&status, true); } else { let current_status = asset.status_found.clone(); asset.set_status(¤t_status, true); } if !outcome.additional_fields.is_empty() { asset.additional_fields = outcome.additional_fields.clone(); } let mut payload = Map::new(); payload.insert("responses".into(), outcome.responses); if !outcome.additional_fields.is_empty() { payload.insert( "additional_fields".into(), Value::Object(outcome.additional_fields.clone()), ); } asset.task_responses = Some(Value::Object(payload)); } } fn finalize_audit(&mut self, api_client: &ApiClient, force_missing: bool) -> Result<()> { let remaining = self.remaining_required(); if remaining > 0 { if !force_missing { return Err(anyhow!( "Cannot finalize audit. {} required assets still pending.", remaining )); } for asset in &mut self.expected_assets { if asset.requires_scan() && !asset.scanned { asset.set_status("Missing", true); asset.exception_type = Some(EXCEPTION_OTHER.to_string()); if asset.exception_details.is_none() { asset.exception_details = Some(DEFAULT_MISSING_DETAIL.to_string()); } } } } let user_id = self .user_id .ok_or_else(|| anyhow!("Missing current user id for audit session"))?; let started_at = self .started_at .unwrap_or_else(|| Utc::now() - chrono::Duration::minutes(1)); let completed_at = Utc::now(); let required_total = self.required_total(); let mut found_count = 0; let mut missing_assets = Vec::new(); let mut attention_assets = Vec::new(); let mut exceptions = Vec::new(); let mut unexpected_assets = Vec::new(); for asset in &self.expected_assets { if asset.expected && asset.requires_scan() { if asset.status_found != "Missing" { found_count += 1; } else { missing_assets.push(json!({ "asset_id": asset.asset_id, "asset_tag": asset.asset_tag, "name": asset.name, })); } if asset.status_found != "Good" && asset.status_found != "Missing" { attention_assets.push(json!({ "asset_id": asset.asset_id, "asset_tag": asset.asset_tag, "name": asset.name, "status": asset.status_found, })); } } if let Some(ref exception) = asset.exception_type { exceptions.push(json!({ "asset_id": asset.asset_id, "asset_tag": asset.asset_tag, "name": asset.name, "type": exception, "details": asset.exception_details, })); } if !asset.expected { unexpected_assets.push(json!({ "asset_id": asset.asset_id, "asset_tag": asset.asset_tag, "name": asset.name, "status": asset.status_found, })); } } let mut issues = Map::new(); if !missing_assets.is_empty() { issues.insert("missing_assets".into(), Value::Array(missing_assets)); } if !attention_assets.is_empty() { issues.insert("attention_assets".into(), Value::Array(attention_assets)); } if !exceptions.is_empty() { issues.insert("exceptions".into(), Value::Array(exceptions)); } if !unexpected_assets.is_empty() { issues.insert("unexpected_assets".into(), Value::Array(unexpected_assets)); } let status = if issues.contains_key("missing_assets") || issues.contains_key("attention_assets") { "attention" } else if issues.contains_key("exceptions") || issues.contains_key("unexpected_assets") { "attention" } else { "all-good" }; let mut payload = Map::new(); payload.insert( "audit_type".into(), Value::String(match self.mode { AuditMode::FullZone => "full-zone".to_string(), AuditMode::SpotCheck => "spot-check".to_string(), }), ); if let Some(zone) = &self.zone_info { payload.insert("zone_id".into(), json!(zone.id)); } if !self.audit_name.trim().is_empty() { payload.insert("audit_name".into(), json!(self.audit_name.trim())); } payload.insert("started_by".into(), json!(user_id)); payload.insert( "started_at".into(), json!(started_at.format("%Y-%m-%d %H:%M:%S").to_string()), ); payload.insert( "completed_at".into(), json!(completed_at.format("%Y-%m-%d %H:%M:%S").to_string()), ); payload.insert("status".into(), json!(status)); if let Some(timeout) = self.timeout_minutes { payload.insert("timeout_minutes".into(), json!(timeout)); } if issues.is_empty() { payload.insert("issues_found".into(), Value::Null); } else { payload.insert("issues_found".into(), Value::Object(issues)); } payload.insert("assets_expected".into(), json!(required_total as i64)); payload.insert("assets_found".into(), json!(found_count as i64)); if !self.notes.trim().is_empty() { payload.insert("notes".into(), json!(self.notes.trim())); } let audit_insert = api_client.insert("physical_audits", Value::Object(payload))?; if !audit_insert.success { return Err(anyhow!( "Failed to create audit session: {}", audit_insert .error .unwrap_or_else(|| "unknown error".to_string()) )); } let audit_id = audit_insert.data.unwrap_or(0) as i64; // Insert audit logs for asset in &self.expected_assets { let mut log_payload = Map::new(); log_payload.insert("physical_audit_id".into(), json!(audit_id)); log_payload.insert("asset_id".into(), json!(asset.asset_id)); log_payload.insert("status_found".into(), json!(asset.status_found)); if let Some(task_id) = asset.audit_task_id { log_payload.insert("audit_task_id".into(), json!(task_id)); } if let Some(responses) = &asset.task_responses { log_payload.insert("audit_task_responses".into(), responses.clone()); } if let Some(exception) = &asset.exception_type { log_payload.insert("exception_type".into(), json!(exception)); } if let Some(details) = &asset.exception_details { log_payload.insert("exception_details".into(), json!(details)); } if let Some(zone) = &self.zone_info { log_payload.insert("found_in_zone_id".into(), json!(zone.id)); } if !asset.notes.trim().is_empty() { log_payload.insert("notes".into(), json!(asset.notes.trim())); } let log_insert = api_client.insert("physical_audit_logs", Value::Object(log_payload))?; if !log_insert.success { return Err(anyhow!( "Failed to record audit log for asset {}", asset.asset_tag )); } } let completion = AuditCompletion { audit_id, status: status.to_string(), }; self.completion_snapshot = Some(completion); self.has_recent_completion = true; self.reset_core_state(); Ok(()) } } #[derive(Debug, Clone)] struct AuditTaskDefinition { steps: Vec, index_by_step: HashMap, } impl AuditTaskDefinition { fn from_value(value: Value) -> Result { let sequence_value = match value { Value::Object(ref obj) if obj.contains_key("json_sequence") => { obj.get("json_sequence").cloned().unwrap_or(Value::Null) } other => other, }; let normalized_sequence = match sequence_value { Value::String(ref s) => { if let Ok(bytes) = BASE64_STANDARD.decode(s) { serde_json::from_slice::(&bytes).map_err(|err| { let raw_debug = String::from_utf8_lossy(&bytes).into_owned(); anyhow!( "Invalid audit task JSON sequence: {}\nDecoded payload: {}", err, raw_debug ) })? } else if let Ok(parsed) = serde_json::from_str::(s) { parsed } else { return Err(anyhow!( "Invalid audit task JSON sequence: expected array but got string '{}'.", s )); } } other => other, }; let raw_debug = serde_json::to_string_pretty(&normalized_sequence) .unwrap_or_else(|_| normalized_sequence.to_string()); let steps: Vec = serde_json::from_value(normalized_sequence.clone()) .map_err(|err| { anyhow!( "Invalid audit task JSON sequence: {}\nSequence payload: {}", err, raw_debug ) })?; if steps.is_empty() { return Err(anyhow!("Audit task contains no steps")); } let mut index_by_step = HashMap::new(); for (idx, step) in steps.iter().enumerate() { index_by_step.insert(step.step, idx); } Ok(Self { steps, index_by_step, }) } fn first_step(&self) -> i64 { self.steps.first().map(|s| s.step).unwrap_or(1) } fn get_step(&self, step_id: i64) -> Option<&AuditTaskStep> { self.index_by_step .get(&step_id) .and_then(|idx| self.steps.get(*idx)) } fn next_step(&self, current_id: i64) -> Option { if let Some(idx) = self.index_by_step.get(¤t_id) { self.steps.get(idx + 1).map(|s| s.step) } else { None } } } #[derive(Debug, Clone, Deserialize)] struct AuditTaskStep { step: i64, question: String, #[serde(rename = "type")] question_type: AuditQuestionType, #[serde(default)] options: Vec, #[serde(default)] actions: HashMap, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "snake_case")] enum AuditQuestionType { YesNo, MultipleChoice, TextInput, } #[derive(Debug, Clone, Deserialize, Default)] struct AuditTaskAction { #[serde(default)] next_step: Option, #[serde(default)] set_status: Option, #[serde(default)] set_additional_fields: Option>, #[serde(default)] end_audit: Option, } #[derive(Debug, Clone)] struct AuditTaskRunner { definition: AuditTaskDefinition, current_step: i64, responses: Vec, is_open: bool, user_input: String, asset_label: String, collected_fields: Map, status_override: Option, } impl AuditTaskRunner { fn new(definition: AuditTaskDefinition, asset_label: String) -> Self { let first_step = definition.first_step(); Self { definition, current_step: first_step, responses: Vec::new(), is_open: true, user_input: String::new(), asset_label, collected_fields: Map::new(), status_override: None, } } fn is_open(&self) -> bool { self.is_open } fn show(&mut self, ctx: &egui::Context) -> Option { if !self.is_open { return None; } let mut keep_open = self.is_open; let mut completed: Option = None; let title = format!("Audit Task – {}", self.asset_label); egui::Window::new(title) .id(egui::Id::new("audit_task_runner_window")) .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .open(&mut keep_open) .show(ctx, |ui| { if let Some(step) = self.definition.get_step(self.current_step).cloned() { ui.heading(&step.question); ui.add_space(8.0); match step.question_type { AuditQuestionType::YesNo => { ui.horizontal(|ui| { if ui.button("Yes").clicked() { completed = self.handle_answer( &step, "yes", Value::String("Yes".to_string()), None, ); } if ui.button("No").clicked() { completed = self.handle_answer( &step, "no", Value::String("No".to_string()), None, ); } }); } AuditQuestionType::MultipleChoice => { for option in &step.options { if ui.button(option).clicked() { completed = self.handle_answer( &step, option, Value::String(option.clone()), None, ); if completed.is_some() { break; } } } } AuditQuestionType::TextInput => { ui.label("Answer:"); ui.add( egui::TextEdit::singleline(&mut self.user_input) .desired_width(280.0), ); if ui.button("Submit").clicked() { let answer_value = Value::String(self.user_input.clone()); completed = self.handle_answer( &step, "any", answer_value, Some(self.user_input.clone()), ); self.user_input.clear(); } } } } else { // No step found; close gracefully completed = Some(self.finish()); } }); if !keep_open { self.is_open = false; } if let Some(result) = completed { self.is_open = false; Some(result) } else { None } } fn handle_answer( &mut self, step: &AuditTaskStep, answer_key: &str, answer_value: Value, user_input: Option, ) -> Option { self.responses.push(TaskResponseEntry { step: step.step, question: step.question.clone(), answer: answer_value.clone(), }); let key_lower = answer_key.to_lowercase(); let action = step .actions .get(&key_lower) .or_else(|| step.actions.get(answer_key)) .or_else(|| step.actions.get("any")); if let Some(act) = action { if let Some(ref status) = act.set_status { self.status_override = Some(status.clone()); } if let Some(ref fields) = act.set_additional_fields { for (field, template) in fields { let value = if let Some(ref input) = user_input { template.replace("{user_input}", input) } else { template.clone() }; self.collected_fields .insert(field.clone(), Value::String(value)); } } if act.end_audit.unwrap_or(false) { return Some(self.finish()); } if let Some(next_step) = act.next_step { self.current_step = next_step; return None; } } if let Some(next) = self.definition.next_step(step.step) { self.current_step = next; None } else { Some(self.finish()) } } fn finish(&mut self) -> AuditTaskOutcome { let responses = Value::Array( self.responses .iter() .map(|entry| { json!({ "step": entry.step, "question": entry.question, "answer": entry.answer, }) }) .collect(), ); AuditTaskOutcome { status_override: self.status_override.clone(), additional_fields: self.collected_fields.clone(), responses, } } }