From 8323fdd73272a2882781aba3c499ba0be3dff2a6 Mon Sep 17 00:00:00 2001 From: UMTS at Teleco Date: Sat, 13 Dec 2025 02:51:15 +0100 Subject: committing to insanity --- src/api.rs | 636 ++++++++++ src/assets/app-icon/AppIcon.icns | Bin 0 -> 389899 bytes src/assets/app-icon/AppIcon.png | Bin 0 -> 179552 bytes src/config.rs | 43 + src/core/components/clone.rs | 69 ++ src/core/components/filter_builder.rs | 698 +++++++++++ src/core/components/form_builder.rs | 371 ++++++ src/core/components/help.rs | 66 ++ src/core/components/interactions.rs | 225 ++++ src/core/components/mod.rs | 12 + src/core/components/stats.rs | 57 + src/core/data/asset_fields.rs | 1008 ++++++++++++++++ src/core/data/counters.rs | 43 + src/core/data/data_loader.rs | 99 ++ src/core/data/mod.rs | 8 + src/core/mod.rs | 26 + src/core/operations/asset_operations.rs | 613 ++++++++++ src/core/operations/mod.rs | 4 + src/core/print/mod.rs | 15 + src/core/print/parsing.rs | 219 ++++ src/core/print/plugins/mod.rs | 2 + src/core/print/plugins/pdf.rs | 27 + src/core/print/plugins/system.rs | 49 + src/core/print/printer_manager.rs | 228 ++++ src/core/print/renderer.rs | 1537 ++++++++++++++++++++++++ src/core/print/ui/mod.rs | 3 + src/core/print/ui/print_dialog.rs | 999 ++++++++++++++++ src/core/table_renderer.rs | 739 ++++++++++++ src/core/tables.rs | 1570 +++++++++++++++++++++++++ src/core/utils/mod.rs | 4 + src/core/utils/search.rs | 135 +++ src/core/workflows/add_from_template.rs | 1488 ++++++++++++++++++++++++ src/core/workflows/audit.rs | 1719 +++++++++++++++++++++++++++ src/core/workflows/borrow_flow.rs | 1450 +++++++++++++++++++++++ src/core/workflows/mod.rs | 9 + src/core/workflows/return_flow.rs | 924 +++++++++++++++ src/main.rs | 106 ++ src/models.rs | 274 +++++ src/session.rs | 161 +++ src/ui/app.rs | 1268 ++++++++++++++++++++ src/ui/audits.rs | 898 ++++++++++++++ src/ui/borrowing.rs | 1618 ++++++++++++++++++++++++++ src/ui/categories.rs | 892 ++++++++++++++ src/ui/dashboard.rs | 384 ++++++ src/ui/inventory.rs | 1933 +++++++++++++++++++++++++++++++ src/ui/issues.rs | 773 ++++++++++++ src/ui/label_templates.rs | 607 ++++++++++ src/ui/login.rs | 272 +++++ src/ui/mod.rs | 14 + src/ui/printers.rs | 943 +++++++++++++++ src/ui/ribbon.rs | 1056 +++++++++++++++++ src/ui/suppliers.rs | 802 +++++++++++++ src/ui/templates.rs | 1113 ++++++++++++++++++ src/ui/zones.rs | 990 ++++++++++++++++ 54 files changed, 29199 insertions(+) create mode 100644 src/api.rs create mode 100644 src/assets/app-icon/AppIcon.icns create mode 100644 src/assets/app-icon/AppIcon.png create mode 100644 src/config.rs create mode 100644 src/core/components/clone.rs create mode 100644 src/core/components/filter_builder.rs create mode 100644 src/core/components/form_builder.rs create mode 100644 src/core/components/help.rs create mode 100644 src/core/components/interactions.rs create mode 100644 src/core/components/mod.rs create mode 100644 src/core/components/stats.rs create mode 100644 src/core/data/asset_fields.rs create mode 100644 src/core/data/counters.rs create mode 100644 src/core/data/data_loader.rs create mode 100644 src/core/data/mod.rs create mode 100644 src/core/mod.rs create mode 100644 src/core/operations/asset_operations.rs create mode 100644 src/core/operations/mod.rs create mode 100644 src/core/print/mod.rs create mode 100644 src/core/print/parsing.rs create mode 100644 src/core/print/plugins/mod.rs create mode 100644 src/core/print/plugins/pdf.rs create mode 100644 src/core/print/plugins/system.rs create mode 100644 src/core/print/printer_manager.rs create mode 100644 src/core/print/renderer.rs create mode 100644 src/core/print/ui/mod.rs create mode 100644 src/core/print/ui/print_dialog.rs create mode 100644 src/core/table_renderer.rs create mode 100644 src/core/tables.rs create mode 100644 src/core/utils/mod.rs create mode 100644 src/core/utils/search.rs create mode 100644 src/core/workflows/add_from_template.rs create mode 100644 src/core/workflows/audit.rs create mode 100644 src/core/workflows/borrow_flow.rs create mode 100644 src/core/workflows/mod.rs create mode 100644 src/core/workflows/return_flow.rs create mode 100644 src/main.rs create mode 100644 src/models.rs create mode 100644 src/session.rs create mode 100644 src/ui/app.rs create mode 100644 src/ui/audits.rs create mode 100644 src/ui/borrowing.rs create mode 100644 src/ui/categories.rs create mode 100644 src/ui/dashboard.rs create mode 100644 src/ui/inventory.rs create mode 100644 src/ui/issues.rs create mode 100644 src/ui/label_templates.rs create mode 100644 src/ui/login.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/printers.rs create mode 100644 src/ui/ribbon.rs create mode 100644 src/ui/suppliers.rs create mode 100644 src/ui/templates.rs create mode 100644 src/ui/zones.rs (limited to 'src') diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..0321103 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,636 @@ +use anyhow::{Context, Result}; +use reqwest::{blocking::{Client, Response}, header}; +use serde_json::json; +use std::sync::{atomic::{AtomicBool, Ordering}, Arc}; +use std::time::Duration; + +use crate::models::*; + +/// API Client for BeepZone backend +#[derive(Clone)] +pub struct ApiClient { + client: Client, + base_url: String, + token: Option, + db_timeout_flag: Arc, +} + +impl ApiClient { + /// Create a new API client + pub fn new(base_url: String) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .context("Failed to create HTTP client")?; + + Ok(Self { + client, + base_url: base_url.trim_end_matches('/').to_string(), + token: None, + db_timeout_flag: Arc::new(AtomicBool::new(false)), + }) + } + + fn flag_timeout_signal(&self) { + self.db_timeout_flag.store(true, Ordering::SeqCst); + } + + fn observe_response_error(&self, error: &Option) { + if Self::is_database_timeout_error(error) { + self.flag_timeout_signal(); + } + } + + fn send_request( + &self, + builder: reqwest::blocking::RequestBuilder, + context_msg: &'static str, + ) -> Result { + builder + .send() + .map_err(|err| { + self.flag_timeout_signal(); + err + }) + .context(context_msg) + } + + /// Returns true if a timeout signal was previously raised (and clears it) + pub fn take_timeout_signal(&self) -> bool { + self.db_timeout_flag.swap(false, Ordering::SeqCst) + } + + /// Set the authentication token + pub fn set_token(&mut self, token: String) { + self.token = Some(token); + } + + /// Clear the authentication token + #[allow(dead_code)] + pub fn clear_token(&mut self) { + self.token = None; + } + + /// Check if server is reachable + pub fn health_check(&self) -> Result { + let url = format!("{}/health", self.base_url); + let response = self + .send_request(self.client.get(&url), "Failed to perform health check")?; + Ok(response.status().is_success()) + } + + /// Get health details (tries to parse JSON; returns None if non-JSON) + pub fn health_info(&self) -> Result> { + let url = format!("{}/health", self.base_url); + let response = self + .send_request(self.client.get(&url), "Failed to fetch health info")?; + if !response.status().is_success() { + return Ok(None); + } + // Try to parse as JSON; if it fails, just return None (some servers return plain text) + let text = response.text()?; + match serde_json::from_str::(&text) { + Ok(v) => Ok(Some(v)), + Err(_) => Ok(None), + } + } + + /// Check if the error message indicates a database timeout + pub fn is_database_timeout_error(error: &Option) -> bool { + if let Some(err) = error { + err.contains("Database temporarily unavailable") + } else { + false + } + } + + // Authentication Methods + + /// Login with username and password + pub fn login_password(&self, username: &str, password: &str) -> Result { + let url = format!("{}/auth/login", self.base_url); + let body = LoginRequest { + method: "password".to_string(), + username: Some(username.to_string()), + password: Some(password.to_string()), + pin: None, + login_string: None, + }; + + let response = self.send_request( + self.client.post(&url).json(&body), + "Failed to send login request", + )?; + + let result: LoginResponse = response.json().context("Failed to parse login response")?; + + Ok(result) + } + + /// Login with PIN + #[allow(dead_code)] + pub fn login_pin(&self, username: &str, pin: &str) -> Result> { + let url = format!("{}/auth/login", self.base_url); + let body = LoginRequest { + method: "pin".to_string(), + username: Some(username.to_string()), + password: None, + pin: Some(pin.to_string()), + login_string: None, + }; + + let response = self.send_request( + self.client.post(&url).json(&body), + "Failed to send PIN login request", + )?; + + let result: ApiResponse = + response.json().context("Failed to parse login response")?; + self.observe_response_error(&result.error); + + Ok(result) + } + + /// Login with token/RFID string + #[allow(dead_code)] + pub fn login_token(&self, login_string: &str) -> Result> { + let url = format!("{}/auth/login", self.base_url); + let body = LoginRequest { + method: "token".to_string(), + username: None, + password: None, + pin: None, + login_string: Some(login_string.to_string()), + }; + + let response = self.send_request( + self.client.post(&url).json(&body), + "Failed to send token login request", + )?; + + let result: ApiResponse = + response.json().context("Failed to parse login response")?; + self.observe_response_error(&result.error); + + Ok(result) + } + + /// Logout current session + pub fn logout(&self) -> Result> { + let url = format!("{}/auth/logout", self.base_url); + let response = self.send_request( + self.make_authorized_request(reqwest::Method::POST, &url)?, + "Failed to send logout request", + )?; + + let result: ApiResponse<()> = response.json()?; + self.observe_response_error(&result.error); + Ok(result) + } + + /// Check session status + #[allow(dead_code)] + pub fn check_session(&self) -> Result> { + let url = format!("{}/auth/status", self.base_url); + let response = self.send_request( + self.make_authorized_request(reqwest::Method::GET, &url)?, + "Failed to check session status", + )?; + + let result: ApiResponse = response.json()?; + self.observe_response_error(&result.error); + Ok(result) + } + + /// Best-effort session validity check. + /// Returns Ok(true) when the session appears valid, Ok(false) when clearly invalid (401/403 or explicit valid=false). + /// Be tolerant of different backend response shapes and assume valid on ambiguous 2xx responses. + pub fn check_session_valid(&self) -> Result { + let url = format!("{}/auth/status", self.base_url); + let response = self.send_request( + self.make_authorized_request(reqwest::Method::GET, &url)?, + "Failed to check session status", + )?; + + let status = response.status(); + let text = response.text()?; + + // Explicitly invalid if unauthorized/forbidden + if status.as_u16() == 401 || status.as_u16() == 403 { + return Ok(false); + } + + // Parse generic JSON and look for common shapes first + if let Ok(val) = serde_json::from_str::(&text) { + // data.valid + if let Some(valid) = val + .get("data") + .and_then(|d| d.get("valid")) + .and_then(|v| v.as_bool()) + { + return Ok(valid); + } + // top-level valid + if let Some(valid) = val.get("valid").and_then(|v| v.as_bool()) { + return Ok(valid); + } + // success=true is generally a good sign + if val.get("success").and_then(|v| v.as_bool()) == Some(true) { + return Ok(true); + } + } + + // As a last attempt, try strict ApiResponse + if let Ok(parsed) = serde_json::from_str::>(&text) { + if let Some(data) = parsed.data { + return Ok(data.valid); + } + // If no data provided, treat success=true as valid by default + return Ok(parsed.success || status.is_success()); + } + + // Last resort: if response was 2xx and not explicitly invalid, assume valid + Ok(status.is_success()) + } + + // Permissions & Preferences + + /// Get current user's permissions + #[allow(dead_code)] + pub fn get_permissions(&self) -> Result> { + let url = format!("{}/permissions", self.base_url); + let response = self.send_request( + self.make_authorized_request(reqwest::Method::GET, &url)?, + "Failed to get permissions", + )?; + + let result: ApiResponse = response.json()?; + self.observe_response_error(&result.error); + Ok(result) + } + + /// Get user preferences + #[allow(dead_code)] + pub fn get_preferences( + &self, + user_id: Option, + ) -> Result> { + let url = format!("{}/preferences", self.base_url); + let body = PreferencesRequest { + action: "get".to_string(), + user_id, + preferences: None, + }; + + let response = self.send_request( + self.make_authorized_request(reqwest::Method::POST, &url)? + .json(&body), + "Failed to get preferences", + )?; + + let result: ApiResponse = response.json()?; + self.observe_response_error(&result.error); + Ok(result) + } + + /// Set user preferences + #[allow(dead_code)] + pub fn set_preferences( + &self, + values: serde_json::Value, + user_id: Option, + ) -> Result> { + let url = format!("{}/preferences", self.base_url); + let body = PreferencesRequest { + action: "set".to_string(), + user_id, + preferences: Some(values), + }; + + let response = self.send_request( + self.make_authorized_request(reqwest::Method::POST, &url)? + .json(&body), + "Failed to set preferences", + )?; + + let result: ApiResponse = response.json()?; + self.observe_response_error(&result.error); + Ok(result) + } + + // Query Methods + + /// Execute a generic query + pub fn query(&self, request: &QueryRequest) -> Result> { + let url = format!("{}/query", self.base_url); + + // Log the serialized request for debugging + let body = serde_json::to_value(request)?; + log::debug!("Query request JSON: {}", serde_json::to_string(&body)?); + + // Log the request for debugging JOINs + if request.joins.is_some() { + log::debug!( + "Query with JOINs: table={}, columns={:?}, joins={:?}", + request.table, + request.columns.as_ref().map(|c| c.len()), + request.joins.as_ref().map(|j| j.len()) + ); + } + + let response = self.send_request( + self.make_authorized_request(reqwest::Method::POST, &url)? + .json(&body), + "Failed to execute query", + )?; + + // Try to get the response text for debugging + let status = response.status(); + let response_text = response.text()?; + + // Log the raw response for debugging + if !status.is_success() { + log::error!("API error ({}): {}", status, response_text); + } else { + log::debug!( + "API response (first 500 chars): {}", + if response_text.len() > 500 { + &response_text[..500] + } else { + &response_text + } + ); + } + + // Now try to parse it + let result: ApiResponse = serde_json::from_str(&response_text) + .with_context(|| { + format!( + "Failed to parse API response. Status: {}, Body: {}", + status, + if response_text.len() > 200 { + &response_text[..200] + } else { + &response_text + } + ) + })?; + self.observe_response_error(&result.error); + + Ok(result) + } + + /// Select records from a table + pub fn select( + &self, + table: &str, + columns: Option>, + where_clause: Option, + order_by: Option>, + limit: Option, + ) -> Result>> { + let request = QueryRequest { + action: "select".to_string(), + table: table.to_string(), + columns, + data: None, + r#where: where_clause, + filter: None, + order_by, + limit, + offset: None, + joins: None, + }; + + let response = self.query(&request)?; + if response.success { + let data = response.data.unwrap_or(json!([])); + let records: Vec = serde_json::from_value(data)?; + Ok(ApiResponse { + success: true, + data: Some(records), + error: None, + message: response.message, + }) + } else { + Ok(ApiResponse { + success: false, + data: None, + error: response.error, + message: response.message, + }) + } + } + + /// Select records from a table with JOINs + pub fn select_with_joins( + &self, + table: &str, + columns: Option>, + where_clause: Option, + filter: Option, + order_by: Option>, + limit: Option, + joins: Option>, + ) -> Result>> { + let request = QueryRequest { + action: "select".to_string(), + table: table.to_string(), + columns, + data: None, + r#where: where_clause, + filter, + order_by, + limit, + offset: None, + joins, + }; + + let response = self.query(&request)?; + if response.success { + let data = response.data.unwrap_or(json!([])); + let records: Vec = serde_json::from_value(data)?; + Ok(ApiResponse { + success: true, + data: Some(records), + error: None, + message: response.message, + }) + } else { + Ok(ApiResponse { + success: false, + data: None, + error: response.error, + message: response.message, + }) + } + } + + /// Insert a record + pub fn insert(&self, table: &str, values: serde_json::Value) -> Result> { + let request = QueryRequest { + action: "insert".to_string(), + table: table.to_string(), + columns: None, + data: Some(values), + r#where: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + let response = self.query(&request)?; + if response.success { + let id: i32 = serde_json::from_value(response.data.unwrap_or(json!(0)))?; + Ok(ApiResponse { + success: true, + data: Some(id), + error: None, + message: response.message, + }) + } else { + Ok(ApiResponse { + success: false, + data: None, + error: response.error, + message: response.message, + }) + } + } + + /// Update records + pub fn update( + &self, + table: &str, + values: serde_json::Value, + where_clause: serde_json::Value, + ) -> Result> { + let request = QueryRequest { + action: "update".to_string(), + table: table.to_string(), + columns: None, + data: Some(values), + r#where: Some(where_clause), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + let response = self.query(&request)?; + if response.success { + let count: u32 = serde_json::from_value(response.data.unwrap_or(json!(0)))?; + Ok(ApiResponse { + success: true, + data: Some(count), + error: None, + message: response.message, + }) + } else { + Ok(ApiResponse { + success: false, + data: None, + error: response.error, + message: response.message, + }) + } + } + + /// Delete records + pub fn delete(&self, table: &str, where_clause: serde_json::Value) -> Result> { + let request = QueryRequest { + action: "delete".to_string(), + table: table.to_string(), + columns: None, + data: None, + r#where: Some(where_clause), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + let response = self.query(&request)?; + if response.success { + let count: u32 = serde_json::from_value(response.data.unwrap_or(json!(0)))?; + Ok(ApiResponse { + success: true, + data: Some(count), + error: None, + message: response.message, + }) + } else { + Ok(ApiResponse { + success: false, + data: None, + error: response.error, + message: response.message, + }) + } + } + + /// Cunt records + pub fn count( + &self, + table: &str, + where_clause: Option, + ) -> Result> { + let request = QueryRequest { + action: "count".to_string(), + table: table.to_string(), + columns: None, + data: None, + r#where: where_clause, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + let response = self.query(&request)?; + if response.success { + let count: i32 = serde_json::from_value(response.data.unwrap_or(json!(0)))?; + Ok(ApiResponse { + success: true, + data: Some(count), + error: None, + message: response.message, + }) + } else { + Ok(ApiResponse { + success: false, + data: None, + error: response.error, + message: response.message, + }) + } + } + + // Helper Methods + + /// Create an authorized request with proper headers + fn make_authorized_request( + &self, + method: reqwest::Method, + url: &str, + ) -> Result { + let token = self.token.as_ref().context("No authentication token set")?; + + let builder = self + .client + .request(method, url) + .header(header::AUTHORIZATION, format!("Bearer {}", token)); + + Ok(builder) + } + + /// Get the based URL + pub fn base_url(&self) -> &str { + &self.base_url + } +} diff --git a/src/assets/app-icon/AppIcon.icns b/src/assets/app-icon/AppIcon.icns new file mode 100644 index 0000000..903c575 Binary files /dev/null and b/src/assets/app-icon/AppIcon.icns differ diff --git a/src/assets/app-icon/AppIcon.png b/src/assets/app-icon/AppIcon.png new file mode 100644 index 0000000..62dcc94 Binary files /dev/null and b/src/assets/app-icon/AppIcon.png differ diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..f78d58f --- /dev/null +++ b/src/config.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +use crate::ui::ribbon::RibbonConfig; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + pub success: bool, + pub preferences: Preferences, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Preferences { + pub common: CommonPreferences, + #[serde(rename = "bbc-json-pie")] + pub bbc_json_pie: BbcJsonPie, + pub web: WebPreferences, + pub mobile: MobilePreferences, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommonPreferences { + pub language: String, + pub timezone: String, + pub date_format: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BbcJsonPie { + pub table_definitions: serde_json::Value, + pub ribbon: RibbonConfig, + pub views: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebPreferences { + pub sidebar_collapsed: bool, + pub items_per_page: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MobilePreferences { + pub scan_mode: String, +} diff --git a/src/core/components/clone.rs b/src/core/components/clone.rs new file mode 100644 index 0000000..023ca16 --- /dev/null +++ b/src/core/components/clone.rs @@ -0,0 +1,69 @@ +use serde_json::{Map, Value}; + +/// Utilities to prepare cloned JSON records for INSERT dialogs. +/// These helpers mutate a cloned Value by clearing identifiers/unique fields, +/// removing editor metadata and timestamps, and optionally appending a suffix to a name field. + +/// Remove common editor metadata and timestamp/audit fields from an object map. +fn remove_metadata_fields(obj: &mut Map) { + // Remove __editor_* keys + let keys: Vec = obj + .keys() + .filter(|k| k.starts_with("__editor_")) + .cloned() + .collect(); + for k in keys { + obj.remove(&k); + } + + // Common timestamp/audit fields we don't want to copy + for k in [ + "created_at", + "created_date", + "created_by", + "last_modified", + "last_modified_at", + "last_modified_date", + "last_modified_by", + "last_modified_by_username", + "updated_at", + ] { + obj.remove(k); + } +} + +/// Clear a list of keys by setting them to an empty string (so editor treats as blank/new). +fn clear_keys(obj: &mut Map, keys_to_clear: &[&str]) { + for k in keys_to_clear { + obj.insert((*k).to_string(), Value::String(String::new())); + } +} + +/// Optionally append a suffix to the value of a given field if it is a string. +fn append_suffix(obj: &mut Map, field: &str, suffix: &str) { + if let Some(name) = obj.get(field).and_then(|v| v.as_str()) { + let new_val = format!("{}{}", name, suffix); + obj.insert(field.to_string(), Value::String(new_val)); + } +} + +/// Prepare a cloned Value for opening an "Add" dialog. +/// - Clears provided keys (e.g., id, codes) by setting them to "" +/// - Removes common metadata/timestamps and editor-only fields +/// - Optionally appends a suffix to a display/name field +pub fn prepare_cloned_value( + original: &Value, + keys_to_clear: &[&str], + name_field: Option<&str>, + name_suffix: Option<&str>, +) -> Value { + let mut cloned = original.clone(); + if let Some(obj) = cloned.as_object_mut() { + remove_metadata_fields(obj); + clear_keys(obj, keys_to_clear); + if let (Some(field), Some(suffix)) = (name_field, name_suffix) { + append_suffix(obj, field, suffix); + } + } + cloned +} diff --git a/src/core/components/filter_builder.rs b/src/core/components/filter_builder.rs new file mode 100644 index 0000000..48b7e15 --- /dev/null +++ b/src/core/components/filter_builder.rs @@ -0,0 +1,698 @@ +use eframe::egui; +use serde_json::{json, Value}; + +#[derive(Debug, Clone, PartialEq)] +pub enum FilterOperator { + Is, + IsNot, + Contains, + DoesntContain, + IsNull, + IsNotNull, +} + +impl FilterOperator { + pub fn to_sql_op(&self) -> &'static str { + match self { + FilterOperator::Is => "=", + FilterOperator::IsNot => "!=", + FilterOperator::Contains => "like", + FilterOperator::DoesntContain => "not like", + FilterOperator::IsNull => "IS", + FilterOperator::IsNotNull => "IS NOT", + } + } + + pub fn display_name(&self) -> &'static str { + match self { + FilterOperator::Is => "IS", + FilterOperator::IsNot => "IS NOT", + FilterOperator::Contains => "Contains", + FilterOperator::DoesntContain => "Doesn't Contain", + FilterOperator::IsNull => "IS NULL", + FilterOperator::IsNotNull => "IS NOT NULL", + } + } + + pub fn all() -> Vec { + vec![ + FilterOperator::Is, + FilterOperator::IsNot, + FilterOperator::Contains, + FilterOperator::DoesntContain, + FilterOperator::IsNull, + FilterOperator::IsNotNull, + ] + } + + pub fn needs_value(&self) -> bool { + !matches!(self, FilterOperator::IsNull | FilterOperator::IsNotNull) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LogicalOperator { + And, + Or, +} + +impl LogicalOperator { + pub fn display_name(&self) -> &'static str { + match self { + LogicalOperator::And => "AND", + LogicalOperator::Or => "OR", + } + } + + pub fn all() -> Vec { + vec![LogicalOperator::And, LogicalOperator::Or] + } +} + +#[derive(Debug, Clone)] +pub struct FilterCondition { + pub column: String, + pub operator: FilterOperator, + pub value: String, +} + +impl FilterCondition { + pub fn new() -> Self { + Self { + column: "Any".to_string(), + operator: FilterOperator::Contains, + value: String::new(), + } + } + + pub fn is_valid(&self) -> bool { + // Check if column is valid (not "Any" and not empty) + if self.column == "Any" || self.column.is_empty() { + return false; + } + + // Check if operator needs a value and value is provided + if self.operator.needs_value() && self.value.trim().is_empty() { + return false; + } + + true + } + + pub fn to_json(&self, table_prefix: &str) -> Value { + let column_name = if self.column.contains('.') { + self.column.clone() + } else { + format!("{}.{}", table_prefix, self.column) + }; + + match self.operator { + FilterOperator::Contains | FilterOperator::DoesntContain => { + json!({ + "column": column_name, + "op": self.operator.to_sql_op(), + "value": format!("%{}%", self.value) + }) + } + FilterOperator::IsNull => { + json!({ + "column": column_name, + "op": "is_null", + "value": null + }) + } + FilterOperator::IsNotNull => { + json!({ + "column": column_name, + "op": "is_not_null", + "value": null + }) + } + _ => { + json!({ + "column": column_name, + "op": self.operator.to_sql_op(), + "value": self.value + }) + } + } + } +} + +#[derive(Debug, Clone)] +pub struct FilterGroup { + pub conditions: Vec, + pub logical_operators: Vec, +} + +impl FilterGroup { + pub fn new() -> Self { + Self { + conditions: vec![FilterCondition::new()], + logical_operators: Vec::new(), + } + } + + pub fn add_condition(&mut self) { + if !self.conditions.is_empty() { + self.logical_operators.push(LogicalOperator::And); + } + self.conditions.push(FilterCondition::new()); + } + + pub fn remove_condition(&mut self, index: usize) { + if index < self.conditions.len() { + self.conditions.remove(index); + + // Remove corresponding logical operator + if index < self.logical_operators.len() { + self.logical_operators.remove(index); + } else if index > 0 && !self.logical_operators.is_empty() { + self.logical_operators.remove(index - 1); + } + } + } + + pub fn is_valid(&self) -> bool { + !self.conditions.is_empty() && self.conditions.iter().any(|c| c.is_valid()) + } + + pub fn to_json(&self, table_prefix: &str) -> Option { + let valid_conditions: Vec<_> = self.conditions.iter().filter(|c| c.is_valid()).collect(); + + if valid_conditions.is_empty() { + return None; + } + + // For single condition, return it directly without wrapping in and/or + if valid_conditions.len() == 1 { + return Some(valid_conditions[0].to_json(table_prefix)); + } + + // Build complex filter with logical operators for multiple conditions + let mut filter_conditions = Vec::new(); + for condition in valid_conditions.iter() { + filter_conditions.push(condition.to_json(table_prefix)); + } + + // For now, we'll use the first logical operator for the entire group + // In a more advanced implementation, we could support mixed operators + let primary_operator = self + .logical_operators + .first() + .unwrap_or(&LogicalOperator::And); + + Some(json!({ + primary_operator.display_name().to_lowercase(): filter_conditions + })) + } + + pub fn clear(&mut self) { + self.conditions = vec![FilterCondition::new()]; + self.logical_operators.clear(); + } +} + +pub struct FilterBuilder { + pub filter_group: FilterGroup, + pub available_columns: Vec<(String, String)>, // (display_name, field_name) + #[allow(dead_code)] + pub is_open: bool, + pub popup_open: bool, // For popup window +} + +#[allow(dead_code)] +impl FilterBuilder { + pub fn new() -> Self { + Self { + filter_group: FilterGroup::new(), + available_columns: Self::default_asset_columns(), + is_open: false, + popup_open: false, + } + } + + pub fn with_columns(mut self, columns: Vec<(String, String)>) -> Self { + self.available_columns = columns; + self + } + + fn default_asset_columns() -> Vec<(String, String)> { + vec![ + ("Any".to_string(), "Any".to_string()), + ("ID".to_string(), "id".to_string()), + ("Asset Tag".to_string(), "asset_tag".to_string()), + ("Numeric ID".to_string(), "asset_numeric_id".to_string()), + ("Type".to_string(), "asset_type".to_string()), + ("Name".to_string(), "name".to_string()), + ( + "Category".to_string(), + "categories.category_name".to_string(), + ), + ("Manufacturer".to_string(), "manufacturer".to_string()), + ("Model".to_string(), "model".to_string()), + ("Serial Number".to_string(), "serial_number".to_string()), + ("Zone".to_string(), "zones.zone_code".to_string()), + ("Zone Plus".to_string(), "zone_plus".to_string()), + ("Zone Note".to_string(), "zone_note".to_string()), + ("Status".to_string(), "status".to_string()), + ("Last Audit".to_string(), "last_audit".to_string()), + ( + "Last Audit Status".to_string(), + "last_audit_status".to_string(), + ), + ("Price".to_string(), "price".to_string()), + ("Purchase Date".to_string(), "purchase_date".to_string()), + ("Warranty Until".to_string(), "warranty_until".to_string()), + ("Expiry Date".to_string(), "expiry_date".to_string()), + ( + "Qty Available".to_string(), + "quantity_available".to_string(), + ), + ("Qty Total".to_string(), "quantity_total".to_string()), + ("Qty Used".to_string(), "quantity_used".to_string()), + ("Supplier".to_string(), "suppliers.name".to_string()), + ("Lendable".to_string(), "lendable".to_string()), + ( + "Min Role".to_string(), + "minimum_role_for_lending".to_string(), + ), + ("Lending Status".to_string(), "lending_status".to_string()), + ( + "Current Borrower".to_string(), + "current_borrower.name".to_string(), + ), + ("Due Date".to_string(), "due_date".to_string()), + ( + "Previous Borrower".to_string(), + "previous_borrower.name".to_string(), + ), + ("No Scan".to_string(), "no_scan".to_string()), + ("Notes".to_string(), "notes".to_string()), + ("Created Date".to_string(), "created_date".to_string()), + ( + "Created By".to_string(), + "created_by_user.username".to_string(), + ), + ( + "Last Modified".to_string(), + "last_modified_date".to_string(), + ), + ( + "Modified By".to_string(), + "modified_by_user.username".to_string(), + ), + ] + } + + fn default_zone_columns() -> Vec<(String, String)> { + vec![ + ("Any".to_string(), "Any".to_string()), + ("ID".to_string(), "zones.id".to_string()), + ("Zone Code".to_string(), "zones.zone_code".to_string()), + ("Zone Name".to_string(), "zones.zone_name".to_string()), + ("Zone Type".to_string(), "zones.zone_type".to_string()), + ("Parent ID".to_string(), "zones.parent_id".to_string()), + ( + "Include in Parent".to_string(), + "zones.include_in_parent".to_string(), + ), + ( + "Audit Timeout (minutes)".to_string(), + "zones.audit_timeout_minutes".to_string(), + ), + ("Zone Notes".to_string(), "zones.zone_notes".to_string()), + ] + } + + /// Set columns based on the context (table type) + pub fn set_columns_for_context(&mut self, context: &str) { + self.available_columns = match context { + "zones" => Self::default_zone_columns(), + "assets" | _ => Self::default_asset_columns(), + }; + } + + pub fn show(&mut self, ui: &mut egui::Ui) -> bool { + let mut filter_changed = false; + + ui.horizontal(|ui| { + ui.label("Filter Builder:"); + + if ui + .button(if self.is_open { "▼ Hide" } else { "▶ Show" }) + .clicked() + { + self.is_open = !self.is_open; + } + + if self.is_open { + ui.separator(); + + if ui.button("Clear All").clicked() { + self.filter_group.clear(); + filter_changed = true; + } + + let has_valid_filter = self.filter_group.is_valid(); + ui.add_enabled_ui(has_valid_filter, |ui| { + if ui.button("Apply Filter").clicked() { + filter_changed = true; + } + }); + } + }); + + if self.is_open { + ui.separator(); + + egui::ScrollArea::vertical() + .max_height(120.0) + .show(ui, |ui| { + filter_changed |= self.show_conditions(ui); + }); + + ui.horizontal(|ui| { + if ui.button("➕ Add Condition").clicked() { + self.filter_group.add_condition(); + } + + ui.separator(); + + let condition_count = self.filter_group.conditions.len(); + let valid_count = self + .filter_group + .conditions + .iter() + .filter(|c| c.is_valid()) + .count(); + + ui.label(format!( + "{} conditions ({} valid)", + condition_count, valid_count + )); + }); + } + + filter_changed + } + + fn show_conditions(&mut self, ui: &mut egui::Ui) -> bool { + let mut filter_changed = false; + let mut to_remove = None; + let conditions_len = self.filter_group.conditions.len(); + + egui::Grid::new("filter_conditions_grid") + .num_columns(6) + .spacing([6.0, 4.0]) + .striped(false) + .show(ui, |ui| { + for (i, condition) in self.filter_group.conditions.iter_mut().enumerate() { + // Logical operator column + if i > 0 { + let op_index = (i - 1).min(self.filter_group.logical_operators.len() - 1); + if let Some(logical_op) = + self.filter_group.logical_operators.get_mut(op_index) + { + let mut selected_op = logical_op.clone(); + egui::ComboBox::from_id_salt(format!("logical_op_{}", i)) + .selected_text(selected_op.display_name()) + .width(50.0) + .show_ui(ui, |ui| { + for op in LogicalOperator::all() { + if ui + .selectable_value( + &mut selected_op, + op.clone(), + op.display_name(), + ) + .clicked() + { + *logical_op = selected_op.clone(); + filter_changed = true; + } + } + }); + } + } else { + ui.label(""); // Empty cell for first row + } + + // Column selector + let mut selected_column = condition.column.clone(); + egui::ComboBox::from_id_salt(format!("column_{}", i)) + .selected_text(&selected_column) + .width(120.0) + .show_ui(ui, |ui| { + for (display_name, field_name) in &self.available_columns { + if ui + .selectable_value( + &mut selected_column, + field_name.clone(), + display_name, + ) + .clicked() + { + condition.column = selected_column.clone(); + filter_changed = true; + } + } + }); + + // Operator selector + let mut selected_operator = condition.operator.clone(); + egui::ComboBox::from_id_salt(format!("operator_{}", i)) + .selected_text(selected_operator.display_name()) + .width(90.0) + .show_ui(ui, |ui| { + for op in FilterOperator::all() { + if ui + .selectable_value( + &mut selected_operator, + op.clone(), + op.display_name(), + ) + .clicked() + { + condition.operator = selected_operator.clone(); + filter_changed = true; + } + } + }); + + // Value input + if condition.operator.needs_value() { + if ui + .add_sized( + [140.0, 20.0], + egui::TextEdit::singleline(&mut condition.value), + ) + .changed() + { + filter_changed = true; + } + } else { + ui.label("(no value)"); + } + + // Status icon + let icon = if condition.is_valid() { "OK" } else { "!" }; + ui.label(icon); + + // Remove button + if conditions_len > 1 { + if ui.button("X").clicked() { + to_remove = Some(i); + filter_changed = true; + } + } else { + ui.label(""); // Empty cell to maintain grid structure + } + + ui.end_row(); + } + }); + + // Remove condition if requested + if let Some(index) = to_remove { + self.filter_group.remove_condition(index); + } + + filter_changed + } + + pub fn get_filter_json(&self, table_prefix: &str) -> Option { + self.filter_group.to_json(table_prefix) + } + + pub fn has_valid_filter(&self) -> bool { + self.filter_group.is_valid() + } + + pub fn clear(&mut self) { + self.filter_group.clear(); + } + + /// Set a single filter condition programmatically + pub fn set_single_filter(&mut self, column: String, operator: FilterOperator, value: String) { + self.filter_group.clear(); + if let Some(first_condition) = self.filter_group.conditions.first_mut() { + first_condition.column = column; + first_condition.operator = operator; + first_condition.value = value; + } + } + + /// Get a short summary of active filters for display + pub fn get_filter_summary(&self) -> String { + let valid_conditions: Vec<_> = self + .filter_group + .conditions + .iter() + .filter(|c| c.is_valid()) + .collect(); + + if valid_conditions.is_empty() { + "No custom filters".to_string() + } else if valid_conditions.len() == 1 { + let condition = &valid_conditions[0]; + let column_display = self + .available_columns + .iter() + .find(|(_, field)| field == &condition.column) + .map(|(display, _)| display.as_str()) + .unwrap_or(&condition.column); + + format!( + "{} {} {}", + column_display, + condition.operator.display_name(), + if condition.operator.needs_value() { + &condition.value + } else { + "" + } + ) + .trim() + .to_string() + } else { + format!("{} conditions", valid_conditions.len()) + } + } + + /// Compact ribbon display with popup button + pub fn show_compact(&mut self, ui: &mut egui::Ui) -> bool { + let mut filter_changed = false; + + ui.vertical(|ui| { + ui.horizontal(|ui| { + if ui.button("Open Filter Builder").clicked() { + self.popup_open = true; + } + + if ui.button("Clear All").clicked() { + self.filter_group.clear(); + filter_changed = true; + } + }); + + ui.separator(); + + // Filter summary + ui.label(format!("Active: {}", self.get_filter_summary())); + }); + + filter_changed + } + + /// Show popup window with full FilterBuilder interface + pub fn show_popup(&mut self, ctx: &egui::Context) -> bool { + let mut filter_changed = false; + + if self.popup_open { + let mut popup_open = self.popup_open; + let response = egui::Window::new("Filter Builder") + .open(&mut popup_open) + .default_width(580.0) + .min_height(150.0) + .max_height(500.0) + .resizable(true) + .collapsible(false) + .show(ctx, |ui| self.show_full_interface(ui)); + + self.popup_open = popup_open; + + if let Some(inner_response) = response { + if let Some(changed) = inner_response.inner { + filter_changed = changed; + } + } + } + + filter_changed + } + + /// Full FilterBuilder interface (used in popup) + pub fn show_full_interface(&mut self, ui: &mut egui::Ui) -> bool { + let mut filter_changed = false; + + // Header with action buttons + ui.horizontal(|ui| { + ui.label("Build filters:"); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let has_valid_filter = self.filter_group.is_valid(); + ui.add_enabled_ui(has_valid_filter, |ui| { + if ui.button("Apply Filters").clicked() { + filter_changed = true; + self.popup_open = false; + } + }); + + if ui.button("Clear All").clicked() { + self.filter_group.clear(); + filter_changed = true; + } + }); + }); + + ui.separator(); + + // Scrollable conditions area + egui::ScrollArea::vertical() + .auto_shrink([false, true]) + .show(ui, |ui| { + filter_changed |= self.show_conditions(ui); + }); + + ui.separator(); + + // Footer with add button and status + ui.horizontal(|ui| { + if ui.button("+ Add Condition").clicked() { + self.filter_group.add_condition(); + } + + let condition_count = self.filter_group.conditions.len(); + let valid_count = self + .filter_group + .conditions + .iter() + .filter(|c| c.is_valid()) + .count(); + + ui.label(format!( + "Conditions: {}/{} valid", + valid_count, condition_count + )); + }); + + filter_changed + } +} + +impl Default for FilterBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/src/core/components/form_builder.rs b/src/core/components/form_builder.rs new file mode 100644 index 0000000..30f25ef --- /dev/null +++ b/src/core/components/form_builder.rs @@ -0,0 +1,371 @@ +use eframe::egui; +use serde_json::Value; +use std::collections::HashMap; + +use super::help::{show_help_window, HelpWindowOptions}; +use egui_commonmark::CommonMarkCache; +use egui_phosphor::regular as icons; + +/// Field types supported by the generic editor +#[derive(Clone)] +pub enum FieldType { + Text, + #[allow(dead_code)] + Dropdown(Vec<(String, String)>), // (value, label) + MultilineText, + Checkbox, + Date, // simple single-line date input (YYYY-MM-DD) +} + +/// Definition of an editable field +#[derive(Clone)] +pub struct EditorField { + pub name: String, + pub label: String, + pub field_type: FieldType, + pub required: bool, + pub read_only: bool, +} + +/// Replacement for FormBuilder that uses egui_form + garde for validation. +/// Maintains compatibility with existing EditorField schema. +pub struct FormBuilder { + pub title: String, + pub fields: Vec, + pub data: HashMap, // Store as strings for form editing + pub original_data: serde_json::Map, // Store original JSON data + pub show: bool, + pub item_id: Option, + pub is_new: bool, + field_help: HashMap, + pub form_help_text: Option, + pub show_form_help: bool, + help_cache: CommonMarkCache, +} + +impl FormBuilder { + pub fn new(title: impl Into, fields: Vec) -> Self { + Self { + title: title.into(), + fields, + data: HashMap::new(), + original_data: serde_json::Map::new(), + show: false, + item_id: None, + is_new: false, + field_help: HashMap::new(), + form_help_text: None, + show_form_help: false, + help_cache: CommonMarkCache::default(), + } + } + + #[allow(dead_code)] + pub fn with_help(mut self, help_text: impl Into) -> Self { + self.form_help_text = Some(help_text.into()); + self + } + + pub fn open(&mut self, item: &Value) { + self.show = true; + self.data.clear(); + self.original_data.clear(); + + // Convert JSON to string map + if let Some(obj) = item.as_object() { + self.original_data = obj.clone(); + for (k, v) in obj { + let value_str = match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + _ => serde_json::to_string(v).unwrap_or_default(), + }; + self.data.insert(k.clone(), value_str); + } + + self.item_id = obj.get("id").and_then(|v| match v { + Value::String(s) => Some(s.clone()), + Value::Number(n) => n.as_i64().map(|i| i.to_string()), + _ => None, + }); + self.is_new = false; + } + } + + pub fn open_new(&mut self, preset: Option<&serde_json::Map>) { + self.show = true; + self.data.clear(); + + if let Some(p) = preset { + for (k, v) in p { + let value_str = match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + _ => serde_json::to_string(v).unwrap_or_default(), + }; + self.data.insert(k.clone(), value_str); + } + } + + self.item_id = None; + self.is_new = true; + } + + pub fn close(&mut self) { + self.show = false; + self.data.clear(); + self.original_data.clear(); + self.item_id = None; + self.is_new = false; + } + + /// Show the form editor and return Some(data) if saved, None if still open + pub fn show_editor( + &mut self, + ctx: &egui::Context, + ) -> Option>> { + if !self.show { + return None; + } + + let mut result = None; + let mut close_requested = false; + + // Dynamic sizing + 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; + let vertical_margin = 24.0; + + let max_w = (root_bounds.width() - horizontal_margin) + .min(screen_bounds.width() - horizontal_margin) + .max(260.0); + let max_h = (root_bounds.height() - vertical_margin) + .min(screen_bounds.height() - vertical_margin) + .max(260.0); + + let default_w = (root_bounds.width() * 0.6).clamp(320.0, max_w); + let default_h = (root_bounds.height() * 0.7).clamp(300.0, max_h); + let content_max_h = (max_h - 160.0).max(180.0); + let _window_response = egui::Window::new(&self.title) + .collapsible(false) + .resizable(true) + .default_width(default_w) + .default_height(default_h) + .min_width(f32::min(280.0, max_w)) + .min_height(f32::min(260.0, max_h)) + .max_width(max_w) + .max_height(max_h) + .open(&mut self.show) + .show(ctx, |ui| { + egui::ScrollArea::vertical() + .max_height(content_max_h) + .show(ui, |ui| { + ui.vertical(|ui| { + for field in &self.fields.clone() { + let field_value = self + .data + .entry(field.name.clone()) + .or_insert_with(String::new); + + match &field.field_type { + FieldType::Text => { + let label_text = if field.required { + format!("{} *", field.label) + } else { + field.label.clone() + }; + ui.label(&label_text); + ui.add( + egui::TextEdit::singleline(field_value) + .desired_width(f32::INFINITY) + .interactive(!field.read_only), + ); + } + FieldType::MultilineText => { + let label_text = if field.required { + format!("{} *", field.label) + } else { + field.label.clone() + }; + ui.label(&label_text); + ui.add( + egui::TextEdit::multiline(field_value) + .desired_width(f32::INFINITY) + .interactive(!field.read_only), + ); + } + FieldType::Checkbox => { + let mut checked = + field_value == "true" || field_value == "1"; + ui.add_enabled( + !field.read_only, + egui::Checkbox::new(&mut checked, &field.label), + ); + if !field.read_only { + *field_value = if checked { + "true".to_string() + } else { + "false".to_string() + }; + } + } + FieldType::Date => { + let label_text = if field.required { + format!("{} *", field.label) + } else { + field.label.clone() + }; + ui.label(&label_text); + ui.add( + egui::TextEdit::singleline(field_value) + .hint_text("YYYY-MM-DD") + .desired_width(f32::INFINITY) + .interactive(!field.read_only), + ); + } + FieldType::Dropdown(options) => { + let label_text = if field.required { + format!("{} *", field.label) + } else { + field.label.clone() + }; + ui.label(&label_text); + ui.add_enabled_ui(!field.read_only, |ui| { + egui::ComboBox::from_id_salt(&field.name) + .width(ui.available_width()) + .selected_text( + options + .iter() + .find(|(v, _)| v == field_value) + .map(|(_, l)| l.as_str()) + .unwrap_or(""), + ) + .show_ui(ui, |ui| { + for (value, label) in options { + ui.selectable_value( + field_value, + value.clone(), + label, + ); + } + }); + }); + } + } + + // Show help text if available + if let Some(help) = self.field_help.get(&field.name) { + ui.label( + egui::RichText::new(help) + .small() + .color(ui.visuals().weak_text_color()), + ); + } + + ui.add_space(8.0); + } + }); + }); + + ui.separator(); + + ui.horizontal(|ui| { + // Help button if help text is available + if self.form_help_text.is_some() { + if ui.button(format!("{} Help", icons::QUESTION)).clicked() { + self.show_form_help = true; + } + ui.separator(); + } + + // Submit button + if ui.button(format!("{} Save", icons::CHECK)).clicked() { + // Validate required fields + let mut missing_fields = Vec::new(); + for field in &self.fields { + if field.required { + let value = + self.data.get(&field.name).map(|s| s.as_str()).unwrap_or(""); + if value.trim().is_empty() { + missing_fields.push(field.label.clone()); + } + } + } + + if !missing_fields.is_empty() { + log::warn!("Missing required fields: {}", missing_fields.join(", ")); + // Show error in UI - for now just log, could add error message field + } else { + // Convert string map back to JSON + let mut json_map = serde_json::Map::new(); + for (k, v) in &self.data { + // Try to preserve types + let json_value = if v == "true" { + Value::Bool(true) + } else if v == "false" { + Value::Bool(false) + } else if let Ok(n) = v.parse::() { + Value::Number(n.into()) + } else if let Ok(n) = v.parse::() { + serde_json::Number::from_f64(n) + .map(Value::Number) + .unwrap_or_else(|| Value::String(v.clone())) + } else if v.is_empty() { + Value::Null + } else { + Value::String(v.clone()) + }; + json_map.insert(k.clone(), json_value); + } + + // CRITICAL: Include the item_id so updates work + if let Some(ref id) = self.item_id { + json_map.insert( + "__editor_item_id".to_string(), + Value::String(id.clone()), + ); + } + + result = Some(Some(json_map)); + close_requested = true; + } + } + + if ui.button(format!("{} Cancel", icons::X)).clicked() { + result = Some(None); + close_requested = true; + } + }); + }); + if close_requested || !self.show { + self.close(); + } + + // Show help window if requested + if let Some(help_text) = &self.form_help_text { + if self.show_form_help { + show_help_window( + ctx, + &mut self.help_cache, + format!("{}_help", self.title), + &format!("{} - Help", self.title), + help_text, + &mut self.show_form_help, + HelpWindowOptions::default(), + ); + } + } + + result + } +} diff --git a/src/core/components/help.rs b/src/core/components/help.rs new file mode 100644 index 0000000..fb7ede8 --- /dev/null +++ b/src/core/components/help.rs @@ -0,0 +1,66 @@ +use eframe::egui; +use egui_commonmark::{CommonMarkCache, CommonMarkViewer}; + +#[derive(Clone, Copy)] +pub struct HelpWindowOptions { + pub min_size: egui::Vec2, + pub max_size_factor: f32, + pub default_width_factor: f32, + pub default_height_factor: f32, +} + +impl Default for HelpWindowOptions { + fn default() -> Self { + Self { + min_size: egui::vec2(320.0, 240.0), + max_size_factor: 0.9, + default_width_factor: 0.5, + default_height_factor: 0.6, + } + } +} + +pub fn show_help_window( + ctx: &egui::Context, + cache: &mut CommonMarkCache, + id_source: impl std::hash::Hash, + title: &str, + markdown_content: &str, + is_open: &mut bool, + options: HelpWindowOptions, +) { + if !*is_open { + return; + } + + let viewport = ctx.available_rect(); + let max_size = egui::vec2( + viewport.width() * options.max_size_factor, + viewport.height() * options.max_size_factor, + ); + let default_size = egui::vec2( + (viewport.width() * options.default_width_factor) + .clamp(options.min_size.x, max_size.x.max(options.min_size.x)), + (viewport.height() * options.default_height_factor) + .clamp(options.min_size.y, max_size.y.max(options.min_size.y)), + ); + + let mut open = *is_open; + egui::Window::new(title) + .id(egui::Id::new(id_source)) + .collapsible(false) + .resizable(true) + .default_size(default_size) + .min_size(options.min_size) + .max_size(max_size) + .open(&mut open) + .show(ctx, |ui| { + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + CommonMarkViewer::new().show(ui, cache, markdown_content); + }); + }); + + *is_open = open; +} diff --git a/src/core/components/interactions.rs b/src/core/components/interactions.rs new file mode 100644 index 0000000..ab9e5ac --- /dev/null +++ b/src/core/components/interactions.rs @@ -0,0 +1,225 @@ +use eframe::egui; +use std::collections::HashMap; + +/// Optional input field for confirmation dialogs +#[allow(dead_code)] +#[derive(Clone)] +pub struct ConfirmInputField { + pub label: String, + pub hint: String, + pub value: String, + pub multiline: bool, +} + +#[allow(dead_code)] +impl ConfirmInputField { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + hint: String::new(), + value: String::new(), + multiline: false, + } + } + + pub fn hint(mut self, hint: impl Into) -> Self { + self.hint = hint.into(); + self + } + + pub fn multiline(mut self, multiline: bool) -> Self { + self.multiline = multiline; + self + } +} + +/// A reusable confirmation dialog for destructive actions +pub struct ConfirmDialog { + pub title: String, + pub message: String, + pub item_name: Option, + pub item_id: Option, + pub show: bool, + pub is_dangerous: bool, + pub confirm_text: String, + pub cancel_text: String, + pub input_fields: Vec, +} + +impl ConfirmDialog { + pub fn new(title: impl Into, message: impl Into) -> Self { + Self { + title: title.into(), + message: message.into(), + item_name: None, + item_id: None, + show: false, + is_dangerous: true, + confirm_text: "Confirm".to_string(), + cancel_text: "Cancel".to_string(), + input_fields: Vec::new(), + } + } + + #[allow(dead_code)] + pub fn with_item(mut self, name: impl Into, id: impl Into) -> Self { + self.item_name = Some(name.into()); + self.item_id = Some(id.into()); + self + } + + #[allow(dead_code)] + pub fn dangerous(mut self, dangerous: bool) -> Self { + self.is_dangerous = dangerous; + self + } + + #[allow(dead_code)] + pub fn confirm_text(mut self, text: impl Into) -> Self { + self.confirm_text = text.into(); + self + } + + #[allow(dead_code)] + pub fn cancel_text(mut self, text: impl Into) -> Self { + self.cancel_text = text.into(); + self + } + + #[allow(dead_code)] + pub fn with_input_field(mut self, field: ConfirmInputField) -> Self { + self.input_fields.push(field); + self + } + + pub fn open(&mut self, name: impl Into, id: impl Into) { + self.item_name = Some(name.into()); + self.item_id = Some(id.into()); + self.show = true; + } + + pub fn close(&mut self) { + self.show = false; + self.item_name = None; + self.item_id = None; + // Clear input field values + for field in &mut self.input_fields { + field.value.clear(); + } + } + + /// Get the values of input fields as a HashMap + #[allow(dead_code)] + pub fn get_input_values(&self) -> HashMap { + self.input_fields + .iter() + .map(|field| (field.label.clone(), field.value.clone())) + .collect() + } + + /// Shows the dialog and returns Some(true) if confirmed, Some(false) if cancelled, None if still open + pub fn show_dialog(&mut self, ctx: &egui::Context) -> Option { + if !self.show { + return None; + } + + let mut result = None; + let mut keep_open = true; + + 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 default_pos = screen_rect.center() - egui::vec2(180.0, 120.0); + default_pos.x = default_pos.x.max(0.0); + default_pos.y = default_pos.y.max(0.0); + + egui::Window::new(&self.title) + .collapsible(false) + .resizable(false) + .movable(true) + .default_pos(default_pos) + .open(&mut keep_open) + .show(ctx, |ui| { + ui.label(&self.message); + + if let (Some(name), Some(id)) = (&self.item_name, &self.item_id) { + ui.add_space(8.0); + ui.label(egui::RichText::new(format!("Name: {}", name)).strong()); + ui.label(egui::RichText::new(format!("ID: {}", id)).strong()); + } + + if self.is_dangerous { + ui.add_space(12.0); + ui.colored_label( + egui::Color32::from_rgb(244, 67, 54), + "⚠ This action cannot be undone!", + ); + } + + // Render input fields if any + if !self.input_fields.is_empty() { + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + + for field in &mut self.input_fields { + ui.label(&field.label); + if field.multiline { + ui.add( + egui::TextEdit::multiline(&mut field.value) + .hint_text(&field.hint) + .desired_rows(3), + ); + } else { + ui.add( + egui::TextEdit::singleline(&mut field.value).hint_text(&field.hint), + ); + } + ui.add_space(4.0); + } + } + + ui.add_space(12.0); + + ui.horizontal(|ui| { + if ui.button(&self.cancel_text).clicked() { + result = Some(false); + self.close(); + } + ui.add_space(8.0); + + let confirm_button = if self.is_dangerous { + ui.add( + egui::Button::new( + egui::RichText::new(&self.confirm_text).color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(244, 67, 54)), + ) + } else { + ui.button(&self.confirm_text) + }; + + if confirm_button.clicked() { + result = Some(true); + self.close(); + } + }); + }); + + if !keep_open { + self.close(); + result = Some(false); + } + + result + } +} + +impl Default for ConfirmDialog { + fn default() -> Self { + Self::new("Confirm Action", "Are you sure?") + } +} diff --git a/src/core/components/mod.rs b/src/core/components/mod.rs new file mode 100644 index 0000000..68d2eb8 --- /dev/null +++ b/src/core/components/mod.rs @@ -0,0 +1,12 @@ +/// Reusable UI components and utilities +pub mod clone; +pub mod filter_builder; +pub mod form_builder; +pub mod help; +pub mod interactions; +pub mod stats; + +pub use clone::prepare_cloned_value; +pub use form_builder::{EditorField, FieldType, FormBuilder}; +// Other components available via direct module access: +// - filter_builder, help, interactions, stats diff --git a/src/core/components/stats.rs b/src/core/components/stats.rs new file mode 100644 index 0000000..356d458 --- /dev/null +++ b/src/core/components/stats.rs @@ -0,0 +1,57 @@ +use crate::api::ApiClient; +use crate::core::counters::count_entities; +use crate::models::DashboardStats; +use anyhow::Result; +use serde_json::json; + +/// Fetch all dashboard statistics using the generic counter +pub fn fetch_dashboard_stats(api_client: &ApiClient) -> Result { + log::debug!("Fetching dashboard statistics..."); + + let mut stats = DashboardStats::default(); + + // 1. Total Assets - count everything + stats.total_assets = count_entities(api_client, "assets", None).unwrap_or_else(|e| { + log::error!("Failed to count total assets: {}", e); + 0 + }); + + // 2. Okay Items - assets with status "Good" + stats.okay_items = count_entities(api_client, "assets", Some(json!({"status": "Good"}))) + .unwrap_or_else(|e| { + log::error!("Failed to count okay items: {}", e); + 0 + }); + + // 3. Attention Items - anything that needs attention + // Count: Faulty, Missing, Attention status + Overdue lending status + let faulty = + count_entities(api_client, "assets", Some(json!({"status": "Faulty"}))).unwrap_or(0); + + let missing = + count_entities(api_client, "assets", Some(json!({"status": "Missing"}))).unwrap_or(0); + + let attention_status = + count_entities(api_client, "assets", Some(json!({"status": "Attention"}))).unwrap_or(0); + + let scrapped = + count_entities(api_client, "assets", Some(json!({"status": "Scrapped"}))).unwrap_or(0); + + let overdue = count_entities( + api_client, + "assets", + Some(json!({"lending_status": "Overdue"})), + ) + .unwrap_or(0); + + stats.attention_items = faulty + missing + attention_status + scrapped + overdue; + + log::info!( + "Dashboard stats: {} total, {} okay, {} need attention", + stats.total_assets, + stats.okay_items, + stats.attention_items + ); + + Ok(stats) +} diff --git a/src/core/data/asset_fields.rs b/src/core/data/asset_fields.rs new file mode 100644 index 0000000..c9b6a78 --- /dev/null +++ b/src/core/data/asset_fields.rs @@ -0,0 +1,1008 @@ +use crate::api::ApiClient; +use crate::core::components::form_builder::FormBuilder; +use crate::core::{EditorField, FieldType}; +use serde_json::Value; + +/// Struct to hold commonly used dropdown options for assets +pub struct AssetDropdownOptions { + pub asset_types: Vec<(String, String)>, + pub status_options: Vec<(String, String)>, + pub lending_status_options: Vec<(String, String)>, + pub no_scan_options: Vec<(String, String)>, + pub zone_plus_options: Vec<(String, String)>, + pub category_options: Vec<(String, String)>, + pub zone_options: Vec<(String, String)>, + pub supplier_options: Vec<(String, String)>, + pub label_template_options: Vec<(String, String)>, + pub audit_task_options: Vec<(String, String)>, +} + +impl AssetDropdownOptions { + /// Create dropdown options by fetching from API + pub fn new(api_client: &ApiClient) -> Self { + // Static options + let asset_types = vec![ + ("N".to_string(), "Normal".to_string()), + ("B".to_string(), "Basic".to_string()), + ("L".to_string(), "License".to_string()), + ("C".to_string(), "Consumable".to_string()), + ]; + + // Status options: include full set supported by schema. Some installations may use "Retired" while others use "Scrapped". + // We include both to allow selection wherever the backend enum allows it. + let status_options = vec![ + ("Good".to_string(), "Good".to_string()), + ("Attention".to_string(), "Attention".to_string()), + ("Faulty".to_string(), "Faulty".to_string()), + ("Missing".to_string(), "Missing".to_string()), + ("In Repair".to_string(), "In Repair".to_string()), + ("In Transit".to_string(), "In Transit".to_string()), + ("Expired".to_string(), "Expired".to_string()), + ("Unmanaged".to_string(), "Unmanaged".to_string()), + ("Retired".to_string(), "Retired".to_string()), + ("Scrapped".to_string(), "Scrapped".to_string()), + ]; + + let lending_status_options = vec![ + ("Available".to_string(), "Available".to_string()), + ("Borrowed".to_string(), "Borrowed".to_string()), + ("Overdue".to_string(), "Overdue".to_string()), + ("Deployed".to_string(), "Deployed".to_string()), + ( + "Illegally Handed Out".to_string(), + "Illegally Handed Out".to_string(), + ), + ("Stolen".to_string(), "Stolen".to_string()), + ]; + + let no_scan_options = vec![ + ("No".to_string(), "No".to_string()), + ("Ask".to_string(), "Ask".to_string()), + ("Yes".to_string(), "Yes".to_string()), + ]; + + let zone_plus_options = vec![ + ("".into(), "".into()), + ("Floating Local".into(), "Floating Local".into()), + ("Floating Global".into(), "Floating Global".into()), + ("Clarify".into(), "Clarify".into()), + ]; + + // Fetch categories from API + let mut category_options: Vec<(String, String)> = Vec::new(); + if let Ok(resp) = api_client.select( + "categories", + Some(vec!["id".into(), "category_name".into()]), + None, + Some(vec![crate::models::OrderBy { + column: "category_name".into(), + direction: "ASC".into(), + }]), + None, + ) { + if resp.success { + if let Some(data) = resp.data { + for row in data { + let id = row + .get("id") + .and_then(|v| v.as_i64()) + .map(|n| n.to_string()) + .unwrap_or_default(); + let name = row + .get("category_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + category_options.push((id, name)); + } + } + } + } + + // Fetch zones from API + let mut zone_options: Vec<(String, String)> = Vec::new(); + if let Ok(resp) = api_client.select( + "zones", + Some(vec!["id".into(), "zone_code".into(), "zone_name".into()]), + None, + Some(vec![crate::models::OrderBy { + column: "zone_code".into(), + direction: "ASC".into(), + }]), + None, + ) { + if resp.success { + if let Some(data) = resp.data { + for row in data { + let id = row + .get("id") + .and_then(|v| v.as_i64()) + .map(|n| n.to_string()) + .unwrap_or_default(); + let code = row + .get("zone_code") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let name = row + .get("zone_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + zone_options.push((id, format!("{} - {}", code, name))); + } + } + } + } + + // Fetch suppliers from API + let mut supplier_options: Vec<(String, String)> = Vec::new(); + if let Ok(resp) = api_client.select( + "suppliers", + Some(vec!["id".into(), "name".into()]), + None, + Some(vec![crate::models::OrderBy { + column: "name".into(), + direction: "ASC".into(), + }]), + None, + ) { + if resp.success { + if let Some(data) = resp.data { + for row in data { + let id = row + .get("id") + .and_then(|v| v.as_i64()) + .map(|n| n.to_string()) + .unwrap_or_default(); + let name = row + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + supplier_options.push((id, name)); + } + } + } + } + + // Fetch label templates for dropdown + let mut label_template_options: Vec<(String, String)> = Vec::new(); + if let Ok(resp) = api_client.select( + "label_templates", + Some(vec!["id".into(), "template_name".into()]), + None, + Some(vec![crate::models::OrderBy { + column: "template_name".into(), + direction: "ASC".into(), + }]), + None, + ) { + if resp.success { + if let Some(data) = resp.data { + for row in data { + let id = row + .get("id") + .and_then(|v| v.as_i64()) + .map(|n| n.to_string()) + .unwrap_or_default(); + let name = row + .get("template_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + label_template_options.push((id, name)); + } + } + } + } + + // Fetch audit tasks and add default "None" option + let mut audit_task_options: Vec<(String, String)> = + vec![(String::new(), "-- None --".to_string())]; + if let Ok(resp) = api_client.select( + "audit_tasks", + Some(vec!["id".into(), "task_name".into()]), + None, + Some(vec![crate::models::OrderBy { + column: "task_name".into(), + direction: "ASC".into(), + }]), + None, + ) { + if resp.success { + if let Some(data) = resp.data { + for row in data { + if let Some(id) = row.get("id").and_then(|v| v.as_i64()) { + let name = row + .get("task_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + audit_task_options.push((id.to_string(), name)); + } + } + } + } + } + + Self { + asset_types, + status_options, + lending_status_options, + no_scan_options, + zone_plus_options, + category_options, + zone_options, + supplier_options, + label_template_options, + audit_task_options, + } + } +} + +/// Asset field configuration builder - provides standardized field definitions for asset forms +pub struct AssetFieldBuilder; + +impl AssetFieldBuilder { + /// Create a Full Add dialog that shows (nearly) all asset fields similar to Advanced Edit, + /// but configured for inserting a new asset (no ID fields, audit fields stay read-only). + pub fn create_full_add_dialog(api_client: &ApiClient) -> FormBuilder { + let options = AssetDropdownOptions::new(api_client); + + let fields: Vec = vec![ + // Core identifiers for new record + EditorField { + name: "asset_tag".into(), + label: "Asset Tag".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "asset_type".into(), + label: "Type".into(), + field_type: FieldType::Dropdown(options.asset_types.clone()), + required: true, + read_only: false, + }, + EditorField { + name: "name".into(), + label: "Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + // Classification + EditorField { + name: "category_id".into(), + label: "Category".into(), + field_type: FieldType::Dropdown(options.category_options.clone()), + required: false, + read_only: false, + }, + // Quick add category + EditorField { + name: "new_category_name".into(), + label: "Add Category - Name".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "new_category_code".into(), + label: "Add Category - Code".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + // Location + EditorField { + name: "zone_id".into(), + label: "Zone".into(), + field_type: FieldType::Dropdown(options.zone_options.clone()), + required: false, + read_only: false, + }, + // Quick add zone + EditorField { + name: "new_zone_parent_id".into(), + label: "Add Zone - Parent".into(), + field_type: FieldType::Dropdown(options.zone_options.clone()), + required: false, + read_only: false, + }, + EditorField { + name: "new_zone_mini_code".into(), + label: "Add Zone - Mini Code".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "new_zone_name".into(), + label: "Add Zone - Name".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "zone_plus".into(), + label: "Zone Plus".into(), + field_type: FieldType::Dropdown(options.zone_plus_options.clone()), + required: false, + read_only: false, + }, + EditorField { + name: "no_scan".into(), + label: "No Scan".into(), + field_type: FieldType::Dropdown(options.no_scan_options.clone()), + required: false, + read_only: false, + }, + // Make/model + EditorField { + name: "manufacturer".into(), + label: "Manufacturer".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "model".into(), + label: "Model".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "serial_number".into(), + label: "Serial Number".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + // Status + EditorField { + name: "status".into(), + label: "Status".into(), + field_type: FieldType::Dropdown(options.status_options.clone()), + required: true, + read_only: false, + }, + // Financial / dates + EditorField { + name: "price".into(), + label: "Price".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "purchase_date".into(), + label: "Purchase Date".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + EditorField { + name: "warranty_until".into(), + label: "Warranty Until".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + EditorField { + name: "expiry_date".into(), + label: "Expiry Date".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + // Lendable + EditorField { + name: "lendable".into(), + label: "Lendable".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "lending_status".into(), + label: "Lending Status".into(), + field_type: FieldType::Dropdown(options.lending_status_options.clone()), + required: false, + read_only: false, + }, + EditorField { + name: "due_date".into(), + label: "Due Date".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + // Supplier + EditorField { + name: "supplier_id".into(), + label: "Supplier".into(), + field_type: FieldType::Dropdown(options.supplier_options.clone()), + required: false, + read_only: false, + }, + // Quick add supplier + EditorField { + name: "new_supplier_name".into(), + label: "Add Supplier - Name".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + // Label template + EditorField { + name: "label_template_id".into(), + label: "Label Template".into(), + field_type: FieldType::Dropdown(options.label_template_options.clone()), + required: false, + read_only: false, + }, + // Notes + EditorField { + name: "notes".into(), + label: "Notes".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + // Optional: print on add toggle + EditorField { + name: "print_label".into(), + label: "Print Label".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + // Audit/meta (read-only informational) + EditorField { + name: "created_date".into(), + label: "Created Date".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "created_by_username".into(), + label: "Created By".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "last_modified_date".into(), + label: "Last Modified".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "last_modified_by_username".into(), + label: "Modified By".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + ]; + + let mut dialog = FormBuilder::new("Add Asset (Full)", fields); + + // Prefill sensible defaults + let mut preset = serde_json::Map::new(); + preset.insert("asset_type".to_string(), Value::String("N".to_string())); + preset.insert("status".to_string(), Value::String("Good".to_string())); + preset.insert("lendable".to_string(), Value::Bool(true)); + preset.insert("print_label".to_string(), Value::Bool(true)); + dialog.open_new(Some(&preset)); + + dialog + } + /// Create a comprehensive Advanced Edit dialog with all asset fields + pub fn create_advanced_edit_dialog(api_client: &ApiClient) -> FormBuilder { + let options = AssetDropdownOptions::new(api_client); + + let fields: Vec = vec![ + // Identifiers (read-only) + EditorField { + name: "id".into(), + label: "ID".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "asset_numeric_id".into(), + label: "Numeric ID".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "asset_tag".into(), + label: "Asset Tag".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + // Core fields + EditorField { + name: "asset_type".into(), + label: "Type".into(), + field_type: FieldType::Dropdown(options.asset_types), + required: true, + read_only: false, + }, + EditorField { + name: "name".into(), + label: "Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "description".into(), + label: "Description".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + // Classification + EditorField { + name: "category_id".into(), + label: "Category".into(), + field_type: FieldType::Dropdown(options.category_options.clone()), + required: false, + read_only: false, + }, + // Quick add category + EditorField { + name: "new_category_name".into(), + label: "Add Category - Name".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "new_category_code".into(), + label: "Add Category - Code".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + // Location + EditorField { + name: "zone_id".into(), + label: "Zone".into(), + field_type: FieldType::Dropdown(options.zone_options.clone()), + required: false, + read_only: false, + }, + // Quick add zone + EditorField { + name: "new_zone_parent_id".into(), + label: "Add Zone - Parent".into(), + field_type: FieldType::Dropdown(options.zone_options.clone()), + required: false, + read_only: false, + }, + EditorField { + name: "new_zone_mini_code".into(), + label: "Add Zone - Mini Code".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "new_zone_name".into(), + label: "Add Zone - Name".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "zone_plus".into(), + label: "Zone Plus".into(), + field_type: FieldType::Dropdown(options.zone_plus_options), + required: false, + read_only: false, + }, + EditorField { + name: "no_scan".into(), + label: "No Scan".into(), + field_type: FieldType::Dropdown(options.no_scan_options), + required: false, + read_only: false, + }, + EditorField { + name: "audit_task_id".into(), + label: "Audit Task".into(), + field_type: FieldType::Dropdown(options.audit_task_options.clone()), + required: false, + read_only: false, + }, + // Make/model + EditorField { + name: "manufacturer".into(), + label: "Manufacturer".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "model".into(), + label: "Model".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "serial_number".into(), + label: "Serial Number".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + // Status + EditorField { + name: "status".into(), + label: "Status".into(), + field_type: FieldType::Dropdown(options.status_options), + required: true, + read_only: false, + }, + // Financial / dates + EditorField { + name: "price".into(), + label: "Price".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "purchase_date".into(), + label: "Purchase Date".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + EditorField { + name: "warranty_until".into(), + label: "Warranty Until".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + EditorField { + name: "expiry_date".into(), + label: "Expiry Date".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + // Lendable + EditorField { + name: "lendable".into(), + label: "Lendable".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "lending_status".into(), + label: "Lending Status".into(), + field_type: FieldType::Dropdown(options.lending_status_options), + required: false, + read_only: false, + }, + EditorField { + name: "due_date".into(), + label: "Due Date".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + EditorField { + name: "supplier_id".into(), + label: "Supplier".into(), + field_type: FieldType::Dropdown(options.supplier_options), + required: false, + read_only: false, + }, + // Quick add supplier + EditorField { + name: "new_supplier_name".into(), + label: "Add Supplier - Name".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + // Borrowers (read-only historical) + EditorField { + name: "previous_borrower_id".into(), + label: "Prev Borrower".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "current_borrower_id".into(), + label: "Current Borrower".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + // Label template selection + EditorField { + name: "label_template_id".into(), + label: "Label Template".into(), + field_type: FieldType::Dropdown(options.label_template_options.clone()), + required: false, + read_only: false, + }, + // Notes / images + EditorField { + name: "notes".into(), + label: "Notes".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + // Audit/meta (read-only) + EditorField { + name: "created_date".into(), + label: "Created Date".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "created_by_username".into(), + label: "Created By".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "last_modified_date".into(), + label: "Last Modified".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "last_modified_by_username".into(), + label: "Modified By".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + ]; + + FormBuilder::new("Advanced Edit Asset", fields) + } + + /// Create an Easy Edit dialog with essential asset fields only + pub fn create_easy_edit_dialog(api_client: &ApiClient) -> FormBuilder { + let options = AssetDropdownOptions::new(api_client); + + let fields = vec![ + EditorField { + name: "asset_tag".to_string(), + label: "Asset Tag".to_string(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "asset_type".to_string(), + label: "Type".to_string(), + field_type: FieldType::Dropdown(options.asset_types), + required: true, + read_only: false, + }, + EditorField { + name: "name".to_string(), + label: "Name".to_string(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "category_id".to_string(), + label: "Category".to_string(), + field_type: FieldType::Dropdown(options.category_options), + required: false, + read_only: false, + }, + EditorField { + name: "manufacturer".to_string(), + label: "Manufacturer".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "model".to_string(), + label: "Model".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "zone_id".to_string(), + label: "Zone".to_string(), + field_type: FieldType::Dropdown(options.zone_options), + required: false, + read_only: false, + }, + EditorField { + name: "status".to_string(), + label: "Status".to_string(), + field_type: FieldType::Dropdown(options.status_options), + required: true, + read_only: false, + }, + EditorField { + name: "lendable".to_string(), + label: "Lendable".to_string(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + ]; + + FormBuilder::new("Easy Edit Asset", fields) + } + + /// Create an Add Asset dialog with quick-add functionality for categories/zones/suppliers + pub fn create_add_dialog_with_preset(api_client: &ApiClient) -> FormBuilder { + let options = AssetDropdownOptions::new(api_client); + + let fields = vec![ + EditorField { + name: "asset_tag".to_string(), + label: "Asset Tag".to_string(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "asset_type".to_string(), + label: "Type".to_string(), + field_type: FieldType::Dropdown(options.asset_types), + required: true, + read_only: false, + }, + EditorField { + name: "name".to_string(), + label: "Name".to_string(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + // Category dropdown + add-new placeholders + EditorField { + name: "category_id".to_string(), + label: "Category".to_string(), + field_type: FieldType::Dropdown(options.category_options), + required: false, + read_only: false, + }, + // Label template selection + EditorField { + name: "label_template_id".to_string(), + label: "Label Template".to_string(), + field_type: FieldType::Dropdown(options.label_template_options.clone()), + required: false, + read_only: false, + }, + // Print label option + EditorField { + name: "print_label".to_string(), + label: "Print Label".to_string(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + // Add new category name/code as text fields + EditorField { + name: "new_category_name".to_string(), + label: "Add New Category - Name".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "new_category_code".to_string(), + label: "Add New Category - Code".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "manufacturer".to_string(), + label: "Manufacturer".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "model".to_string(), + label: "Model".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "zone_id".to_string(), + label: "Zone".to_string(), + field_type: FieldType::Dropdown(options.zone_options), + required: false, + read_only: false, + }, + EditorField { + name: "status".to_string(), + label: "Status".to_string(), + field_type: FieldType::Dropdown(options.status_options), + required: true, + read_only: false, + }, + EditorField { + name: "lendable".to_string(), + label: "Lendable".to_string(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + ]; + + let mut dialog = FormBuilder::new("Add Asset", fields); + + // Open with sensible defaults + let mut preset = serde_json::Map::new(); + preset.insert("asset_type".to_string(), Value::String("N".to_string())); + preset.insert("status".to_string(), Value::String("Good".to_string())); + preset.insert("lendable".to_string(), Value::Bool(true)); + // Default to printing a label after adding when possible + preset.insert("print_label".to_string(), Value::Bool(true)); + + dialog.open_new(Some(&preset)); + dialog + } + + /// Get the list of fields that are allowed to be updated via API + #[allow(dead_code)] + pub fn get_allowed_update_fields() -> Vec { + vec![ + "asset_tag".to_string(), + "asset_type".to_string(), + "name".to_string(), + "description".to_string(), + "category_id".to_string(), + "zone_id".to_string(), + "zone_plus".to_string(), + "no_scan".to_string(), + "manufacturer".to_string(), + "model".to_string(), + "serial_number".to_string(), + "status".to_string(), + "price".to_string(), + "purchase_date".to_string(), + "warranty_until".to_string(), + "expiry_date".to_string(), + "lendable".to_string(), + "lending_status".to_string(), + "due_date".to_string(), + "supplier_id".to_string(), + "notes".to_string(), + "label_template_id".to_string(), + ] + } +} diff --git a/src/core/data/counters.rs b/src/core/data/counters.rs new file mode 100644 index 0000000..485a590 --- /dev/null +++ b/src/core/data/counters.rs @@ -0,0 +1,43 @@ +use crate::api::ApiClient; +use anyhow::Result; +use serde_json::Value; + +/// Generic counter function - can count anything from any table with any conditions +/// +/// # Examples +/// ``` +/// // Count all assets +/// count_entities(api, "assets", None)?; +/// +/// // Count available assets +/// count_entities(api, "assets", Some(json!({"lending_status": "Available"})))?; +/// +/// // Count with multiple conditions +/// count_entities(api, "assets", Some(json!({ +/// "lendable": true, +/// "lending_status": "Available" +/// })))?; +/// ``` +pub fn count_entities( + api_client: &ApiClient, + table: &str, + where_conditions: Option, +) -> Result { + log::debug!("Counting {} with conditions: {:?}", table, where_conditions); + let response = api_client.count(table, where_conditions)?; + log::debug!( + "Count response: success={}, data={:?}", + response.success, + response.data + ); + + // Check for database timeout errors + if !response.success { + if crate::api::ApiClient::is_database_timeout_error(&response.error) { + log::warn!("Database timeout detected while counting {}", table); + } + anyhow::bail!("API error: {:?}", response.error); + } + + Ok(response.data.unwrap_or(0)) +} diff --git a/src/core/data/data_loader.rs b/src/core/data/data_loader.rs new file mode 100644 index 0000000..7eb4125 --- /dev/null +++ b/src/core/data/data_loader.rs @@ -0,0 +1,99 @@ +use crate::api::ApiClient; +use serde_json::Value; + +/// Loading state management for UI views +#[derive(Default)] +pub struct LoadingState { + pub is_loading: bool, + pub last_error: Option, + pub last_load_time: Option, +} + +impl LoadingState { + pub fn new() -> Self { + Self::default() + } + + pub fn start_loading(&mut self) { + self.is_loading = true; + self.last_error = None; + self.last_load_time = Some(std::time::Instant::now()); + } + + pub fn finish_loading(&mut self, error: Option) { + self.is_loading = false; + self.last_error = error; + } + + pub fn finish_success(&mut self) { + self.finish_loading(None); + } + + pub fn finish_error(&mut self, error: String) { + self.finish_loading(Some(error)); + } + + #[allow(dead_code)] + pub fn has_error(&self) -> bool { + self.last_error.is_some() + } + + pub fn get_error(&self) -> Option<&str> { + self.last_error.as_deref() + } + + #[allow(dead_code)] + pub fn should_auto_retry(&self, retry_after_seconds: u64) -> bool { + if let (Some(error), Some(load_time)) = (&self.last_error, self.last_load_time) { + !error.is_empty() && load_time.elapsed().as_secs() > retry_after_seconds + } else { + false + } + } +} + +/// Data loader for assets +pub struct DataLoader; + +impl DataLoader { + pub fn load_assets( + api_client: &ApiClient, + limit: Option, + where_clause: Option, + filter: Option, + ) -> Result, String> { + log::info!( + "Loading inventory assets (limit={:?}, where={:?}, filter={:?})...", + limit, + where_clause, + filter + ); + + // Use select_with_joins to load assets with zone and category data + let response = api_client + .select_with_joins( + "assets", + None, // columns (None = all) + where_clause, + filter, + None, // order_by + limit, + None, // joins (None = use default joins) + ) + .map_err(|e| format!("Failed to load assets: {}", e))?; + + if !response.success { + // Check if this is a database timeout error + if ApiClient::is_database_timeout_error(&response.error) { + log::warn!("Database timeout detected while loading assets"); + } + let error_msg = format!("API error: {:?}", response.error); + log::error!("{}", error_msg); + return Err(error_msg); + } + + let assets = response.data.unwrap_or_default(); + log::info!("Loaded {} assets successfully (with JOINs)", assets.len()); + Ok(assets) + } +} diff --git a/src/core/data/mod.rs b/src/core/data/mod.rs new file mode 100644 index 0000000..edb61ab --- /dev/null +++ b/src/core/data/mod.rs @@ -0,0 +1,8 @@ +/// Data management and dropdown options +pub mod asset_fields; +pub mod counters; +pub mod data_loader; + +pub use asset_fields::*; +pub use data_loader::*; +// counters module available but not currently used diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..7911d77 --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,26 @@ +// Core business logic and data management + +/// UI components (forms, dialogs, helpers) +pub mod components; +/// Data models and dropdown options +pub mod data; +/// Asset operations and CRUD +pub mod operations; +/// Print system +pub mod print; +/// Table rendering +pub mod table_renderer; +/// Table data management +pub mod tables; +/// Utility functions +pub mod utils; +/// Multi-step workflows +pub mod workflows; + +// Re-exports for convenience +pub use components::stats::*; +pub use components::{EditorField, FieldType, FormBuilder}; +pub use data::*; +pub use operations::*; +pub use table_renderer::*; +pub use tables::*; diff --git a/src/core/operations/asset_operations.rs b/src/core/operations/asset_operations.rs new file mode 100644 index 0000000..459aeb5 --- /dev/null +++ b/src/core/operations/asset_operations.rs @@ -0,0 +1,613 @@ +use crate::api::ApiClient; +use crate::models::api_error_detail; +use serde_json::{Map, Value}; +/// Asset CRUD operations that can be reused across different entity types +pub struct AssetOperations; + +impl AssetOperations { + /// Apply updates to one or more assets + pub fn apply_updates( + api: &ApiClient, + updated: Map, + pending_edit_ids: &mut Vec, + easy_dialog_item_id: Option<&str>, + advanced_dialog_item_id: Option<&str>, + find_asset_fn: impl Fn(i64) -> Option, + limit: Option, + reload_fn: impl FnOnce(&ApiClient, Option), + ) where + T: serde::Serialize, + { + let ids: Vec = std::mem::take(pending_edit_ids); + log::info!("Pending edit IDs from ribbon: {:?}", ids); + + if ids.is_empty() { + // Try to get ID from either dialog or from the embedded ID in the diff + let item_id = updated + .get("__editor_item_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| easy_dialog_item_id.map(|s| s.to_string())) + .or_else(|| advanced_dialog_item_id.map(|s| s.to_string())); + + if let Some(id_str) = item_id { + if let Ok(id) = id_str.parse::() { + // Compute diff against the current asset so we only send changed fields + let only_changed = if let Some(orig) = find_asset_fn(id) { + log::info!("Found original asset data for comparison"); + let orig_value = serde_json::to_value(&orig).unwrap_or_default(); + let mut diff = Map::new(); + for (k, v) in updated.iter() { + match orig_value.get(k) { + Some(ov) if ov == v => { + log::debug!("Field '{}' unchanged: {:?}", k, v); + } + _ => { + log::info!( + "Field '{}' CHANGED: old={:?}, new={:?}", + k, + orig_value.get(k), + v + ); + diff.insert(k.clone(), v.clone()); + } + } + } + log::info!("Final diff map to send: {:?}", diff); + diff + } else { + log::warn!( + "Asset ID {} not found in local cache, sending full update", + id + ); + updated.clone() + }; + + if only_changed.is_empty() { + log::warn!("No changes detected - update will be skipped!"); + } else { + log::info!("Calling update_one with {} changes", only_changed.len()); + } + Self::update_one(api, id, &only_changed); + } else { + log::error!("FAILED to parse asset ID: '{}' - UPDATE SKIPPED!", id_str); + } + } else { + log::error!("NO ITEM ID FOUND - This is the bug! UPDATE COMPLETELY SKIPPED!"); + log::error!("Easy dialog item_id: {:?}", easy_dialog_item_id); + log::error!("Advanced dialog item_id: {:?}", advanced_dialog_item_id); + } + } else { + log::info!("Bulk edit mode for {} assets", ids.len()); + // Bulk edit: apply provided fields to each id without diffing per-record + for id in ids { + log::info!("Bulk updating asset ID: {}", id); + Self::update_one(api, id, &updated); + } + } + + reload_fn(api, limit); + } + + /// Handle quick-add fields for category, zone, and supplier when editing + pub fn preprocess_quick_adds(api: &ApiClient, data: &mut Map) { + // CATEGORY + let new_cat_name = data + .get("new_category_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let new_cat_code = data + .get("new_category_code") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let has_selected_cat = data.get("category_id").and_then(|v| v.as_i64()).is_some() + || data + .get("category_id") + .and_then(|v| v.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + if !has_selected_cat && !new_cat_name.is_empty() && !new_cat_code.is_empty() { + let values = serde_json::json!({ + "category_name": new_cat_name, + "category_code": new_cat_code, + }); + match api.insert("categories", values) { + Ok(resp) if resp.success => { + if let Some(id) = resp.data { + data.insert("category_id".into(), Value::Number((id as i64).into())); + } + } + Ok(resp) => { + log::error!( + "Quick-add category failed: {}", + api_error_detail(&resp.error) + ); + } + Err(e) => { + log::error!("Quick-add category err: {}", e); + } + } + } + + // ZONE + let new_zone_name = data + .get("new_zone_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + // Prefer new_zone_mini_code (new), fallback to legacy new_zone_code + let new_zone_mini_code = data + .get("new_zone_mini_code") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let legacy_new_zone_code = data + .get("new_zone_code") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let parent_id_val = data.get("new_zone_parent_id").cloned(); + let has_selected_zone = data.get("zone_id").and_then(|v| v.as_i64()).is_some() + || data + .get("zone_id") + .and_then(|v| v.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + if !has_selected_zone && !new_zone_name.is_empty() { + // parent optional, parse int if provided + let mut zone_obj = Map::new(); + zone_obj.insert("zone_name".into(), Value::String(new_zone_name)); + // Determine mini_code to use + let mini_code = if !new_zone_mini_code.is_empty() { + new_zone_mini_code.clone() + } else { + legacy_new_zone_code.clone() + }; + if !mini_code.is_empty() { + // Compute full zone_code using parent if provided + let full_code = if let Some(v) = parent_id_val.clone() { + if let Some(pid) = v + .as_i64() + .or_else(|| v.as_str().and_then(|s| s.parse::().ok())) + { + // Fetch parent's zone_code + if let Ok(resp) = api.select( + "zones", + Some(vec!["zone_code".into()]), + Some(serde_json::json!({"id": pid})), + None, + Some(1), + ) { + if resp.success { + if let Some(rows) = resp.data { + if let Some(row) = rows.into_iter().next() { + let pcode = row + .get("zone_code") + .and_then(|v| v.as_str()) + .unwrap_or(""); + format!("{}-{}", pcode, mini_code) + } else { + mini_code.clone() + } + } else { + mini_code.clone() + } + } else { + mini_code.clone() + } + } else { + mini_code.clone() + } + } else { + mini_code.clone() + } + } else { + mini_code.clone() + }; + // Send both mini_code and computed full zone_code + zone_obj.insert("mini_code".into(), Value::String(mini_code)); + zone_obj.insert("zone_code".into(), Value::String(full_code)); + } + if let Some(v) = parent_id_val { + if let Some(n) = v.as_i64() { + zone_obj.insert("parent_id".into(), Value::Number(n.into())); + } else if let Some(s) = v.as_str() { + if let Ok(n) = s.parse::() { + zone_obj.insert("parent_id".into(), Value::Number(n.into())); + } + } + } + match api.insert("zones", Value::Object(zone_obj)) { + Ok(resp) if resp.success => { + if let Some(id) = resp.data { + data.insert("zone_id".into(), Value::Number((id as i64).into())); + } + } + Ok(resp) => { + log::error!("Quick-add zone failed: {}", api_error_detail(&resp.error)); + } + Err(e) => { + log::error!("Quick-add zone err: {}", e); + } + } + } + + // SUPPLIER + let new_supplier_name = data + .get("new_supplier_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let has_selected_supplier = data.get("supplier_id").and_then(|v| v.as_i64()).is_some() + || data + .get("supplier_id") + .and_then(|v| v.as_str()) + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + + if !has_selected_supplier && !new_supplier_name.is_empty() { + let values = serde_json::json!({ "name": new_supplier_name }); + match api.insert("suppliers", values) { + Ok(resp) if resp.success => { + if let Some(id) = resp.data { + data.insert("supplier_id".into(), Value::Number((id as i64).into())); + } + } + Ok(resp) => { + log::error!( + "Quick-add supplier failed: {}", + api_error_detail(&resp.error) + ); + } + Err(e) => { + log::error!("Quick-add supplier err: {}", e); + } + } + } + } + + /// Filter update data to only include allowed fields with proper type coercion + pub fn filtered_update_fields(data: &Map) -> Map { + // Allow only writable/meaningful asset fields (exclude IDs, timestamps, joined names) + let allowed = [ + "asset_tag", + "asset_type", + "name", + "category_id", + "zone_id", + "zone_plus", + "zone_note", + "manufacturer", + "model", + "serial_number", + "status", + "label_template_id", + "price", + "purchase_date", + "warranty_until", + "expiry_date", + "supplier_id", + "lendable", + "lending_status", + "due_date", + "no_scan", + "quantity_available", + "quantity_total", + "quantity_used", + "minimum_role_for_lending", + "audit_task_id", + "asset_image", + "notes", + "additional_fields", + ]; + let allowed_set: std::collections::HashSet<&str> = allowed.iter().copied().collect(); + let mut out = Map::new(); + + for (k, v) in data.iter() { + // Skip internal editor fields + if k.starts_with("__editor_") { + continue; + } + // Map template-only "description" to asset "notes" to avoid DB column mismatch + if k == "description" { + let coerced = if let Some(s) = v.as_str() { + Value::String(s.to_string()) + } else if v.is_null() { + Value::Null + } else { + Value::String(v.to_string()) + }; + if !out.contains_key("notes") { + out.insert("notes".to_string(), coerced); + } + continue; + } + if !allowed_set.contains(k.as_str()) { + continue; + } + + // Coerce common types where Advanced Editor may send strings + let coerced = match k.as_str() { + // Integers (IDs and quantities) + "category_id" + | "zone_id" + | "label_template_id" + | "supplier_id" + | "audit_task_id" + | "quantity_available" + | "quantity_total" + | "quantity_used" + | "minimum_role_for_lending" => { + if let Some(n) = v.as_i64() { + Value::Number(n.into()) + } else if let Some(s) = v.as_str() { + if s.trim().is_empty() { + Value::Null + } else if let Ok(n) = s.trim().parse::() { + Value::Number(n.into()) + } else { + Value::Null + } + } else { + v.clone() + } + } + + // Booleans + "lendable" => { + if let Some(b) = v.as_bool() { + Value::Bool(b) + } else if let Some(s) = v.as_str() { + match s.trim().to_lowercase().as_str() { + "true" | "1" | "yes" => Value::Bool(true), + "false" | "0" | "no" => Value::Bool(false), + _ => v.clone(), + } + } else { + v.clone() + } + } + + // Price as decimal number + "price" => { + if let Some(f) = v.as_f64() { + Value::Number( + serde_json::Number::from_f64(f) + .unwrap_or_else(|| serde_json::Number::from(0)), + ) + } else if let Some(s) = v.as_str() { + if s.trim().is_empty() { + Value::Null + } else if let Ok(f) = s.trim().parse::() { + Value::Number( + serde_json::Number::from_f64(f) + .unwrap_or_else(|| serde_json::Number::from(0)), + ) + } else { + Value::Null + } + } else { + v.clone() + } + } + + // Date fields: accept YYYY-MM-DD strings; treat empty strings as NULL + "purchase_date" | "warranty_until" | "expiry_date" | "due_date" => { + if let Some(s) = v.as_str() { + let t = s.trim(); + if t.is_empty() { + Value::Null + } else { + Value::String(t.to_string()) + } + } else if v.is_null() { + Value::Null + } else { + // Fallback: stringify other types + Value::String(v.to_string()) + } + } + + // String fields - ensure they're strings (not null if empty) + "asset_tag" | "asset_type" | "name" | "manufacturer" | "model" + | "serial_number" | "status" | "zone_plus" | "zone_note" | "lending_status" + | "no_scan" | "notes" => { + if let Some(s) = v.as_str() { + Value::String(s.to_string()) + } else if v.is_null() { + Value::Null + } else { + Value::String(v.to_string()) + } + } + + _ => v.clone(), + }; + out.insert(k.clone(), coerced); + } + out + } + + /// Update a single entity record + pub fn update_one(api: &ApiClient, id: i64, data: &Map) { + Self::update_one_table(api, "assets", id, data); + } + + /// Update a single record in any table + pub fn update_one_table(api: &ApiClient, table: &str, id: i64, data: &Map) { + log::info!("=== UPDATE_ONE START for {} ID {} ===", table, id); + log::info!("Raw input data: {:?}", data); + + let values_map = Self::filtered_update_fields(data); + log::info!("Filtered values_map: {:?}", values_map); + + if values_map.is_empty() { + log::warn!( + "No allowed fields found after filtering for {} ID {}, SKIPPING UPDATE", + table, + id + ); + log::warn!("Original data keys: {:?}", data.keys().collect::>()); + return; + } + + let values = Value::Object(values_map.clone()); + let where_clause = serde_json::json!({"id": id}); + + log::info!("SENDING UPDATE to server:"); + log::info!(" TABLE: {}", table); + log::info!(" WHERE: {:?}", where_clause); + log::info!(" VALUES: {:?}", values); + + match api.update(table, values, where_clause) { + Ok(resp) if resp.success => { + log::info!("Successfully updated {} ID {}", table, id); + } + Ok(resp) => { + log::error!( + "Server rejected update for {} ID {}: {}", + table, + id, + api_error_detail(&resp.error) + ); + } + Err(e) => { + log::error!("Network/API error updating {} ID {}: {}", table, id, e); + } + } + log::info!("=== UPDATE_ONE END ==="); + } + + /// Insert a new asset record with preprocessing and return its DB id (if available) + pub fn insert_new_asset( + api: &ApiClient, + mut data: Map, + limit: Option, + reload_fn: impl FnOnce(&ApiClient, Option), + ) -> Option { + log::info!("=== INSERT_NEW_ASSET START ==="); + log::info!("Raw asset data: {:?}", data); + + // Process quick-add fields first + Self::preprocess_quick_adds(api, &mut data); + + // Ensure mandatory defaults if missing or blank + let needs_default = |v: Option<&Value>| -> bool { + match v { + None => true, + Some(Value::Null) => true, + Some(Value::String(s)) => s.trim().is_empty(), + _ => false, + } + }; + if needs_default(data.get("asset_type")) { + data.insert("asset_type".into(), Value::String("N".to_string())); + } + if needs_default(data.get("status")) { + data.insert("status".into(), Value::String("Good".to_string())); + } + + // Filter to allowed fields + let filtered_data = Self::filtered_update_fields(&data); + log::info!("Filtered asset data: {:?}", filtered_data); + + if filtered_data.is_empty() { + log::error!("No valid data to insert"); + return None; + } + + let values = Value::Object(filtered_data); + log::info!("SENDING INSERT to server: {:?}", values); + + let result = match api.insert("assets", values) { + Ok(resp) if resp.success => { + log::info!("Successfully created new asset"); + let id = resp.data.map(|d| d as i64); + log::info!("New asset DB id from server: {:?}", id); + reload_fn(api, limit); + id + } + Ok(resp) => { + log::error!( + "Server rejected asset creation: {}", + api_error_detail(&resp.error) + ); + None + } + Err(e) => { + log::error!("Network/API error creating asset: {}", e); + None + } + }; + log::info!("=== INSERT_NEW_ASSET END ==="); + result + } + + /// Find an asset by ID in a collection + pub fn find_by_id( + collection: &[T], + id: i64, + id_extractor: impl Fn(&T) -> Option, + ) -> Option + where + T: Clone, + { + collection + .iter() + .find(|item| id_extractor(item) == Some(id)) + .cloned() + } + + /// Get selected IDs from a collection based on row indices + #[allow(dead_code)] + pub fn get_selected_ids( + collection: &[T], + selected_rows: &std::collections::HashSet, + id_extractor: impl Fn(&T) -> Option, + ) -> Vec { + let mut ids = Vec::new(); + for &row in selected_rows { + if let Some(item) = collection.get(row) { + if let Some(id) = id_extractor(item) { + ids.push(id); + } + } + } + ids + } + + /// Filter and search through JSON data + #[allow(dead_code)] + pub fn filter_and_search( + data: &[Value], + search_query: &str, + search_fields: &[&str], + ) -> Vec { + if search_query.is_empty() { + return data.to_vec(); + } + + let search_lower = search_query.to_lowercase(); + data.iter() + .filter(|item| { + search_fields.iter().any(|field| { + item.get(field) + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase().contains(&search_lower)) + .unwrap_or(false) + }) + }) + .cloned() + .collect() + } +} diff --git a/src/core/operations/mod.rs b/src/core/operations/mod.rs new file mode 100644 index 0000000..655e385 --- /dev/null +++ b/src/core/operations/mod.rs @@ -0,0 +1,4 @@ +/// Operations on assets and other entities +pub mod asset_operations; + +pub use asset_operations::*; diff --git a/src/core/print/mod.rs b/src/core/print/mod.rs new file mode 100644 index 0000000..a958b6a --- /dev/null +++ b/src/core/print/mod.rs @@ -0,0 +1,15 @@ +// Print module for BeepZone label printing +// This module contains the label renderer and printing UI + +pub mod parsing; +pub mod plugins; +pub mod printer_manager; +pub mod renderer; +pub mod ui; // system printer discovery & direct print + +// Re-export commonly used types +pub use ui::print_dialog::{PrintDialog, PrintOptions}; +// Other types available via submodules: +// - parsing::{parse_layout_json, parse_printer_settings, CenterMode, PrinterSettings} +// - plugins::{pdf::PdfPlugin, system::SystemPrintPlugin} +// - renderer::{LabelElement, LabelLayout, LabelRenderer} diff --git a/src/core/print/parsing.rs b/src/core/print/parsing.rs new file mode 100644 index 0000000..01edf37 --- /dev/null +++ b/src/core/print/parsing.rs @@ -0,0 +1,219 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// This file now centralizes parsing logic that was previously in system_print.rs +// It helps decouple the UI and plugins from the direct implementation of parsing. + +/// Represents the layout of a label, deserialized from JSON. +// NOTE: This assumes LabelLayout is defined in your renderer module. +// If not, you might need to move or publicly export it. +use super::renderer::LabelLayout; + +/// Represents printer-specific settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrinterSettings { + #[serde(default = "default_paper_size")] + pub paper_size: String, + #[serde(default = "default_orientation")] + pub orientation: String, + #[serde(default)] + pub margins: PrinterMargins, + #[serde(default = "default_color")] + pub color: bool, + #[serde(default = "default_quality")] + pub quality: String, + #[serde(default = "default_copies")] + pub copies: u32, + #[serde(default)] + pub duplex: bool, + #[serde(default)] + pub center: Option, + #[serde(default)] + pub center_disabled: bool, + #[serde(default = "default_scale_mode")] + pub scale_mode: ScaleMode, + #[serde(default = "default_scale_factor")] + pub scale_factor: f32, + #[serde(default)] + pub custom_width_mm: Option, + #[serde(default)] + pub custom_height_mm: Option, + // New optional direct-print fields + #[serde(default)] + pub printer_name: Option, + #[serde(default)] + pub show_dialog_if_unfound: Option, + #[serde(default)] + pub compatibility_mode: bool, +} + +impl Default for PrinterSettings { + fn default() -> Self { + Self { + paper_size: default_paper_size(), + orientation: default_orientation(), + margins: PrinterMargins::default(), + color: default_color(), + quality: default_quality(), + copies: default_copies(), + duplex: false, + center: None, + center_disabled: false, + scale_mode: default_scale_mode(), + scale_factor: default_scale_factor(), + custom_width_mm: None, + custom_height_mm: None, + printer_name: None, + show_dialog_if_unfound: None, + compatibility_mode: false, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CenterMode { + None, + Horizontal, + Vertical, + Both, +} + +impl CenterMode { + pub fn includes_horizontal(self) -> bool { + matches!(self, CenterMode::Horizontal | CenterMode::Both) + } + + pub fn includes_vertical(self) -> bool { + matches!(self, CenterMode::Vertical | CenterMode::Both) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PrinterMargins { + pub top: f32, + pub right: f32, + pub bottom: f32, + pub left: f32, +} + +// Default value functions for PrinterSettings +fn default_paper_size() -> String { + "A4".to_string() +} +fn default_orientation() -> String { + "portrait".to_string() +} +#[allow(dead_code)] +fn default_scale() -> f32 { + 1.0 +} +fn default_color() -> bool { + false +} +fn default_quality() -> String { + "high".to_string() +} +fn default_copies() -> u32 { + 1 +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ScaleMode { + Fit, + FitX, + FitY, + MaxBoth, + MaxX, + MaxY, + Manual, +} + +fn default_scale_mode() -> ScaleMode { + ScaleMode::Fit +} + +fn default_scale_factor() -> f32 { + 1.0 +} + +impl PrinterSettings { + pub fn canonicalize_dimensions(&mut self) { + // No-op: dimensions are used as specified + } + + pub fn get_dimensions_mm(&self) -> (f32, f32) { + if let (Some(w), Some(h)) = (self.custom_width_mm, self.custom_height_mm) { + // For custom dimensions, swap if landscape to create rotated PDF + let orientation = self.orientation.to_ascii_lowercase(); + + let result = if orientation == "landscape" { + // Landscape: swap dimensions for PDF (rotate 90°) + (h, w) + } else { + // Portrait: use as-is + (w, h) + }; + + log::info!( + "get_dimensions_mm: custom {}×{} mm, orientation='{}' → PDF {}×{} mm", + w, + h, + self.orientation, + result.0, + result.1 + ); + + result + } else { + // Standard paper sizes + let (width, height) = match self.paper_size.as_str() { + "A4" => (210.0, 297.0), + "A5" => (148.0, 210.0), + "Letter" => (215.9, 279.4), + _ => (100.0, 150.0), // Default + }; + if self.orientation == "landscape" { + (height, width) + } else { + (width, height) + } + } + } +} + +/// Utility function to parse a JSON value that might be a raw string, +/// a base64-encoded string, or a direct JSON object. +fn parse_flexible_json(value: &Value) -> Result +where + T: for<'de> Deserialize<'de>, +{ + match value { + Value::String(s) => { + if let Ok(parsed) = serde_json::from_str(s) { + return Ok(parsed); + } + match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s) { + Ok(decoded_bytes) => { + let decoded_str = String::from_utf8(decoded_bytes) + .context("Decoded base64 is not valid UTF-8")?; + serde_json::from_str(&decoded_str) + .context("Failed to parse base64-decoded JSON") + } + Err(_) => anyhow::bail!("Value is not valid JSON or base64-encoded JSON"), + } + } + json_obj => serde_json::from_value(json_obj.clone()) + .context("Failed to parse value as a direct JSON object"), + } +} + +pub fn parse_layout_json(layout_json_value: &Value) -> Result { + parse_flexible_json(layout_json_value) +} + +pub fn parse_printer_settings(settings_value: &Value) -> Result { + parse_flexible_json(settings_value) +} diff --git a/src/core/print/plugins/mod.rs b/src/core/print/plugins/mod.rs new file mode 100644 index 0000000..8decf3b --- /dev/null +++ b/src/core/print/plugins/mod.rs @@ -0,0 +1,2 @@ +pub mod pdf; +pub mod system; diff --git a/src/core/print/plugins/pdf.rs b/src/core/print/plugins/pdf.rs new file mode 100644 index 0000000..2456edb --- /dev/null +++ b/src/core/print/plugins/pdf.rs @@ -0,0 +1,27 @@ +use anyhow::{Context, Result}; +use printpdf::PdfDocumentReference; +use std::fs::File; +use std::io::BufWriter; +use std::path::PathBuf; + +pub struct PdfPlugin; + +impl PdfPlugin { + pub fn new() -> Self { + Self + } + + pub fn export_pdf(&self, doc: PdfDocumentReference, path: &PathBuf) -> Result<()> { + let file = File::create(path).context("Failed to create PDF file for export")?; + let mut writer = BufWriter::new(file); + doc.save(&mut writer) + .context("Failed to save PDF to specified path")?; + Ok(()) + } +} + +impl Default for PdfPlugin { + fn default() -> Self { + Self::new() + } +} diff --git a/src/core/print/plugins/system.rs b/src/core/print/plugins/system.rs new file mode 100644 index 0000000..7525a03 --- /dev/null +++ b/src/core/print/plugins/system.rs @@ -0,0 +1,49 @@ +use anyhow::{Context, Result}; +use printpdf::PdfDocumentReference; +use std::fs::File; +use std::io::BufWriter; +use std::path::PathBuf; + +pub struct SystemPrintPlugin { + temp_dir: PathBuf, +} + +impl SystemPrintPlugin { + pub fn new() -> Result { + let temp_dir = std::env::temp_dir().join("beepzone_labels"); + std::fs::create_dir_all(&temp_dir)?; + Ok(Self { temp_dir }) + } + + #[allow(dead_code)] + pub fn print_label(&self, doc: PdfDocumentReference) -> Result<()> { + let pdf_path = self.save_pdf_to_temp(doc)?; + log::info!("Generated temporary PDF at: {:?}", pdf_path); + self.open_print_dialog(&pdf_path)?; + Ok(()) + } + + pub fn save_pdf_to_temp(&self, doc: PdfDocumentReference) -> Result { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let pdf_path = self.temp_dir.join(format!("label_{}.pdf", timestamp)); + let file = File::create(&pdf_path).context("Failed to create temp PDF file")?; + let mut writer = BufWriter::new(file); + doc.save(&mut writer).context("Failed to save temp PDF")?; + Ok(pdf_path) + } + + pub fn open_print_dialog(&self, pdf_path: &PathBuf) -> Result<()> { + open::that(pdf_path).context("Failed to open PDF with system default application")?; + log::info!("PDF opened successfully. User can print from the PDF viewer."); + Ok(()) + } +} + +impl Default for SystemPrintPlugin { + fn default() -> Self { + Self::new().expect("Failed to initialize SystemPrintPlugin") + } +} diff --git a/src/core/print/printer_manager.rs b/src/core/print/printer_manager.rs new file mode 100644 index 0000000..e8dd7fd --- /dev/null +++ b/src/core/print/printer_manager.rs @@ -0,0 +1,228 @@ +use printers::common::base::job::PrinterJobOptions; +use printers::{get_default_printer, get_printer_by_name, get_printers}; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use crate::core::print::parsing::PrinterSettings; + +#[derive(Clone)] +pub struct PrinterInfo { + pub name: String, + #[allow(dead_code)] + pub is_default: bool, +} + +pub struct PrinterManager { + available_printers: Vec, + last_refresh: Instant, +} + +impl PrinterManager { + pub fn new() -> Self { + let mut manager = Self { + available_printers: Vec::new(), + last_refresh: Instant::now() - Duration::from_secs(3600), // Force refresh on first call + }; + manager.refresh_printers(); + manager + } + + /// Refresh the list of available printers from the system. + pub fn refresh_printers(&mut self) { + log::info!("Refreshing printer list..."); + let default_printer = get_default_printer(); + let default_name = default_printer.as_ref().map(|p| p.name.clone()); + + self.available_printers = get_printers() + .into_iter() + .map(|p| { + let name = p.name.clone(); + let is_default = default_name.as_ref() == Some(&name); + PrinterInfo { name, is_default } + }) + .collect(); + + self.last_refresh = Instant::now(); + log::info!("Found {} printers.", self.available_printers.len()); + } + + /// Get a list of all available printers, refreshing if cache is stale. + pub fn get_printers(&mut self) -> &[PrinterInfo] { + if self.last_refresh.elapsed() > Duration::from_secs(60) { + self.refresh_printers(); + } + &self.available_printers + } + + /// Print a PDF file to the specified printer. + pub fn print_pdf_to( + &self, + printer_name: &str, + pdf_path: &Path, + printer_settings: Option<&PrinterSettings>, + ) -> Result<(), String> { + let normalized_settings = printer_settings.map(|ps| { + let mut copy = ps.clone(); + copy.canonicalize_dimensions(); + copy + }); + let effective_settings = normalized_settings.as_ref(); + + if let Some(ps) = effective_settings { + let (page_w, page_h) = ps.get_dimensions_mm(); + log::info!( + "Attempting to print '{}' to printer '{}' (paper_size={}, orientation={}, page={}×{} mm)", + pdf_path.display(), + printer_name, + ps.paper_size, + ps.orientation, + page_w, + page_h + ); + } else { + log::info!( + "Attempting to print '{}' to printer '{}' without explicit printer settings", + pdf_path.display(), + printer_name + ); + } + let printer = get_printer_by_name(printer_name) + .ok_or_else(|| format!("Printer '{}' not found on the system.", printer_name))?; + + let pdf_path_str = pdf_path + .to_str() + .ok_or_else(|| format!("PDF path '{}' contains invalid UTF-8", pdf_path.display()))?; + + let owned_options = Self::build_job_options(effective_settings); + let borrowed_options: Vec<(&str, &str)> = owned_options + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + + let result = if borrowed_options.is_empty() { + printer.print_file(pdf_path_str, PrinterJobOptions::none()) + } else { + log::info!( + "Applying {} print option(s) via CUPS", + borrowed_options.len() + ); + for (key, value) in borrowed_options.iter() { + log::debug!(" job option: {}={}", key, value); + } + let job_options = PrinterJobOptions { + name: Some("BeepZone Label"), + raw_properties: borrowed_options.as_slice(), + }; + printer.print_file(pdf_path_str, job_options) + }; + result + .map(|_| ()) + .map_err(|e| format!("Failed to send print job: {}", e)) + } + + fn build_job_options(printer_settings: Option<&PrinterSettings>) -> Vec<(String, String)> { + let mut owned: Vec<(String, String)> = Vec::new(); + + if let Some(ps) = printer_settings { + let compat_mode = ps.compatibility_mode; + + // In strict compatibility mode, send NO job options at all + // This avoids triggering buggy printer filters + if compat_mode { + log::info!("Compatibility mode enabled - sending no CUPS job options"); + return owned; + } + + // Determine media first (always in portrait orientation) + if let Some(media_value) = Self::media_to_cups(ps) { + owned.push(("media".to_string(), media_value.clone())); + owned.push(("PageSize".to_string(), media_value)); + } + + // Send orientation-requested to tell CUPS to rotate the media + if let Some(orientation_code) = Self::orientation_to_cups(ps) { + owned.push(("orientation-requested".to_string(), orientation_code)); + } + + if ps.copies > 1 { + owned.push(("copies".to_string(), ps.copies.to_string())); + } + } + + owned + } + + fn orientation_to_cups(ps: &PrinterSettings) -> Option { + let orientation_raw = ps.orientation.trim(); + if orientation_raw.is_empty() { + return None; + } + + match orientation_raw.to_ascii_lowercase().as_str() { + "portrait" => Some("3".to_string()), + "landscape" => Some("4".to_string()), + "reverse_landscape" | "reverse-landscape" => Some("5".to_string()), + "reverse_portrait" | "reverse-portrait" => Some("6".to_string()), + _ => None, + } + } + + fn media_to_cups(ps: &PrinterSettings) -> Option { + if let (Some(w), Some(h)) = (ps.custom_width_mm, ps.custom_height_mm) { + // For custom sizes, use dimensions exactly as specified + // The user knows their media dimensions and orientation needs + let width_str = Self::format_mm(w); + let height_str = Self::format_mm(h); + return Some(format!("Custom.{width_str}x{height_str}mm")); + } + + let paper = ps.paper_size.trim(); + if paper.is_empty() { + return None; + } + + Some(paper.to_string()) + } + + fn format_mm(value: f32) -> String { + let rounded = (value * 100.0).round() / 100.0; + if (rounded - rounded.round()).abs() < 0.005 { + format!("{:.0}", rounded.round()) + } else { + format!("{:.2}", rounded) + } + } +} + +// A thread-safe, shared wrapper for the PrinterManager +#[derive(Clone)] +pub struct SharedPrinterManager(Arc>); + +impl SharedPrinterManager { + pub fn new() -> Self { + Self(Arc::new(Mutex::new(PrinterManager::new()))) + } + + pub fn get_printers(&self) -> Vec { + self.0.lock().unwrap().get_printers().to_vec() + } + + pub fn print_pdf_to( + &self, + printer_name: &str, + pdf_path: &Path, + printer_settings: Option<&PrinterSettings>, + ) -> Result<(), String> { + self.0 + .lock() + .unwrap() + .print_pdf_to(printer_name, pdf_path, printer_settings) + } +} + +impl Default for SharedPrinterManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src/core/print/renderer.rs b/src/core/print/renderer.rs new file mode 100644 index 0000000..79a8702 --- /dev/null +++ b/src/core/print/renderer.rs @@ -0,0 +1,1537 @@ +use anyhow::{bail, Context, Result}; +use base64::Engine; +use eframe::egui; +use printpdf::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::core::print::parsing::{PrinterMargins, PrinterSettings, ScaleMode}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- +const POINTS_TO_MM: f32 = 0.352_777_78; // 1 pt -> mm +const TEXT_DESCENT_RATIO: f32 = 0.2; + +// Fallback page if no printer settings provided +const DEFAULT_CANVAS_WIDTH_MM: f32 = 100.0; +const DEFAULT_CANVAS_HEIGHT_MM: f32 = 50.0; + +// ----------------------------------------------------------------------------- +// Grid / Space definition for new layout system +// ----------------------------------------------------------------------------- +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct LayoutSpace { + pub width: f32, + pub height: f32, +} + +impl Default for LayoutSpace { + fn default() -> Self { + Self { + width: 256.0, + height: 128.0, + } + } +} + +// ----------------------------------------------------------------------------- +// Core layout structs (grid-based) +// ----------------------------------------------------------------------------- +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LabelLayout { + #[serde(default)] + pub background: Option, + #[serde(default)] + pub space: LayoutSpace, + #[serde(default)] + pub elements: Vec, +} + +fn default_font_size() -> f32 { + 12.0 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum LabelElement { + Text { + field: String, + x: f32, + y: f32, + #[serde(rename = "fontSize", default = "default_font_size")] + font_size: f32, + #[serde(rename = "fontWeight", default)] + font_weight: Option, + #[serde(rename = "fontFamily", default)] + font_family: Option, + #[serde(rename = "maxWidth", default)] + max_width: Option, + #[serde(default)] + wrap: Option, + #[serde(default)] + color: Option, + }, + QrCode { + field: String, + x: f32, + y: f32, + size: f32, + #[serde(rename = "showText", default)] + show_text: Option, + }, + Barcode { + field: String, + x: f32, + y: f32, + width: f32, + height: f32, + #[serde(default)] + format: Option, + #[serde(rename = "showText", default)] + show_text: Option, + }, + DataMatrix { + field: String, + x: f32, + y: f32, + size: f32, + #[serde(rename = "showText", default)] + show_text: Option, + }, + Rect { + x: f32, + y: f32, + width: f32, + height: f32, + #[serde(default)] + fill: Option, + }, + Svg { + data: String, + x: f32, + y: f32, + width: f32, + height: f32, + }, +} + +#[derive(Debug, Clone)] +pub struct LabelRenderer { + pub layout: LabelLayout, +} + +impl LabelRenderer { + pub fn new(layout: LabelLayout) -> Self { + Self { layout } + } +} + +// Bounds actually used by elements (tight box) +#[derive(Debug, Clone, Copy)] +struct LayoutBounds { + min_x: f32, + min_y: f32, + max_x: f32, + max_y: f32, +} + +impl LayoutBounds { + fn empty() -> Self { + Self { + min_x: f32::INFINITY, + min_y: f32::INFINITY, + max_x: f32::NEG_INFINITY, + max_y: f32::NEG_INFINITY, + } + } + fn is_empty(&self) -> bool { + !self.min_x.is_finite() + } + fn extend_point(&mut self, x: f32, y: f32) { + self.min_x = self.min_x.min(x); + self.min_y = self.min_y.min(y); + self.max_x = self.max_x.max(x); + self.max_y = self.max_y.max(y); + } + fn extend_rect(&mut self, x: f32, y: f32, w: f32, h: f32) { + let (min_x, max_x) = if w >= 0.0 { (x, x + w) } else { (x + w, x) }; + let (min_y, max_y) = if h >= 0.0 { (y, y + h) } else { (y + h, y) }; + self.extend_point(min_x, min_y); + self.extend_point(max_x, max_y); + } + fn width(&self) -> f32 { + (self.max_x - self.min_x).max(0.0) + } + fn height(&self) -> f32 { + (self.max_y - self.min_y).max(0.0) + } + fn normalize_x(&self, x: f32) -> f32 { + if self.min_x.is_finite() { + x - self.min_x + } else { + x + } + } + fn normalize_y(&self, y: f32) -> f32 { + if self.min_y.is_finite() { + y - self.min_y + } else { + y + } + } +} + +// LayoutTransform maps layout-space units onto final mm coordinates +#[derive(Debug, Clone)] +#[allow(dead_code)] +struct LayoutTransform { + bounds: LayoutBounds, + page_width: f32, + page_height: f32, + printable_width: f32, + printable_height: f32, + scale_x: f32, + scale_y: f32, + uniform_scale: f32, + offset_x: f32, + offset_y: f32, + rendered_width: f32, + rendered_height: f32, + margins: PrinterMargins, +} + +impl LayoutTransform { + fn new(bounds: LayoutBounds, settings: Option<&PrinterSettings>) -> Result { + let margins = settings.map(|s| s.margins.clone()).unwrap_or_default(); + let (page_w, page_h) = if let Some(s) = settings { + // Respect printer-provided orientation (already canonicalized by get_dimensions_mm) + s.get_dimensions_mm() + } else { + // No settings: default preview page matches design aspect + ( + bounds.width().max(DEFAULT_CANVAS_WIDTH_MM), + bounds.height().max(DEFAULT_CANVAS_HEIGHT_MM), + ) + }; + let printable_w = (page_w - margins.left - margins.right).max(1.0); + let printable_h = (page_h - margins.top - margins.bottom).max(1.0); + let design_w = bounds.width().max(1.0); + let design_h = bounds.height().max(1.0); + + let scale_mode = settings.map(|s| s.scale_mode).unwrap_or(ScaleMode::Fit); + let user_factor = settings.map(|s| s.scale_factor).unwrap_or(1.0).max(0.0); + + let mut sx = printable_w / design_w; + let mut sy = printable_h / design_h; + match scale_mode { + ScaleMode::Fit => { + let uni = sx.min(sy); + sx = uni; + sy = uni; + } + ScaleMode::FitX => { + sy = sx; + } + ScaleMode::FitY => { + sx = sy; + } + ScaleMode::MaxBoth => { /* stretch independently */ } + ScaleMode::MaxX => { + sy = sx; + } + ScaleMode::MaxY => { + sx = sy; + } + ScaleMode::Manual => { + sx = user_factor; + sy = user_factor; + } + } + sx *= user_factor; + sy *= user_factor; // Manual already multiplies; harmless if 1.0 + if !sx.is_finite() || sx <= 0.0 { + sx = 1.0; + } + if !sy.is_finite() || sy <= 0.0 { + sy = 1.0; + } + let uniform = sx.min(sy); + let rendered_w = design_w * sx; + let rendered_h = design_h * sy; + // Centering + let mut offset_x = margins.left; + let mut offset_y = margins.top; + if let Some(s) = settings { + if let Some(center_mode) = s.center.filter(|_| !s.center_disabled) { + if center_mode.includes_horizontal() { + let extra = printable_w - rendered_w; + if extra > 0.0 { + offset_x = margins.left + extra / 2.0; + } + } + if center_mode.includes_vertical() { + let extra = printable_h - rendered_h; + if extra > 0.0 { + offset_y = margins.top + extra / 2.0; + } + } + } + } + log::info!("layout_transform: page {:.2}x{:.2}mm printable {:.2}x{:.2}mm design {:.2}x{:.2} units scale_x {:.4} scale_y {:.4} uniform {:.4} offsets {:.2},{:.2}", + page_w, page_h, printable_w, printable_h, design_w, design_h, sx, sy, uniform, offset_x, offset_y); + Ok(Self { + bounds, + page_width: page_w, + page_height: page_h, + printable_width: printable_w, + printable_height: printable_h, + scale_x: sx, + scale_y: sy, + uniform_scale: uniform, + offset_x, + offset_y, + rendered_width: rendered_w, + rendered_height: rendered_h, + margins, + }) + } + fn x_mm(&self, x: f32) -> f32 { + self.offset_x + self.scale_x * self.bounds.normalize_x(x) + } + fn y_mm(&self, y: f32) -> f32 { + self.offset_y + self.scale_y * self.bounds.normalize_y(y) + } + fn width_mm(&self, w: f32) -> f32 { + self.scale_x * w + } + fn height_mm(&self, h: f32) -> f32 { + self.scale_y * h + } + fn uniform_mm(&self, s: f32) -> f32 { + self.uniform_scale * s + } +} + +impl LabelRenderer { + fn render_pdf_internal( + &self, + data: &HashMap, + printer_settings: Option<&PrinterSettings>, + ) -> Result<( + PdfDocumentReference, + PdfPageIndex, + PdfLayerIndex, + LayoutTransform, + )> { + let bounds = self.calculate_layout_bounds()?; + let transform = LayoutTransform::new(bounds, printer_settings)?; + + let (doc, page_index, layer_index) = PdfDocument::new( + "BeepZone Label", + Mm(transform.page_width), + Mm(transform.page_height), + "Layer 1", + ); + + let font = doc + .add_builtin_font(printpdf::BuiltinFont::Helvetica) + .context("Failed to add Helvetica font")?; + + let layer = doc.get_page(page_index).get_layer(layer_index); + self.render_pdf_elements(&layer, &font, data, &transform)?; + + Ok((doc, page_index, layer_index, transform)) + } + + fn render_pdf_elements( + &self, + layer: &PdfLayerReference, + font: &IndirectFontRef, + data: &HashMap, + transform: &LayoutTransform, + ) -> Result<()> { + for element in &self.layout.elements { + match element { + LabelElement::Text { + field, + x, + y, + font_size, + color, + max_width, + wrap, + .. + } => { + let value = Self::resolve_field(field, data); + let x_mm = transform.x_mm(*x); + let y_mm = transform.y_mm(*y); + let font_pt = (*font_size * transform.uniform_scale).max(0.1); + let color_ref = color.as_deref(); + + let wrap_enabled = wrap.unwrap_or(false); + if wrap_enabled { + if let Some(max_w) = max_width { + let max_w_mm = transform.width_mm(*max_w); + let lines = Self::wrap_lines(&value, max_w_mm, font_pt); + let line_gap_mm = font_pt * POINTS_TO_MM * 1.2; + for (i, line) in lines.iter().enumerate() { + let line_top_mm = y_mm + (i as f32) * line_gap_mm; + let baseline = Self::layout_text_baseline( + line_top_mm, + font_pt, + transform.page_height, + ); + self.render_text_to_pdf( + layer, font, x_mm, baseline, font_pt, line, color_ref, + )?; + } + } else { + let baseline = + Self::layout_text_baseline(y_mm, font_pt, transform.page_height); + self.render_text_to_pdf( + layer, font, x_mm, baseline, font_pt, &value, color_ref, + )?; + } + } else { + let baseline = + Self::layout_text_baseline(y_mm, font_pt, transform.page_height); + self.render_text_to_pdf( + layer, font, x_mm, baseline, font_pt, &value, color_ref, + )?; + } + } + LabelElement::QrCode { + field, + x, + y, + size, + show_text, + .. + } => { + let value = Self::resolve_field(field, data); + let x_mm = transform.x_mm(*x); + let y_mm = transform.y_mm(*y); + let side_mm = transform.uniform_mm(*size); + let y_bottom = + Self::layout_top_to_pdf_bottom(y_mm, side_mm, transform.page_height); + self.render_qrcode_to_pdf(layer, x_mm, y_bottom, side_mm, &value)?; + + if show_text.unwrap_or(false) { + let gap_mm = 1.5 * transform.uniform_scale; + let label_top_mm = y_mm + side_mm + gap_mm; + let label_font_pt = 10.0 * transform.uniform_scale; + let baseline = Self::layout_text_baseline( + label_top_mm, + label_font_pt, + transform.page_height, + ); + self.render_text_to_pdf( + layer, + font, + x_mm, + baseline, + label_font_pt, + &value, + None, + )?; + } + } + LabelElement::DataMatrix { + field, + x, + y, + size, + show_text, + } => { + let value = Self::resolve_field(field, data); + let x_mm = transform.x_mm(*x); + let y_mm = transform.y_mm(*y); + let side_mm = transform.uniform_mm(*size); + let y_bottom = + Self::layout_top_to_pdf_bottom(y_mm, side_mm, transform.page_height); + self.render_datamatrix_to_pdf(layer, x_mm, y_bottom, side_mm, &value)?; + + if show_text.unwrap_or(false) { + let gap_mm = 1.5 * transform.uniform_scale; + let label_top_mm = y_mm + side_mm + gap_mm; + let label_font_pt = 8.0 * transform.uniform_scale; + let baseline = Self::layout_text_baseline( + label_top_mm, + label_font_pt, + transform.page_height, + ); + self.render_text_to_pdf( + layer, + font, + x_mm, + baseline, + label_font_pt, + &value, + None, + )?; + } + } + LabelElement::Barcode { + field, + x, + y, + width, + height, + format, + show_text, + } => { + let value = Self::resolve_field(field, data); + let x_mm = transform.x_mm(*x); + let y_mm = transform.y_mm(*y); + let width_mm = transform.width_mm(*width); + let height_mm = transform.height_mm(*height); + let y_bottom = + Self::layout_top_to_pdf_bottom(y_mm, height_mm, transform.page_height); + + self.render_barcode_to_pdf( + layer, + x_mm, + y_bottom, + width_mm, + height_mm, + &value, + format.as_deref(), + )?; + + if show_text.unwrap_or(false) { + let gap_mm = 1.5 * transform.uniform_scale; + let label_top_mm = y_mm + height_mm + gap_mm; + let label_font_pt = 8.0 * transform.uniform_scale; + let baseline = Self::layout_text_baseline( + label_top_mm, + label_font_pt, + transform.page_height, + ); + self.render_text_to_pdf( + layer, + font, + x_mm, + baseline, + label_font_pt, + &value, + None, + )?; + } + } + LabelElement::Rect { + x, + y, + width, + height, + fill, + } => { + let x_mm = transform.x_mm(*x); + let y_mm = transform.y_mm(*y); + let width_mm = transform.width_mm(*width); + let height_mm = transform.height_mm(*height); + let y_bottom = + Self::layout_top_to_pdf_bottom(y_mm, height_mm, transform.page_height); + self.render_rect_to_pdf( + layer, + x_mm, + y_bottom, + width_mm, + height_mm, + fill.as_deref(), + )?; + } + LabelElement::Svg { + x, + y, + width, + height, + data, + } => { + let x_mm = transform.x_mm(*x); + let y_mm = transform.y_mm(*y); + let width_mm = transform.width_mm(*width).max(0.1); + let height_mm = transform.height_mm(*height).max(0.1); + let y_bottom = + Self::layout_top_to_pdf_bottom(y_mm, height_mm, transform.page_height); + + if let Some(svg_xml) = Self::decode_svg_data_uri(data) { + let px_w = (width_mm * 3.78).ceil().max(1.0) as u32; + let px_h = (height_mm * 3.78).ceil().max(1.0) as u32; + if let Some(rgba) = Self::rasterize_svg_to_rgba(&svg_xml, px_w, px_h) { + let mut rgb: Vec = Vec::with_capacity((px_w * px_h * 3) as usize); + for chunk in rgba.chunks(4) { + let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]); + if a == 255 { + rgb.extend_from_slice(&[r, g, b]); + } else { + let af = a as f32 / 255.0; + let blend = |c: u8| { + ((c as f32 * af) + 255.0 * (1.0 - af)).round() as u8 + }; + rgb.extend_from_slice(&[blend(r), blend(g), blend(b)]); + } + } + + let image_xobj = printpdf::ImageXObject { + width: printpdf::Px(px_w as usize), + height: printpdf::Px(px_h as usize), + color_space: printpdf::ColorSpace::Rgb, + bits_per_component: printpdf::ColorBits::Bit8, + interpolate: true, + image_data: rgb, + image_filter: None, + clipping_bbox: None, + smask: None, + }; + + let image = printpdf::Image::from(image_xobj); + let base_w_mm = (px_w as f32) * 25.4 / 300.0; + let base_h_mm = (px_h as f32) * 25.4 / 300.0; + let sx = if base_w_mm > 0.0 { + width_mm / base_w_mm + } else { + 1.0 + }; + let sy = if base_h_mm > 0.0 { + height_mm / base_h_mm + } else { + 1.0 + }; + let transform_img = printpdf::ImageTransform { + translate_x: Some(printpdf::Mm(x_mm)), + translate_y: Some(printpdf::Mm(y_bottom)), + rotate: None, + scale_x: Some(sx), + scale_y: Some(sy), + dpi: Some(300.0), + }; + image.add_to_layer(layer.clone(), transform_img); + } else { + self.render_rect_to_pdf( + layer, + x_mm, + y_bottom, + width_mm, + height_mm, + Some("#DDDDDD"), + )?; + } + } else { + self.render_rect_to_pdf( + layer, + x_mm, + y_bottom, + width_mm, + height_mm, + Some("#EEEEEE"), + )?; + } + } + } + } + + Ok(()) + } + + fn resolve_field(field: &str, data: &HashMap) -> String { + if !field.contains("{{") { + return data + .get(field) + .cloned() + .unwrap_or_else(|| field.to_string()); + } + + let mut result = String::new(); + let mut rest = field; + + while let Some(open) = rest.find("{{") { + let (prefix, tail) = rest.split_at(open); + result.push_str(prefix); + + if let Some(close) = tail.find("}}") { + let var = tail[2..close].trim(); + // Exact match first, then case-insensitive fallback + if let Some(value) = data.get(var) { + result.push_str(value); + } else if let Some((_, v)) = data.iter().find(|(k, _)| k.eq_ignore_ascii_case(var)) + { + result.push_str(v); + } // else: missing vars become empty string + rest = &tail[close + 2..]; + } else { + result.push_str(tail); + return result; + } + } + + result.push_str(rest); + result + } + + fn calculate_layout_bounds(&self) -> Result { + let space = self.layout.space; + if !space.width.is_finite() || !space.height.is_finite() { + bail!("layout space must provide finite width and height"); + } + if space.width <= 0.0 || space.height <= 0.0 { + bail!("layout space must define positive width and height"); + } + + let mut used = LayoutBounds::empty(); + + for element in &self.layout.elements { + match element { + LabelElement::Text { + x, + y, + font_size, + max_width, + .. + } => { + let height = (*font_size * POINTS_TO_MM).max(0.1); + // Wider heuristic for text width so long strings trigger downscaling. + let width = max_width + .and_then(|w| { + if w.is_finite() && w > 0.0 { + Some(w) + } else { + None + } + }) + .unwrap_or_else(|| (*font_size * POINTS_TO_MM * 8.5).max(1.0)); + used.extend_rect(*x, *y, width, height); + } + LabelElement::QrCode { x, y, size, .. } + | LabelElement::DataMatrix { x, y, size, .. } => { + used.extend_rect(*x, *y, *size, *size); + } + LabelElement::Barcode { + x, + y, + width, + height, + .. + } => { + used.extend_rect(*x, *y, *width, *height); + } + LabelElement::Rect { + x, + y, + width, + height, + .. + } => { + used.extend_rect(*x, *y, *width, *height); + } + LabelElement::Svg { + x, + y, + width, + height, + .. + } => { + used.extend_rect(*x, *y, *width, *height); + } + } + } + + if used.is_empty() { + return Ok(LayoutBounds { + min_x: 0.0, + min_y: 0.0, + max_x: space.width, + max_y: space.height, + }); + } + + let mut bounds = used; + bounds.min_x = bounds.min_x.max(0.0); + bounds.min_y = bounds.min_y.max(0.0); + + let min_width = (space.width * 0.01).max(1.0); + let min_height = (space.height * 0.01).max(1.0); + + if bounds.width() < min_width { + bounds.min_x = 0.0; + bounds.max_x = space.width; + } else if bounds.max_x > space.width { + // allow overhang but ensure width positive + bounds.max_x = bounds.max_x.max(bounds.min_x + min_width); + } + + if bounds.height() < min_height { + bounds.min_y = 0.0; + bounds.max_y = space.height; + } else if bounds.max_y > space.height { + bounds.max_y = bounds.max_y.max(bounds.min_y + min_height); + } + + // No seal() needed; bounds already finalized + Ok(bounds) + } + + fn parse_hex_color(hex: &str) -> Option { + let raw = hex.trim(); + let raw = raw.strip_prefix('#').unwrap_or(raw); + if raw.len() != 6 { + return None; + } + + let r = u8::from_str_radix(&raw[0..2], 16).ok()?; + let g = u8::from_str_radix(&raw[2..4], 16).ok()?; + let b = u8::from_str_radix(&raw[4..6], 16).ok()?; + + Some(egui::Color32::from_rgb(r, g, b)) + } + + #[allow(dead_code)] + pub fn generate_pdf( + &self, + data: &HashMap, + ) -> Result<(PdfDocumentReference, PdfPageIndex, PdfLayerIndex)> { + let (doc, page, layer, _) = self.render_pdf_internal(data, None)?; + Ok((doc, page, layer)) + } + + pub fn generate_pdf_with_settings( + &self, + data: &HashMap, + printer_settings: &PrinterSettings, + ) -> Result<(PdfDocumentReference, PdfPageIndex, PdfLayerIndex)> { + let (doc, page, layer, _) = self.render_pdf_internal(data, Some(printer_settings))?; + Ok((doc, page, layer)) + } + + // Removed legacy template bounds calculation (numeric widths now direct) + + fn render_text_to_pdf( + &self, + layer: &PdfLayerReference, + font: &IndirectFontRef, + x: f32, + baseline_y: f32, + font_size_pt: f32, + text: &str, + color: Option<&str>, + ) -> Result<()> { + let (r, g, b) = color.map(Self::parse_hex_to_rgb).unwrap_or((0.0, 0.0, 0.0)); + + layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new(r, g, b, None))); + layer.use_text(text, font_size_pt, Mm(x), Mm(baseline_y), font); + + Ok(()) + } + + fn render_qrcode_to_pdf( + &self, + layer: &PdfLayerReference, + x: f32, + y_bottom: f32, + size: f32, + data: &str, + ) -> Result<()> { + use qrcodegen::{QrCode, QrCodeEcc}; + + let qr = + QrCode::encode_text(data, QrCodeEcc::Medium).context("Failed to generate QR code")?; + + let qr_size = qr.size() as usize; + let module_mm = size / qr_size as f32; + + layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new( + 0.0, 0.0, 0.0, None, + ))); + + for y in 0..qr_size { + for x_idx in 0..qr_size { + if qr.get_module(x_idx as i32, y as i32) { + let px = x + (x_idx as f32 * module_mm); + let py = y_bottom + ((qr_size - 1 - y) as f32 * module_mm); + Self::draw_filled_rect(layer, px, py, module_mm, module_mm); + } + } + } + + Ok(()) + } + + fn render_datamatrix_to_pdf( + &self, + layer: &PdfLayerReference, + x: f32, + y_bottom: f32, + size: f32, + data: &str, + ) -> Result<()> { + use datamatrix::{DataMatrix, SymbolList}; + let encoded = match DataMatrix::encode_str(data, SymbolList::default()) { + Ok(dm) => dm, + Err(e) => { + log::error!("Failed to generate DataMatrix for '{}': {:?}", data, e); + return Ok(()); + } + }; + let bmp = encoded.bitmap(); + let rows = bmp.height() as usize; + let cols = bmp.width() as usize; + if rows == 0 || cols == 0 { + return Ok(()); + } + let module_mm = size / rows.max(cols) as f32; + layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new( + 0.0, 0.0, 0.0, None, + ))); + for (px_idx, py_idx) in bmp.pixels() { + // (x,y) + let px_mm = x + px_idx as f32 * module_mm; + let py_mm = y_bottom + ((rows - 1 - py_idx) as f32 * module_mm); + Self::draw_filled_rect(layer, px_mm, py_mm, module_mm, module_mm); + } + Ok(()) + } + + fn render_barcode_to_pdf( + &self, + layer: &PdfLayerReference, + x: f32, + y_bottom: f32, + width: f32, + height: f32, + data: &str, + format: Option<&str>, + ) -> Result<()> { + use barcoders::sym::{code11::Code11, code128::Code128}; + + // Choose symbology + enum Sym { + C128(String), + C11(String), + } + + let sym = match format.map(|s| s.to_lowercase()) { + Some(ref f) if f == "code11" => { + // Code11 supports digits and '-' + let cleaned: String = data + .chars() + .filter(|c| c.is_ascii_digit() || *c == '-') + .collect(); + if cleaned.is_empty() { + log::warn!("Skipping Code11 - invalid payload: '{}'", data); + return Ok(()); + } + Sym::C11(cleaned) + } + _ => { + // Default Code128 with smart preparation + match Self::prepare_code128_payload(data) { + Some(p) => Sym::C128(p), + None => { + log::warn!("Skipping barcode - unsupported payload: '{}'", data); + return Ok(()); + } + } + } + }; + + let modules: Vec = match sym { + Sym::C128(p) => match Code128::new(&p) { + Ok(c) => c.encode(), + Err(e) => { + log::error!("Code128 encode failed: {:?}", e); + return Ok(()); + } + }, + Sym::C11(p) => match Code11::new(&p) { + Ok(c) => c.encode(), + Err(e) => { + log::error!("Code11 encode failed: {:?}", e); + return Ok(()); + } + }, + }; + + if modules.is_empty() { + log::warn!("Barcode produced no modules"); + return Ok(()); + } + + let module_width = width / modules.len() as f32; + if module_width <= 0.0 { + log::warn!("Computed non-positive module width, skipping"); + return Ok(()); + } + + layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new( + 0.0, 0.0, 0.0, None, + ))); + + let mut run_start: Option = None; + for (idx, bit) in modules.iter().enumerate() { + if *bit == 1 { + run_start.get_or_insert(idx); + } else if let Some(start) = run_start.take() { + let bar_start = x + start as f32 * module_width; + let bar_width = (idx - start) as f32 * module_width; + if bar_width > 0.0 { + Self::draw_filled_rect(layer, bar_start, y_bottom, bar_width, height); + } + } + } + + if let Some(start) = run_start.take() { + let bar_start = x + start as f32 * module_width; + let bar_width = (modules.len() - start) as f32 * module_width; + if bar_width > 0.0 { + Self::draw_filled_rect(layer, bar_start, y_bottom, bar_width, height); + } + } + + Ok(()) + } + + fn render_rect_to_pdf( + &self, + layer: &PdfLayerReference, + x: f32, + y_bottom: f32, + width_mm: f32, + height_mm: f32, + fill: Option<&str>, + ) -> Result<()> { + use printpdf::path::{PaintMode, WindingOrder}; + + let (r, g, b) = fill.map(Self::parse_hex_to_rgb).unwrap_or((0.5, 0.5, 0.5)); + + layer.set_fill_color(printpdf::Color::Rgb(printpdf::Rgb::new(r, g, b, None))); + + let points = vec![ + (Point::new(Mm(x), Mm(y_bottom)), false), + (Point::new(Mm(x + width_mm), Mm(y_bottom)), false), + ( + Point::new(Mm(x + width_mm), Mm(y_bottom + height_mm)), + false, + ), + (Point::new(Mm(x), Mm(y_bottom + height_mm)), false), + ]; + + let polygon = printpdf::Polygon { + rings: vec![points], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + + layer.add_polygon(polygon); + Ok(()) + } + + fn parse_hex_to_rgb(hex: &str) -> (f32, f32, f32) { + let raw = hex.trim(); + let raw = raw.strip_prefix('#').unwrap_or(raw); + if raw.len() != 6 { + return (0.0, 0.0, 0.0); + } + + let r = u8::from_str_radix(&raw[0..2], 16).unwrap_or(0) as f32 / 255.0; + let g = u8::from_str_radix(&raw[2..4], 16).unwrap_or(0) as f32 / 255.0; + let b = u8::from_str_radix(&raw[4..6], 16).unwrap_or(0) as f32 / 255.0; + + (r, g, b) + } + + fn draw_filled_rect(layer: &PdfLayerReference, x: f32, y_bottom: f32, width: f32, height: f32) { + use printpdf::path::{PaintMode, WindingOrder}; + + let points = vec![ + (Point::new(Mm(x), Mm(y_bottom)), false), + (Point::new(Mm(x + width), Mm(y_bottom)), false), + (Point::new(Mm(x + width), Mm(y_bottom + height)), false), + (Point::new(Mm(x), Mm(y_bottom + height)), false), + ]; + + let polygon = printpdf::Polygon { + rings: vec![points], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }; + + layer.add_polygon(polygon); + } + + fn layout_top_to_pdf_bottom(y_top: f32, element_height: f32, page_height: f32) -> f32 { + page_height - y_top - element_height + } + + fn layout_text_baseline(y_top: f32, font_size_pt: f32, page_height: f32) -> f32 { + let text_height_mm = font_size_pt * POINTS_TO_MM; + let bottom = Self::layout_top_to_pdf_bottom(y_top, text_height_mm, page_height); + bottom + text_height_mm * TEXT_DESCENT_RATIO + } + + // Removed resolve_rect_width: Rect.width is now numeric grid units directly + + pub fn from_json(raw: &str) -> Result { + let json = if raw.trim_start().starts_with('{') { + raw.to_string() + } else { + // Attempt base64 decode; fall back to raw + match base64::engine::general_purpose::STANDARD.decode(raw) { + Ok(bytes) => String::from_utf8(bytes).unwrap_or_else(|_| raw.to_string()), + Err(_) => raw.to_string(), + } + }; + let layout: LabelLayout = + serde_json::from_str(&json).context("Failed to parse label layout JSON")?; + Ok(LabelRenderer::new(layout)) + } + + pub fn render_preview( + &self, + ui: &mut egui::Ui, + data: &HashMap, + preview_scale: f32, + printer_settings: Option<&PrinterSettings>, + ) -> Result<()> { + let bounds = self.calculate_layout_bounds()?; + let transform = LayoutTransform::new(bounds, printer_settings)?; + let canvas_w_px = (transform.page_width * preview_scale).ceil().max(1.0); + let canvas_h_px = (transform.page_height * preview_scale).ceil().max(1.0); + let (resp, painter) = + ui.allocate_painter(egui::vec2(canvas_w_px, canvas_h_px), egui::Sense::hover()); + let rect = resp.rect; + // Background + let page_bg = egui::Color32::from_rgb(250, 250, 250); + painter.rect_filled(rect, egui::CornerRadius::ZERO, page_bg); + // Draw printable area to visualize margins + let printable_rect = egui::Rect::from_min_size( + egui::pos2( + rect.min.x + transform.margins.left * preview_scale, + rect.min.y + transform.margins.top * preview_scale, + ), + egui::vec2( + transform.printable_width * preview_scale, + transform.printable_height * preview_scale, + ), + ); + let printable_bg = self + .layout + .background + .as_deref() + .and_then(Self::parse_hex_color) + .unwrap_or(egui::Color32::WHITE); + painter.rect_filled(printable_rect, egui::CornerRadius::ZERO, printable_bg); + + for element in &self.layout.elements { + match element { + LabelElement::Text { + field, + x, + y, + font_size, + color, + max_width, + wrap, + .. + } => { + let value = Self::resolve_field(field, data); + let x_px = transform.x_mm(*x) * preview_scale; + let y_top_mm = transform.y_mm(*y); + let font_pt = (*font_size * transform.uniform_scale).max(0.5); + let color32 = color + .as_deref() + .and_then(Self::parse_hex_color) + .unwrap_or(egui::Color32::BLACK); + let line_height_mm = font_pt * POINTS_TO_MM * 1.2; + let lines: Vec = if wrap.unwrap_or(false) && max_width.is_some() { + Self::wrap_lines(&value, transform.width_mm(max_width.unwrap()), font_pt) + } else { + vec![value] + }; + for (i, line) in lines.iter().enumerate() { + let line_y_mm = y_top_mm + i as f32 * line_height_mm; + let baseline_mm = + line_y_mm + font_pt * POINTS_TO_MM * (1.0 - TEXT_DESCENT_RATIO); + let baseline_px = baseline_mm * preview_scale; + painter.text( + egui::pos2(rect.min.x + x_px, rect.min.y + baseline_px), + egui::Align2::LEFT_BOTTOM, + line, + egui::FontId::proportional(font_pt), + color32, + ); + } + } + LabelElement::QrCode { + field, + x, + y, + size, + show_text, + .. + } => { + let value = Self::resolve_field(field, data); + let side_mm = transform.uniform_mm(*size).max(0.1); + let x_px = transform.x_mm(*x) * preview_scale; + let y_px = transform.y_mm(*y) * preview_scale; + let side_px = side_mm * preview_scale; + // Simple placeholder squares for modules (not rendering actual QR in preview for speed) + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(rect.min.x + x_px, rect.min.y + y_px), + egui::vec2(side_px, side_px), + ), + egui::CornerRadius::ZERO, + egui::Color32::BLACK, + ); + if show_text.unwrap_or(false) { + let font_pt = 10.0 * transform.uniform_scale; + let text_y_px = rect.min.y + + (transform.y_mm(*y) + side_mm + 1.5 * transform.uniform_scale) + * preview_scale + + font_pt * POINTS_TO_MM * (1.0 - TEXT_DESCENT_RATIO) * preview_scale; + painter.text( + egui::pos2(rect.min.x + x_px, text_y_px), + egui::Align2::LEFT_BOTTOM, + &value, + egui::FontId::proportional(font_pt), + egui::Color32::BLACK, + ); + } + } + LabelElement::DataMatrix { + field, + x, + y, + size, + show_text, + } => { + let value = Self::resolve_field(field, data); + let side_mm = transform.uniform_mm(*size).max(0.1); + let x_px = transform.x_mm(*x) * preview_scale; + let y_px = transform.y_mm(*y) * preview_scale; + let side_px = side_mm * preview_scale; + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(rect.min.x + x_px, rect.min.y + y_px), + egui::vec2(side_px, side_px), + ), + egui::CornerRadius::ZERO, + egui::Color32::DARK_GRAY, + ); + if show_text.unwrap_or(false) { + let font_pt = 8.0 * transform.uniform_scale; + let text_y_px = rect.min.y + + (transform.y_mm(*y) + side_mm + 1.5 * transform.uniform_scale) + * preview_scale + + font_pt * POINTS_TO_MM * (1.0 - TEXT_DESCENT_RATIO) * preview_scale; + painter.text( + egui::pos2(rect.min.x + x_px, text_y_px), + egui::Align2::LEFT_BOTTOM, + &value, + egui::FontId::proportional(font_pt), + egui::Color32::BLACK, + ); + } + } + LabelElement::Barcode { + field, + x, + y, + width, + height, + show_text, + .. + } => { + let value = Self::resolve_field(field, data); + let w_mm = transform.width_mm(*width).max(0.1); + let h_mm = transform.height_mm(*height).max(0.1); + let x_px = transform.x_mm(*x) * preview_scale; + let y_px = transform.y_mm(*y) * preview_scale; + let w_px = w_mm * preview_scale; + let h_px = h_mm * preview_scale; + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(rect.min.x + x_px, rect.min.y + y_px), + egui::vec2(w_px, h_px), + ), + egui::CornerRadius::ZERO, + egui::Color32::BLACK, + ); + if show_text.unwrap_or(false) { + let font_pt = 8.0 * transform.uniform_scale; + let text_y_px = rect.min.y + + (transform.y_mm(*y) + h_mm + 1.5 * transform.uniform_scale) + * preview_scale + + font_pt * POINTS_TO_MM * (1.0 - TEXT_DESCENT_RATIO) * preview_scale; + painter.text( + egui::pos2(rect.min.x + x_px, text_y_px), + egui::Align2::LEFT_BOTTOM, + &value, + egui::FontId::proportional(font_pt), + egui::Color32::BLACK, + ); + } + } + LabelElement::Rect { + x, + y, + width, + height, + fill, + } => { + let w_mm = transform.width_mm(*width).max(0.1); + let h_mm = transform.height_mm(*height).max(0.1); + let x_px = transform.x_mm(*x) * preview_scale; + let y_px = transform.y_mm(*y) * preview_scale; + let w_px = w_mm * preview_scale; + let h_px = h_mm * preview_scale; + let color32 = fill + .as_deref() + .and_then(Self::parse_hex_color) + .unwrap_or(egui::Color32::from_gray(180)); + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(rect.min.x + x_px, rect.min.y + y_px), + egui::vec2(w_px, h_px), + ), + egui::CornerRadius::ZERO, + color32, + ); + } + LabelElement::Svg { + x, + y, + width, + height, + .. + } => { + let w_mm = transform.width_mm(*width).max(0.1); + let h_mm = transform.height_mm(*height).max(0.1); + let x_px = transform.x_mm(*x) * preview_scale; + let y_px = transform.y_mm(*y) * preview_scale; + let w_px = w_mm * preview_scale; + let h_px = h_mm * preview_scale; + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(rect.min.x + x_px, rect.min.y + y_px), + egui::vec2(w_px, h_px), + ), + egui::CornerRadius::ZERO, + egui::Color32::from_gray(200), + ); + } + } + } + Ok(()) + } + + fn prepare_code128_payload(data: &str) -> Option { + // Strip BOM and surrounding whitespace + let mut s = data.trim().trim_start_matches('\u{FEFF}').to_string(); + if s.is_empty() { + return None; + } + + // Allow user-provided advanced Code128 sequence (already has start set char) + if let Some(first) = s.chars().next() { + if matches!(first, 'À' | 'Ɓ' | 'Ć') { + // Minimal length check (library requires at least 2 chars total) + if s.len() >= 2 { + return Some(s); + } else { + return None; + } + } + } + + // Remove internal whitespace + s.retain(|c| !c.is_whitespace()); + if s.is_empty() { + return None; + } + + // Pure digits: use Code Set C (double-density). Must be even length; pad leading 0 if needed. + if s.chars().all(|c| c.is_ascii_digit()) { + if s.len() % 2 == 1 { + s.insert(0, '0'); + } + // Prefix with Set C start char 'Ć' + return Some(format!("Ć{}", s)); + } + + // General printable ASCII: choose Set B start ('Ɓ'). Filter to printable 32..=126. + let mut cleaned = String::new(); + for ch in s.chars() { + let code = ch as u32; + if (32..=126).contains(&code) { + cleaned.push(ch); + } + } + if cleaned.is_empty() { + return None; + } + Some(format!("Ɓ{}", cleaned)) + } + + // Naive word-wrap: estimate character width ~= 0.55 * font_size_pt * POINTS_TO_MM + fn wrap_lines(text: &str, max_width_mm: f32, font_size_pt: f32) -> Vec { + let approx_char_mm = font_size_pt * POINTS_TO_MM * 0.55; + if approx_char_mm <= 0.0 || max_width_mm <= 0.0 { + return vec![text.to_string()]; + } + let max_chars = (max_width_mm / approx_char_mm).floor().max(1.0) as usize; + let mut lines = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + if current.is_empty() { + current.push_str(word); + continue; + } + if current.len() + 1 + word.len() <= max_chars { + current.push(' '); + current.push_str(word); + } else { + lines.push(std::mem::take(&mut current)); + current.push_str(word); + } + } + if !current.is_empty() { + lines.push(current); + } + if lines.is_empty() { + lines.push(String::new()); + } + lines + } + + // Decode data URI to raw SVG XML string + fn decode_svg_data_uri(data_uri: &str) -> Option { + if let Some(idx) = data_uri.find(',') { + let (header, payload) = data_uri.split_at(idx + 1); + if header.contains("base64") { + let bytes = base64::engine::general_purpose::STANDARD + .decode(payload) + .ok()?; + String::from_utf8(bytes).ok() + } else { + Some(payload.to_string()) + } + } else { + if data_uri.contains(" Option> { + use tiny_skia::Pixmap; + use usvg::Options; + + let opt = Options::default(); + let tree = usvg::Tree::from_str(svg_xml, &opt).ok()?; + let mut pixmap = Pixmap::new(target_w, target_h)?; + // Compute uniform scale to fit preserving aspect + let view_size = tree.size(); + let sx = target_w as f32 / view_size.width(); + let sy = target_h as f32 / view_size.height(); + let scale = sx.min(sy); + let transform = tiny_skia::Transform::from_scale(scale, scale); + // Render using resvg + resvg::render(&tree, transform, &mut pixmap.as_mut()); + Some(pixmap.data().to_vec()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn code128_numeric_even_len_encodes() { + let raw = "75650012"; // even length digits + let payload = LabelRenderer::prepare_code128_payload(raw).expect("payload"); + assert!(payload.starts_with('Ć')); + let code = barcoders::sym::code128::Code128::new(&payload).expect("encode code128"); + assert!(!code.encode().is_empty()); + } + + #[test] + fn code128_numeric_odd_len_padded() { + let raw = "123"; + let payload = LabelRenderer::prepare_code128_payload(raw).expect("payload"); + assert!(payload.starts_with('Ć')); + // Collect digits after the first unicode character (start set) + let digits: String = payload.chars().skip(1).collect(); + assert_eq!(digits.len() % 2, 0); + let code = barcoders::sym::code128::Code128::new(&payload).expect("encode code128"); + assert!(!code.encode().is_empty()); + } + + #[test] + fn code128_basic_ascii_encodes() { + let payload = LabelRenderer::prepare_code128_payload("HELLO-123").expect("payload"); + assert!(payload.starts_with('Ɓ')); + let code = barcoders::sym::code128::Code128::new(&payload).expect("encode code128"); + assert!(!code.encode().is_empty()); + } + + #[test] + fn code11_accepts_digits_and_dash() { + use barcoders::sym::code11::Code11; + // Valid payload containing digits and dash + let c = Code11::new("123-45").expect("encode code11"); + assert!(!c.encode().is_empty()); + // Library should reject invalid characters; ensure it errors + assert!(Code11::new("12A45").is_err()); + } + + #[test] + fn datamatrix_encodes_nonempty_bitmap() { + use datamatrix::{DataMatrix, SymbolList}; + let dm = + DataMatrix::encode_str("DM-OK-123", SymbolList::default()).expect("encode datamatrix"); + let bmp = dm.bitmap(); + assert!(bmp.width() > 0 && bmp.height() > 0); + assert!(bmp.pixels().next().is_some()); + } + + #[test] + fn layout_deserialize_show_text_flags_raw_and_base64() { + // Minimal layout exercising showText flags across elements + let raw_json = r##"{ + "background": "#FFFFFF", + "elements": [ + {"type": "qrcode", "field": "A", "x": 5, "y": 5, "size": 20, "showText": true}, + {"type": "datamatrix", "field": "B", "x": 30, "y": 5, "size": 20, "showText": false}, + {"type": "barcode", "field": "C", "x": 5, "y": 30, "width": 40, "height": 12, "format": "code128", "showText": true} + ] + }"##; + + // Parse raw + let r1 = LabelRenderer::from_json(raw_json).expect("raw parse"); + assert_eq!(r1.layout.elements.len(), 3); + + // Parse base64 of same JSON + let b64 = base64::engine::general_purpose::STANDARD.encode(raw_json); + let r2 = LabelRenderer::from_json(&b64).expect("b64 parse"); + assert_eq!(r2.layout.elements.len(), 3); + + // Spot-check variant fields carry show_text flags via serde mapping + match &r1.layout.elements[0] { + LabelElement::QrCode { show_text, .. } => assert_eq!(show_text.unwrap_or(false), true), + _ => panic!("expected qrcode"), + } + match &r1.layout.elements[1] { + LabelElement::DataMatrix { show_text, .. } => { + assert_eq!(show_text.unwrap_or(true), false) + } + _ => panic!("expected datamatrix"), + } + match &r1.layout.elements[2] { + LabelElement::Barcode { show_text, .. } => assert_eq!(show_text.unwrap_or(false), true), + _ => panic!("expected barcode"), + } + } +} diff --git a/src/core/print/ui/mod.rs b/src/core/print/ui/mod.rs new file mode 100644 index 0000000..b134f6e --- /dev/null +++ b/src/core/print/ui/mod.rs @@ -0,0 +1,3 @@ +pub mod print_dialog; + +// PrintDialog is re-exported at crate::core::print level diff --git a/src/core/print/ui/print_dialog.rs b/src/core/print/ui/print_dialog.rs new file mode 100644 index 0000000..8ac503a --- /dev/null +++ b/src/core/print/ui/print_dialog.rs @@ -0,0 +1,999 @@ +use anyhow::Result; +use eframe::egui; +use serde_json::Value; +use std::collections::HashMap; + +use crate::api::ApiClient; +use crate::core::print::parsing::{parse_layout_json, parse_printer_settings, PrinterSettings}; +use crate::core::print::plugins::pdf::PdfPlugin; +use crate::core::print::printer_manager::{PrinterInfo, SharedPrinterManager}; +use crate::core::print::renderer::LabelRenderer; +use poll_promise::Promise; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PaperSizeOverride { + UseSaved, + A4, + Letter, + Custom, +} + +/// Print options selected by user +#[derive(Debug, Clone)] +pub struct PrintOptions { + pub printer_id: Option, + pub printer_name: String, + pub label_template_id: Option, + pub label_template_name: String, + pub copies: i32, +} + +impl Default for PrintOptions { + fn default() -> Self { + Self { + printer_id: None, + printer_name: String::new(), + label_template_id: None, + label_template_name: String::new(), + copies: 1, + } + } +} + +/// Print dialog for selecting printer, template, and preview +pub struct PrintDialog { + options: PrintOptions, + pub asset_data: HashMap, + printers: Vec, + templates: Vec, + renderer: Option, + preview_scale: f32, + error_message: Option, + loading: bool, + // Promise for handling async PDF export + pdf_export_promise: Option>>, + // OS printer fallback popup + os_popup_visible: bool, + os_printers: Vec, + os_selected_index: usize, + os_print_path: Option, + os_error_message: Option, + os_base_settings: Option, + os_renderer: Option, + os_size_override: PaperSizeOverride, + os_custom_width_mm: f32, + os_custom_height_mm: f32, +} + +impl PrintDialog { + /// Create new print dialog with asset data + pub fn new(asset_data: HashMap) -> Self { + Self { + options: PrintOptions::default(), + asset_data, + printers: Vec::new(), + templates: Vec::new(), + renderer: None, + preview_scale: 3.78, // Default scale: 1mm = 3.78px at 96 DPI + error_message: None, + loading: false, + pdf_export_promise: None, + os_popup_visible: false, + os_printers: Vec::new(), + os_selected_index: 0, + os_print_path: None, + os_error_message: None, + os_base_settings: None, + os_renderer: None, + os_size_override: PaperSizeOverride::UseSaved, + os_custom_width_mm: 0.0, + os_custom_height_mm: 0.0, + } + } + + /// Initialize with default printer and template if available + pub fn with_defaults( + mut self, + default_printer_id: Option, + label_template_id: Option, + last_printer_id: Option, + ) -> Self { + // Prefer last-used printer if available, otherwise fall back to default + self.options.printer_id = last_printer_id.or(default_printer_id); + // Label template is *not* persisted across sessions; if none is set on the asset, + // the dialog will require the user to choose one. + self.options.label_template_id = label_template_id; + self + } + + /// Load printers and templates from API + pub fn load_data(&mut self, api_client: &ApiClient) -> Result<()> { + self.loading = true; + self.error_message = None; + + // Load printers + match crate::core::tables::get_printers(api_client) { + Ok(printers) => self.printers = printers, + Err(e) => { + self.error_message = Some(format!("Failed to load printers: {}", e)); + return Err(e); + } + } + + // Load templates + match crate::core::tables::get_label_templates(api_client) { + Ok(templates) => self.templates = templates, + Err(e) => { + self.error_message = Some(format!("Failed to load templates: {}", e)); + return Err(e); + } + } + + // Set default selections if IDs provided + if let Some(printer_id) = self.options.printer_id { + if let Some(printer) = self + .printers + .iter() + .find(|p| p.get("id").and_then(|v| v.as_i64()) == Some(printer_id)) + { + self.options.printer_name = printer + .get("printer_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Fetch printer_settings for preview sizing/orientation + let resp = api_client.select( + "printer_settings", + Some(vec!["printer_settings".into()]), + Some(serde_json::json!({"id": printer_id})), + None, + Some(1), + )?; + if let Some(first) = resp.data.as_ref().and_then(|d| d.get(0)) { + if let Some(ps_val) = first.get("printer_settings") { + if let Ok(ps) = parse_printer_settings(ps_val) { + self.os_base_settings = Some(ps); + } + } + } + } + } + + if let Some(template_id) = self.options.label_template_id { + if let Some(template) = self + .templates + .iter() + .find(|t| t.get("id").and_then(|v| v.as_i64()) == Some(template_id)) + { + let template_name = template + .get("template_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + self.options.label_template_name = template_name.clone(); + + // Load renderer for preview + if let Some(layout_json) = template.get("layout_json").and_then(|v| v.as_str()) { + if layout_json.trim().is_empty() { + log::warn!("Label template '{}' has empty layout_json", template_name); + self.error_message = Some("This label template has no layout defined. Please edit the template in Label Templates view.".to_string()); + } else { + match LabelRenderer::from_json(layout_json) { + Ok(renderer) => self.renderer = Some(renderer), + Err(e) => { + log::warn!( + "Failed to parse label layout for '{}': {}", + template_name, + e + ); + self.error_message = Some(format!("Invalid template layout JSON. Please fix in Label Templates view.\n\nError: {}", e)); + } + } + } + } else { + log::warn!( + "Label template '{}' missing layout_json field", + template_name + ); + self.error_message = Some( + "This label template is missing layout data. Please edit the template." + .to_string(), + ); + } + } + } + + self.loading = false; + Ok(()) + } + + /// Show the dialog and return true if user clicked Print and the action is complete + pub fn show( + &mut self, + ctx: &egui::Context, + open: &mut bool, + api_client: Option<&ApiClient>, + ) -> bool { + let mut print_action_complete = false; + let mut close_dialog = false; + + if let Some(_response) = egui::Window::new("Print Label") + .open(open) + .resizable(true) + .default_width(600.0) + .default_height(500.0) + .show(ctx, |ui| { + // Load data if not loaded yet + if self.printers.is_empty() && !self.loading && api_client.is_some() { + if let Err(e) = self.load_data(api_client.unwrap()) { + log::error!("Failed to load print data: {}", e); + } + } + + // Show error if any + if let Some(error) = &self.error_message { + ui.colored_label(egui::Color32::RED, error); + ui.add_space(8.0); + } + + // Show loading spinner + if self.loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Loading printers and templates..."); + }); + return; + } + + // Options panel + egui::ScrollArea::vertical() + .id_salt("print_options_scroll") + .show(ui, |ui| { + self.show_options(ui); + ui.add_space(12.0); + self.show_preview(ui); + }); + + // Handle PDF export promise + if let Some(promise) = &self.pdf_export_promise { + if let Some(result) = promise.ready() { + match result { + Some(path) => { + log::info!("PDF export promise ready, path: {:?}", path); + // The file dialog is done, now we can save the file. + // We need the ApiClient and other details again. + if let Some(client) = api_client { + if let Err(e) = self.execute_pdf_export(path, client) { + self.error_message = + Some(format!("Failed to export PDF: {}", e)); + } else { + // Successfully exported, close dialog + print_action_complete = true; + close_dialog = true; + } + } else { + self.error_message = Some( + "API client not available for PDF export.".to_string(), + ); + } + } + None => { + // User cancelled the dialog + log::info!("PDF export cancelled by user."); + } + } + self.pdf_export_promise = None; // Consume the promise + } else { + ui.spinner(); + ui.label("Waiting for file path..."); + } + } + + // Bottom buttons + ui.add_space(8.0); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + close_dialog = true; + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let can_print = self.options.printer_id.is_some() + && self.options.label_template_id.is_some() + && self.options.copies > 0 + && self.pdf_export_promise.is_none(); // Disable while waiting for path + + ui.add_enabled_ui(can_print, |ui| { + if ui.button("Print").clicked() { + if let Some(client) = api_client { + match self.execute_print(client) { + Ok(completed) => { + if completed { + print_action_complete = true; + close_dialog = true; + } + // if not completed, dialog stays open for promise + } + Err(e) => { + self.error_message = + Some(format!("Print error: {}", e)); + } + } + } else { + self.error_message = + Some("API Client not available.".to_string()); + } + } + }); + }); + }); + }) + { + // Window was shown + } + + // Render OS printer fallback popup if requested + if self.os_popup_visible { + let mut close_os_popup = false; + let mut keep_open_flag = true; + egui::Window::new("Select System Printer") + .collapsible(false) + .resizable(true) + .default_width(420.0) + .open(&mut keep_open_flag) + .show(ctx, |ui| { + if let Some(err) = &self.os_error_message { + ui.colored_label(egui::Color32::RED, err); + ui.separator(); + } + if self.os_printers.is_empty() { + let mgr = SharedPrinterManager::new(); + self.os_printers = mgr.get_printers(); + if let Some(base) = &self.os_base_settings { + if let Some(target_name) = &base.printer_name { + if let Some((idx, _)) = self + .os_printers + .iter() + .enumerate() + .find(|(_, p)| &p.name == target_name) + { + self.os_selected_index = idx; + } + } + } + } + if self.os_printers.is_empty() { + ui.label("No system printers found."); + } else { + if self.os_selected_index >= self.os_printers.len() { + self.os_selected_index = 0; + } + let current = self + .os_printers + .get(self.os_selected_index) + .map(|p| p.name.clone()) + .unwrap_or_default(); + egui::ComboBox::from_id_salt("os_printers_combo") + .selected_text(if current.is_empty() { "Select printer" } else { ¤t }) + .show_ui(ui, |ui| { + for (i, p) in self.os_printers.iter().enumerate() { + if ui + .selectable_label(i == self.os_selected_index, &p.name) + .clicked() + { + self.os_selected_index = i; + } + } + }); + } + ui.separator(); + + if let Some(base) = &self.os_base_settings { + let saved_label = format!( + "Use saved ({})", + base.paper_size.as_str() + ); + + egui::ComboBox::from_id_salt("os_size_override") + .selected_text(match self.os_size_override { + PaperSizeOverride::UseSaved => saved_label.clone(), + PaperSizeOverride::A4 => "A4 (210×297 mm)".into(), + PaperSizeOverride::Letter => "Letter (215.9×279.4 mm)".into(), + PaperSizeOverride::Custom => "Custom size".into(), + }) + .show_ui(ui, |ui| { + if ui + .selectable_value( + &mut self.os_size_override, + PaperSizeOverride::UseSaved, + saved_label, + ) + .clicked() + { + self.os_error_message = None; + } + if ui + .selectable_value( + &mut self.os_size_override, + PaperSizeOverride::A4, + "A4 (210×297 mm)", + ) + .clicked() + { + self.os_error_message = None; + } + if ui + .selectable_value( + &mut self.os_size_override, + PaperSizeOverride::Letter, + "Letter (215.9×279.4 mm)", + ) + .clicked() + { + self.os_error_message = None; + } + if ui + .selectable_value( + &mut self.os_size_override, + PaperSizeOverride::Custom, + "Custom size", + ) + .clicked() + { + if base.custom_width_mm.is_some() + && base.custom_height_mm.is_some() + { + let (w, h) = base.get_dimensions_mm(); + self.os_custom_width_mm = w; + self.os_custom_height_mm = h; + } else { + self.os_custom_width_mm = 0.0; + self.os_custom_height_mm = 0.0; + } + self.os_error_message = None; + } + }); + + if matches!(self.os_size_override, PaperSizeOverride::Custom) { + ui.vertical(|ui| { + ui.label("Custom page size (mm)"); + ui.horizontal(|ui| { + ui.label("Width:"); + ui.add( + egui::DragValue::new(&mut self.os_custom_width_mm) + .range(10.0..=600.0) + .suffix(" mm"), + ); + ui.label("Height:"); + ui.add( + egui::DragValue::new(&mut self.os_custom_height_mm) + .range(10.0..=600.0) + .suffix(" mm"), + ); + }); + }); + } + } + + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + self.os_print_path = None; + self.os_base_settings = None; + close_os_popup = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let can_print = !self.os_printers.is_empty() + && self + .os_printers + .get(self.os_selected_index) + .is_some(); + ui.add_enabled_ui(can_print, |ui| { + if ui.button("Print").clicked() { + let selected_name = self + .os_printers + .get(self.os_selected_index) + .map(|p| p.name.clone()); + if let Some(name) = selected_name { + match self.print_via_os_popup(&name) { + Ok(true) => { + self.os_print_path = None; + self.os_base_settings = None; + close_os_popup = true; + print_action_complete = true; + close_dialog = true; + } + Ok(false) => { /* not used: function only returns true on success */ } + Err(e) => { + self.os_error_message = Some(e); + } + } + } + } + }); + }); + }); + }); + // Apply window close state after rendering + if !keep_open_flag { + close_os_popup = true; + } + if close_os_popup { + self.os_popup_visible = false; + self.os_base_settings = None; + } + } + + if close_dialog { + *open = false; + } + + print_action_complete + } + + /// Show options section + fn show_options(&mut self, ui: &mut egui::Ui) { + ui.heading("Print Options"); + ui.add_space(8.0); + + egui::Grid::new("print_options_grid") + .num_columns(2) + .spacing([8.0, 8.0]) + .show(ui, |ui| { + // Printer selection + ui.label("Printer:"); + egui::ComboBox::from_id_salt("printer_select") + .selected_text(&self.options.printer_name) + .width(300.0) + .show_ui(ui, |ui| { + for printer in &self.printers { + let printer_id = printer.get("id").and_then(|v| v.as_i64()); + let printer_name = printer + .get("printer_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + + if ui + .selectable_label( + self.options.printer_id == printer_id, + printer_name, + ) + .clicked() + { + self.options.printer_id = printer_id; + self.options.printer_name = printer_name.to_string(); + // Try to parse printer settings for preview (if provided by the DB row) + if let Some(ps_val) = printer.get("printer_settings") { + match parse_printer_settings(ps_val) { + Ok(ps) => { + self.os_base_settings = Some(ps); + } + Err(e) => { + log::warn!( + "Failed to parse printer_settings for preview: {}", + e + ); + self.os_base_settings = None; + } + } + } else { + self.os_base_settings = None; + } + } + } + }); + ui.end_row(); + + // Template selection + ui.label("Label Template:"); + egui::ComboBox::from_id_salt("template_select") + .selected_text(&self.options.label_template_name) + .width(300.0) + .show_ui(ui, |ui| { + for template in &self.templates { + let template_id = template.get("id").and_then(|v| v.as_i64()); + let template_name = template + .get("template_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + + if ui + .selectable_label( + self.options.label_template_id == template_id, + template_name, + ) + .clicked() + { + self.options.label_template_id = template_id; + self.options.label_template_name = template_name.to_string(); + + // Update renderer + if let Some(layout_json) = + template.get("layout_json").and_then(|v| v.as_str()) + { + match LabelRenderer::from_json(layout_json) { + Ok(renderer) => { + self.renderer = Some(renderer); + self.error_message = None; + } + Err(e) => { + log::warn!("Failed to parse label layout: {}", e); + self.error_message = + Some(format!("Invalid template: {}", e)); + self.renderer = None; + } + } + } + } + } + }); + ui.end_row(); + + // Number of copies + ui.label("Copies:"); + ui.add(egui::DragValue::new(&mut self.options.copies).range(1..=99)); + ui.end_row(); + }); + } + + /// Show preview section + fn show_preview(&mut self, ui: &mut egui::Ui) { + ui.add_space(8.0); + ui.heading("Preview"); + ui.add_space(8.0); + + // Preview scale control + ui.horizontal(|ui| { + ui.label("Scale:"); + ui.add(egui::Slider::new(&mut self.preview_scale, 2.0..=8.0).suffix("x")); + }); + + ui.add_space(8.0); + + // Render preview + if let Some(renderer) = &self.renderer { + egui::ScrollArea::both() // Enable both horizontal and vertical scrolling + .max_height(300.0) + .auto_shrink([false, false]) // Don't shrink in either direction + .show(ui, |ui| { + egui::Frame::new() + .fill(egui::Color32::from_gray(240)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(200))) + .inner_margin(16.0) + .show(ui, |ui| { + if let Err(e) = renderer.render_preview( + ui, + &self.asset_data, + self.preview_scale, + self.os_base_settings.as_ref(), + ) { + ui.colored_label( + egui::Color32::RED, + format!("Preview error: {}", e), + ); + } + }); + }); + } else { + ui.colored_label( + egui::Color32::from_gray(150), + "Select a label template to see preview", + ); + } + } + + /// Get asset data reference + pub fn asset_data(&self) -> &HashMap { + &self.asset_data + } + + /// Get current print options + pub fn options(&self) -> &PrintOptions { + &self.options + } + + /// Executes the actual PDF file saving. This is called after the promise resolves. + fn execute_pdf_export(&self, path: &PathBuf, api_client: &ApiClient) -> Result<()> { + let template_id = self + .options + .label_template_id + .ok_or_else(|| anyhow::anyhow!("No template selected"))?; + let printer_id = self + .options + .printer_id + .ok_or_else(|| anyhow::anyhow!("No printer selected"))?; + + // Fetch template + let template_resp = api_client.select( + "label_templates", + Some(vec!["layout_json".into()]), + Some(serde_json::json!({"id": template_id})), + None, + Some(1), + )?; + let template_data = template_resp + .data + .as_ref() + .and_then(|d| d.get(0)) + .ok_or_else(|| anyhow::anyhow!("Template not found"))?; + let layout_json = template_data + .get("layout_json") + .ok_or_else(|| anyhow::anyhow!("No layout JSON"))?; + let layout = parse_layout_json(layout_json)?; + + // Fetch printer settings + let printer_resp = api_client.select( + "printer_settings", + Some(vec!["printer_settings".into()]), + Some(serde_json::json!({"id": printer_id})), + None, + Some(1), + )?; + let printer_data = printer_resp + .data + .as_ref() + .and_then(|d| d.get(0)) + .ok_or_else(|| anyhow::anyhow!("Printer settings not found"))?; + let printer_settings_value = printer_data + .get("printer_settings") + .ok_or_else(|| anyhow::anyhow!("No printer settings JSON"))?; + let printer_settings = parse_printer_settings(printer_settings_value)?; + + // Generate and save PDF + let renderer = LabelRenderer::new(layout); + let (doc, _, _) = + renderer.generate_pdf_with_settings(&self.asset_data, &printer_settings)?; + let pdf_plugin = PdfPlugin::new(); + pdf_plugin.export_pdf(doc, path) + } + + /// Execute print job - handles all the loading, parsing, and printing. + /// Returns Ok(true) if the job is complete, Ok(false) if it's pending (e.g., PDF export). + pub fn execute_print(&mut self, api_client: &ApiClient) -> Result { + let printer_id = self + .options + .printer_id + .ok_or_else(|| anyhow::anyhow!("No printer selected"))?; + let template_id = self + .options + .label_template_id + .ok_or_else(|| anyhow::anyhow!("No template selected"))?; + + log::info!( + "Executing print: printer_id={}, template_id={}, copies={}", + printer_id, + template_id, + self.options.copies + ); + + // 1. Load printer settings and plugin info + let printer_resp = api_client.select( + "printer_settings", + Some(vec![ + "printer_name".into(), + "printer_settings".into(), + "printer_plugin".into(), + ]), + Some(serde_json::json!({ "id": printer_id })), + None, + Some(1), + )?; + + let printer_data = printer_resp + .data + .as_ref() + .and_then(|d| if !d.is_empty() { Some(d) } else { None }) + .ok_or_else(|| anyhow::anyhow!("Printer {} not found", printer_id))?; + + let printer_plugin = printer_data[0] + .get("printer_plugin") + .and_then(|v| v.as_str()) + .unwrap_or("System"); + + let printer_settings_value = printer_data[0] + .get("printer_settings") + .ok_or_else(|| anyhow::anyhow!("printer_settings field not found"))?; + let printer_settings = parse_printer_settings(printer_settings_value)?; + + // 2. Load label template layout + let template_resp = api_client.select( + "label_templates", + Some(vec!["layout_json".into()]), + Some(serde_json::json!({"id": template_id})), + None, + Some(1), + )?; + + let template_data = template_resp + .data + .as_ref() + .and_then(|d| if !d.is_empty() { Some(d) } else { None }) + .ok_or_else(|| anyhow::anyhow!("Label template {} not found", template_id))?; + + let layout_json_value = template_data[0] + .get("layout_json") + .ok_or_else(|| anyhow::anyhow!("layout_json field not found in template"))?; + let layout = parse_layout_json(layout_json_value)?; + + // 3. Dispatch to appropriate plugin based on the printer_plugin field + match printer_plugin { + "PDF" => { + // Use a promise to handle the blocking file dialog in a background thread + let promise = Promise::spawn_thread("pdf_export_dialog", || { + rfd::FileDialog::new() + .add_filter("PDF Document", &["pdf"]) + .set_file_name("label.pdf") + .save_file() + }); + self.pdf_export_promise = Some(promise); + } + "System" | _ => { + // Use SystemPrintPlugin for system printing + use crate::core::print::plugins::system::SystemPrintPlugin; + let renderer = LabelRenderer::new(layout); + let (doc, _, _) = + renderer.generate_pdf_with_settings(&self.asset_data, &printer_settings)?; + + let system_plugin = SystemPrintPlugin::new() + .map_err(|e| anyhow::anyhow!("Failed to initialize system print: {}", e))?; + + // Save PDF first since doc can't be cloned + let pdf_path = system_plugin.save_pdf_to_temp(doc)?; + + // Try direct print to named system printer if provided + if let Some(name) = printer_settings.printer_name.clone() { + let mgr = SharedPrinterManager::new(); + match mgr.print_pdf_to(&name, pdf_path.as_path(), Some(&printer_settings)) { + Ok(()) => { + return Ok(true); + } + Err(e) => { + log::warn!("Direct system print failed: {}", e); + let fallback = printer_settings.show_dialog_if_unfound.unwrap_or(true); + if fallback { + // Show OS printer chooser popup + self.os_print_path = Some(pdf_path); + self.os_popup_visible = true; + self.os_error_message = Some(format!( + "Named printer '{}' not found. Please select a system printer.", + name + )); + // Provide base settings and renderer so overrides can regenerate PDF + self.os_base_settings = Some(printer_settings.clone()); + self.os_renderer = Some(renderer.clone()); + self.os_size_override = PaperSizeOverride::UseSaved; + return Ok(false); + } else { + // Fallback to opening in viewer using SystemPrintPlugin + system_plugin.open_print_dialog(&pdf_path)?; + return Ok(true); + } + } + } + } else { + // No printer_name provided: either show chooser or open viewer + if printer_settings.show_dialog_if_unfound.unwrap_or(true) { + self.os_print_path = Some(pdf_path); + self.os_popup_visible = true; + self.os_error_message = None; + // Provide base settings and renderer so overrides can regenerate PDF + self.os_base_settings = Some(printer_settings.clone()); + self.os_renderer = Some(renderer.clone()); + self.os_size_override = PaperSizeOverride::UseSaved; + return Ok(false); + } else { + system_plugin.open_print_dialog(&pdf_path)?; + return Ok(true); + } + } + } + } + + log::info!("Print job for plugin '{}' dispatched.", printer_plugin); + Ok(false) // Dialog should remain open for PDF export + } + + /// Print via the OS popup selection with optional paper size overrides. + /// Returns Ok(true) if a job was sent, Err(message) on failure. + fn print_via_os_popup(&mut self, target_printer_name: &str) -> Result { + // Determine the PDF to print: reuse existing if no override, or regenerate if overridden + let (path_to_print, job_settings) = match self.os_size_override { + PaperSizeOverride::UseSaved => { + let mut settings = self + .os_base_settings + .clone() + .unwrap_or_else(|| PrinterSettings::default()); + settings.canonicalize_dimensions(); + let path = self + .os_print_path + .clone() + .ok_or_else(|| "No PDF available to print".to_string())?; + (path, settings) + } + PaperSizeOverride::A4 | PaperSizeOverride::Letter | PaperSizeOverride::Custom => { + let base = self + .os_base_settings + .clone() + .ok_or_else(|| "Missing base printer settings for override".to_string())?; + let renderer = self + .os_renderer + .clone() + .ok_or_else(|| "Missing renderer for override".to_string())?; + + let mut settings = base.clone(); + match self.os_size_override { + PaperSizeOverride::A4 => { + settings.paper_size = "A4".to_string(); + settings.custom_width_mm = None; + settings.custom_height_mm = None; + } + PaperSizeOverride::Letter => { + settings.paper_size = "Letter".to_string(); + settings.custom_width_mm = None; + settings.custom_height_mm = None; + } + PaperSizeOverride::Custom => { + let w = self.os_custom_width_mm.max(0.0); + let h = self.os_custom_height_mm.max(0.0); + if w <= 0.0 || h <= 0.0 { + return Err("Please enter a valid custom size in mm".into()); + } + settings.custom_width_mm = Some(w); + settings.custom_height_mm = Some(h); + } + PaperSizeOverride::UseSaved => unreachable!(), + } + + settings.canonicalize_dimensions(); + + // Regenerate the PDF with overridden settings + let (doc, _, _) = renderer + .generate_pdf_with_settings(&self.asset_data, &settings) + .map_err(|e| format!("Failed to generate PDF: {}", e))?; + let new_path = Self::save_pdf_to_temp(doc) + .map_err(|e| format!("Failed to save PDF: {}", e))?; + // Update stored state for potential re-prints + self.os_print_path = Some(new_path.clone()); + self.os_base_settings = Some(settings.clone()); + (new_path, settings) + } + }; + + // Send to the selected OS printer + let mgr = SharedPrinterManager::new(); + let job_settings_owned = job_settings; + let result = mgr.print_pdf_to( + target_printer_name, + path_to_print.as_path(), + Some(&job_settings_owned), + ); + + if result.is_ok() { + // Ensure latest settings persist for future retries when using saved path + self.os_base_settings = Some(job_settings_owned.clone()); + self.os_print_path = Some(path_to_print.clone()); + } + + result.map(|_| true) + } + + fn save_pdf_to_temp(doc: printpdf::PdfDocumentReference) -> Result { + use anyhow::Context; + use std::fs::File; + use std::io::BufWriter; + let temp_dir = std::env::temp_dir().join("beepzone_labels"); + std::fs::create_dir_all(&temp_dir).context("Failed to create temp directory for labels")?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let pdf_path = temp_dir.join(format!("label_{}.pdf", timestamp)); + let file = File::create(&pdf_path).context("Failed to create temp PDF file")?; + let mut writer = BufWriter::new(file); + doc.save(&mut writer).context("Failed to save temp PDF")?; + Ok(pdf_path) + } +} diff --git a/src/core/table_renderer.rs b/src/core/table_renderer.rs new file mode 100644 index 0000000..ca16fd4 --- /dev/null +++ b/src/core/table_renderer.rs @@ -0,0 +1,739 @@ +use eframe::egui; +use egui_phosphor::variants::regular as icons; +use serde_json::Value; +use std::collections::HashSet; + +/// Column configuration for table rendering +#[derive(Clone)] +pub struct ColumnConfig { + pub name: String, + pub field: String, + pub visible: bool, + pub width: f32, + #[allow(dead_code)] + pub min_width: f32, +} + +impl ColumnConfig { + pub fn new(name: impl Into, field: impl Into) -> Self { + Self { + name: name.into(), + field: field.into(), + visible: true, + width: 100.0, + min_width: 50.0, + } + } + + pub fn with_width(mut self, width: f32) -> Self { + self.width = width; + self + } + + #[allow(dead_code)] + pub fn with_min_width(mut self, min_width: f32) -> Self { + self.min_width = min_width; + self + } + + pub fn hidden(mut self) -> Self { + self.visible = false; + self + } +} + +/// Sorting configuration +#[derive(Clone)] +pub struct SortConfig { + pub field: Option, + pub ascending: bool, +} + +impl Default for SortConfig { + fn default() -> Self { + Self { + field: None, + ascending: true, + } + } +} + +/// Multi-selection state management +pub struct SelectionManager { + pub selected_rows: HashSet, + pub selection_anchor: Option, + pub last_click_time: Option, + pub last_click_row: Option, +} + +impl Default for SelectionManager { + fn default() -> Self { + Self { + selected_rows: HashSet::new(), + selection_anchor: None, + last_click_time: None, + last_click_row: None, + } + } +} + +impl SelectionManager { + pub fn new() -> Self { + Self::default() + } + + pub fn is_selected(&self, row: usize) -> bool { + self.selected_rows.contains(&row) + } + + pub fn select_all(&mut self, row_count: usize) { + self.selected_rows = (0..row_count).collect(); + } + + pub fn clear_selection(&mut self) { + self.selected_rows.clear(); + self.selection_anchor = None; + } + + pub fn toggle_row(&mut self, row: usize, modifier: SelectionModifier) { + match modifier { + SelectionModifier::None => { + self.selected_rows.clear(); + self.selected_rows.insert(row); + self.selection_anchor = Some(row); + } + SelectionModifier::Ctrl => { + if self.selected_rows.contains(&row) { + self.selected_rows.remove(&row); + } else { + self.selected_rows.insert(row); + } + self.selection_anchor = Some(row); + } + SelectionModifier::Shift => { + let anchor = self.selection_anchor.unwrap_or(row); + let (start, end) = if anchor <= row { + (anchor, row) + } else { + (row, anchor) + }; + for i in start..=end { + self.selected_rows.insert(i); + } + } + } + } + + pub fn get_selected_count(&self) -> usize { + self.selected_rows.len() + } + + pub fn get_selected_indices(&self) -> Vec { + let mut indices: Vec<_> = self.selected_rows.iter().cloned().collect(); + indices.sort(); + indices + } +} + +pub enum SelectionModifier { + None, + Ctrl, + Shift, +} + +/// Callbacks for table events +pub trait TableEventHandler { + fn on_double_click(&mut self, item: &T, row_index: usize); + fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &T, row_index: usize); + fn on_selection_changed(&mut self, selected_indices: &[usize]); +} + +/// Generic table renderer that can display any data with configurable columns +pub struct TableRenderer { + pub columns: Vec, + pub sort_config: SortConfig, + pub selection: SelectionManager, + pub search_query: String, + pub search_fields: Vec, +} + +impl Default for TableRenderer { + fn default() -> Self { + Self::new() + } +} + +impl TableRenderer { + pub fn new() -> Self { + Self { + columns: Vec::new(), + sort_config: SortConfig::default(), + selection: SelectionManager::new(), + search_query: String::new(), + search_fields: vec![ + // Default search fields for assets/inventory + "name".to_string(), + "asset_tag".to_string(), + "manufacturer".to_string(), + "model".to_string(), + "serial_number".to_string(), + "first_name".to_string(), + "last_name".to_string(), + "email".to_string(), + ], + } + } + + pub fn with_columns(mut self, columns: Vec) -> Self { + self.columns = columns; + self + } + + pub fn with_default_sort(mut self, field: &str, ascending: bool) -> Self { + self.sort_config = SortConfig { + field: Some(field.to_string()), + ascending, + }; + self + } + + #[allow(dead_code)] + pub fn add_column(mut self, column: ColumnConfig) -> Self { + self.columns.push(column); + self + } + + #[allow(dead_code)] + pub fn set_search_query(&mut self, query: String) { + self.search_query = query; + } + + pub fn with_search_fields(mut self, fields: Vec) -> Self { + self.search_fields = fields; + self + } + + /// Filter and sort JSON values based on current configuration + pub fn prepare_json_data<'a>(&self, data: &'a [Value]) -> Vec<(usize, &'a Value)> { + let mut filtered: Vec<(usize, &Value)> = data + .iter() + .enumerate() + .filter(|(_, item)| { + if self.search_query.is_empty() { + true + } else { + // Simple search across configured fields + let search_lower = self.search_query.to_lowercase(); + self.search_fields.iter().any(|field| { + item.get(field) + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase().contains(&search_lower)) + .unwrap_or(false) + }) + } + }) + .collect(); + + // Sort if configured + if let Some(ref field) = self.sort_config.field { + let field = field.clone(); + let ascending = self.sort_config.ascending; + filtered.sort_by(|a, b| { + let val_a = a.1.get(&field); + let val_b = b.1.get(&field); + + let cmp = match (val_a, val_b) { + (Some(a), Some(b)) => { + // Try to compare as strings first + match (a.as_str(), b.as_str()) { + (Some(s_a), Some(s_b)) => s_a.cmp(s_b), + _ => { + // Try to compare as numbers + match (a.as_i64(), b.as_i64()) { + (Some(n_a), Some(n_b)) => n_a.cmp(&n_b), + _ => std::cmp::Ordering::Equal, + } + } + } + } + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }; + + if ascending { + cmp + } else { + cmp.reverse() + } + }); + } + + filtered + } + + /// Render the table with JSON data + pub fn render_json_table<'a>( + &mut self, + ui: &mut egui::Ui, + data: &'a [(usize, &'a Value)], + mut event_handler: Option<&mut dyn TableEventHandler>, + ) -> egui::Vec2 { + use egui_extras::{Column, TableBuilder}; + + let visible_columns: Vec<_> = self.columns.iter().filter(|c| c.visible).collect(); + + let mut table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .max_scroll_height(f32::MAX); + + // Add selection checkbox column first, then remainder columns + table = table.column(Column::initial(28.0)); + for _column in &visible_columns { + table = table.column(Column::remainder().resizable(true).clip(true)); + } + + table + .header(24.0, |mut header| { + // Select-all checkbox header + header.col(|ui| { + let all_selected = data.len() > 0 + && (0..data.len()).all(|i| self.selection.selected_rows.contains(&i)); + let mut chk = all_selected; + if ui + .checkbox(&mut chk, "") + .on_hover_text("Select All") + .clicked() + { + if chk { + self.selection.select_all(data.len()); + } else { + self.selection.clear_selection(); + } + if let Some(ref mut handler) = event_handler { + handler.on_selection_changed(&self.selection.get_selected_indices()); + } + } + }); + + // Column headers with sorting + for column in &visible_columns { + header.col(|ui| { + let is_sorted = self.sort_config.field.as_ref() == Some(&column.field); + let label = if is_sorted { + if self.sort_config.ascending { + format!("{} {}", column.name, icons::ARROW_UP) + } else { + format!("{} {}", column.name, icons::ARROW_DOWN) + } + } else { + column.name.clone() + }; + let button = egui::Button::new(label).frame(false); + if ui.add(button).clicked() { + if is_sorted { + self.sort_config.ascending = !self.sort_config.ascending; + } else { + self.sort_config.field = Some(column.field.clone()); + self.sort_config.ascending = true; + } + } + }); + } + }) + .body(|mut body| { + for (idx, (_orig_idx, item)) in data.iter().enumerate() { + let _item_clone = (*item).clone(); + let is_selected = self.selection.is_selected(idx); + + body.row(20.0, |mut row| { + // Apply selection highlight + if is_selected { + row.set_selected(true); + } + + // Checkbox column + row.col(|ui| { + let mut checked = self.selection.is_selected(idx); + let resp = ui.checkbox(&mut checked, ""); + if resp.changed() { + let mods = ui.input(|i| i.modifiers); + let modifier = if mods.shift { + SelectionModifier::Shift + } else if mods.command || mods.ctrl { + SelectionModifier::Ctrl + } else { + SelectionModifier::None + }; + + if checked { + self.selection.toggle_row(idx, modifier); + } else { + self.selection.selected_rows.remove(&idx); + } + + if let Some(ref mut handler) = event_handler { + handler.on_selection_changed( + &self.selection.get_selected_indices(), + ); + } + } + }); + + // Render data cells and collect their responses + let mut combined_cell_response: Option = None; + for column in &visible_columns { + row.col(|ui| { + let resp = JsonCellRenderer::render_cell(ui, item, &column.field); + combined_cell_response = + Some(match combined_cell_response.take() { + Some(prev) => prev.union(resp), + None => resp, + }); + }); + } + + // Handle row interactions + let mut row_response = row.response(); + if let Some(cell_resp) = combined_cell_response { + row_response = row_response.union(cell_resp); + } + + // Handle clicks + if row_response.clicked() { + // Double-click detection + let now = std::time::Instant::now(); + let is_double_click = if let (Some(last_time), Some(last_row)) = ( + self.selection.last_click_time, + self.selection.last_click_row, + ) { + last_row == idx && now.duration_since(last_time).as_millis() < 500 + } else { + false + }; + + if is_double_click { + if let Some(ref mut handler) = event_handler { + handler.on_double_click(item, idx); + } + self.selection.last_click_row = None; + self.selection.last_click_time = None; + } else { + // Single click selection + let mods = row_response.ctx.input(|i| i.modifiers); + let modifier = if mods.shift { + SelectionModifier::Shift + } else if mods.command || mods.ctrl { + SelectionModifier::Ctrl + } else { + SelectionModifier::None + }; + + self.selection.toggle_row(idx, modifier); + self.selection.last_click_row = Some(idx); + self.selection.last_click_time = Some(now); + + if let Some(ref mut handler) = event_handler { + handler.on_selection_changed( + &self.selection.get_selected_indices(), + ); + } + row_response.ctx.request_repaint(); + } + } + + // Handle right-click context menu + if let Some(ref mut handler) = event_handler { + row_response.context_menu(|ui| { + handler.on_context_menu(ui, item, idx); + }); + } + }); + } + }); + + ui.available_size() + } + + /// Show column selector panel + pub fn show_column_selector(&mut self, ui: &mut egui::Ui, _id_suffix: &str) { + ui.heading("Column Visibility"); + ui.separator(); + + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + for col in &mut self.columns { + ui.checkbox(&mut col.visible, &col.name); + } + }); + } +} + +/// JSON-specific cell renderer for asset-like data +pub struct JsonCellRenderer; + +impl JsonCellRenderer { + pub fn render_cell(ui: &mut egui::Ui, data: &Value, field: &str) -> egui::Response { + let json_value = data.get(field); + + // Handle null values + if json_value.is_none() || json_value.unwrap().is_null() { + return ui.add(egui::Label::new("-").sense(egui::Sense::click())); + } + + let json_value = json_value.unwrap(); + + match field { + // Integer fields + "id" + | "asset_numeric_id" + | "category_id" + | "zone_id" + | "supplier_id" + | "current_borrower_id" + | "previous_borrower_id" + | "created_by" + | "last_modified_by" => { + let text = json_value + .as_i64() + .map(|n| n.to_string()) + .unwrap_or_else(|| "-".to_string()); + ui.add(egui::Label::new(text).sense(egui::Sense::click())) + } + + // Quantity fields + "quantity_available" | "quantity_total" | "quantity_used" => { + let text = json_value + .as_i64() + .map(|n| n.to_string()) + .unwrap_or_else(|| "0".to_string()); + ui.add(egui::Label::new(text).sense(egui::Sense::click())) + } + + // Price field + "price" => { + let text = if let Some(num) = json_value.as_f64() { + format!("${:.2}", num) + } else if let Some(num) = json_value.as_i64() { + format!("${:.2}", num as f64) + } else { + "-".to_string() + }; + ui.add(egui::Label::new(text).sense(egui::Sense::click())) + } + + // Boolean lendable field (normalize bool/number/string) + "lendable" => { + let is_lendable = match json_value { + serde_json::Value::Bool(b) => *b, + serde_json::Value::Number(n) => n.as_i64() == Some(1) || n.as_u64() == Some(1), + serde_json::Value::String(s) => { + let s = s.to_lowercase(); + s == "true" || s == "1" || s == "yes" || s == "y" + } + _ => false, + }; + let (text, color) = if is_lendable { + ("Yes", egui::Color32::from_rgb(76, 175, 80)) + } else { + ("No", egui::Color32::GRAY) + }; + ui.add( + egui::Label::new(egui::RichText::new(text).color(color)) + .sense(egui::Sense::click()), + ) + } + + // Boolean banned field + "banned" => { + let is_banned = json_value.as_bool().unwrap_or(false); + let (text, color) = if is_banned { + ("YES!", egui::Color32::from_rgb(244, 67, 54)) + } else { + ("No", egui::Color32::from_rgb(76, 175, 80)) + }; + ui.add( + egui::Label::new(egui::RichText::new(text).color(color)) + .sense(egui::Sense::click()), + ) + } + + // Asset type enum + "asset_type" => { + let value = json_value.as_str().unwrap_or(""); + let (display, color) = match value { + "N" => ("Normal", egui::Color32::from_rgb(33, 150, 243)), + "B" => ("Basic", egui::Color32::from_rgb(76, 175, 80)), + "L" => ("License", egui::Color32::from_rgb(156, 39, 176)), + "C" => ("Consumable", egui::Color32::from_rgb(255, 152, 0)), + _ => ("Unknown", ui.visuals().text_color()), + }; + ui.add( + egui::Label::new(egui::RichText::new(display).color(color)) + .sense(egui::Sense::click()), + ) + } + + // Status enum (supports both asset and audit statuses) + "status" => { + let value = json_value.as_str().unwrap_or(""); + let (display, color) = match value { + // Audit status values + "in-progress" => ("in-progress", egui::Color32::from_rgb(66, 133, 244)), + "attention" => ("attention", egui::Color32::from_rgb(255, 152, 0)), + "timeout" => ("timeout", egui::Color32::from_rgb(244, 67, 54)), + "cancelled" => ("cancelled", egui::Color32::from_rgb(158, 158, 158)), + "all-good" => ("all-good", egui::Color32::from_rgb(76, 175, 80)), + + // Asset status values + "Good" => (value, egui::Color32::from_rgb(76, 175, 80)), + "Attention" => (value, egui::Color32::from_rgb(255, 193, 7)), + // Faulty should be strong red to indicate severe issues + "Faulty" => (value, egui::Color32::from_rgb(244, 67, 54)), + "Missing" => (value, egui::Color32::from_rgb(244, 67, 54)), + "Retired" => (value, egui::Color32::GRAY), + "In Repair" => (value, egui::Color32::from_rgb(156, 39, 176)), + "In Transit" => (value, egui::Color32::from_rgb(33, 150, 243)), + "Expired" => (value, egui::Color32::from_rgb(183, 28, 28)), + "Unmanaged" => (value, egui::Color32::DARK_GRAY), + _ => (value, ui.visuals().text_color()), + }; + ui.add( + egui::Label::new(egui::RichText::new(display).color(color)) + .sense(egui::Sense::click()), + ) + } + + // Audit log specific status field + "status_found" => { + let value = json_value.as_str().unwrap_or(""); + let color = match value { + "Good" => egui::Color32::from_rgb(76, 175, 80), + "Attention" => egui::Color32::from_rgb(255, 152, 0), + "Faulty" | "Missing" => egui::Color32::from_rgb(244, 67, 54), + "In Repair" | "In Transit" => egui::Color32::from_rgb(66, 133, 244), + _ => ui.visuals().text_color(), + }; + ui.add( + egui::Label::new(egui::RichText::new(value).color(color)) + .sense(egui::Sense::click()), + ) + } + + // Lending status enum + "lending_status" => { + let value = json_value.as_str().unwrap_or(""); + let color = match value { + "Available" => egui::Color32::from_rgb(76, 175, 80), + "Borrowed" => egui::Color32::from_rgb(255, 152, 0), + "Overdue" => egui::Color32::from_rgb(244, 67, 54), + "Deployed" => egui::Color32::from_rgb(33, 150, 243), + "Illegally Handed Out" => egui::Color32::from_rgb(183, 28, 28), + "Stolen" => egui::Color32::from_rgb(136, 14, 79), + _ => egui::Color32::GRAY, + }; + if !value.is_empty() { + ui.add( + egui::Label::new(egui::RichText::new(value).color(color)) + .sense(egui::Sense::click()), + ) + } else { + ui.add(egui::Label::new("-").sense(egui::Sense::click())) + } + } + + // Zone plus enum + "zone_plus" => { + let value = json_value.as_str().unwrap_or("-"); + let color = match value { + "Floating Local" => egui::Color32::from_rgb(33, 150, 243), + "Floating Global" => egui::Color32::from_rgb(156, 39, 176), + "Clarify" => egui::Color32::from_rgb(255, 152, 0), + _ => ui.visuals().text_color(), + }; + ui.add( + egui::Label::new(egui::RichText::new(value).color(color)) + .sense(egui::Sense::click()), + ) + } + + // No scan enum + "no_scan" => { + let value = json_value.as_str().unwrap_or("No"); + let color = match value { + "Yes" => egui::Color32::from_rgb(244, 67, 54), + "Ask" => egui::Color32::from_rgb(255, 152, 0), + "No" => egui::Color32::from_rgb(76, 175, 80), + _ => ui.visuals().text_color(), + }; + ui.add( + egui::Label::new(egui::RichText::new(value).color(color)) + .sense(egui::Sense::click()), + ) + } + + // Date fields + "purchase_date" | "warranty_until" | "expiry_date" | "due_date" | "last_audit" + | "checkout_date" | "return_date" => { + if let Some(date_str) = json_value.as_str() { + let text = + if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { + date.format("%b %d, %Y").to_string() + } else { + date_str.to_string() + }; + ui.add(egui::Label::new(text).sense(egui::Sense::click())) + } else { + ui.add(egui::Label::new("-").sense(egui::Sense::click())) + } + } + + // DateTime fields + "created_date" | "last_modified_date" => { + if let Some(datetime_str) = json_value.as_str() { + let text = if let Ok(dt) = + chrono::NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S") + { + dt.format("%b %d, %Y %H:%M").to_string() + } else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(datetime_str) { + dt.format("%b %d, %Y %H:%M").to_string() + } else { + datetime_str.to_string() + }; + ui.add(egui::Label::new(text).sense(egui::Sense::click())) + } else { + ui.add(egui::Label::new("-").sense(egui::Sense::click())) + } + } + + // Default text/string fields + _ => { + let (display, hover) = if let Some(text) = json_value.as_str() { + if text.is_empty() { + ("-".to_string(), None) + } else if text.len() > 50 { + (format!("{}...", &text[..47]), Some(text.to_string())) + } else { + (text.to_string(), None) + } + } else if let Some(num) = json_value.as_i64() { + (num.to_string(), None) + } else if let Some(num) = json_value.as_f64() { + (format!("{:.2}", num), None) + } else { + ("-".to_string(), None) + }; + + let resp = ui.add(egui::Label::new(display).sense(egui::Sense::click())); + if let Some(h) = hover { + resp.on_hover_text(h) + } else { + resp + } + } + } + } +} diff --git a/src/core/tables.rs b/src/core/tables.rs new file mode 100644 index 0000000..248dda0 --- /dev/null +++ b/src/core/tables.rs @@ -0,0 +1,1570 @@ +use crate::api::ApiClient; +use crate::models::{Join, OrderBy}; +use anyhow::Result; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine as _; +use serde_json::{json, Value}; + +fn decode_base64_json(value: Option<&serde_json::Value>) -> Option { + let s = value.and_then(|v| v.as_str())?; + if s.is_empty() || s == "NULL" { + return None; + } + match BASE64_STANDARD.decode(s) { + Ok(bytes) => serde_json::from_slice::(&bytes).ok(), + Err(_) => None, + } +} + +fn compact_json(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => String::new(), + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()), + } +} + +fn format_asset_change_short(action: &str, changed_fields: Option<&serde_json::Value>) -> String { + match action { + "INSERT" => "Created".to_string(), + "DELETE" => "Deleted".to_string(), + "UPDATE" => { + if let Some(serde_json::Value::Array(fields)) = changed_fields { + if fields.len() == 1 { + let field = fields[0].as_str().unwrap_or(""); + match field { + "status" => "Status changed".to_string(), + "zone_id" => "Moved".to_string(), + "name" => "Renamed".to_string(), + _ => field + .replace('_', " ") + .chars() + .next() + .map(|c| c.to_uppercase().collect::() + &field[1..]) + .unwrap_or_else(|| "Updated".to_string()), + } + } else if fields.len() <= 3 { + format!("{} fields", fields.len()) + } else { + format!("{} changes", fields.len()) + } + } else { + "Updated".to_string() + } + } + _ => action.to_string(), + } +} + +/// Get recent asset changes from the change log +pub fn get_asset_changes(api_client: &ApiClient, limit: u32) -> Result> { + log::debug!( + "Loading {} recent asset changes (with JOINs and formatting)...", + limit + ); + + // Attempt a JOIN query for richer context (asset tag, user name) + let joins = vec![ + Join { + table: "assets".into(), + on: "asset_change_log.record_id = assets.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "users".into(), + on: "asset_change_log.changed_by_id = users.id".into(), + join_type: "LEFT".into(), + }, + ]; + let columns = vec![ + "asset_change_log.id".into(), + "asset_change_log.table_name".into(), + "asset_change_log.action".into(), + "asset_change_log.record_id".into(), + "asset_change_log.changed_fields".into(), + "asset_change_log.old_values".into(), + "asset_change_log.new_values".into(), + "asset_change_log.changed_at".into(), + "asset_change_log.changed_by_username".into(), + "assets.asset_tag".into(), + "users.name as user_full_name".into(), + ]; + + let resp = api_client.select_with_joins( + "asset_change_log", + Some(columns), + None, // where_clause + None, // filter + Some(vec![OrderBy { + column: "asset_change_log.changed_at".into(), + direction: "DESC".into(), + }]), // order_by + Some(limit), // limit + Some(joins), // joins + )?; + + let mut rows = if resp.success { + resp.data.unwrap_or_default() + } else { + Vec::new() + }; + + // Fallback: simple query if JOIN returns nothing + if rows.is_empty() { + log::debug!("JOIN query returned 0 rows, falling back to simple query"); + let fallback = api_client.select( + "asset_change_log", + Some(vec!["*".into()]), + None, + Some(vec![OrderBy { + column: "changed_at".into(), + direction: "DESC".into(), + }]), + Some(5), + )?; + rows = if fallback.success { + fallback.data.unwrap_or_default() + } else { + Vec::new() + }; + } + + // Transform rows into display-friendly objects + let mut out = Vec::new(); + for (i, row) in rows.into_iter().enumerate() { + if i == 0 { + log::debug!( + "First asset_change_log row keys: {:?}", + row.as_object() + .map(|o| o.keys().cloned().collect::>()) + ); + } + + let action = row.get("action").and_then(|v| v.as_str()).unwrap_or(""); + let decoded_fields = decode_base64_json(row.get("changed_fields")); + let summary = format_asset_change_short(action, decoded_fields.as_ref()); + + let asset_tag = row + .get("asset_tag") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + format!( + "ID:{}", + row.get("record_id").and_then(|v| v.as_i64()).unwrap_or(0) + ) + }); + + let display = serde_json::json!({ + "asset_tag": asset_tag, + "action": action, + "changes": summary, + "date": row.get("changed_at").and_then(|v| v.as_str()).unwrap_or(""), + "user": row.get("user_full_name").and_then(|v| v.as_str()).or_else(|| row.get("changed_by_username").and_then(|v| v.as_str())).unwrap_or("System"), + }); + out.push(display); + } + + Ok(out) +} + +/// Get recent issue tracker changes from the change log +pub fn get_issue_changes(api_client: &ApiClient, limit: u32) -> Result> { + log::debug!( + "Loading {} recent issue changes (with JOINs and formatting)...", + limit + ); + + let joins = vec![ + Join { + table: "issue_tracker".into(), + on: "issue_tracker_change_log.issue_id = issue_tracker.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "users".into(), + on: "issue_tracker_change_log.changed_by = users.id".into(), + join_type: "LEFT".into(), + }, + ]; + let columns = vec![ + "issue_tracker_change_log.id".into(), + "issue_tracker_change_log.issue_id".into(), + "issue_tracker_change_log.change_type".into(), + "issue_tracker_change_log.changed_fields".into(), + "issue_tracker_change_log.old_values".into(), + "issue_tracker_change_log.new_values".into(), + "issue_tracker_change_log.change_date".into(), + "issue_tracker.title".into(), + "issue_tracker.severity".into(), + "users.name as changed_by_name".into(), + ]; + + let resp = api_client.select_with_joins( + "issue_tracker_change_log", + Some(columns), + None, // where_clause + None, // filter + Some(vec![OrderBy { + column: "issue_tracker_change_log.change_date".into(), + direction: "DESC".into(), + }]), // order_by + Some(limit), // limit + Some(joins), // joins + )?; + + let rows = if resp.success { + resp.data.unwrap_or_default() + } else { + Vec::new() + }; + + let mut out = Vec::new(); + for row in rows { + // Try to parse changed_fields which may be JSON string array + let changed_fields = match row.get("changed_fields") { + Some(serde_json::Value::String(s)) => serde_json::from_str::(s).ok(), + Some(v @ serde_json::Value::Array(_)) => Some(v.clone()), + _ => None, + }; + + // Create a short summary similar to Python + let change_type = row + .get("change_type") + .and_then(|v| v.as_str()) + .unwrap_or("UPDATE"); + let summary = if change_type == "INSERT" { + "Created".to_string() + } else if change_type == "DELETE" { + "Deleted".to_string() + } else { + if let Some(serde_json::Value::Array(fields)) = changed_fields { + if fields.contains(&serde_json::Value::String("status".into())) { + if let Some(new_values) = row + .get("new_values") + .and_then(|v| v.as_str()) + .and_then(|s| serde_json::from_str::(s).ok()) + { + if let Some(status) = new_values.get("status").and_then(|v| v.as_str()) { + format!("Status → {}", status) + } else { + "Updated".to_string() + } + } else { + "Updated".to_string() + } + } else if fields.contains(&serde_json::Value::String("assigned_to".into())) { + "Reassigned".to_string() + } else if fields.contains(&serde_json::Value::String("severity".into())) { + "Priority changed".to_string() + } else if fields.contains(&serde_json::Value::String("title".into())) { + "Title updated".to_string() + } else if fields.contains(&serde_json::Value::String("description".into())) { + "Description updated".to_string() + } else if fields.len() == 1 { + let field = fields[0].as_str().unwrap_or("").replace('_', " "); + format!("{} updated", capitalize(&field)) + } else if fields.len() <= 3 { + format!("{} fields", fields.len()) + } else { + format!("{} changes", fields.len()) + } + } else { + "Updated".to_string() + } + }; + + let issue_title = row + .get("title") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + format!( + "Issue #{}", + row.get("issue_id").and_then(|v| v.as_i64()).unwrap_or(0) + ) + }); + + let display = serde_json::json!({ + "issue": issue_title, + "changes": summary, + "date": row.get("change_date").and_then(|v| v.as_str()).unwrap_or(""), + "user": row.get("changed_by_name").and_then(|v| v.as_str()).unwrap_or("System"), + }); + out.push(display); + } + + Ok(out) +} + +fn capitalize(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + Some(f) => f.to_uppercase().collect::() + c.as_str(), + None => String::new(), + } +} + +/// Get issues with useful labels +pub fn get_issues(api_client: &ApiClient, limit: Option) -> Result> { + let columns = Some(vec![ + "issue_tracker.id".into(), + "issue_tracker.issue_type".into(), + "issue_tracker.asset_id".into(), + "issue_tracker.borrower_id".into(), + "issue_tracker.title".into(), + "issue_tracker.description".into(), + "issue_tracker.severity".into(), + "issue_tracker.priority".into(), + "issue_tracker.status".into(), + "issue_tracker.solution".into(), + "issue_tracker.solution_plus".into(), + "issue_tracker.auto_detected".into(), + "issue_tracker.detection_trigger".into(), + "issue_tracker.replacement_asset_id".into(), + "issue_tracker.cost".into(), + "issue_tracker.notes".into(), + // Dashboard schema uses created_date / updated_date / resolved_date + "issue_tracker.created_date AS created_at".into(), + "issue_tracker.updated_date AS updated_at".into(), + "issue_tracker.resolved_date".into(), + // joins/labels + "assets.asset_tag".into(), + "assets.name as asset_name".into(), + "borrowers.name as borrower_name".into(), + // Assignee name + "users.name as assigned_to_name".into(), + ]); + let joins = Some(vec![ + Join { + table: "assets".into(), + on: "issue_tracker.asset_id = assets.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "borrowers".into(), + on: "issue_tracker.borrower_id = borrowers.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "users".into(), + on: "issue_tracker.assigned_to = users.id".into(), + join_type: "LEFT".into(), + }, + ]); + // Sort by updated_date (aliased as updated_at) + let order = Some(vec![OrderBy { + column: "issue_tracker.updated_date".into(), + direction: "DESC".into(), + }]); + let resp = + api_client.select_with_joins("issue_tracker", columns, None, None, order, limit, joins)?; + if resp.success { + Ok(resp.data.unwrap_or_default()) + } else { + anyhow::bail!("Failed to load issues: {:?}", resp.error) + } +} + +/// Get all assets from inventory with proper JOINs for categories, zones, and suppliers +#[allow(dead_code)] +pub fn get_all_assets( + api_client: &ApiClient, + limit: Option, + where_clause: Option, + filter: Option, +) -> Result> { + let columns = Some(vec![ + "assets.id".to_string(), + "assets.asset_tag".to_string(), + "assets.asset_numeric_id".to_string(), + "assets.asset_type".to_string(), + "assets.name".to_string(), + "assets.category_id".to_string(), + "assets.manufacturer".to_string(), + "assets.model".to_string(), + "assets.serial_number".to_string(), + "assets.status".to_string(), + "assets.zone_id".to_string(), + "assets.zone_plus".to_string(), + "assets.zone_note".to_string(), + "assets.supplier_id".to_string(), + "assets.price".to_string(), + "assets.purchase_date".to_string(), + "assets.warranty_until".to_string(), + "assets.expiry_date".to_string(), + "assets.quantity_available".to_string(), + "assets.quantity_total".to_string(), + "assets.quantity_used".to_string(), + "assets.lendable".to_string(), + "assets.minimum_role_for_lending".to_string(), + "assets.lending_status".to_string(), + "assets.current_borrower_id".to_string(), + // Due date stored on asset (flows keep it in sync) + "assets.due_date".to_string(), + "assets.previous_borrower_id".to_string(), + "assets.last_audit".to_string(), + "assets.last_audit_status".to_string(), + "assets.no_scan".to_string(), + "assets.notes".to_string(), + "assets.created_date".to_string(), + "assets.created_by".to_string(), + "assets.last_modified_date".to_string(), + "assets.last_modified_by".to_string(), + "assets.label_template_id".to_string(), + // JOINed fields + "categories.category_name".to_string(), + "label_templates.template_name AS label_template_name".to_string(), + "zones.zone_name".to_string(), + "zones.zone_code".to_string(), + "suppliers.name AS supplier_name".to_string(), + // Borrower joined from asset field + "current_borrower.name AS current_borrower_name".to_string(), + "previous_borrower.name AS previous_borrower_name".to_string(), + "created_by_user.username AS created_by_username".to_string(), + "modified_by_user.username AS last_modified_by_username".to_string(), + ]); + + let joins = Some(vec![ + Join { + table: "categories".to_string(), + on: "assets.category_id = categories.id".to_string(), + join_type: "LEFT".to_string(), + }, + Join { + table: "zones".to_string(), + on: "assets.zone_id = zones.id".to_string(), + join_type: "LEFT".to_string(), + }, + Join { + table: "suppliers".to_string(), + on: "assets.supplier_id = suppliers.id".to_string(), + join_type: "LEFT".to_string(), + }, + Join { + table: "borrowers AS current_borrower".to_string(), + on: "assets.current_borrower_id = current_borrower.id".to_string(), + join_type: "LEFT".to_string(), + }, + Join { + table: "borrowers AS previous_borrower".to_string(), + on: "assets.previous_borrower_id = previous_borrower.id".to_string(), + join_type: "LEFT".to_string(), + }, + Join { + table: "users AS created_by_user".to_string(), + on: "assets.created_by = created_by_user.id".to_string(), + join_type: "LEFT".to_string(), + }, + Join { + table: "users AS modified_by_user".to_string(), + on: "assets.last_modified_by = modified_by_user.id".to_string(), + join_type: "LEFT".to_string(), + }, + Join { + table: "label_templates".to_string(), + on: "assets.label_template_id = label_templates.id".to_string(), + join_type: "LEFT".to_string(), + }, + ]); + + let response = api_client.select_with_joins( + "assets", + columns, + where_clause, + filter, + None, + limit, + joins, + )?; + + if response.success { + if let Some(data) = response.data { + log::info!("Loaded {} assets successfully (with JOINs)", data.len()); + Ok(data) + } else { + Ok(vec![]) + } + } else { + log::error!("Failed to load assets: {:?}", response.error); + anyhow::bail!("Failed to load assets: {:?}", response.error) + } +} + +/// Get all zones (flat list) with parent relationships +pub fn get_all_zones_with_filter( + api_client: &ApiClient, + filter: Option, +) -> Result> { + use crate::models::QueryRequest; + + let columns = Some(vec![ + "zones.id".to_string(), + "zones.zone_code".to_string(), + "zones.mini_code".to_string(), + "zones.zone_name as name".to_string(), + "zones.zone_type".to_string(), + "zones.parent_id".to_string(), + "zones.zone_notes".to_string(), + "zones.include_in_parent".to_string(), + "zones.audit_timeout_minutes".to_string(), + ]); + + let request = QueryRequest { + action: "select".to_string(), + table: "zones".to_string(), + columns, + data: None, + r#where: None, + filter, + order_by: Some(vec![OrderBy { + column: "zones.zone_code".into(), + direction: "ASC".into(), + }]), + limit: None, + offset: None, + joins: None, + }; + + let response = api_client.query(&request)?; + + if response.success { + if let Some(data) = response.data { + if let Some(array) = data.as_array() { + Ok(array.clone()) + } else { + Ok(vec![]) + } + } else { + Ok(vec![]) + } + } else { + log::error!("Failed to load zones: {:?}", response.error); + anyhow::bail!("Failed to load zones: {:?}", response.error) + } +} + +/// Get assets in a specific zone (minimal fields) +pub fn get_assets_in_zone( + api_client: &ApiClient, + zone_id: i32, + limit: Option, +) -> Result> { + let columns = Some(vec![ + "assets.id".to_string(), + "assets.asset_numeric_id".to_string(), + "assets.asset_tag".to_string(), + "assets.name".to_string(), + "assets.status".to_string(), + "assets.lending_status".to_string(), + "assets.no_scan".to_string(), + "assets.audit_task_id".to_string(), + "assets.zone_id".to_string(), + ]); + let where_clause = Some(serde_json::json!({ "assets.zone_id": zone_id })); + let order = Some(vec![OrderBy { + column: "assets.name".into(), + direction: "ASC".into(), + }]); + let resp = api_client.select("assets", columns, where_clause, order, limit)?; + if resp.success { + Ok(resp.data.unwrap_or_default()) + } else { + anyhow::bail!("Failed to load assets for zone {}", zone_id) + } +} + +/// Find a zone by its zone_code (case-insensitive match on the exact code) +pub fn find_zone_by_code(api_client: &ApiClient, zone_code: &str) -> Result> { + if zone_code.trim().is_empty() { + return Ok(None); + } + + let columns = Some(vec![ + "id".into(), + "zone_code".into(), + "zone_name".into(), + "zone_type".into(), + "audit_timeout_minutes".into(), + "parent_id".into(), + ]); + let where_clause = Some(json!({ "zone_code": zone_code })); + + let response = api_client.select("zones", columns, where_clause, None, Some(1))?; + if response.success { + if let Some(data) = response.data { + if let Some(mut task) = data.into_iter().next() { + if let Some(map) = task.as_object_mut() { + if let Some(decoded) = decode_base64_json(map.get("json_sequence")) { + map.insert("json_sequence".into(), decoded); + } else if let Some(raw) = map.get("json_sequence").cloned() { + if let Value::String(s) = raw { + if let Ok(parsed) = serde_json::from_str::(&s) { + map.insert("json_sequence".into(), parsed); + } + } + } + } + Ok(Some(task)) + } else { + Ok(None) + } + } else { + Ok(None) + } + } else { + anyhow::bail!("Failed to lookup zone: {:?}", response.error) + } +} + +/// Find an asset by tag or numeric identifier (exact match) +pub fn find_asset_by_tag_or_numeric( + api_client: &ApiClient, + identifier: &str, +) -> Result> { + let trimmed = identifier.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + let mut or_filters = vec![json!({ + "column": "assets.asset_tag", + "op": "=", + "value": trimmed + })]; + + if let Ok(numeric) = trimmed.parse::() { + or_filters.push(json!({ + "column": "assets.asset_numeric_id", + "op": "=", + "value": numeric + })); + } else { + // Allow matching numeric id stored as string just in case + or_filters.push(json!({ + "column": "assets.asset_numeric_id", + "op": "=", + "value": trimmed + })); + } + + let filter = json!({ "or": or_filters }); + let columns = Some(vec![ + "assets.id".to_string(), + "assets.asset_numeric_id".to_string(), + "assets.asset_tag".to_string(), + "assets.name".to_string(), + "assets.zone_id".to_string(), + "assets.status".to_string(), + "assets.no_scan".to_string(), + "assets.audit_task_id".to_string(), + ]); + + let response = + api_client.select_with_joins("assets", columns, None, Some(filter), None, Some(1), None)?; + + if response.success { + if let Some(data) = response.data { + Ok(data.into_iter().next()) + } else { + Ok(None) + } + } else { + anyhow::bail!("Failed to lookup asset: {:?}", response.error) + } +} + +/// Fetch a single audit task definition by ID +pub fn get_audit_task_definition(api_client: &ApiClient, task_id: i64) -> Result> { + let columns = Some(vec![ + "id".into(), + "task_name".into(), + "json_sequence".into(), + "created_at".into(), + "updated_at".into(), + ]); + + let where_clause = Some(json!({ "id": task_id })); + let response = api_client.select("audit_tasks", columns, where_clause, None, Some(1))?; + + if response.success { + if let Some(data) = response.data { + Ok(data.into_iter().next()) + } else { + Ok(None) + } + } else { + anyhow::bail!( + "Failed to load audit task {}: {:?}", + task_id, + response.error + ) + } +} + +/// Fetch audit task definitions with preview metadata for the audits UI +pub fn get_audit_tasks(api_client: &ApiClient, limit: Option) -> Result> { + let columns = Some(vec![ + "audit_tasks.id".into(), + "audit_tasks.task_name".into(), + "audit_tasks.json_sequence".into(), + "audit_tasks.created_at".into(), + "audit_tasks.updated_at".into(), + ]); + + let order_by = Some(vec![OrderBy { + column: "audit_tasks.updated_at".into(), + direction: "DESC".into(), + }]); + + let response = api_client.select("audit_tasks", columns, None, order_by, limit)?; + + if response.success { + let mut rows = response.data.unwrap_or_default(); + for row in &mut rows { + if let Some(map) = row.as_object_mut() { + let sequence_value = + if let Some(decoded) = decode_base64_json(map.get("json_sequence")) { + map.insert("json_sequence".into(), decoded.clone()); + decoded + } else { + let raw = map.get("json_sequence").cloned().unwrap_or(Value::Null); + if let Value::String(s) = &raw { + if let Ok(parsed) = serde_json::from_str::(s) { + map.insert("json_sequence".into(), parsed.clone()); + parsed + } else { + raw + } + } else { + raw + } + }; + + let preview = if sequence_value.is_null() { + String::new() + } else { + compact_json(&sequence_value) + }; + + let step_count = match &sequence_value { + Value::Array(arr) => arr.len() as i64, + Value::Object(obj) => obj.len() as i64, + _ => 0, + }; + + map.insert("sequence_preview".into(), Value::String(preview)); + map.insert("step_count".into(), Value::Number(step_count.into())); + } + } + + Ok(rows) + } else { + anyhow::bail!("Failed to load audit tasks: {:?}", response.error) + } +} + +/// Get active loans (borrowed/overdue/stolen), joined with borrower info +pub fn get_active_loans( + api_client: &ApiClient, + limit: Option, +) -> Result> { + // Query lending_history table with JOINs to get complete loan information + let columns = Some(vec![ + "lending_history.id".to_string(), + "lending_history.asset_id".to_string(), + "lending_history.borrower_id".to_string(), + "lending_history.checkout_date".to_string(), + "lending_history.due_date".to_string(), + "lending_history.return_date".to_string(), + "lending_history.notes".to_string(), + "assets.asset_tag".to_string(), + "assets.name".to_string(), + "assets.lending_status".to_string(), + "borrowers.name as borrower_name".to_string(), + "borrowers.class_name".to_string(), + ]); + let joins = Some(vec![ + Join { + table: "assets".into(), + on: "lending_history.asset_id = assets.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "borrowers".into(), + on: "lending_history.borrower_id = borrowers.id".into(), + join_type: "LEFT".into(), + }, + ]); + // Filter to active loans (no return date) + let filter = Some(serde_json::json!({ + "column": "lending_history.return_date", + "op": "is_null", + "value": null + })); + let order_by = Some(vec![OrderBy { + column: "lending_history.due_date".into(), + direction: "ASC".into(), + }]); + let resp = api_client.select_with_joins( + "lending_history", + columns, + None, + filter, + order_by, + limit, + joins, + )?; + if resp.success { + Ok(resp.data.unwrap_or_default()) + } else { + anyhow::bail!("Failed to load active loans: {:?}", resp.error) + } +} + +/// Get ALL loans (both active and returned), joined with borrower info +pub fn get_all_loans(api_client: &ApiClient, limit: Option) -> Result> { + // Query lending_history table with JOINs to get complete loan information + let columns = Some(vec![ + "lending_history.id".to_string(), + "lending_history.asset_id".to_string(), + "lending_history.borrower_id".to_string(), + "lending_history.checkout_date".to_string(), + "lending_history.due_date".to_string(), + "lending_history.return_date".to_string(), + "lending_history.notes".to_string(), + "assets.asset_tag".to_string(), + "assets.name".to_string(), + "assets.lending_status".to_string(), + "borrowers.name as borrower_name".to_string(), + "borrowers.class_name".to_string(), + ]); + let joins = Some(vec![ + Join { + table: "assets".into(), + on: "lending_history.asset_id = assets.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "borrowers".into(), + on: "lending_history.borrower_id = borrowers.id".into(), + join_type: "LEFT".into(), + }, + ]); + // No filter - get all loans + let order_by = Some(vec![ + OrderBy { + column: "lending_history.return_date".into(), + direction: "DESC".into(), + }, + OrderBy { + column: "lending_history.checkout_date".into(), + direction: "DESC".into(), + }, + ]); + let resp = api_client.select_with_joins( + "lending_history", + columns, + None, + None, + order_by, + limit, + joins, + )?; + if resp.success { + Ok(resp.data.unwrap_or_default()) + } else { + anyhow::bail!("Failed to load all loans: {:?}", resp.error) + } +} + +/// Get the most recent returned loan per asset for a given set of asset IDs +pub fn get_recent_returns_for_assets( + api_client: &ApiClient, + asset_ids: &[i64], + limit_per_asset: Option, + overall_limit: Option, +) -> Result> { + use crate::models::{Join, OrderBy, QueryRequest}; + + if asset_ids.is_empty() { + return Ok(vec![]); + } + + // Build a filter: return_date IS NOT NULL AND asset_id IN (...) + let filter = serde_json::json!({ + "and": [ + { "column": "lending_history.return_date", "op": "is_not_null", "value": null }, + { "column": "lending_history.asset_id", "op": "in", "value": asset_ids } + ] + }); + + let columns = Some(vec![ + "lending_history.id".to_string(), + "lending_history.asset_id".to_string(), + "lending_history.borrower_id".to_string(), + "lending_history.checkout_date".to_string(), + "lending_history.due_date".to_string(), + "lending_history.return_date".to_string(), + "borrowers.name as borrower_name".to_string(), + ]); + + let joins = Some(vec![Join { + table: "borrowers".to_string(), + on: "lending_history.borrower_id = borrowers.id".to_string(), + join_type: "LEFT".to_string(), + }]); + + // We sort by return_date DESC to get the most recent first + let order_by = Some(vec![OrderBy { + column: "lending_history.return_date".to_string(), + direction: "DESC".to_string(), + }]); + + let request = QueryRequest { + action: "select".to_string(), + table: "lending_history".to_string(), + columns, + data: None, + r#where: None, + filter: Some(filter), + order_by, + limit: overall_limit, + offset: None, + joins, + }; + + let resp = api_client.query(&request)?; + if resp.success { + let mut rows = if let Some(data) = resp.data { + if let Some(arr) = data.as_array() { + arr.clone() + } else { + vec![] + } + } else { + vec![] + }; + + // If a per-asset limit is desired, reduce here client-side + if let Some(max_per) = limit_per_asset { + use std::collections::HashMap; + let mut counts: HashMap = HashMap::new(); + rows.retain(|row| { + let aid = row.get("asset_id").and_then(|v| v.as_i64()).unwrap_or(-1); + let c = counts.entry(aid).or_insert(0); + if *c < max_per { + *c += 1; + true + } else { + false + } + }); + } + + Ok(rows) + } else { + anyhow::bail!("Failed to load recent returns: {:?}", resp.error) + } +} + +/// Summarize borrowers with active loan counts and overdue counts +pub fn get_borrowers_summary(api_client: &ApiClient) -> Result> { + // First, get all borrowers from the database + let all_borrowers_resp = api_client.select( + "borrowers", + Some(vec![ + "id".to_string(), + "name".to_string(), + "email".to_string(), + "phone_number".to_string(), + "class_name".to_string(), + "role".to_string(), + "notes".to_string(), + "banned".to_string(), + "unban_fine".to_string(), + ]), + None, + Some(vec![OrderBy { + column: "name".into(), + direction: "ASC".into(), + }]), + None, + )?; + + let all_borrowers = if all_borrowers_resp.success { + all_borrowers_resp.data.unwrap_or_default() + } else { + Vec::new() + }; + + // Fetch all active loans to calculate counts + let loans = get_active_loans(api_client, None)?; + use std::collections::HashMap; + // key: borrower_id, value: (total, overdue) + let mut loan_counts: HashMap = HashMap::new(); + for row in loans { + let borrower_id = row + .get("borrower_id") + .and_then(|v| v.as_i64()) + .unwrap_or(-1); + let status = row + .get("lending_status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let entry = loan_counts.entry(borrower_id).or_insert((0, 0)); + entry.0 += 1; // total + if status == "Overdue" || status == "Stolen" { + entry.1 += 1; + } + } + + // Combine borrower info with loan counts + let mut out = Vec::new(); + for borrower in all_borrowers { + let borrower_id = borrower.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); + let name = borrower + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let email = borrower + .get("email") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let phone = borrower + .get("phone_number") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let class_name = borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let role = borrower + .get("role") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let notes = borrower + .get("notes") + .cloned() + .unwrap_or(serde_json::Value::Null); + let banned = borrower + .get("banned") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let unban_fine = borrower + .get("unban_fine") + .cloned() + .unwrap_or(serde_json::Value::Null); + + let (active_loans, overdue_loans) = + loan_counts.get(&borrower_id).copied().unwrap_or((0, 0)); + + out.push(serde_json::json!({ + "borrower_id": borrower_id, + "borrower_name": name, + "email": email, + "phone_number": phone, + "class_name": class_name, + "role": role, + "notes": notes, + "active_loans": active_loans, + "overdue_loans": overdue_loans, + "banned": banned, + "unban_fine": unban_fine, + })); + } + + // Sort by overdue desc, then active loans desc, then name asc + out.sort_by(|a, b| { + let ao = a.get("overdue_loans").and_then(|v| v.as_i64()).unwrap_or(0); + let bo = b.get("overdue_loans").and_then(|v| v.as_i64()).unwrap_or(0); + ao.cmp(&bo).reverse().then_with(|| { + let at = a.get("active_loans").and_then(|v| v.as_i64()).unwrap_or(0); + let bt = b.get("active_loans").and_then(|v| v.as_i64()).unwrap_or(0); + at.cmp(&bt).reverse().then_with(|| { + let an = a + .get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let bn = b + .get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + an.cmp(bn) + }) + }) + }); + Ok(out) +} + +/// Get recent physical audits with zone and starter info +pub fn get_recent_audits( + api_client: &ApiClient, + limit: Option, +) -> Result> { + let columns = Some(vec![ + "physical_audits.id".into(), + "physical_audits.audit_type".into(), + "physical_audits.zone_id".into(), + "physical_audits.audit_name".into(), + "physical_audits.started_by".into(), + "physical_audits.started_at".into(), + "physical_audits.completed_at".into(), + "physical_audits.status".into(), + "physical_audits.timeout_minutes".into(), + "physical_audits.issues_found".into(), + "physical_audits.assets_expected".into(), + "physical_audits.assets_found".into(), + "physical_audits.notes".into(), + "physical_audits.cancelled_reason".into(), + // Joined labels + "zones.zone_code".into(), + "zones.zone_name".into(), + "users.name as started_by_name".into(), + ]); + let joins = Some(vec![ + Join { + table: "zones".into(), + on: "physical_audits.zone_id = zones.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "users".into(), + on: "physical_audits.started_by = users.id".into(), + join_type: "LEFT".into(), + }, + ]); + let order_by = Some(vec![OrderBy { + column: "physical_audits.started_at".into(), + direction: "DESC".into(), + }]); + let resp = api_client.select_with_joins( + "physical_audits", + columns, + None, + None, + order_by, + limit, + joins, + )?; + if resp.success { + let mut rows = resp.data.unwrap_or_default(); + for row in &mut rows { + if let Some(map) = row.as_object_mut() { + let zone_code = map.get("zone_code").and_then(|v| v.as_str()).unwrap_or(""); + let zone_name = map.get("zone_name").and_then(|v| v.as_str()).unwrap_or(""); + + let zone_display = if zone_name.is_empty() && zone_code.is_empty() { + "-".to_string() + } else if zone_name.is_empty() { + zone_code.to_string() + } else if zone_code.is_empty() { + zone_name.to_string() + } else { + format!("{} ({})", zone_name, zone_code) + }; + + let issues_value = + if let Some(decoded) = decode_base64_json(map.get("issues_found")) { + map.insert("issues_found".into(), decoded.clone()); + decoded + } else { + map.get("issues_found").cloned().unwrap_or(Value::Null) + }; + + let summary = if issues_value.is_null() { + String::new() + } else { + compact_json(&issues_value) + }; + + map.insert("zone_display".into(), Value::String(zone_display)); + map.insert("issues_summary".into(), Value::String(summary)); + } + } + + Ok(rows) + } else { + anyhow::bail!("Failed to load audits: {:?}", resp.error) + } +} + +/// Get recent physical audit logs with asset and zone info +pub fn get_recent_audit_logs( + api_client: &ApiClient, + limit: Option, +) -> Result> { + let columns = Some(vec![ + "physical_audit_logs.id".into(), + "physical_audit_logs.physical_audit_id".into(), + "physical_audit_logs.asset_id".into(), + "physical_audit_logs.audit_date".into(), + "physical_audit_logs.audited_by".into(), + "physical_audit_logs.status_found".into(), + "physical_audit_logs.audit_task_id".into(), + "physical_audit_logs.audit_task_responses".into(), + "physical_audit_logs.exception_type".into(), + "physical_audit_logs.exception_details".into(), + "physical_audit_logs.found_in_zone_id".into(), + "physical_audit_logs.auditor_action".into(), + "physical_audit_logs.notes".into(), + // Joins + "assets.asset_tag".into(), + "assets.name as asset_name".into(), + "zones.zone_code as found_zone_code".into(), + "zones.zone_name as found_zone_name".into(), + "users.name as audited_by_name".into(), + ]); + let joins = Some(vec![ + Join { + table: "assets".into(), + on: "physical_audit_logs.asset_id = assets.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "zones".into(), + on: "physical_audit_logs.found_in_zone_id = zones.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "users".into(), + on: "physical_audit_logs.audited_by = users.id".into(), + join_type: "LEFT".into(), + }, + ]); + let order_by = Some(vec![OrderBy { + column: "physical_audit_logs.audit_date".into(), + direction: "DESC".into(), + }]); + let resp = api_client.select_with_joins( + "physical_audit_logs", + columns, + None, + None, + order_by, + limit, + joins, + )?; + if resp.success { + let mut rows = resp.data.unwrap_or_default(); + for row in &mut rows { + if let Some(map) = row.as_object_mut() { + let asset_display = match ( + map.get("asset_tag").and_then(|v| v.as_str()), + map.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(), + }; + + let found_zone_display = match ( + map.get("found_zone_code").and_then(|v| v.as_str()), + map.get("found_zone_name").and_then(|v| v.as_str()), + ) { + (Some(code), Some(name)) if !code.is_empty() => { + format!("{} ({})", name, code) + } + (_, Some(name)) => name.to_string(), + _ => "-".to_string(), + }; + + let responses_value = + if let Some(decoded) = decode_base64_json(map.get("audit_task_responses")) { + map.insert("audit_task_responses".into(), decoded.clone()); + decoded + } else { + map.get("audit_task_responses") + .cloned() + .unwrap_or(Value::Null) + }; + + let responses_text = if responses_value.is_null() { + String::new() + } else { + compact_json(&responses_value) + }; + + map.insert("asset_display".into(), Value::String(asset_display)); + map.insert( + "found_zone_display".into(), + Value::String(found_zone_display), + ); + map.insert("task_responses_text".into(), Value::String(responses_text)); + } + } + + Ok(rows) + } else { + anyhow::bail!("Failed to load audit logs: {:?}", resp.error) + } +} + +/// Get templates with useful joined labels +pub fn get_templates(api_client: &ApiClient, limit: Option) -> Result> { + let columns = Some(vec![ + "templates.id".into(), + "templates.template_code".into(), + "templates.asset_tag_generation_string".into(), + "templates.description".into(), + "templates.active".into(), + "templates.asset_type".into(), + "templates.name".into(), + "templates.category_id".into(), + "categories.category_name".into(), + "categories.category_code".into(), + "templates.manufacturer".into(), + "templates.model".into(), + "templates.zone_id".into(), + "zones.zone_code".into(), + "zones.zone_name".into(), + "templates.zone_plus".into(), + "templates.zone_note".into(), + "templates.status".into(), + "templates.price".into(), + // New financial & date base fields + "templates.purchase_date".into(), + "templates.purchase_date_now".into(), + "templates.warranty_until".into(), + // Auto-calc warranty fields + "templates.warranty_auto".into(), + "templates.warranty_auto_amount".into(), + "templates.warranty_auto_unit".into(), + "templates.expiry_date".into(), + // Auto-calc expiry fields + "templates.expiry_auto".into(), + "templates.expiry_auto_amount".into(), + "templates.expiry_auto_unit".into(), + "templates.quantity_total".into(), + "templates.quantity_used".into(), + "templates.supplier_id".into(), + "suppliers.name as supplier_name".into(), + "templates.lendable".into(), + "templates.lending_status".into(), + "templates.minimum_role_for_lending".into(), + "templates.audit_task_id".into(), + "audit_tasks.task_name as audit_task_name".into(), + "templates.no_scan".into(), + "templates.notes".into(), + "templates.additional_fields".into(), + "templates.created_at".into(), + // Label template fields + "templates.label_template_id".into(), + "label_templates.template_name as label_template_name".into(), + ]); + let joins = Some(vec![ + Join { + table: "categories".into(), + on: "templates.category_id = categories.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "zones".into(), + on: "templates.zone_id = zones.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "suppliers".into(), + on: "templates.supplier_id = suppliers.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "label_templates".into(), + on: "templates.label_template_id = label_templates.id".into(), + join_type: "LEFT".into(), + }, + Join { + table: "audit_tasks".into(), + on: "templates.audit_task_id = audit_tasks.id".into(), + join_type: "LEFT".into(), + }, + ]); + let order_by = Some(vec![OrderBy { + column: "templates.created_at".into(), + direction: "DESC".into(), + }]); + let resp = + api_client.select_with_joins("templates", columns, None, None, order_by, limit, joins)?; + if resp.success { + let mut rows = resp.data.unwrap_or_default(); + + for row in rows.iter_mut() { + if let Some(map) = row.as_object_mut() { + // Decode additional_fields JSON (handles base64-wrapped legacy payloads) + if let Some(decoded) = decode_base64_json(map.get("additional_fields")) { + map.insert("additional_fields".into(), decoded); + } else if let Some(Value::String(raw_json)) = map.get("additional_fields") { + if let Ok(parsed) = serde_json::from_str::(raw_json) { + map.insert("additional_fields".into(), parsed); + } + } + } + } + + Ok(rows) + } else { + anyhow::bail!("Failed to load templates: {:?}", resp.error) + } +} + +/// Get suppliers +pub fn get_suppliers(api_client: &ApiClient, limit: Option) -> Result> { + let columns = Some(vec![ + "suppliers.id".into(), + "suppliers.name".into(), + "suppliers.contact".into(), + "suppliers.email".into(), + "suppliers.phone".into(), + "suppliers.website".into(), + "suppliers.notes".into(), + "suppliers.created_at".into(), + ]); + let order_by = Some(vec![OrderBy { + column: "suppliers.name".into(), + direction: "ASC".into(), + }]); + let resp = api_client.select("suppliers", columns, None, order_by, limit)?; + if resp.success { + Ok(resp.data.unwrap_or_default()) + } else { + anyhow::bail!("Failed to load suppliers: {:?}", resp.error) + } +} + +/// Get printers +pub fn get_printers(api_client: &ApiClient) -> Result> { + let columns = Some(vec![ + "printer_settings.id".into(), + "printer_settings.printer_name".into(), + "printer_settings.description".into(), + "printer_settings.log".into(), + "printer_settings.can_be_used_for_reports".into(), + "printer_settings.min_powerlevel_to_use".into(), + "printer_settings.printer_plugin".into(), + "printer_settings.printer_settings".into(), + "printer_settings.created_at".into(), + ]); + let order_by = Some(vec![OrderBy { + column: "printer_settings.printer_name".into(), + direction: "ASC".into(), + }]); + let resp = api_client.select("printer_settings", columns, None, order_by, None)?; + if resp.success { + let mut rows = resp.data.unwrap_or_default(); + // Backend returns printer_settings as JSON object; convert to pretty string for editor display + for row in rows.iter_mut() { + if let Some(printer_settings_val) = row.get("printer_settings") { + // If it's already a JSON object, pretty-print it + if printer_settings_val.is_object() || printer_settings_val.is_array() { + if let Ok(pretty) = serde_json::to_string_pretty(printer_settings_val) { + if let Some(map) = row.as_object_mut() { + map.insert( + "printer_settings".to_string(), + serde_json::Value::String(pretty), + ); + } + } + } + // Fallback: try base64 decode for backward compatibility + else if let Some(decoded) = decode_base64_json(Some(printer_settings_val)) { + if let Ok(pretty) = serde_json::to_string_pretty(&decoded) { + if let Some(map) = row.as_object_mut() { + map.insert( + "printer_settings".to_string(), + serde_json::Value::String(pretty), + ); + } + } + } + } + } + Ok(rows) + } else { + anyhow::bail!("Failed to load printers: {:?}", resp.error) + } +} + +/// Get label templates +pub fn get_label_templates(api_client: &ApiClient) -> Result> { + let columns = Some(vec![ + "label_templates.id".into(), + "label_templates.template_code".into(), + "label_templates.template_name".into(), + "label_templates.layout_json".into(), + "label_templates.created_at".into(), + ]); + let order_by = Some(vec![OrderBy { + column: "label_templates.template_name".into(), + direction: "ASC".into(), + }]); + let resp = api_client.select("label_templates", columns, None, order_by, None)?; + if resp.success { + let mut rows = resp.data.unwrap_or_default(); + // Backend returns layout_json as JSON object; convert to pretty string for editor display + for row in rows.iter_mut() { + if let Some(layout_val) = row.get("layout_json") { + // If it's already a JSON object, pretty-print it + if layout_val.is_object() || layout_val.is_array() { + if let Ok(pretty) = serde_json::to_string_pretty(layout_val) { + if let Some(map) = row.as_object_mut() { + map.insert( + "layout_json".to_string(), + serde_json::Value::String(pretty), + ); + } + } + } + // Fallback: try base64 decode for backward compatibility + else if let Some(decoded) = decode_base64_json(Some(layout_val)) { + if let Ok(pretty) = serde_json::to_string_pretty(&decoded) { + if let Some(map) = row.as_object_mut() { + map.insert( + "layout_json".to_string(), + serde_json::Value::String(pretty), + ); + } + } + } + } + } + Ok(rows) + } else { + anyhow::bail!("Failed to load label templates: {:?}", resp.error) + } +} + +/// Get categories +pub fn get_categories( + api_client: &ApiClient, + limit: Option, +) -> Result> { + let columns = Some(vec![ + "categories.id".into(), + "categories.category_name".into(), + "categories.category_code".into(), + "categories.category_description".into(), + "categories.parent_id".into(), + "parent.category_name AS parent_category_name".into(), + ]); + let joins = Some(vec![Join { + join_type: "LEFT".into(), + table: "categories AS parent".into(), + on: "categories.parent_id = parent.id".into(), + }]); + let order_by = Some(vec![OrderBy { + column: "categories.category_name".into(), + direction: "ASC".into(), + }]); + let resp = + api_client.select_with_joins("categories", columns, None, None, order_by, limit, joins)?; + if resp.success { + Ok(resp.data.unwrap_or_default()) + } else { + anyhow::bail!("Failed to load categories: {:?}", resp.error) + } +} diff --git a/src/core/utils/mod.rs b/src/core/utils/mod.rs new file mode 100644 index 0000000..02800e7 --- /dev/null +++ b/src/core/utils/mod.rs @@ -0,0 +1,4 @@ +/// Utility functions for search and filtering +pub mod search; + +// search module available but not currently used at top level diff --git a/src/core/utils/search.rs b/src/core/utils/search.rs new file mode 100644 index 0000000..81607cd --- /dev/null +++ b/src/core/utils/search.rs @@ -0,0 +1,135 @@ +use serde_json::Value; + +/// Search and filtering utilities for entity data +#[allow(dead_code)] +pub struct SearchFilter; + +#[allow(dead_code)] +impl SearchFilter { + /// Filter a collection of JSON values based on a search query across specified fields + pub fn filter_data(data: &[Value], search_query: &str, search_fields: &[&str]) -> Vec { + if search_query.is_empty() { + return data.to_vec(); + } + + let search_lower = search_query.to_lowercase(); + data.iter() + .filter(|item| { + search_fields.iter().any(|field| { + item.get(field) + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase().contains(&search_lower)) + .unwrap_or(false) + }) + }) + .cloned() + .collect() + } + + /// Filter any generic collection with a custom predicate + pub fn filter_generic(data: &[T], predicate: impl Fn(&T) -> bool) -> Vec + where + T: Clone, + { + data.iter() + .filter(|item| predicate(item)) + .cloned() + .collect() + } + + /// Search assets specifically (common fields: name, asset_tag, manufacturer, model) + pub fn filter_assets(assets: &[Value], search_query: &str) -> Vec { + Self::filter_data( + assets, + search_query, + &[ + "name", + "asset_tag", + "manufacturer", + "model", + "serial_number", + ], + ) + } + + /// Search borrowers (common fields: first_name, last_name, email, username) + pub fn filter_borrowers(borrowers: &[Value], search_query: &str) -> Vec { + Self::filter_data( + borrowers, + search_query, + &["first_name", "last_name", "email", "username"], + ) + } + + /// Search categories (common fields: category_name, category_code) + pub fn filter_categories(categories: &[Value], search_query: &str) -> Vec { + Self::filter_data( + categories, + search_query, + &["category_name", "category_code"], + ) + } + + /// Search zones (common fields: zone_name, zone_code) + pub fn filter_zones(zones: &[Value], search_query: &str) -> Vec { + Self::filter_data(zones, search_query, &["zone_name", "zone_code"]) + } + + /// Search suppliers (common fields: name) + pub fn filter_suppliers(suppliers: &[Value], search_query: &str) -> Vec { + Self::filter_data(suppliers, search_query, &["name"]) + } +} + +/// Sorting utilities +#[allow(dead_code)] +pub struct SortUtils; + +#[allow(dead_code)] +impl SortUtils { + /// Sort JSON values by a specific field + pub fn sort_json_by_field(data: &mut [Value], field: &str, ascending: bool) { + data.sort_by(|a, b| { + let val_a = a.get(field); + let val_b = b.get(field); + + let cmp = match (val_a, val_b) { + (Some(a), Some(b)) => { + // Try to compare as strings first + match (a.as_str(), b.as_str()) { + (Some(s_a), Some(s_b)) => s_a.cmp(s_b), + _ => { + // Try to compare as numbers + match (a.as_i64(), b.as_i64()) { + (Some(n_a), Some(n_b)) => n_a.cmp(&n_b), + _ => { + // Try to compare as floats + match (a.as_f64(), b.as_f64()) { + (Some(f_a), Some(f_b)) => f_a + .partial_cmp(&f_b) + .unwrap_or(std::cmp::Ordering::Equal), + _ => std::cmp::Ordering::Equal, + } + } + } + } + } + } + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }; + + if ascending { + cmp + } else { + cmp.reverse() + } + }); + } + + /// Generic sort function for any collection + pub fn sort_generic(data: &mut [T], compare_fn: impl Fn(&T, &T) -> std::cmp::Ordering) { + data.sort_by(compare_fn); + } +} diff --git a/src/core/workflows/add_from_template.rs b/src/core/workflows/add_from_template.rs new file mode 100644 index 0000000..d1028c6 --- /dev/null +++ b/src/core/workflows/add_from_template.rs @@ -0,0 +1,1488 @@ +/* + * Asset Tag Generation System + * + * The asset tag generation string uses placeholders that get replaced with actual values + * when creating new assets from templates. + * + * Example Generation String: + * {BUILDINGCODE}-{CATEGORYCODE}-{FLOORCODE}{ROOMCODE}-{ZONEASC} + * + * Example Asset Details: + * - Building code: ps52 + * - Category code: fire + * - Floor code: 1 + * - Room code: 08 + * - 3rd item in that zone and category + * + * Generated Tag: ps52-fire-108-03 + * + * Available Placeholders: + * + * Location-based: + * - {BUILDINGCODE} - Building identifier code + * - {FLOORCODE} - Floor number/code + * - {ROOMCODE} - Room number/code + * - {ZONECODE} - Zone identifier code + * - (Zone name was removed, we cant have spaces in asset tags) + * + * Category-based: + * - {CATEGORYCODE} - Category short code + * - (Category name was removed, we cant have spaces in asset tags) + * + * Asset-based: + * - {ASSETTYPE} - Asset type (N/B/L/C) + * - {MANUFACTURER} - Manufacturer name but with spaces replaced with underscores + * - {MODEL} - Model name/number with spaces replaced with underscores + * + * Counters: + * - {ZONEASC} - Ascending counter for items in the same zone and category (01, 02, 03...) + * - {GLOBALASC} - Global ascending counter for all items in same category (useful for laptops, cables, portable items) + * + * Date/Time: + * - {YEAR} - Current year (2025) + * - {MONTH} - Current month (12) + * - {DAY} - Current day (31) + * - {YEARSHORT} - Short year (25) + * + * Special: + * - {SERIAL} - Serial number (if available) + * - {RANDOM4} - 4-digit random number + * - {RANDOM6} - 6-digit random number + * - {USER} - Ask for user input during asset creation + * + * Examples: + * Firewall: {BUILDINGCODE}-{CATEGORYCODE}-{FLOORCODE}{ROOMCODE}-{ZONEASC} + * Home Office Laptop: {CATEGORYCODE}-{GLOBALASC} + * Cable: CBL-{YEAR}-{CATEGORYASC} + * License: LIC-{MODEL}-{CATEGORYASC} + */ + +use chrono::Utc; +use eframe::egui; +use rand::Rng; +use regex::Regex; +use serde_json::Value; + +use crate::api::ApiClient; +use crate::core::asset_fields::AssetFieldBuilder; +use crate::core::components::form_builder::FormBuilder; +use crate::core::{EditorField, FieldType}; + +/// Workflow for adding assets from templates +pub struct AddFromTemplateWorkflow { + /// Template selection dialog state + template_selector: TemplateSelector, + /// Asset editor dialog for filling in template details + asset_editor: Option, + /// Current selected template data + selected_template: Option, + /// Whether the workflow is currently active + is_active: bool, + /// Whether we're in single or multiple mode + is_multiple_mode: bool, + /// Asset tag confirmation dialog state + asset_tag_confirmation: Option, + /// User preference to skip confirmation dialog unless there are errors + skip_confirmation_unless_error: bool, +} + +/// Asset tag confirmation dialog +struct AssetTagConfirmation { + /// The asset data ready for creation + asset_data: Value, + /// The generated asset tag + generated_tag: String, + /// User-editable asset tag + edited_tag: String, + /// Whether the dialog is currently open + is_open: bool, + /// Generation errors if any + generation_errors: Vec, +} + +/// Template selector component +struct TemplateSelector { + /// Available templates + templates: Vec, + /// Filter text for searching templates + filter_text: String, + /// Currently selected template index + selected_index: Option, + /// Whether the selector dialog is open + is_open: bool, + /// Loading state + is_loading: bool, + /// Error message if any + error_message: Option, +} + +impl AddFromTemplateWorkflow { + pub fn new() -> Self { + Self { + template_selector: TemplateSelector::new(), + asset_editor: None, + selected_template: None, + is_active: false, + is_multiple_mode: false, + asset_tag_confirmation: None, + skip_confirmation_unless_error: true, // Default to skipping unless error + } + } + + /// Start the workflow in single mode + pub fn start_single_mode(&mut self, api_client: &ApiClient) { + self.is_active = true; + self.is_multiple_mode = false; + self.template_selector.load_templates(api_client); + self.template_selector.is_open = true; + } + + /// Start the workflow in multiple mode + pub fn start_multiple_mode(&mut self, api_client: &ApiClient) { + self.is_active = true; + self.is_multiple_mode = true; + self.template_selector.load_templates(api_client); + self.template_selector.is_open = true; + } + + /// Show the workflow UI and handle user interactions + /// Returns Some(asset_data) if an asset should be created, None if workflow continues or is cancelled + pub fn show(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) -> Option { + if !self.is_active { + return None; + } + + let mut result = None; + + // Show template selector first + if self.template_selector.is_open { + let selected_template = self.template_selector.show(ui, api_client); + if let Some(template) = selected_template { + // Template selected, prepare asset editor + self.selected_template = Some(template.clone()); + self.prepare_asset_editor(&template, api_client); + self.template_selector.is_open = false; + } else if !self.template_selector.is_open { + // Template selector was cancelled + self.cancel(); + } + } + + // Show asset editor if template is selected + if let Some(ref mut editor) = self.asset_editor { + if let Some(editor_result) = editor.show_editor(ui.ctx()) { + match editor_result { + Some(asset_data_diff) => { + // Reconstruct full data: original + diff (editor.data is cleared on close) + let mut full_asset_data = editor.original_data.clone(); + for (k, v) in asset_data_diff.iter() { + full_asset_data.insert(k.clone(), v.clone()); + } + + // Read and persist the skip confirmation preference (stored as an editor field) + if let Some(skip) = full_asset_data + .get("skip_tag_confirmation") + .and_then(|v| v.as_bool()) + { + self.skip_confirmation_unless_error = skip; + } + // Remove UI-only field from the final asset payload + full_asset_data.remove("skip_tag_confirmation"); + + log::info!( + "Editor diff data: {}", + serde_json::to_string_pretty(&asset_data_diff) + .unwrap_or_else(|_| "failed to serialize".to_string()) + ); + log::info!( + "Full asset data from editor: {}", + serde_json::to_string_pretty(&full_asset_data) + .unwrap_or_else(|_| "failed to serialize".to_string()) + ); + + // Apply auto-generation logic for purchase_date_now (safety: re-apply in case template requested it) + if let Some(template) = &self.selected_template { + if let Some(purchase_date_now) = + template.get("purchase_date_now").and_then(|v| v.as_bool()) + { + if purchase_date_now { + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + full_asset_data.insert( + "purchase_date".to_string(), + Value::String(today.clone()), + ); + log::info!("Auto-generated purchase_date: {}", today); + } + } + + // Apply warranty auto-calculation if enabled + if let Some(warranty_auto) = + template.get("warranty_auto").and_then(|v| v.as_bool()) + { + if warranty_auto { + if let (Some(amount), Some(unit)) = ( + template + .get("warranty_auto_amount") + .and_then(|v| v.as_i64()), + template.get("warranty_auto_unit").and_then(|v| v.as_str()), + ) { + let today = chrono::Utc::now().date_naive(); + let warranty_date = match unit { + "days" => today + chrono::Duration::days(amount), + "months" => today + chrono::Duration::days(amount * 30), // Approximate + "years" => today + chrono::Duration::days(amount * 365), // Approximate + _ => today, + }; + let warranty_str = + warranty_date.format("%Y-%m-%d").to_string(); + full_asset_data.insert( + "warranty_until".to_string(), + Value::String(warranty_str.clone()), + ); + log::info!( + "Auto-calculated warranty_until: {} ({} {})", + warranty_str, + amount, + unit + ); + } + } + } + + // Apply expiry auto-calculation if enabled + if let Some(expiry_auto) = + template.get("expiry_auto").and_then(|v| v.as_bool()) + { + if expiry_auto { + if let (Some(amount), Some(unit)) = ( + template.get("expiry_auto_amount").and_then(|v| v.as_i64()), + template.get("expiry_auto_unit").and_then(|v| v.as_str()), + ) { + let today = chrono::Utc::now().date_naive(); + let expiry_date = match unit { + "days" => today + chrono::Duration::days(amount), + "months" => today + chrono::Duration::days(amount * 30), // Approximate + "years" => today + chrono::Duration::days(amount * 365), // Approximate + _ => today, + }; + let expiry_str = expiry_date.format("%Y-%m-%d").to_string(); + full_asset_data.insert( + "expiry_date".to_string(), + Value::String(expiry_str.clone()), + ); + log::info!( + "Auto-calculated expiry_date: {} ({} {})", + expiry_str, + amount, + unit + ); + } + } + } + } + + // Validate and prepare asset tag confirmation + self.prepare_asset_tag_confirmation( + Value::Object(full_asset_data), + api_client, + ); + self.asset_editor = None; // Close the asset editor + } + None => { + // Asset editor was cancelled + if self.is_multiple_mode { + // In multiple mode, go back to template selector + self.template_selector.is_open = true; + self.asset_editor = None; + self.selected_template = None; + } else { + // In single mode, cancel entire workflow + self.cancel(); + } + } + } + } + } + + // Show asset tag confirmation dialog (or handle skipped case) + if let Some(ref mut confirmation) = self.asset_tag_confirmation { + if confirmation.is_open { + // Show the dialog + if let Some(confirmed_asset) = confirmation.show(ui) { + result = Some(confirmed_asset); + self.asset_tag_confirmation = None; // Close confirmation dialog + + if !self.is_multiple_mode { + self.cancel(); // Single mode - finish after one item + } else { + // Multiple mode - reset for next item but keep template selected + self.reset_for_next_item(api_client); + } + } + } else { + // Dialog was skipped - return asset data immediately + result = Some(confirmation.asset_data.clone()); + self.asset_tag_confirmation = None; + + if !self.is_multiple_mode { + self.cancel(); // Single mode - finish after one item + } else { + // Multiple mode - reset for next item but keep template selected + self.reset_for_next_item(api_client); + } + } + } + + result + } + + /// Prepare the asset editor with template data + fn prepare_asset_editor(&mut self, template: &Value, api_client: &ApiClient) { + log::info!( + "Preparing asset editor with template: {}", + serde_json::to_string_pretty(template) + .unwrap_or_else(|_| "failed to serialize".to_string()) + ); + + // Create editor with all fields (using existing asset field builder) + let mut editor = AssetFieldBuilder::create_advanced_edit_dialog(api_client); + + // Pre-populate with template data + let mut asset_data = template.clone(); + + // Clear ID and other fields that shouldn't be copied from template + asset_data["id"] = Value::String("".to_string()); + asset_data["asset_tag"] = Value::String("".to_string()); // Will be auto-generated + asset_data["created_date"] = Value::Null; + asset_data["last_modified_date"] = Value::Null; + asset_data["created_at"] = Value::Null; // Template creation date shouldn't be copied + + // Map joined template data to field names expected by asset tag generation + if let Some(category_code) = template.get("category_code").and_then(|v| v.as_str()) { + asset_data["category_code"] = Value::String(category_code.to_string()); + log::info!("Mapped category_code from template: {}", category_code); + } else { + log::warn!("Template has no category_code field"); + } + if let Some(zone_code) = template.get("zone_code").and_then(|v| v.as_str()) { + asset_data["zone_code"] = Value::String(zone_code.to_string()); + log::info!("Mapped zone_code from template: {}", zone_code); + } else { + log::warn!("Template has no zone_code field (this is normal if zone_id is null)"); + } + + // Apply initial auto-generation so the user sees defaults inside the editor + // 1) Purchase date now + if template + .get("purchase_date_now") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + asset_data["purchase_date"] = Value::String(today.clone()); + log::info!("[Editor init] Auto-set purchase_date: {}", today); + } + // 2) Warranty auto-calc + if template + .get("warranty_auto") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + if let (Some(amount), Some(unit)) = ( + template + .get("warranty_auto_amount") + .and_then(|v| v.as_i64()), + template.get("warranty_auto_unit").and_then(|v| v.as_str()), + ) { + // Base date: purchase_date if present, else today + let base_date = asset_data.get("purchase_date").and_then(|v| v.as_str()); + let start = if let Some(d) = base_date { + chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d") + .unwrap_or_else(|_| chrono::Utc::now().date_naive()) + } else { + chrono::Utc::now().date_naive() + }; + let warranty_date = match unit { + "days" => start + chrono::Duration::days(amount), + "months" => start + chrono::Duration::days(amount * 30), // approx + "years" => start + chrono::Duration::days(amount * 365), // approx + _ => start, + }; + let warranty_str = warranty_date.format("%Y-%m-%d").to_string(); + asset_data["warranty_until"] = Value::String(warranty_str.clone()); + log::info!( + "[Editor init] Auto-set warranty_until: {} ({} {})", + warranty_str, + amount, + unit + ); + } + } + // 3) Expiry auto-calc + if template + .get("expiry_auto") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + if let (Some(amount), Some(unit)) = ( + template.get("expiry_auto_amount").and_then(|v| v.as_i64()), + template.get("expiry_auto_unit").and_then(|v| v.as_str()), + ) { + // Base date: purchase_date if present, else today + let base_date = asset_data.get("purchase_date").and_then(|v| v.as_str()); + let start = if let Some(d) = base_date { + chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d") + .unwrap_or_else(|_| chrono::Utc::now().date_naive()) + } else { + chrono::Utc::now().date_naive() + }; + let expiry_date = match unit { + "days" => start + chrono::Duration::days(amount), + "months" => start + chrono::Duration::days(amount * 30), // approx + "years" => start + chrono::Duration::days(amount * 365), // approx + _ => start, + }; + let expiry_str = expiry_date.format("%Y-%m-%d").to_string(); + asset_data["expiry_date"] = Value::String(expiry_str.clone()); + log::info!( + "[Editor init] Auto-set expiry_date: {} ({} {})", + expiry_str, + amount, + unit + ); + } + } + + // Note: Zone hierarchy extraction will happen later when we have the actual zone_id + // from the user's selection in the asset editor, not from the template + + // Set dialog title + let template_name = template + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown Template"); + let template_code = template + .get("template_code") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + editor.title = if !template_code.is_empty() { + format!( + "Add Asset from Template: {} ({})", + template_name, template_code + ) + } else { + format!("Add Asset from Template: {}", template_name) + }; + + // Add an in-editor UX toggle: skip confirmation unless errors + // Seed the data so the checkbox shows current preference + asset_data["skip_tag_confirmation"] = Value::Bool(self.skip_confirmation_unless_error); + // Add Print Label option (default on) so user can immediately print after creation + asset_data["print_label"] = Value::Bool(true); + + // Open editor with pre-populated data + editor.open(&asset_data); + // Inject extra editor fields so they show inside the editor window + editor.fields.push(EditorField { + name: "skip_tag_confirmation".into(), + label: "Skip tag confirmation unless errors".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }); + editor.fields.push(EditorField { + name: "print_label".into(), + label: "Print Label".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }); + self.asset_editor = Some(editor); + } + + /// Validate asset data and prepare for creation + #[allow(dead_code)] + fn validate_and_prepare_asset( + &self, + api_client: &ApiClient, + mut asset_data: Value, + ) -> Option { + let template = self.selected_template.as_ref()?; + + log::info!( + "Validating asset data: {}", + serde_json::to_string_pretty(&asset_data) + .unwrap_or_else(|_| "failed to serialize".to_string()) + ); + + // Check if asset tag generation string is required + if let Some(generation_string) = template + .get("asset_tag_generation_string") + .and_then(|v| v.as_str()) + { + if !generation_string.is_empty() { + log::info!("Using asset tag generation string: '{}'", generation_string); + // Generate asset tag using the template's asset generation string + match self.generate_asset_tag(api_client, &asset_data, generation_string) { + Ok(asset_tag) => { + log::info!("Generated asset tag: '{}'", asset_tag); + asset_data["asset_tag"] = Value::String(asset_tag); + } + Err(missing_fields) => { + // Show error about missing required fields + log::error!( + "Cannot generate asset tag: missing fields: {:?}", + missing_fields + ); + return None; // Don't allow creation until all required fields are filled + } + } + } else { + // No generation string - asset tag is required field + if let Some(tag) = asset_data.get("asset_tag").and_then(|v| v.as_str()) { + if tag.trim().is_empty() { + log::error!("Asset tag is required when template has no generation string"); + return None; + } + } else { + log::error!("Asset tag is required when template has no generation string"); + return None; + } + } + } else { + log::warn!("No asset_tag_generation_string found in template"); + } + + log::info!( + "Asset validation successful, final data: {}", + serde_json::to_string_pretty(&asset_data) + .unwrap_or_else(|_| "failed to serialize".to_string()) + ); + Some(asset_data) + } + + /// Generate partial asset tag (showing what we can resolve, leaving placeholders for missing fields) + fn generate_partial_asset_tag( + &self, + api_client: &ApiClient, + asset_data: &Value, + generation_string: &str, + ) -> Result> { + let mut result = generation_string.to_string(); + + log::info!( + "Available asset_data keys: {:?}", + asset_data.as_object().map(|o| o.keys().collect::>()) + ); + + // Get current date/time + let now = Utc::now(); + + // Find all placeholders in the generation string + let re = Regex::new(r"\{([^}]+)\}").unwrap(); + + for cap in re.captures_iter(generation_string) { + let placeholder = &cap[1]; + + // Generate replacement value as owned string - only replace if we have a value + let replacement_value = match placeholder { + // Location-based placeholders + "BUILDINGCODE" => asset_data + .get("building_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + "FLOORCODE" => asset_data + .get("floor_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + "ROOMCODE" => asset_data + .get("room_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + "ZONECODE" => asset_data + .get("zone_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + "FULLZONECODE" => asset_data + .get("zone_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + + // Category-based placeholders + "CATEGORYCODE" => asset_data + .get("category_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + + // Asset-based placeholders + "ASSETTYPE" => asset_data + .get("asset_type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + "MANUFACTURER" => asset_data + .get("manufacturer") + .and_then(|v| v.as_str()) + .map(|s| s.replace(" ", "_")), + "MODEL" => asset_data + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.replace(" ", "_")), + + // Date/Time placeholders - these always work + "YEAR" => Some(now.format("%Y").to_string()), + "MONTH" => Some(now.format("%m").to_string()), + "DAY" => Some(now.format("%d").to_string()), + "YEARSHORT" => Some(now.format("%y").to_string()), + + // Special placeholders + "SERIAL" => asset_data + .get("serial_number") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + "RANDOM4" => Some(format!("{:04}", rand::thread_rng().gen_range(0..10000))), + "RANDOM6" => Some(format!("{:06}", rand::thread_rng().gen_range(0..1000000))), + + // Counter placeholders + "ZONEASC" => self.get_next_zone_counter(api_client, asset_data), + "GLOBALASC" => self.get_next_global_counter(api_client, asset_data), + + // Fallback: try direct field lookup + _ => asset_data + .get(placeholder) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }; + + // Only replace if we have a valid value + if let Some(value) = replacement_value { + if !value.trim().is_empty() { + log::info!("Replacing {{{}}} with '{}'", placeholder, value); + result = result.replace(&format!("{{{}}}", placeholder), &value); + } else { + log::warn!( + "Placeholder {{{}}} has empty value, leaving as placeholder", + placeholder + ); + } + } else { + log::warn!( + "No value found for placeholder {{{}}}, leaving as placeholder", + placeholder + ); + } + // If no value, leave the placeholder as-is in the result + } + + Ok(result) + } + + /// Generate asset tag from template's generation string + fn generate_asset_tag( + &self, + api_client: &ApiClient, + asset_data: &Value, + generation_string: &str, + ) -> Result> { + let mut result = generation_string.to_string(); + let mut missing_fields = Vec::new(); + + // Get current date/time + let now = Utc::now(); + + // Find all placeholders in the generation string + let re = Regex::new(r"\{([^}]+)\}").unwrap(); + + for cap in re.captures_iter(generation_string) { + let placeholder = &cap[1]; + + // Generate replacement value as owned string + let replacement_value = match placeholder { + // Location-based placeholders + "BUILDINGCODE" => asset_data + .get("building_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + "FLOORCODE" => asset_data + .get("floor_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + "ROOMCODE" => asset_data + .get("room_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + "ZONECODE" => asset_data + .get("zone_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + "FULLZONECODE" => asset_data + .get("zone_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + + // Category-based placeholders + "CATEGORYCODE" => asset_data + .get("category_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + + // Asset-based placeholders + "ASSETTYPE" => asset_data + .get("asset_type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + "MANUFACTURER" => asset_data + .get("manufacturer") + .and_then(|v| v.as_str()) + .map(|s| s.replace(" ", "_")), + "MODEL" => asset_data + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.replace(" ", "_")), + + // Date/Time placeholders + "YEAR" => Some(now.format("%Y").to_string()), + "MONTH" => Some(now.format("%m").to_string()), + "DAY" => Some(now.format("%d").to_string()), + "YEARSHORT" => Some(now.format("%y").to_string()), + + // Special placeholders + "SERIAL" => asset_data + .get("serial_number") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + "RANDOM4" => Some(format!("{:04}", rand::thread_rng().gen_range(0..10000))), + "RANDOM6" => Some(format!("{:06}", rand::thread_rng().gen_range(0..1000000))), + + // Counter placeholders + "ZONEASC" => { + // Get next counter for zone+category combination + self.get_next_zone_counter(api_client, asset_data) + } + "GLOBALASC" => { + // Get next global counter for category + self.get_next_global_counter(api_client, asset_data) + } + + // Fallback: try direct field lookup + _ => asset_data + .get(placeholder) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }; + + match replacement_value { + Some(value) if !value.trim().is_empty() => { + result = result.replace(&format!("{{{}}}", placeholder), &value); + } + _ => { + // For counter placeholders, treat as missing (TODO items) + if placeholder.starts_with("ZONEASC") || placeholder.starts_with("GLOBALASC") { + // Skip - already handled above + } else if matches!(placeholder, "BUILDINGCODE" | "FLOORCODE" | "ROOMCODE") { + // These are often missing in templates, use placeholder values + let placeholder_value = match placeholder { + "BUILDINGCODE" => "BLD", + "FLOORCODE" => "00", + "ROOMCODE" => "00", + _ => "UNK", + }; + result = result.replace(&format!("{{{}}}", placeholder), placeholder_value); + log::warn!( + "Using placeholder '{}' for missing field {}", + placeholder_value, + placeholder + ); + } else { + // Other missing fields are required + missing_fields.push(placeholder.to_string()); + } + } + } + } + + if missing_fields.is_empty() { + Ok(result) + } else { + Err(missing_fields) + } + } + + /// Reset for next item in multiple mode + fn reset_for_next_item(&mut self, api_client: &ApiClient) { + if let Some(template) = self.selected_template.clone() { + self.prepare_asset_editor(&template, api_client); + } + } + + /// Cancel the workflow + pub fn cancel(&mut self) { + self.is_active = false; + self.template_selector.is_open = false; + self.asset_editor = None; + self.selected_template = None; + self.is_multiple_mode = false; + self.asset_tag_confirmation = None; + // Don't reset skip_confirmation_unless_error - let user preference persist + } + + /// Get next zone-based counter (ZONEASC) for assets in same zone and category + fn get_next_zone_counter(&self, api_client: &ApiClient, asset_data: &Value) -> Option { + // Determine next ascending number for assets in the same zone and category + // Uses: COUNT(*) WHERE zone_id = ? AND category_id = ? + let zone_id = asset_data + .get("zone_id") + .and_then(|v| v.as_i64()) + .or_else(|| { + asset_data + .get("zone_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) + }); + let category_id = asset_data + .get("category_id") + .and_then(|v| v.as_i64()) + .or_else(|| { + asset_data + .get("category_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) + }); + + let (zone_id, category_id) = match (zone_id, category_id) { + (Some(z), Some(c)) => (z, c), + _ => return None, + }; + + let where_clause = serde_json::json!({ + "zone_id": zone_id, + "category_id": category_id + }); + log::info!( + "Calculating ZONEASC with where: zone_id={}, category_id={}", + zone_id, + category_id + ); + match api_client.count("assets", Some(where_clause)) { + Ok(resp) if resp.success => { + let current = resp.data.unwrap_or(0); + let next = (current as i64) + 1; + // pad to 2 digits minimum + Some(format!("{:02}", next)) + } + Ok(resp) => { + log::error!("Failed to count ZONEASC: {:?}", resp.error); + None + } + Err(e) => { + log::error!("API error counting ZONEASC: {}", e); + None + } + } + } + + /// Get next global counter (GLOBALASC) for assets in same category + fn get_next_global_counter( + &self, + api_client: &ApiClient, + asset_data: &Value, + ) -> Option { + // Determine next ascending number for assets in the same category (global) + let category_id = asset_data + .get("category_id") + .and_then(|v| v.as_i64()) + .or_else(|| { + asset_data + .get("category_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) + }); + let category_id = match category_id { + Some(c) => c, + None => return None, + }; + + let where_clause = serde_json::json!({ + "category_id": category_id + }); + log::info!( + "Calculating GLOBALASC with where: category_id={}", + category_id + ); + match api_client.count("assets", Some(where_clause)) { + Ok(resp) if resp.success => { + let current = resp.data.unwrap_or(0); + let next = (current as i64) + 1; + // pad to 3 digits minimum for global + Some(format!("{:03}", next)) + } + Ok(resp) => { + log::error!("Failed to count GLOBALASC: {:?}", resp.error); + None + } + Err(e) => { + log::error!("API error counting GLOBALASC: {}", e); + None + } + } + } + + /// Get zone hierarchy information (building, floor, room codes) by walking up the zone tree + fn get_zone_hierarchy( + &self, + api_client: &ApiClient, + zone_id: i64, + ) -> Option> { + use std::collections::HashMap; + + let mut hierarchy = HashMap::new(); + let mut current_zone_id = zone_id; + + // Walk up the zone hierarchy to collect codes + for depth in 0..10 { + // Prevent infinite loops + log::debug!( + "Zone hierarchy depth {}: looking up zone_id {}", + depth, + current_zone_id + ); + + match self.get_zone_info(api_client, current_zone_id) { + Some(zone_info) => { + log::debug!( + "Found zone info: {}", + serde_json::to_string_pretty(&zone_info) + .unwrap_or_else(|_| "failed to serialize".to_string()) + ); + + let zone_type = zone_info + .get("zone_type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let zone_code_full = zone_info + .get("zone_code") + .and_then(|v| v.as_str()) + .unwrap_or(""); + // Backward-compatible: if mini_code missing (pre-migration), fall back to zone_code + let mini_code = zone_info + .get("mini_code") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .unwrap_or(zone_code_full); + + log::info!( + "Zone {} (type: {}) has mini_code='{}' full_code='{}'", + current_zone_id, + zone_type, + mini_code, + zone_code_full + ); + + if depth == 0 { + if !zone_code_full.is_empty() { + hierarchy + .insert("full_zone_code".to_string(), zone_code_full.to_string()); + } + } + + match zone_type { + "Building" => { + hierarchy.insert("building_code".to_string(), mini_code.to_string()); + log::info!("Added building_code (mini): {}", mini_code); + } + "Floor" => { + hierarchy.insert("floor_code".to_string(), mini_code.to_string()); + log::info!("Added floor_code (mini): {}", mini_code); + } + "Room" => { + hierarchy.insert("room_code".to_string(), mini_code.to_string()); + log::info!("Added room_code (mini): {}", mini_code); + } + _ => { + log::warn!( + "Unknown zone type '{}' for zone {}", + zone_type, + current_zone_id + ); + } + } + + // Move to parent zone + if let Some(parent_id) = zone_info.get("parent_id").and_then(|v| v.as_i64()) { + current_zone_id = parent_id; + } else { + break; // No parent, reached root + } + } + None => { + log::error!("Failed to get zone info for zone_id: {}", current_zone_id); + break; // Zone not found + } + } + } + + Some(hierarchy) + } + + /// Get zone information by ID + fn get_zone_info(&self, api_client: &ApiClient, zone_id: i64) -> Option { + let columns = Some(vec![ + "id".to_string(), + "zone_code".to_string(), + "mini_code".to_string(), + "zone_type".to_string(), + "parent_id".to_string(), + ]); + let where_clause = Some(serde_json::json!({"id": zone_id})); + + log::debug!( + "Querying zones table for zone_id: {} with columns: {:?}", + zone_id, + columns + ); + + match api_client.select("zones", columns, where_clause, None, Some(1)) { + Ok(resp) => { + log::debug!( + "Zone query response success: {}, data: {:?}", + resp.success, + resp.data + ); + if resp.success { + resp.data.and_then(|data| data.into_iter().next()) + } else { + log::error!( + "Zone query failed: {}", + resp.message.unwrap_or_else(|| "Unknown error".to_string()) + ); + None + } + } + Err(e) => { + log::error!("Zone query API error: {}", e); + None + } + } + } + + /// Get category code by category ID + fn get_category_code(&self, api_client: &ApiClient, category_id: i64) -> Option { + let columns = Some(vec!["id".to_string(), "category_code".to_string()]); + let where_clause = Some(serde_json::json!({"id": category_id})); + + log::debug!( + "Querying categories table for category_id: {} with columns: {:?}", + category_id, + columns + ); + + match api_client.select("categories", columns, where_clause, None, Some(1)) { + Ok(resp) => { + log::debug!( + "Category query response success: {}, data: {:?}", + resp.success, + resp.data + ); + if resp.success { + resp.data + .and_then(|data| data.into_iter().next()) + .and_then(|category| { + category + .get("category_code") + .and_then(|v| v.as_str().map(|s| s.to_string())) + }) + } else { + log::error!( + "Category query failed: {}", + resp.message.unwrap_or_else(|| "Unknown error".to_string()) + ); + None + } + } + Err(e) => { + log::error!("Category query API error: {}", e); + None + } + } + } + + /// Prepare asset tag confirmation dialog + fn prepare_asset_tag_confirmation(&mut self, mut asset_data: Value, api_client: &ApiClient) { + let template = match self.selected_template.as_ref() { + Some(t) => t, + None => { + log::error!("No template selected for asset tag confirmation"); + return; + } + }; + + log::info!("Preparing asset tag confirmation with full asset data"); + + // Extract zone hierarchy NOW that we have the actual zone_id from the user's selection + let zone_id_parsed = asset_data.get("zone_id").and_then(|v| { + // Handle both string and integer zone_id values + if let Some(id_int) = v.as_i64() { + Some(id_int) + } else if let Some(id_str) = v.as_str() { + id_str.parse::().ok() + } else { + None + } + }); + + if let Some(zone_id) = zone_id_parsed { + log::info!( + "Asset has zone_id: {}, extracting zone hierarchy for tag generation", + zone_id + ); + if let Some(zone_hierarchy) = self.get_zone_hierarchy(api_client, zone_id) { + log::info!( + "Successfully extracted zone hierarchy for asset: {:?}", + zone_hierarchy + ); + if let Some(building_code) = zone_hierarchy.get("building_code") { + asset_data["building_code"] = Value::String(building_code.clone()); + log::info!("Set building_code to: {}", building_code); + } + if let Some(floor_code) = zone_hierarchy.get("floor_code") { + asset_data["floor_code"] = Value::String(floor_code.clone()); + log::info!("Set floor_code to: {}", floor_code); + } + if let Some(room_code) = zone_hierarchy.get("room_code") { + asset_data["room_code"] = Value::String(room_code.clone()); + log::info!("Set room_code to: {}", room_code); + } + if let Some(full_zone_code) = zone_hierarchy.get("full_zone_code") { + // Ensure ZONECODE/FULLZONECODE map to the full path + asset_data["zone_code"] = Value::String(full_zone_code.clone()); + log::info!("Set zone_code (full) to: {}", full_zone_code); + } + } else { + log::error!( + "Failed to extract zone hierarchy for asset zone_id: {}", + zone_id + ); + } + } else { + log::warn!("Asset has no zone_id set, cannot extract zone hierarchy"); + } + + // Also ensure category_code is available from the asset's category_id + let category_id_parsed = asset_data.get("category_id").and_then(|v| { + // Handle both string and integer category_id values + if let Some(id_int) = v.as_i64() { + Some(id_int) + } else if let Some(id_str) = v.as_str() { + id_str.parse::().ok() + } else { + None + } + }); + + if let Some(category_id) = category_id_parsed { + if let Some(category_code) = self.get_category_code(api_client, category_id) { + asset_data["category_code"] = Value::String(category_code.clone()); + log::info!( + "Set category_code from category_id {}: {}", + category_id, + category_code + ); + } else { + log::error!( + "Failed to get category_code for category_id: {}", + category_id + ); + } + } + + let mut generated_tag = String::new(); + let mut generation_errors = Vec::new(); + let skip_unless_error = self.skip_confirmation_unless_error; + + // Check if asset tag was manually filled + let asset_tag_manually_set = asset_data + .get("asset_tag") + .and_then(|v| v.as_str()) + .map_or(false, |s| !s.trim().is_empty()); + + // Try to generate asset tag if not manually set + if !asset_tag_manually_set { + if let Some(generation_string) = template + .get("asset_tag_generation_string") + .and_then(|v| v.as_str()) + { + if !generation_string.is_empty() { + match self.generate_asset_tag(api_client, &asset_data, generation_string) { + Ok(tag) => { + generated_tag = tag; + asset_data["asset_tag"] = Value::String(generated_tag.clone()); + } + Err(errors) => { + generation_errors = errors; + // Generate partial tag showing what we could resolve + match self.generate_partial_asset_tag( + api_client, + &asset_data, + generation_string, + ) { + Ok(partial_tag) => { + generated_tag = partial_tag; + log::warn!( + "Generated partial asset tag due to missing fields: {}", + generated_tag + ); + } + Err(_) => { + generated_tag = generation_string.to_string(); + // Fallback to original template + } + } + } + } + } + } + } + + // Show confirmation dialog if: + // 1. Asset tag wasn't manually set AND generation failed, OR + // 2. Skip unless error is unchecked + let should_show_dialog = + (!asset_tag_manually_set && !generation_errors.is_empty()) || !skip_unless_error; + + if should_show_dialog { + self.asset_tag_confirmation = Some(AssetTagConfirmation { + asset_data: asset_data.clone(), + generated_tag: generated_tag.clone(), + edited_tag: generated_tag, + is_open: true, + generation_errors, + }); + } else { + // Skip dialog - create confirmation that immediately returns the asset data + self.asset_tag_confirmation = Some(AssetTagConfirmation { + asset_data: asset_data.clone(), + generated_tag: generated_tag.clone(), + edited_tag: generated_tag, + is_open: false, // Don't show dialog, just return data immediately + generation_errors, + }); + log::info!("Skipping asset tag confirmation dialog - no errors and skip_unless_error is enabled"); + } + } +} + +impl TemplateSelector { + fn new() -> Self { + Self { + templates: Vec::new(), + filter_text: String::new(), + selected_index: None, + is_open: false, + is_loading: false, + error_message: None, + } + } + + fn load_templates(&mut self, api_client: &ApiClient) { + self.is_loading = true; + self.error_message = None; + + // Load templates from API + match crate::core::tables::get_templates(api_client, None) { + Ok(templates) => { + self.templates = templates; + self.is_loading = false; + } + Err(e) => { + self.error_message = Some(format!("Failed to load templates: {}", e)); + self.is_loading = false; + } + } + } + + /// Show template selector dialog + /// Returns Some(template) if selected, None if cancelled or still selecting + fn show(&mut self, ui: &mut egui::Ui, _api_client: &ApiClient) -> Option { + let mut result = None; + let mut close_dialog = false; + + let _response = egui::Window::new("Select Template") + .default_size([500.0, 400.0]) + .open(&mut self.is_open) + .show(ui.ctx(), |ui| { + if self.is_loading { + ui.spinner(); + ui.label("Loading templates..."); + return; + } + + if let Some(ref error) = self.error_message { + ui.colored_label(egui::Color32::RED, error); + return; + } + + // Search filter + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(&mut self.filter_text); + }); + + ui.separator(); + + // Filter templates based on search + let filtered_templates: Vec<(usize, &Value)> = self + .templates + .iter() + .enumerate() + .filter(|(_, template)| { + if self.filter_text.is_empty() { + return true; + } + let filter_lower = self.filter_text.to_lowercase(); + + // Search in template code, name, and description + template + .get("template_code") + .and_then(|v| v.as_str()) + .map_or(false, |s| s.to_lowercase().contains(&filter_lower)) + || template + .get("name") + .and_then(|v| v.as_str()) + .map_or(false, |s| s.to_lowercase().contains(&filter_lower)) + || template + .get("description") + .and_then(|v| v.as_str()) + .map_or(false, |s| s.to_lowercase().contains(&filter_lower)) + }) + .collect(); + + // Template list + egui::ScrollArea::vertical().show(ui, |ui| { + for (original_index, template) in filtered_templates { + let template_code = template + .get("template_code") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let template_name = template + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unnamed Template"); + let description = template + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let label = if !template_code.is_empty() { + format!("{} - {}", template_code, template_name) + } else { + template_name.to_string() + }; + + let is_selected = self.selected_index == Some(original_index); + if ui.selectable_label(is_selected, &label).clicked() { + self.selected_index = Some(original_index); + } + + // Show description if available + if !description.is_empty() { + ui.indent("desc", |ui| { + ui.small(description); + }); + } + } + }); + + ui.separator(); + + // Buttons + ui.horizontal(|ui| { + let can_select = self.selected_index.is_some() + && self.selected_index.unwrap() < self.templates.len(); + + if ui + .add_enabled(can_select, egui::Button::new("Select")) + .clicked() + { + if let Some(index) = self.selected_index { + result = Some(self.templates[index].clone()); + close_dialog = true; + } + } + + if ui.button("Cancel").clicked() { + close_dialog = true; + } + }); + }); + + if close_dialog { + self.is_open = false; + } + + result + } +} + +impl AssetTagConfirmation { + /// Show the asset tag confirmation dialog + /// Returns Some(asset_data) if confirmed, None if still editing or cancelled + fn show(&mut self, ui: &mut egui::Ui) -> Option { + if !self.is_open { + return None; + } + + let mut result = None; + let mut close_dialog = false; + + egui::Window::new("Confirm Asset Tag") + .default_size([500.0, 400.0]) + .resizable(true) + .show(ui.ctx(), |ui| { + ui.vertical(|ui| { + ui.heading("Asset Tag Generation"); + ui.add_space(10.0); + + // Show generation errors if any + if !self.generation_errors.is_empty() { + ui.colored_label(egui::Color32::RED, "⚠ Generation Errors:"); + for error in &self.generation_errors { + ui.colored_label(egui::Color32::RED, format!("• {}", error)); + } + ui.add_space(10.0); + } + + // Asset tag input + ui.horizontal(|ui| { + ui.label("Asset Tag:"); + ui.text_edit_singleline(&mut self.edited_tag); + }); + + if !self.generated_tag.is_empty() && self.generation_errors.is_empty() { + ui.small(format!("Generated: {}", self.generated_tag)); + } + + ui.add_space(20.0); + + // Buttons + ui.horizontal(|ui| { + if ui.button("Create Asset").clicked() { + // Update asset data with edited tag + let mut final_asset_data = self.asset_data.clone(); + final_asset_data["asset_tag"] = Value::String(self.edited_tag.clone()); + result = Some(final_asset_data); + close_dialog = true; + } + + if ui.button("Cancel").clicked() { + close_dialog = true; + } + }); + }); + }); + + if close_dialog { + self.is_open = false; + } + result + } +} diff --git a/src/core/workflows/audit.rs b/src/core/workflows/audit.rs new file mode 100644 index 0000000..69ae733 --- /dev/null +++ b/src/core/workflows/audit.rs @@ -0,0 +1,1719 @@ +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, + } + } +} diff --git a/src/core/workflows/borrow_flow.rs b/src/core/workflows/borrow_flow.rs new file mode 100644 index 0000000..08c287f --- /dev/null +++ b/src/core/workflows/borrow_flow.rs @@ -0,0 +1,1450 @@ +use anyhow::Result; +use chrono::{Duration, Local}; +use eframe::egui; +use egui_phosphor::variants::regular as icons; +use serde_json::Value; + +use crate::api::ApiClient; +use crate::models::QueryRequest; + +#[derive(Debug, Clone, PartialEq)] +pub enum BorrowStep { + SelectAsset, + SelectBorrower, + SelectDuration, + Confirm, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum BorrowerSelection { + None, + Existing(Value), // Existing borrower data + NewRegistration { + // New borrower being registered + name: String, + department: String, // "class" in the UI + borrower_type: String, // "role" in the UI + phone: String, + email: String, + }, +} + +pub struct BorrowFlow { + // State + pub is_open: bool, + pub current_step: BorrowStep, + + // Step 1: Asset Selection + pub scan_input: String, + pub available_assets: Vec, + pub selected_asset: Option, + pub asset_search: String, + pub asset_loading: bool, + + // Step 2: Borrower Selection + pub borrower_selection: BorrowerSelection, + pub registered_borrowers: Vec, + pub banned_borrowers: Vec, + pub borrower_search: String, + pub borrower_loading: bool, + + // New borrower registration fields + pub new_borrower_name: String, + pub new_borrower_class: String, + pub new_borrower_role: String, + pub new_borrower_phone: String, + pub new_borrower_email: String, + + // Step 3: Duration Selection + pub selected_duration_days: Option, + pub custom_due_date: String, + + // Step 4: Confirmation + pub lending_notes: String, + + // Confirmation for lending risky items (Faulty/Attention) + pub confirm_risky_asset: bool, + + // Error handling + pub error_message: Option, + pub success_message: Option, + pub just_completed_successfully: bool, +} + +impl Default for BorrowFlow { + fn default() -> Self { + Self { + is_open: false, + current_step: BorrowStep::SelectAsset, + + scan_input: String::new(), + available_assets: Vec::new(), + selected_asset: None, + asset_search: String::new(), + asset_loading: false, + + borrower_selection: BorrowerSelection::None, + registered_borrowers: Vec::new(), + banned_borrowers: Vec::new(), + borrower_search: String::new(), + borrower_loading: false, + + new_borrower_name: String::new(), + new_borrower_class: String::new(), + new_borrower_role: String::from("Student"), + new_borrower_phone: String::new(), + new_borrower_email: String::new(), + + selected_duration_days: None, + custom_due_date: String::new(), + + lending_notes: String::new(), + confirm_risky_asset: false, + + error_message: None, + success_message: None, + just_completed_successfully: false, + } + } +} + +impl BorrowFlow { + pub fn new() -> Self { + Self::default() + } + + pub fn open(&mut self, api_client: &ApiClient) { + self.is_open = true; + self.current_step = BorrowStep::SelectAsset; + self.reset_fields(); + self.just_completed_successfully = false; + self.load_available_assets(api_client); + } + + pub fn close(&mut self) { + self.is_open = false; + self.reset_fields(); + } + + pub fn take_recent_success(&mut self) -> bool { + if self.just_completed_successfully { + self.just_completed_successfully = false; + true + } else { + false + } + } + + fn reset_fields(&mut self) { + self.scan_input.clear(); + self.available_assets.clear(); + self.selected_asset = None; + self.asset_search.clear(); + + self.borrower_selection = BorrowerSelection::None; + self.registered_borrowers.clear(); + self.banned_borrowers.clear(); + self.borrower_search.clear(); + + self.new_borrower_name.clear(); + self.new_borrower_class.clear(); + self.new_borrower_role = String::from("Student"); + self.new_borrower_phone.clear(); + self.new_borrower_email.clear(); + + self.selected_duration_days = None; + self.custom_due_date.clear(); + self.lending_notes.clear(); + self.confirm_risky_asset = false; + + self.error_message = None; + self.success_message = None; + } + + pub fn show(&mut self, ctx: &egui::Context, api_client: &ApiClient) -> bool { + let was_open = self.is_open; + let mut keep_open = self.is_open; + + egui::Window::new("Borrow an Item") + .id(egui::Id::new("borrow_flow_main_window")) + .default_size(egui::vec2(1100.0, 800.0)) + .resizable(true) + .collapsible(false) + .open(&mut keep_open) + .show(ctx, |ui| { + // Progress indicator + self.show_progress_bar(ui); + + ui.separator(); + + // Show error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(egui::Color32::RED, err); + ui.separator(); + } + if let Some(msg) = &self.success_message { + ui.colored_label(egui::Color32::GREEN, msg); + ui.separator(); + } + + // Main content area + egui::ScrollArea::vertical() + .id_salt("borrow_flow_main_scroll") + .show(ui, |ui| match self.current_step { + BorrowStep::SelectAsset => self.show_asset_selection(ui, api_client), + BorrowStep::SelectBorrower => self.show_borrower_selection(ui, api_client), + BorrowStep::SelectDuration => self.show_duration_selection(ui), + BorrowStep::Confirm => self.show_confirmation(ui), + }); + + ui.separator(); + + // Navigation buttons + self.show_navigation_buttons(ui, api_client); + }); + if !self.is_open { + keep_open = false; + } + + self.is_open = keep_open; + + if !keep_open && was_open { + self.close(); + } + + keep_open + } + + fn show_progress_bar(&self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + let step_index = match self.current_step { + BorrowStep::SelectAsset => 0, + BorrowStep::SelectBorrower => 1, + BorrowStep::SelectDuration => 2, + BorrowStep::Confirm => 3, + }; + + let steps = [ + (icons::PACKAGE, "Asset"), + (icons::USER, "Borrower"), + (icons::CLOCK, "Duration"), + (icons::CHECK_CIRCLE, "Confirm"), + ]; + for (i, (icon, step_name)) in steps.iter().enumerate() { + let color = if i == step_index { + egui::Color32::from_rgb(100, 149, 237) + } else if i < step_index { + egui::Color32::from_rgb(60, 179, 113) + } else { + egui::Color32::GRAY + }; + + ui.colored_label(color, format!("{} {}", icon, step_name)); + if i < steps.len() - 1 { + ui.add_space(5.0); + ui.label(icons::CARET_RIGHT); + ui.add_space(5.0); + } + } + }); + } + + fn show_asset_selection(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.heading("What do you want to borrow?"); + ui.add_space(10.0); + + // Scan field + ui.horizontal(|ui| { + ui.label("Scan or Enter Asset Tag/ID:"); + let response = ui.add( + egui::TextEdit::singleline(&mut self.scan_input) + .id(egui::Id::new("borrow_flow_scan_input")) + .hint_text("Scan barcode or type asset tag...") + .desired_width(300.0), + ); + + if response.changed() && !self.scan_input.is_empty() { + self.try_scan_asset(api_client); + } + + if ui.button("Clear").clicked() { + self.scan_input.clear(); + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Search bar + ui.horizontal(|ui| { + ui.label("Search:"); + if ui + .add( + egui::TextEdit::singleline(&mut self.asset_search) + .id(egui::Id::new("borrow_flow_asset_search")), + ) + .changed() + { + // Filter is applied in the table rendering + } + if ui.button("Refresh").clicked() { + self.load_available_assets(api_client); + } + }); + + ui.add_space(5.0); + + // Assets table + ui.label(egui::RichText::new("All Lendable Items").strong()); + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), 300.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + self.render_assets_table(ui); + }, + ); + } + + fn show_borrower_selection(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.heading("Who will borrow it?"); + ui.add_space(10.0); + + // New borrower registration section + egui::CollapsingHeader::new(egui::RichText::new("Register New Borrower").strong()) + .id_salt("borrow_flow_new_borrower_header") + .default_open(false) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Name:"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_name) + .id(egui::Id::new("borrow_flow_new_borrower_name")), + ); + }); + + ui.horizontal(|ui| { + ui.label("Class:"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_class) + .id(egui::Id::new("borrow_flow_new_borrower_class")), + ); + }); + + ui.horizontal(|ui| { + ui.label("Role:"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_role) + .id(egui::Id::new("borrow_flow_new_borrower_role")) + .hint_text("e.g. Student, Faculty, Staff, External"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Phone (optional):"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_phone) + .id(egui::Id::new("borrow_flow_new_borrower_phone")), + ); + }); + + ui.horizontal(|ui| { + ui.label("Email (optional):"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_email) + .id(egui::Id::new("borrow_flow_new_borrower_email")), + ); + }); + + ui.add_space(5.0); + + if ui.button("Use This New Borrower").clicked() { + if self.new_borrower_name.trim().is_empty() { + self.error_message = Some("Name is required".to_string()); + } else { + self.borrower_selection = BorrowerSelection::NewRegistration { + name: self.new_borrower_name.clone(), + department: self.new_borrower_class.clone(), + borrower_type: self.new_borrower_role.clone(), + phone: self.new_borrower_phone.clone(), + email: self.new_borrower_email.clone(), + }; + self.error_message = None; + } + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Banned borrowers warning section + if !self.banned_borrowers.is_empty() { + ui.colored_label( + egui::Color32::RED, + egui::RichText::new("WARNING: DO NOT LEND TO THESE BORROWERS!").strong(), + ); + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), 150.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + self.render_banned_borrowers_table(ui); + }, + ); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + } + + // Registered borrowers section + ui.label(egui::RichText::new("Select Registered Borrower").strong()); + + ui.horizontal(|ui| { + ui.label("Search:"); + if ui + .add( + egui::TextEdit::singleline(&mut self.borrower_search) + .id(egui::Id::new("borrow_flow_borrower_search")), + ) + .changed() + { + // Filter is applied in table rendering + } + if ui.button("Refresh").clicked() { + self.load_borrowers(api_client); + } + }); + + ui.add_space(5.0); + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), 300.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + self.render_borrowers_table(ui); + }, + ); + } + + fn show_duration_selection(&mut self, ui: &mut egui::Ui) { + ui.heading("How long does the borrower need it?"); + ui.add_space(10.0); + + ui.label(egui::RichText::new("Common Timeframes:").strong()); + ui.add_space(5.0); + + // Common duration buttons in a grid + egui::Grid::new("duration_grid") + .num_columns(4) + .spacing(egui::vec2(8.0, 8.0)) + .show(ui, |ui| { + for (days, label) in [(1, "1 Day"), (2, "2 Days"), (3, "3 Days"), (4, "4 Days")] { + let selected = self.selected_duration_days == Some(days); + if ui.selectable_label(selected, label).clicked() { + self.selected_duration_days = Some(days); + self.custom_due_date.clear(); + } + } + ui.end_row(); + + for (days, label) in [(5, "5 Days"), (6, "6 Days"), (7, "1 Week"), (14, "2 Weeks")] + { + let selected = self.selected_duration_days == Some(days); + if ui.selectable_label(selected, label).clicked() { + self.selected_duration_days = Some(days); + self.custom_due_date.clear(); + } + } + ui.end_row(); + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Special option: Deploy (indefinite) - separate from time options + ui.horizontal(|ui| { + ui.label("Special:"); + let selected = self.selected_duration_days == Some(0); + let deploy_label = format!("{} Deploy (Indefinite)", icons::ROCKET_LAUNCH); + if ui.selectable_label(selected, deploy_label).clicked() { + self.selected_duration_days = Some(0); + self.custom_due_date.clear(); + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.label("Or specify a custom date (YYYY-MM-DD):"); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut self.custom_due_date) + .id(egui::Id::new("borrow_flow_custom_due_date")), + ); + if ui.button("Clear").clicked() { + self.custom_due_date.clear(); + self.selected_duration_days = None; + } + }); + + if !self.custom_due_date.is_empty() { + self.selected_duration_days = None; + } + } + + fn show_confirmation(&mut self, ui: &mut egui::Ui) { + ui.heading("Overview"); + ui.add_space(10.0); + + // Summary box + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.label(egui::RichText::new("You will authorize lending:").strong()); + ui.add_space(5.0); + + // Asset info + if let Some(asset) = &self.selected_asset { + let tag = asset + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let name = asset.get("name").and_then(|v| v.as_str()).unwrap_or("N/A"); + ui.label(format!("Asset: {} - {}", tag, name)); + } + + // Borrower info + match &self.borrower_selection { + BorrowerSelection::Existing(borrower) => { + let name = borrower + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let class = borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + ui.label(format!("Borrower: {} ({})", name, class)); + } + BorrowerSelection::NewRegistration { + name, + department, + borrower_type, + .. + } => { + ui.label(format!( + "New Borrower: {} ({}) - {}", + name, department, borrower_type + )); + } + BorrowerSelection::None => { + ui.colored_label(egui::Color32::RED, "WARNING: No borrower selected!"); + } + } + + // Duration info + if let Some(days) = self.selected_duration_days { + if days == 0 { + ui.label(format!( + "{} Deployed (Indefinite - No due date)", + icons::ROCKET_LAUNCH + )); + } else { + let due_date = Local::now() + Duration::days(days as i64); + ui.label(format!( + "Duration: {} days (Due: {})", + days, + due_date.format("%Y-%m-%d") + )); + } + } else if !self.custom_due_date.is_empty() { + ui.label(format!("Due Date: {}", self.custom_due_date)); + } else { + ui.colored_label(egui::Color32::RED, "WARNING: No duration selected!"); + } + }); + + ui.add_space(15.0); + + // Risk warning for Faulty/Attention assets + if let Some(asset) = &self.selected_asset { + let status = asset.get("status").and_then(|v| v.as_str()).unwrap_or(""); + if status == "Faulty" || status == "Attention" { + let (color, label) = if status == "Faulty" { + ( + egui::Color32::from_rgb(244, 67, 54), + "This item is marked as Faulty and may be unsafe or unusable.", + ) + } else { + ( + egui::Color32::from_rgb(255, 193, 7), + "This item has Attention status and may have minor defects.", + ) + }; + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.colored_label(color, label); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.checkbox( + &mut self.confirm_risky_asset, + "I acknowledge the issues and still wish to lend this item", + ); + }); + }); + ui.add_space(10.0); + } + } + + // Optional notes + ui.label("Optional Lending Notes:"); + ui.add( + egui::TextEdit::multiline(&mut self.lending_notes) + .id(egui::Id::new("borrow_flow_lending_notes")) + .desired_width(f32::INFINITY) + .desired_rows(4), + ); + } + + fn show_navigation_buttons(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.horizontal(|ui| { + // Back button + if self.current_step != BorrowStep::SelectAsset { + if ui.button(format!("{} Back", icons::ARROW_LEFT)).clicked() { + self.go_back(); + } + } + + ui.add_space(10.0); + + // Cancel button + if ui.button(format!("{} Cancel", icons::X)).clicked() { + self.close(); + } + + // Spacer + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Next/Approve button + match self.current_step { + BorrowStep::SelectAsset => { + let enabled = self.selected_asset.is_some(); + if ui + .add_enabled( + enabled, + egui::Button::new(format!("{} Next", icons::ARROW_RIGHT)), + ) + .clicked() + { + self.go_to_borrower_selection(api_client); + } + } + BorrowStep::SelectBorrower => { + let enabled = self.borrower_selection != BorrowerSelection::None; + if ui + .add_enabled( + enabled, + egui::Button::new(format!("{} Next", icons::ARROW_RIGHT)), + ) + .clicked() + { + self.current_step = BorrowStep::SelectDuration; + self.error_message = None; + } + } + BorrowStep::SelectDuration => { + let enabled = self.selected_duration_days.is_some() + || !self.custom_due_date.is_empty(); + if ui + .add_enabled( + enabled, + egui::Button::new(format!("{} Next", icons::ARROW_RIGHT)), + ) + .clicked() + { + self.current_step = BorrowStep::Confirm; + self.error_message = None; + } + } + BorrowStep::Confirm => { + // If asset is risky (Faulty/Attention), require explicit acknowledgment before enabling submit + let mut risky_requires_ack = false; + if let Some(asset) = &self.selected_asset { + let status = asset.get("status").and_then(|v| v.as_str()).unwrap_or(""); + if status == "Faulty" || status == "Attention" { + risky_requires_ack = true; + } + } + + let can_submit = !risky_requires_ack || self.confirm_risky_asset; + if ui + .add_enabled( + can_submit, + egui::Button::new(format!( + "{} Approve & Submit", + icons::ARROW_LEFT + )), + ) + .clicked() + { + self.submit_lending(api_client); + } + } + } + }); + }); + } + + fn go_back(&mut self) { + self.error_message = None; + self.current_step = match self.current_step { + BorrowStep::SelectAsset => BorrowStep::SelectAsset, + BorrowStep::SelectBorrower => BorrowStep::SelectAsset, + BorrowStep::SelectDuration => BorrowStep::SelectBorrower, + BorrowStep::Confirm => BorrowStep::SelectDuration, + }; + } + + fn go_to_borrower_selection(&mut self, api_client: &ApiClient) { + self.current_step = BorrowStep::SelectBorrower; + self.load_borrowers(api_client); + self.error_message = None; + } + + // Data loading methods + fn load_available_assets(&mut self, api_client: &ApiClient) { + self.asset_loading = true; + self.error_message = None; + + let request = QueryRequest { + action: "select".to_string(), + table: "assets".to_string(), + columns: Some(vec![ + "assets.id".to_string(), + "assets.asset_tag".to_string(), + "assets.name".to_string(), + "assets.category_id".to_string(), + "assets.lending_status".to_string(), + "categories.category_name".to_string(), + ]), + r#where: Some(serde_json::json!({ + "assets.lendable": true, + "assets.lending_status": "Available" + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: Some(vec![crate::models::Join { + table: "categories".to_string(), + on: "assets.category_id = categories.id".to_string(), + join_type: "LEFT".to_string(), + }]), + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + self.available_assets = arr.clone(); + } + } + } else { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to load assets".to_string()), + ); + } + } + Err(e) => { + self.error_message = Some(format!("Error loading assets: {}", e)); + } + } + + self.asset_loading = false; + } + + fn try_scan_asset(&mut self, api_client: &ApiClient) { + let scan_value = self.scan_input.trim(); + if scan_value.is_empty() { + return; + } + + // Try to find by asset_tag or id + let request = QueryRequest { + action: "select".to_string(), + table: "assets".to_string(), + columns: Some(vec![ + "assets.id".to_string(), + "assets.asset_tag".to_string(), + "assets.name".to_string(), + "assets.category_id".to_string(), + "assets.lending_status".to_string(), + "assets.lendable".to_string(), + "categories.category_name".to_string(), + ]), + r#where: Some(serde_json::json!({ + "assets.asset_tag": scan_value + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: Some(vec![crate::models::Join { + table: "categories".to_string(), + on: "assets.category_id = categories.id".to_string(), + join_type: "LEFT".to_string(), + }]), + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + if let Some(asset) = arr.first() { + // Verify it's lendable and available + let lendable = asset + .get("lendable") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let status = asset + .get("lending_status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if lendable && status == "Available" { + self.selected_asset = Some(asset.clone()); + self.error_message = None; + } else { + self.error_message = Some(format!( + "Asset '{}' is not available for lending", + scan_value + )); + } + } else { + self.error_message = + Some(format!("Asset '{}' not found", scan_value)); + } + } + } + } else { + self.error_message = + Some(response.error.unwrap_or_else(|| "Scan failed".to_string())); + } + } + Err(e) => { + self.error_message = Some(format!("Scan error: {}", e)); + } + } + } + + fn load_borrowers(&mut self, api_client: &ApiClient) { + self.borrower_loading = true; + self.error_message = None; + + // Load registered (non-banned) borrowers + let request = QueryRequest { + action: "select".to_string(), + table: "borrowers".to_string(), + columns: Some(vec![ + "id".to_string(), + "name".to_string(), + "email".to_string(), + "phone_number".to_string(), + "role".to_string(), + "class_name".to_string(), + "banned".to_string(), + ]), + r#where: Some(serde_json::json!({ + "banned": false + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + self.registered_borrowers = arr.clone(); + } + } + } + } + Err(e) => { + self.error_message = Some(format!("Error loading borrowers: {}", e)); + } + } + + // Load banned borrowers + let banned_request = QueryRequest { + action: "select".to_string(), + table: "borrowers".to_string(), + columns: Some(vec![ + "id".to_string(), + "name".to_string(), + "class_name".to_string(), + "unban_fine".to_string(), + ]), + r#where: Some(serde_json::json!({ + "banned": true + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&banned_request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + self.banned_borrowers = arr.clone(); + } + } + } + } + Err(_) => { + // Don't overwrite error message if we already have one + } + } + + self.borrower_loading = false; + } + + // Table rendering methods + fn render_assets_table(&mut self, ui: &mut egui::Ui) { + use egui_extras::{Column, TableBuilder}; + + // Filter assets based on search + let filtered_assets: Vec<&Value> = self + .available_assets + .iter() + .filter(|asset| { + if self.asset_search.is_empty() { + return true; + } + let search_lower = self.asset_search.to_lowercase(); + let tag = asset + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let name = asset.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let category = asset + .get("category_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + tag.to_lowercase().contains(&search_lower) + || name.to_lowercase().contains(&search_lower) + || category.to_lowercase().contains(&search_lower) + }) + .collect(); + + TableBuilder::new(ui) + .id_salt("borrow_flow_assets_table") + .striped(true) + .resizable(true) + .sense(egui::Sense::click()) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto().resizable(true)) + .column(Column::remainder().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .min_scrolled_height(0.0) + .max_scroll_height(300.0) + .header(22.0, |mut header| { + header.col(|ui| { + ui.strong("Asset Tag"); + }); + header.col(|ui| { + ui.strong("Name"); + }); + header.col(|ui| { + ui.strong("Category"); + }); + header.col(|ui| { + ui.strong("Action"); + }); + }) + .body(|mut body| { + for asset in filtered_assets { + body.row(20.0, |mut row| { + let asset_id = asset.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + let is_selected = self + .selected_asset + .as_ref() + .and_then(|s| s.get("id").and_then(|v| v.as_i64())) + .map(|id| id == asset_id) + .unwrap_or(false); + + row.col(|ui| { + ui.label( + asset + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label(asset.get("name").and_then(|v| v.as_str()).unwrap_or("N/A")); + }); + row.col(|ui| { + ui.label( + asset + .get("category_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + if is_selected { + ui.colored_label(egui::Color32::GREEN, "Selected"); + } else { + let button_id = format!("select_asset_{}", asset_id); + if ui.button("Select").on_hover_text(&button_id).clicked() { + self.selected_asset = Some((*asset).clone()); + } + } + }); + }); + } + }); + } + + fn render_borrowers_table(&mut self, ui: &mut egui::Ui) { + use egui_extras::{Column, TableBuilder}; + + // Filter borrowers based on search + let filtered_borrowers: Vec<&Value> = self + .registered_borrowers + .iter() + .filter(|borrower| { + if self.borrower_search.is_empty() { + return true; + } + let search_lower = self.borrower_search.to_lowercase(); + let name = borrower.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let class = borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let role = borrower.get("role").and_then(|v| v.as_str()).unwrap_or(""); + + name.to_lowercase().contains(&search_lower) + || class.to_lowercase().contains(&search_lower) + || role.to_lowercase().contains(&search_lower) + }) + .collect(); + + TableBuilder::new(ui) + .id_salt("borrow_flow_borrowers_table") + .striped(true) + .resizable(true) + .sense(egui::Sense::click()) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::remainder().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .min_scrolled_height(0.0) + .max_scroll_height(300.0) + .header(22.0, |mut header| { + header.col(|ui| { + ui.strong("Name"); + }); + header.col(|ui| { + ui.strong("Class"); + }); + header.col(|ui| { + ui.strong("Role"); + }); + header.col(|ui| { + ui.strong("Email"); + }); + header.col(|ui| { + ui.strong("Phone"); + }); + header.col(|ui| { + ui.strong("Action"); + }); + }) + .body(|mut body| { + for borrower in filtered_borrowers { + body.row(20.0, |mut row| { + let borrower_id = borrower.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + let is_selected = match &self.borrower_selection { + BorrowerSelection::Existing(b) => b + .get("id") + .and_then(|v| v.as_i64()) + .map(|id| id == borrower_id) + .unwrap_or(false), + _ => false, + }; + + row.col(|ui| { + ui.label( + borrower + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("role") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("email") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("phone_number") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + if is_selected { + ui.colored_label(egui::Color32::GREEN, "Selected"); + } else { + let button_id = format!("select_borrower_{}", borrower_id); + if ui.button("Select").on_hover_text(&button_id).clicked() { + self.borrower_selection = + BorrowerSelection::Existing((*borrower).clone()); + } + } + }); + }); + } + }); + } + + fn render_banned_borrowers_table(&self, ui: &mut egui::Ui) { + use egui_extras::{Column, TableBuilder}; + + TableBuilder::new(ui) + .id_salt("borrow_flow_banned_borrowers_table") + .striped(true) + .resizable(true) + .sense(egui::Sense::click()) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::remainder().resizable(true)) + .min_scrolled_height(0.0) + .max_scroll_height(150.0) + .header(22.0, |mut header| { + header.col(|ui| { + ui.strong("Name"); + }); + header.col(|ui| { + ui.strong("Class/Dept"); + }); + header.col(|ui| { + ui.strong("Unban Fine"); + }); + }) + .body(|mut body| { + for borrower in &self.banned_borrowers { + body.row(20.0, |mut row| { + row.col(|ui| { + ui.colored_label( + egui::Color32::RED, + borrower + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("unban_fine") + .and_then(|v| v.as_f64()) + .map(|f| format!("${:.2}", f)) + .unwrap_or("N/A".to_string()), + ); + }); + }); + } + }); + } + + // Submission method + fn submit_lending(&mut self, api_client: &ApiClient) { + self.error_message = None; + self.success_message = None; + + // Validate all required data + let asset = match &self.selected_asset { + Some(a) => a, + None => { + self.error_message = Some("No asset selected".to_string()); + return; + } + }; + + let asset_id = match asset.get("id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => { + self.error_message = Some("Invalid asset ID".to_string()); + return; + } + }; + + // Calculate due date (0 days = deployment/indefinite, no due date) + let due_date_str = if let Some(days) = self.selected_duration_days { + if days == 0 { + // Deployment mode: no due date + String::new() + } else { + let due = Local::now() + Duration::days(days as i64); + due.format("%Y-%m-%d").to_string() + } + } else if !self.custom_due_date.is_empty() { + self.custom_due_date.clone() + } else { + self.error_message = Some("No duration selected".to_string()); + return; + }; + + // Handle borrower (either create new or use existing) + let borrower_id = match &self.borrower_selection { + BorrowerSelection::Existing(borrower) => { + match borrower.get("id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => { + self.error_message = Some("Invalid borrower ID".to_string()); + return; + } + } + } + BorrowerSelection::NewRegistration { + name, + department, + borrower_type, + phone, + email, + } => { + // First register the new borrower + match self.register_new_borrower( + api_client, + name, + department, + borrower_type, + phone, + email, + ) { + Ok(id) => id, + Err(e) => { + self.error_message = Some(format!("Failed to register borrower: {}", e)); + return; + } + } + } + BorrowerSelection::None => { + self.error_message = Some("No borrower selected".to_string()); + return; + } + }; + + // Create lending history record + let checkout_date = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + let mut lending_data = serde_json::json!({ + "asset_id": asset_id, + "borrower_id": borrower_id, + "checkout_date": checkout_date + }); + + // Only set due_date if not deployment mode + if !due_date_str.is_empty() { + lending_data["due_date"] = serde_json::Value::String(due_date_str.clone()); + } + + if !self.lending_notes.is_empty() { + lending_data["notes"] = serde_json::Value::String(self.lending_notes.clone()); + } + + let lending_request = QueryRequest { + action: "insert".to_string(), + table: "lending_history".to_string(), + columns: None, + r#where: None, + data: Some(lending_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&lending_request) { + Ok(response) => { + if !response.success { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to create lending record".to_string()), + ); + return; + } + } + Err(e) => { + self.error_message = Some(format!("Error creating lending record: {}", e)); + return; + } + } + + // Update asset status to "Borrowed" or "Deployed" based on duration + let lending_status = if self.selected_duration_days == Some(0) { + "Deployed" + } else { + "Borrowed" + }; + + let mut asset_update_data = serde_json::json!({ + "lending_status": lending_status, + "current_borrower_id": borrower_id + }); + if !due_date_str.is_empty() { + asset_update_data["due_date"] = serde_json::Value::String(due_date_str.clone()); + } + + let asset_update = QueryRequest { + action: "update".to_string(), + table: "assets".to_string(), + columns: None, + r#where: Some(serde_json::json!({ + "id": asset_id + })), + data: Some(asset_update_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&asset_update) { + Ok(response) => { + if response.success { + self.just_completed_successfully = true; + self.success_message = Some("Item successfully lent!".to_string()); + // Auto-close after a brief success message + // In a real app, you might want to add a delay here + self.close(); + } else { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to update asset status".to_string()), + ); + } + } + Err(e) => { + self.error_message = Some(format!("Error updating asset: {}", e)); + } + } + } + + fn register_new_borrower( + &self, + api_client: &ApiClient, + name: &str, + department: &str, + borrower_type: &str, + phone: &str, + email: &str, + ) -> Result { + let mut borrower_data = serde_json::json!({ + "name": name, + "role": borrower_type, + "class_name": department, + }); + + if !phone.is_empty() { + borrower_data["phone_number"] = serde_json::Value::String(phone.to_string()); + } + if !email.is_empty() { + borrower_data["email"] = serde_json::Value::String(email.to_string()); + } + + let request = QueryRequest { + action: "insert".to_string(), + table: "borrowers".to_string(), + columns: None, + r#where: None, + data: Some(borrower_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to register borrower".to_string()))); + } + + // Get the newly created borrower ID from response + if let Some(data) = &response.data { + if let Some(id) = data.get("id").and_then(|v| v.as_i64()) { + Ok(id) + } else if let Some(id) = data.get("inserted_id").and_then(|v| v.as_i64()) { + Ok(id) + } else { + Err(anyhow::anyhow!( + "Failed to get new borrower ID from response" + )) + } + } else { + Err(anyhow::anyhow!( + "No data returned from borrower registration" + )) + } + } +} diff --git a/src/core/workflows/mod.rs b/src/core/workflows/mod.rs new file mode 100644 index 0000000..fd7e7e5 --- /dev/null +++ b/src/core/workflows/mod.rs @@ -0,0 +1,9 @@ +/// Multi-step workflows for complex operations +pub mod add_from_template; +pub mod audit; +pub mod borrow_flow; +pub mod return_flow; + +pub use add_from_template::AddFromTemplateWorkflow; +pub use audit::AuditWorkflow; +// borrow_flow and return_flow accessed via qualified paths in views diff --git a/src/core/workflows/return_flow.rs b/src/core/workflows/return_flow.rs new file mode 100644 index 0000000..3c4667a --- /dev/null +++ b/src/core/workflows/return_flow.rs @@ -0,0 +1,924 @@ +use anyhow::Result; +use chrono::Local; +use eframe::egui; +use egui_phosphor::variants::regular as icons; +use serde_json::Value; + +use crate::api::ApiClient; +use crate::models::QueryRequest; + +#[derive(Debug, Clone, PartialEq)] +pub enum ReturnStep { + SelectLoan, + Confirm, +} + +pub struct ReturnFlow { + // State + pub is_open: bool, + pub current_step: ReturnStep, + + // Step 1: Loan Selection + pub scan_input: String, + pub active_loans: Vec, + pub selected_loan: Option, + pub loan_search: String, + pub loan_loading: bool, + + // Step 2: Notes and Issue Reporting + pub return_notes: String, + + // Issue reporting (optional) + pub report_issue: bool, + pub issue_title: String, + pub issue_description: String, + pub issue_severity: String, + pub issue_priority: String, + + // Error handling + pub error_message: Option, + pub success_message: Option, + pub just_completed_successfully: bool, +} + +impl Default for ReturnFlow { + fn default() -> Self { + Self { + is_open: false, + current_step: ReturnStep::SelectLoan, + + scan_input: String::new(), + active_loans: Vec::new(), + selected_loan: None, + loan_search: String::new(), + loan_loading: false, + + return_notes: String::new(), + + report_issue: false, + issue_title: String::new(), + issue_description: String::new(), + issue_severity: String::from("Medium"), + issue_priority: String::from("Normal"), + + error_message: None, + success_message: None, + just_completed_successfully: false, + } + } +} + +impl ReturnFlow { + pub fn new() -> Self { + Self::default() + } + + pub fn open(&mut self, api_client: &ApiClient) { + self.is_open = true; + self.current_step = ReturnStep::SelectLoan; + self.reset_fields(); + self.just_completed_successfully = false; + self.load_active_loans(api_client); + } + + pub fn close(&mut self) { + self.is_open = false; + self.reset_fields(); + } + + pub fn take_recent_success(&mut self) -> bool { + if self.just_completed_successfully { + self.just_completed_successfully = false; + true + } else { + false + } + } + + fn reset_fields(&mut self) { + self.scan_input.clear(); + self.active_loans.clear(); + self.selected_loan = None; + self.loan_search.clear(); + + self.return_notes.clear(); + + self.report_issue = false; + self.issue_title.clear(); + self.issue_description.clear(); + self.issue_severity = String::from("Medium"); + self.issue_priority = String::from("Normal"); + + self.error_message = None; + self.success_message = None; + } + + pub fn show(&mut self, ctx: &egui::Context, api_client: &ApiClient) -> bool { + let mut keep_open = self.is_open; + + egui::Window::new("Return an Item") + .id(egui::Id::new("return_flow_main_window")) + .default_size(egui::vec2(1000.0, 700.0)) + .resizable(true) + .movable(true) + .collapsible(false) + .open(&mut keep_open) + .show(ctx, |ui| { + // Progress indicator + self.show_progress_bar(ui); + + ui.separator(); + + // Show error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(egui::Color32::RED, err); + ui.separator(); + } + if let Some(msg) = &self.success_message { + ui.colored_label(egui::Color32::GREEN, msg); + ui.separator(); + } + + // Main content area + egui::ScrollArea::vertical() + .id_salt("return_flow_main_scroll") + .show(ui, |ui| match self.current_step { + ReturnStep::SelectLoan => self.show_loan_selection(ui, api_client), + ReturnStep::Confirm => self.show_confirmation(ui), + }); + + ui.separator(); + + // Navigation buttons + self.show_navigation_buttons(ui, api_client); + }); + + if !keep_open { + self.close(); + } + + keep_open + } + + fn show_progress_bar(&self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + let step_index = match self.current_step { + ReturnStep::SelectLoan => 0, + ReturnStep::Confirm => 1, + }; + + let steps = [ + (icons::PACKAGE, "Select Item"), + (icons::CHECK_CIRCLE, "Confirm"), + ]; + for (i, (icon, step_name)) in steps.iter().enumerate() { + let color = if i == step_index { + egui::Color32::from_rgb(100, 149, 237) + } else if i < step_index { + egui::Color32::from_rgb(60, 179, 113) + } else { + egui::Color32::GRAY + }; + + ui.colored_label(color, format!("{} {}", icon, step_name)); + if i < steps.len() - 1 { + ui.add_space(5.0); + ui.label(icons::CARET_RIGHT); + ui.add_space(5.0); + } + } + }); + } + + fn show_loan_selection(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.heading("Which item is being returned?"); + ui.add_space(10.0); + + // Scan field + ui.horizontal(|ui| { + ui.label("Scan or Enter Asset Tag/ID:"); + let response = ui.add( + egui::TextEdit::singleline(&mut self.scan_input) + .id(egui::Id::new("return_flow_scan_input")) + .hint_text("Scan barcode or type asset tag...") + .desired_width(300.0), + ); + + if response.changed() && !self.scan_input.is_empty() { + self.try_scan_loan(api_client); + } + + if ui.button("Clear").clicked() { + self.scan_input.clear(); + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Search bar + ui.horizontal(|ui| { + ui.label("Search:"); + if ui + .add( + egui::TextEdit::singleline(&mut self.loan_search) + .id(egui::Id::new("return_flow_loan_search")), + ) + .changed() + { + // Filter is applied in the table rendering + } + if ui.button("Refresh").clicked() { + self.load_active_loans(api_client); + } + }); + + ui.add_space(5.0); + + // Active loans table + ui.label(egui::RichText::new("Currently Borrowed Items").strong()); + ui.push_id("return_flow_loans_section", |ui| { + self.render_loans_table(ui); + }); + } + + fn show_confirmation(&mut self, ui: &mut egui::Ui) { + ui.heading("Confirm Return"); + ui.add_space(10.0); + + // Summary box + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.label(egui::RichText::new("You are about to process this return:").strong()); + ui.add_space(5.0); + + // Loan info + if let Some(loan) = &self.selected_loan { + let asset_tag = loan + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let asset_name = loan + .get("asset_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let borrower_name = loan + .get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let due_date = loan + .get("due_date") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + + ui.label(format!("Asset: {} - {}", asset_tag, asset_name)); + ui.label(format!("Borrower: {}", borrower_name)); + ui.label(format!("Due Date: {}", due_date)); + + // Check if overdue + if let Some(due_str) = loan.get("due_date").and_then(|v| v.as_str()) { + let today = Local::now().format("%Y-%m-%d").to_string(); + if today.as_str() > due_str { + ui.colored_label(egui::Color32::RED, "⚠ This item is OVERDUE!"); + } else { + ui.colored_label(egui::Color32::GREEN, "✓ Returned on time"); + } + } + + if !self.return_notes.is_empty() { + ui.add_space(5.0); + ui.label(format!("Notes: {}", self.return_notes)); + } + + if self.report_issue { + ui.add_space(5.0); + ui.colored_label( + egui::Color32::from_rgb(255, 193, 7), + format!("⚠ Issue will be reported: {}", self.issue_title), + ); + } + } + }); + + ui.add_space(15.0); + + // Optional return notes + ui.label("Return Notes (optional):"); + ui.add( + egui::TextEdit::multiline(&mut self.return_notes) + .id(egui::Id::new("return_flow_notes_confirm")) + .desired_width(f32::INFINITY) + .desired_rows(3), + ); + + ui.add_space(15.0); + ui.separator(); + ui.add_space(10.0); + + // Issue reporting section + ui.horizontal(|ui| { + if ui.button("🚨 Report Issue with Item").clicked() { + self.report_issue = !self.report_issue; + } + if self.report_issue { + ui.colored_label( + egui::Color32::from_rgb(255, 193, 7), + "(Issue reporting enabled)", + ); + } + }); + + if self.report_issue { + ui.add_space(10.0); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.label(egui::RichText::new("Issue Details:").strong()); + + ui.horizontal(|ui| { + ui.label("Title:"); + ui.add( + egui::TextEdit::singleline(&mut self.issue_title) + .id(egui::Id::new("return_flow_issue_title")) + .hint_text("Brief description of the issue") + .desired_width(400.0), + ); + }); + + ui.horizontal(|ui| { + ui.label("Severity:"); + egui::ComboBox::from_id_salt("return_flow_issue_severity") + .selected_text(&self.issue_severity) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.issue_severity, "Low".to_string(), "Low"); + ui.selectable_value( + &mut self.issue_severity, + "Medium".to_string(), + "Medium", + ); + ui.selectable_value( + &mut self.issue_severity, + "High".to_string(), + "High", + ); + ui.selectable_value( + &mut self.issue_severity, + "Critical".to_string(), + "Critical", + ); + }); + + ui.label("Priority:"); + egui::ComboBox::from_id_salt("return_flow_issue_priority") + .selected_text(&self.issue_priority) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.issue_priority, "Low".to_string(), "Low"); + ui.selectable_value( + &mut self.issue_priority, + "Normal".to_string(), + "Normal", + ); + ui.selectable_value( + &mut self.issue_priority, + "High".to_string(), + "High", + ); + ui.selectable_value( + &mut self.issue_priority, + "Urgent".to_string(), + "Urgent", + ); + }); + }); + + ui.label("Description:"); + ui.add( + egui::TextEdit::multiline(&mut self.issue_description) + .id(egui::Id::new("return_flow_issue_description")) + .hint_text("What's wrong with the item?") + .desired_width(f32::INFINITY) + .desired_rows(4), + ); + }); + } + } + + fn show_navigation_buttons(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.horizontal(|ui| { + // Back button + if self.current_step != ReturnStep::SelectLoan { + if ui.button(format!("{} Back", icons::ARROW_LEFT)).clicked() { + self.go_back(); + } + } + + ui.add_space(10.0); + + // Cancel button + if ui.button(format!("{} Cancel", icons::X)).clicked() { + self.close(); + } + + // Spacer + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Next/Process button + match self.current_step { + ReturnStep::SelectLoan => { + let enabled = self.selected_loan.is_some(); + if ui + .add_enabled( + enabled, + egui::Button::new(format!("{} Next", icons::ARROW_RIGHT)), + ) + .clicked() + { + self.current_step = ReturnStep::Confirm; + self.error_message = None; + } + } + ReturnStep::Confirm => { + if ui + .button(format!("{} Process Return", icons::ARROW_RIGHT)) + .clicked() + { + self.submit_return(api_client); + } + } + } + }); + }); + } + + fn go_back(&mut self) { + self.error_message = None; + self.current_step = match self.current_step { + ReturnStep::SelectLoan => ReturnStep::SelectLoan, + ReturnStep::Confirm => ReturnStep::SelectLoan, + }; + } + + // Data loading methods + fn load_active_loans(&mut self, api_client: &ApiClient) { + self.loan_loading = true; + self.error_message = None; + + let request = QueryRequest { + action: "select".to_string(), + table: "lending_history".to_string(), + columns: Some(vec![ + "lending_history.id".to_string(), + "lending_history.asset_id".to_string(), + "lending_history.borrower_id".to_string(), + "lending_history.checkout_date".to_string(), + "lending_history.due_date".to_string(), + "lending_history.notes".to_string(), + "assets.asset_tag".to_string(), + "assets.name AS asset_name".to_string(), + "borrowers.name AS borrower_name".to_string(), + "borrowers.class_name".to_string(), + ]), + r#where: Some(serde_json::json!({ + "lending_history.return_date": null + })), + data: None, + filter: None, + order_by: Some(vec![crate::models::OrderBy { + column: "lending_history.due_date".to_string(), + direction: "ASC".to_string(), + }]), + limit: None, + offset: None, + joins: Some(vec![ + crate::models::Join { + table: "assets".to_string(), + on: "lending_history.asset_id = assets.id".to_string(), + join_type: "LEFT".to_string(), + }, + crate::models::Join { + table: "borrowers".to_string(), + on: "lending_history.borrower_id = borrowers.id".to_string(), + join_type: "LEFT".to_string(), + }, + ]), + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + self.active_loans = arr.clone(); + } + } + } else { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to load active loans".to_string()), + ); + } + } + Err(e) => { + self.error_message = Some(format!("Error loading loans: {}", e)); + } + } + + self.loan_loading = false; + } + + fn try_scan_loan(&mut self, api_client: &ApiClient) { + let scan_value = self.scan_input.trim(); + if scan_value.is_empty() { + return; + } + + // Try to find active loan by asset_tag + let request = QueryRequest { + action: "select".to_string(), + table: "lending_history".to_string(), + columns: Some(vec![ + "lending_history.id".to_string(), + "lending_history.asset_id".to_string(), + "lending_history.borrower_id".to_string(), + "lending_history.checkout_date".to_string(), + "lending_history.due_date".to_string(), + "lending_history.notes".to_string(), + "assets.asset_tag".to_string(), + "assets.name AS asset_name".to_string(), + "borrowers.name AS borrower_name".to_string(), + "borrowers.class_name".to_string(), + ]), + r#where: Some(serde_json::json!({ + "assets.asset_tag": scan_value, + "lending_history.return_date": null + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: Some(vec![ + crate::models::Join { + table: "assets".to_string(), + on: "lending_history.asset_id = assets.id".to_string(), + join_type: "LEFT".to_string(), + }, + crate::models::Join { + table: "borrowers".to_string(), + on: "lending_history.borrower_id = borrowers.id".to_string(), + join_type: "LEFT".to_string(), + }, + ]), + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + if let Some(loan) = arr.first() { + self.selected_loan = Some(loan.clone()); + self.error_message = None; + } else { + self.error_message = Some(format!( + "No active loan found for asset '{}'", + scan_value + )); + } + } + } + } else { + self.error_message = + Some(response.error.unwrap_or_else(|| "Scan failed".to_string())); + } + } + Err(e) => { + self.error_message = Some(format!("Scan error: {}", e)); + } + } + } + + // Table rendering methods + fn render_loans_table(&mut self, ui: &mut egui::Ui) { + use egui_extras::{Column, TableBuilder}; + + // Filter loans based on search + let filtered_loans: Vec<&Value> = self + .active_loans + .iter() + .filter(|loan| { + if self.loan_search.is_empty() { + return true; + } + let search_lower = self.loan_search.to_lowercase(); + let asset_tag = loan.get("asset_tag").and_then(|v| v.as_str()).unwrap_or(""); + let asset_name = loan + .get("asset_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let borrower_name = loan + .get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + asset_tag.to_lowercase().contains(&search_lower) + || asset_name.to_lowercase().contains(&search_lower) + || borrower_name.to_lowercase().contains(&search_lower) + }) + .collect(); + + TableBuilder::new(ui) + .id_salt("return_flow_loans_table") + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::initial(100.0).resizable(true).at_least(80.0)) + .column(Column::initial(180.0).resizable(true).at_least(120.0)) + .column(Column::initial(150.0).resizable(true).at_least(100.0)) + .column(Column::initial(100.0).resizable(true).at_least(80.0)) + .column(Column::initial(100.0).resizable(true).at_least(80.0)) + .column(Column::initial(100.0).resizable(true).at_least(80.0)) + .max_scroll_height(350.0) + .header(22.0, |mut header| { + header.col(|ui| { + ui.strong("Asset Tag"); + }); + header.col(|ui| { + ui.strong("Name"); + }); + header.col(|ui| { + ui.strong("Borrower"); + }); + header.col(|ui| { + ui.strong("Class"); + }); + header.col(|ui| { + ui.strong("Due Date"); + }); + header.col(|ui| { + ui.strong("Action"); + }); + }) + .body(|mut body| { + for loan in filtered_loans { + body.row(20.0, |mut row| { + let loan_id = loan.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + let is_selected = self + .selected_loan + .as_ref() + .and_then(|s| s.get("id").and_then(|v| v.as_i64())) + .map(|id| id == loan_id) + .unwrap_or(false); + + // Check if overdue + let due_date = loan.get("due_date").and_then(|v| v.as_str()).unwrap_or(""); + let today = Local::now().format("%Y-%m-%d").to_string(); + let is_overdue = !due_date.is_empty() && today.as_str() > due_date; + + row.col(|ui| { + let tag = loan + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + if is_overdue { + ui.colored_label(egui::Color32::RED, tag); + } else { + ui.label(tag); + } + }); + row.col(|ui| { + ui.label( + loan.get("asset_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + loan.get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + loan.get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + if is_overdue { + ui.colored_label(egui::Color32::RED, format!("{} ⚠", due_date)); + } else { + ui.label(due_date); + } + }); + row.col(|ui| { + if is_selected { + ui.colored_label(egui::Color32::GREEN, "Selected"); + } else { + let button_id = format!("select_loan_{}", loan_id); + if ui.button("Select").on_hover_text(&button_id).clicked() { + self.selected_loan = Some((*loan).clone()); + } + } + }); + }); + } + }); + } + + // Submission method + fn submit_return(&mut self, api_client: &ApiClient) { + self.error_message = None; + self.success_message = None; + + // Validate required data + let loan = match &self.selected_loan { + Some(l) => l, + None => { + self.error_message = Some("No loan selected".to_string()); + return; + } + }; + + let loan_id = match loan.get("id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => { + self.error_message = Some("Invalid loan ID".to_string()); + return; + } + }; + + let asset_id = match loan.get("asset_id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => { + self.error_message = Some("Invalid asset ID".to_string()); + return; + } + }; + + let return_date = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + // Update lending history record - just set return_date + let mut update_data = serde_json::json!({ + "return_date": return_date + }); + + // Add notes if provided + if !self.return_notes.is_empty() { + let existing_notes = loan.get("notes").and_then(|v| v.as_str()).unwrap_or(""); + let combined_notes = if existing_notes.is_empty() { + format!("[Return] {}", self.return_notes) + } else { + format!("{}\n[Return] {}", existing_notes, self.return_notes) + }; + update_data["notes"] = serde_json::Value::String(combined_notes); + } + + let update_request = QueryRequest { + action: "update".to_string(), + table: "lending_history".to_string(), + columns: None, + r#where: Some(serde_json::json!({ + "id": loan_id + })), + data: Some(update_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&update_request) { + Ok(response) => { + if !response.success { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to update lending record".to_string()), + ); + return; + } + } + Err(e) => { + self.error_message = Some(format!("Error updating lending record: {}", e)); + return; + } + } + + // Update asset status to "Available" and move current->previous borrower, clear current/due_date + // Note: Use two-step update to read borrower_id from selected loan without another select. + let current_borrower_id = loan.get("borrower_id").and_then(|v| v.as_i64()); + let mut asset_update_payload = serde_json::json!({ + "lending_status": "Available", + "current_borrower_id": serde_json::Value::Null, + "due_date": serde_json::Value::Null + }); + if let Some(cb) = current_borrower_id { + asset_update_payload["previous_borrower_id"] = serde_json::Value::from(cb); + } + + let asset_update = QueryRequest { + action: "update".to_string(), + table: "assets".to_string(), + columns: None, + r#where: Some(serde_json::json!({ + "id": asset_id + })), + data: Some(asset_update_payload), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&asset_update) { + Ok(response) => { + if !response.success { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to update asset status".to_string()), + ); + return; + } + } + Err(e) => { + self.error_message = Some(format!("Error updating asset: {}", e)); + return; + } + } + + // If issue reporting is enabled, create an issue + if self.report_issue { + if let Err(e) = self.create_issue( + api_client, + asset_id, + loan.get("borrower_id").and_then(|v| v.as_i64()), + ) { + // Don't fail the whole return if issue creation fails, just log it + self.error_message = Some(format!( + "Return processed but failed to create issue: {}", + e + )); + return; + } + } + + self.just_completed_successfully = true; + self.success_message = Some("Item successfully returned!".to_string()); + self.close(); + } + + fn create_issue( + &self, + api_client: &ApiClient, + asset_id: i64, + borrower_id: Option, + ) -> Result<()> { + if self.issue_title.trim().is_empty() { + return Err(anyhow::anyhow!("Issue title is required")); + } + + let mut issue_data = serde_json::json!({ + "issue_type": "Asset Issue", + "asset_id": asset_id, + "title": self.issue_title.clone(), + "description": self.issue_description.clone(), + "severity": self.issue_severity.clone(), + "priority": self.issue_priority.clone(), + "status": "Open", + "auto_detected": false, + "detection_trigger": "Manual - Return Flow" + }); + + if let Some(bid) = borrower_id { + issue_data["borrower_id"] = serde_json::Value::Number(bid.into()); + } + + let request = QueryRequest { + action: "insert".to_string(), + table: "issue_tracker".to_string(), + columns: None, + r#where: None, + data: Some(issue_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to create issue".to_string()))); + } + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0a023a8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,106 @@ +use eframe::egui; +use std::sync::Arc; +use tokio::sync::Mutex; + +mod api; +mod config; +mod core; +mod models; +mod session; +mod ui; + +use session::SessionManager; +use ui::app::BeepZoneApp; + +fn main() -> eframe::Result<()> { + // Initialize logging + env_logger::Builder::from_default_env() + .filter_level(log::LevelFilter::Info) + .init(); + + log::info!("Starting BeepZone Inventory Management System"); + + // Configure egui options + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([1280.0, 800.0]) + .with_min_inner_size([800.0, 600.0]) + .with_icon(load_icon()), + persist_window: true, + ..Default::default() + }; + + // Initialize session manager + let session_manager = Arc::new(Mutex::new(SessionManager::new())); + + // Run the application + eframe::run_native( + "BeepZone Inventory System", + options, + Box::new(move |cc| { + // Configure fonts and style + configure_fonts(&cc.egui_ctx); + configure_style(&cc.egui_ctx); + + Ok(Box::new(BeepZoneApp::new(cc, session_manager))) + }), + ) +} + +fn load_icon() -> egui::IconData { + // Load the app icon from assets + let icon_bytes = include_bytes!("assets/app-icon/AppIcon.png"); + + // Parse PNG file + match image::load_from_memory(icon_bytes) { + Ok(img) => { + let rgba = img.to_rgba8(); + let (width, height) = rgba.dimensions(); + + egui::IconData { + rgba: rgba.into_raw(), + width: width as u32, + height: height as u32, + } + } + Err(e) => { + log::warn!("Failed to load app icon: {}. Using fallback.", e); + // Fallback to a simple default icon + egui::IconData { + rgba: vec![255; 32 * 32 * 4], + width: 32, + height: 32, + } + } + } +} + +// Include generated font byte bindings from build.rs (downloaded into OUT_DIR) +// (Removed build.rs embedded fonts; using egui-phosphor instead) + +fn configure_fonts(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + + // Use Phosphor icon font via crate as a portable fallback + egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::variants::Variant::Regular); + + ctx.set_fonts(fonts); +} + +fn configure_style(ctx: &egui::Context) { + // Configure style for consistent appearance across light/dark themes + ctx.all_styles_mut(|style| { + // Configure spacing - these settings apply to both themes + style.spacing.item_spacing = egui::vec2(8.0, 8.0); + style.spacing.button_padding = egui::vec2(12.0, 6.0); + style.spacing.window_margin = egui::Margin::from(12.0); + + // Configure interaction + style.interaction.tooltip_delay = 0.5; + + // Configure visuals for a modern look - applied to both themes + style.visuals.window_corner_radius = egui::CornerRadius::from(8.0); + style.visuals.button_frame = true; + style.visuals.collapsing_header_frame = true; + }); +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..87ef410 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,274 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +// ============================================================================ +// API Response Types +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub error: Option, + pub message: Option, +} + +/// Format backend error payloads into a readable string. +pub fn api_error_detail(error: &Option) -> String { + error + .as_ref() + .map(String::from) + .unwrap_or_else(|| "Unknown backend error".to_string()) +} + +// ============================================================================ +// Authentication +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginRequest { + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub login_string: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginResponse { + pub success: bool, + pub token: String, + pub user: UserInfo, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInfo { + pub id: i32, + pub username: String, + pub role: String, + pub power: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionStatus { + pub valid: bool, + pub user: Option, + pub expires_at: Option>, + pub message: Option, +} + +// ============================================================================ +// Permissions +// ============================================================================ + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionsResponse { + pub user: UserInfo, + pub user_settings_access: String, + pub permissions: serde_json::Value, // Flexible permissions structure + pub security_clearance: Option, +} + +// ============================================================================ +// Preferences +// ============================================================================ + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreferencesRequest { + pub action: String, // "get", "set", "reset" + #[serde(skip_serializing_if = "Option::is_none")] + pub user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "preferences")] + pub preferences: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PreferencesResponse { + pub user_id: i32, + pub preferences: serde_json::Value, +} + +// ============================================================================ +// Query Operations +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryRequest { + pub action: String, // "select", "insert", "update", "delete", "count" + pub table: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub columns: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "data")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#where: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub filter: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub order_by: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub joins: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderBy { + pub column: String, + pub direction: String, // "ASC" or "DESC" +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Join { + pub table: String, + pub on: String, + #[serde(rename = "type")] + pub join_type: String, // "INNER", "LEFT", "RIGHT" - serialized as "type" for API +} + +// ============================================================================ +// Asset Models +// ============================================================================ + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Asset { + pub id: Option, + pub asset_tag: String, + pub asset_numeric_id: Option, + pub asset_type: String, // "N", "T", "C" + pub name: String, + pub description: Option, + pub category_id: Option, + pub zone_id: Option, + pub zone_plus: Option, // "Exact", "Clarify", "Deployed" + pub zone_note: Option, + pub manufacturer: Option, + pub model: Option, + pub serial_number: Option, + pub status: String, // "Good", "Faulty", "Scrapped", "Missing" + pub price: Option, + pub purchase_date: Option, + pub warranty_expiry: Option, + pub supplier_id: Option, + pub lendable: bool, + pub lending_status: Option, // "Available", "Borrowed", "Deployed", "Overdue" + pub asset_image: Option, + pub notes: Option, + pub created_by: Option, + pub created_date: Option>, + pub last_modified_by: Option, + pub last_modified_date: Option>, +} + +// ============================================================================ +// Borrower Models +// ============================================================================ + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Borrower { + pub id: Option, + pub borrower_code: String, + pub name: String, + pub email: Option, + pub phone: Option, + pub borrower_type: String, // "Student", "Faculty", "Staff", "External" + pub department: Option, + pub banned: bool, + pub unban_fine: Option, + pub ban_reason: Option, + pub notes: Option, +} + +// ============================================================================ +// Category & Zone Models +// ============================================================================ + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Category { + pub id: Option, + pub category_code: String, + pub name: String, + pub description: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Zone { + pub id: Option, + pub zone_code: String, + pub name: String, + pub parent_zone_id: Option, + pub level: i32, + pub description: Option, +} + +// ============================================================================ +// Lending History +// ============================================================================ + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LendingHistory { + pub id: Option, + pub asset_id: i32, + pub borrower_id: i32, + pub checkout_date: DateTime, + pub due_date: String, + pub return_date: Option>, + pub status: String, // "Active", "Returned", "Overdue", "Lost" + pub checkout_condition: Option, + pub return_condition: Option, + pub notes: Option, + pub checked_out_by: Option, + pub checked_in_by: Option, +} + +// ============================================================================ +// Issue Tracker +// ============================================================================ + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Issue { + pub id: Option, + pub issue_type: String, // "Asset Issue", "Borrower Issue", "System Issue" + pub asset_id: Option, + pub borrower_id: Option, + pub title: String, + pub description: Option, + pub severity: String, // "Low", "Medium", "High", "Critical" + pub priority: String, // "Low", "Medium", "High", "Critical" + pub status: String, // "Open", "In Progress", "On Hold", "Resolved", "Closed" + pub solution: Option, + pub solution_plus: Option, + pub auto_detected: bool, + pub detection_trigger: Option, + pub reported_by: Option, + pub reported_date: Option>, + pub resolved_by: Option, + pub resolved_date: Option>, +} + +// ============================================================================ +// Dashboard Stats +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DashboardStats { + pub total_assets: i32, + pub okay_items: i32, // All items with status "Good" + pub attention_items: i32, // Faulty, Missing, Overdue, Attention, etc. +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..acb0409 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,161 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::models::UserInfo; + +/// Session data stored to disk +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionData { + pub server_url: String, + pub token: String, + pub user: UserInfo, + pub remember_server: bool, + pub remember_username: bool, + pub saved_username: Option, + #[serde(default)] + pub default_printer_id: Option, + /// Remember last-used printer (may differ from default_printer_id if user overrides per-print) + #[serde(default)] + pub last_printer_id: Option, +} + +/// Manages user session and credentials +pub struct SessionManager { + config_path: PathBuf, + current_session: Option, +} + +impl SessionManager { + /// Create a new session manager + pub fn new() -> Self { + let config_dir = dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("beepzone"); + + // Ensure config directory exists + let _ = fs::create_dir_all(&config_dir); + + let config_path = config_dir.join("session.json"); + + let mut manager = Self { + config_path, + current_session: None, + }; + + // Try to load existing session + let _ = manager.load_session(); + + manager + } + + /// Save a new session + pub fn save_session(&mut self, session: SessionData) -> Result<()> { + self.current_session = Some(session.clone()); + + let json = serde_json::to_string_pretty(&session).context("Failed to serialize session")?; + + fs::write(&self.config_path, json).context("Failed to write session file")?; + + log::info!("Session saved to {:?}", self.config_path); + Ok(()) + } + + /// Load session from disk + pub fn load_session(&mut self) -> Result<()> { + if !self.config_path.exists() { + return Ok(()); + } + + let json = fs::read_to_string(&self.config_path).context("Failed to read session file")?; + + let session: SessionData = + serde_json::from_str(&json).context("Failed to parse session file")?; + + self.current_session = Some(session); + log::info!("Session loaded from {:?}", self.config_path); + Ok(()) + } + + /// Clear the current session + pub fn clear_session(&mut self) -> Result<()> { + self.current_session = None; + + if self.config_path.exists() { + fs::remove_file(&self.config_path).context("Failed to remove session file")?; + log::info!("Session file removed"); + } + + Ok(()) + } + + /// Get the current session + pub fn get_session(&self) -> Option<&SessionData> { + self.current_session.as_ref() + } + + /// Check if there's a valid session + #[allow(dead_code)] + pub fn has_session(&self) -> bool { + self.current_session.is_some() + } + + /// Get the saved server URL (if remember_server is enabled) + pub fn get_saved_server_url(&self) -> Option { + self.current_session + .as_ref() + .filter(|s| s.remember_server) + .map(|s| s.server_url.clone()) + } + + /// Get the saved username (if remember_username is enabled) + pub fn get_saved_username(&self) -> Option { + self.current_session + .as_ref() + .and_then(|s| s.saved_username.clone()) + } + + /// Update session with new token (for token refresh) + #[allow(dead_code)] + pub fn update_token(&mut self, new_token: String) -> Result<()> { + if let Some(session) = &mut self.current_session { + let mut updated_session = session.clone(); + updated_session.token = new_token; + self.save_session(updated_session)?; + } + Ok(()) + } + + /// Update default printer ID + pub fn update_default_printer(&mut self, printer_id: Option) -> Result<()> { + if let Some(session) = &mut self.current_session { + let mut updated_session = session.clone(); + updated_session.default_printer_id = printer_id; + self.save_session(updated_session)?; + } + Ok(()) + } + + /// Get default printer ID + pub fn get_default_printer_id(&self) -> Option { + self.current_session + .as_ref() + .and_then(|s| s.default_printer_id) + } + + /// Get last-used printer ID for printing + pub fn get_last_print_preferences(&self) -> Option { + if let Some(session) = &self.current_session { + session.last_printer_id + } else { + None + } + } +} + +impl Default for SessionManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..d970d37 --- /dev/null +++ b/src/ui/app.rs @@ -0,0 +1,1268 @@ +use eframe::egui; +use std::collections::HashMap; +use std::sync::{mpsc, Arc}; +use tokio::sync::Mutex; + +use super::audits::AuditsView; +use super::borrowing::BorrowingView; +use super::categories::CategoriesView; +use super::dashboard::DashboardView; +use super::inventory::InventoryView; +use super::issues::IssuesView; +use super::label_templates::LabelTemplatesView; +use super::login::LoginScreen; +use super::printers::PrintersView; +use super::ribbon::RibbonUI; +use super::suppliers::SuppliersView; +use super::templates::TemplatesView; +use super::zones::ZonesView; +use crate::api::ApiClient; +use crate::config::AppConfig; +use crate::models::{LoginResponse, UserInfo}; +use crate::session::{SessionData, SessionManager}; + +pub struct BeepZoneApp { + // Session management + session_manager: Arc>, + api_client: Option, + + // Current view state + current_view: AppView, + previous_view: Option, + current_user: Option, + + // Per-view filter state storage + view_filter_states: HashMap, + + // UI components + login_screen: LoginScreen, + dashboard: DashboardView, + inventory: InventoryView, + categories: CategoriesView, + zones: ZonesView, + borrowing: BorrowingView, + audits: AuditsView, + templates: TemplatesView, + suppliers: SuppliersView, + issues: IssuesView, + printers: PrintersView, + label_templates: LabelTemplatesView, + ribbon_ui: Option, + + // Configuration + #[allow(dead_code)] + app_config: Option, + + // State + login_success: Option<(String, LoginResponse)>, + show_about: bool, + + // Status bar state + server_status: ServerStatus, + last_health_check: std::time::Instant, + health_check_in_progress: bool, + health_check_rx: Option>, + // Re-authentication prompt state + reauth_needed: bool, + reauth_password: String, + + // Database outage tracking + db_offline_latch: bool, + last_timeout_at: Option, + consecutive_healthy_checks: u8, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ServerStatus { + Unknown, + Connected, + Disconnected, + Checking, +} + +#[derive(Debug, Clone, Copy)] +struct HealthCheckResult { + status: ServerStatus, + reauth_required: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AppView { + Login, + Dashboard, + Inventory, + Categories, + Zones, + Borrowing, + Audits, + Templates, + Suppliers, + IssueTracker, + Printers, + LabelTemplates, +} + +impl BeepZoneApp { + pub fn new( + _cc: &eframe::CreationContext<'_>, + session_manager: Arc>, + ) -> Self { + let session_manager_blocking = session_manager.blocking_lock(); + let login_screen = LoginScreen::new(&session_manager_blocking); + + // Try to restore session on startup + let (api_client, current_view, current_user) = + if let Some(session) = session_manager_blocking.get_session() { + log::info!("Found saved session, attempting to restore..."); + + // Create API client with saved token + match ApiClient::new(session.server_url.clone()) { + Ok(mut client) => { + client.set_token(session.token.clone()); + + // Verify session is still valid (tolerant) + match client.check_session_valid() { + Ok(true) => { + log::info!( + "Session restored successfully for user: {}", + session.user.username + ); + (Some(client), AppView::Dashboard, Some(session.user.clone())) + } + Ok(false) => { + log::warn!("Saved session check returned invalid"); + (None, AppView::Login, None) + } + Err(e) => { + log::warn!("Saved session validity check error: {}", e); + // Be forgiving on startup: keep client and let periodic checks refine + (Some(client), AppView::Dashboard, Some(session.user.clone())) + } + } + } + Err(e) => { + log::error!("Failed to create API client: {}", e); + (None, AppView::Login, None) + } + } + } else { + log::info!("No saved session found"); + (None, AppView::Login, None) + }; + + drop(session_manager_blocking); + + // Load configuration and initialize ribbon UI + let ribbon_ui = Some(RibbonUI::default()); + let app_config = None; + + let mut app = Self { + session_manager, + api_client, + current_view, + previous_view: None, + view_filter_states: HashMap::new(), + current_user, + login_screen, + dashboard: DashboardView::new(), + inventory: InventoryView::new(), + categories: CategoriesView::new(), + zones: ZonesView::new(), + borrowing: BorrowingView::new(), + audits: AuditsView::new(), + templates: TemplatesView::new(), + suppliers: SuppliersView::new(), + issues: IssuesView::new(), + printers: PrintersView::new(), + label_templates: LabelTemplatesView::new(), + ribbon_ui, + app_config, + login_success: None, + show_about: false, + server_status: ServerStatus::Unknown, + last_health_check: std::time::Instant::now(), + health_check_in_progress: false, + health_check_rx: None, + reauth_needed: false, + reauth_password: String::new(), + db_offline_latch: false, + last_timeout_at: None, + consecutive_healthy_checks: 0, + }; + + // Do initial health check if we have an API client + if app.api_client.is_some() { + app.request_health_check(); + } + + app + } + + fn handle_login_success(&mut self, server_url: String, response: LoginResponse) { + // Capture username for logging before moving fields out of response + let username = response.user.username.clone(); + log::info!("Login successful for user: {}", username); + + // Create API client with token + let mut api_client = match ApiClient::new(server_url.clone()) { + Ok(client) => client, + Err(e) => { + log::error!("Failed to create API client: {}", e); + // This shouldn't happen in normal operation, so just log and continue without client + return; + } + }; + api_client.set_token(response.token.clone()); + + self.api_client = Some(api_client); + self.current_user = Some(response.user.clone()); + + // Save session (blocking is fine here, it's just writing a small JSON file) + let session_data = SessionData { + server_url, + token: response.token, + user: response.user, + remember_server: true, + remember_username: true, + saved_username: self.current_user.as_ref().map(|u| u.username.clone()), + default_printer_id: None, + last_printer_id: None, + }; + + let mut session_manager = self.session_manager.blocking_lock(); + if let Err(e) = session_manager.save_session(session_data) { + log::error!("Failed to save session: {}", e); + } + + // Switch to dashboard + self.current_view = AppView::Dashboard; + self.reauth_needed = false; + self.reauth_password.clear(); + + // Load dashboard data + if let Some(client) = self.api_client.as_ref() { + self.dashboard.refresh_data(client); + } + } + + fn handle_reauth_success(&mut self, server_url: String, response: LoginResponse) { + // Preserve current view but refresh token and user + let mut new_client = match ApiClient::new(server_url.clone()) { + Ok(client) => client, + Err(e) => { + log::error!("Failed to create API client during reauth: {}", e); + self.reauth_needed = true; + return; + } + }; + new_client.set_token(response.token.clone()); + + // Replace client and user + self.api_client = Some(new_client); + self.current_user = Some(response.user.clone()); + + // Save updated session + let session_data = SessionData { + server_url, + token: response.token, + user: response.user, + remember_server: true, + remember_username: true, + saved_username: self.current_user.as_ref().map(|u| u.username.clone()), + default_printer_id: None, + last_printer_id: None, + }; + + let mut session_manager = self.session_manager.blocking_lock(); + if let Err(e) = session_manager.save_session(session_data) { + log::error!("Failed to save session after reauth: {}", e); + } + } + + fn show_top_bar(&mut self, ctx: &egui::Context, disable_actions: bool) { + egui::TopBottomPanel::top("top_bar") + .exact_height(45.0) + .show_separator_line(false) + .frame( + egui::Frame::new() + .fill(ctx.style().visuals.window_fill) + .stroke(egui::Stroke::NONE) + .inner_margin(egui::vec2(16.0, 5.0)), + ) + .show(ctx, |ui| { + // Horizontal layout for title and controls + ui.horizontal(|ui| { + ui.heading("BeepZone"); + + ui.separator(); + + // User info + if let Some(user) = &self.current_user { + ui.label(format!("User: {} ({})", user.username, user.role)); + ui.label(format!("Powah: {}", user.power)); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_enabled_ui(!disable_actions, |ui| { + if ui.button("About").clicked() { + self.show_about = true; + } + + if ui.button("Bye").clicked() { + self.handle_logout(); + } + }); + }); + }); + }); + } + + fn show_ribbon(&mut self, ctx: &egui::Context) -> Option { + let mut action_triggered = None; + + if let Some(ribbon_ui) = &mut self.ribbon_ui { + let min_height = ribbon_ui.preferred_height(); + + // Outer container panel with normal background + egui::TopBottomPanel::top("ribbon_container") + .min_height(min_height + 16.0) + .max_height(min_height + 96.0) + .show_separator_line(false) + .frame(egui::Frame::new().fill(if ctx.style().visuals.dark_mode { + ctx.style().visuals.panel_fill + } else { + // Darker background in light mode + egui::Color32::from_rgb(210, 210, 210) + })) + .show(ctx, |ui| { + ui.add_space(0.0); + + let side_margin: f32 = 16.0; + let inner_pad: f32 = 8.0; + + ui.horizontal(|ui| { + // Left margin + ui.add_space(side_margin); + + // Remaining width after left margin + let remaining = ui.available_width(); + // Leave room for right margin and inner padding on both sides of the frame + let content_width = (remaining - side_margin - inner_pad * 2.0).max(0.0); + + // Custom ribbon background color based on theme + let is_dark_mode = ctx.style().visuals.dark_mode; + let ribbon_bg_color = if is_dark_mode { + // Lighter gray for dark mode - more visible contrast + egui::Color32::from_rgb(45, 45, 45) + } else { + // Lighter/white ribbon in light mode + egui::Color32::from_rgb(248, 248, 248) + }; + + egui::Frame::new() + .fill(ribbon_bg_color) + .inner_margin(inner_pad) + .corner_radius(6.0) + .show(ui, |ui| { + // Constrain to the computed content width so right margin remains + ui.set_width(content_width); + ui.scope(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0); + action_triggered = ribbon_ui.show(ctx, ui); + }); + + // Update current view based on active ribbon tab + if let Some(view_name) = ribbon_ui.get_active_view() { + self.current_view = match view_name.to_lowercase().as_str() { + "dashboard" => AppView::Dashboard, + "inventory" => AppView::Inventory, + "categories" => AppView::Categories, + "zones" => AppView::Zones, + "borrowing" => AppView::Borrowing, + "audits" => AppView::Audits, + "item templates" => AppView::Templates, + "templates" => AppView::Templates, // Backwards compat + "suppliers" => AppView::Suppliers, + "issues" | "issue_tracker" => AppView::IssueTracker, + "printers" => AppView::Printers, + "label templates" => AppView::LabelTemplates, + _ => self.current_view, + }; + } + }); + + // Right margin + ui.add_space(side_margin); + }); + + ui.add_space(8.0); + }); + } else { + // Fallback to simple ribbon if config failed to load + egui::TopBottomPanel::top("ribbon") + .exact_height(38.0) + .show_separator_line(false) + .show(ctx, |ui| { + ui.add_space(2.0); + ui.horizontal_wrapped(|ui| { + ui.selectable_value( + &mut self.current_view, + AppView::Dashboard, + "Dashboard", + ); + ui.selectable_value( + &mut self.current_view, + AppView::Inventory, + "Inventory", + ); + ui.selectable_value( + &mut self.current_view, + AppView::Categories, + "Categories", + ); + ui.selectable_value(&mut self.current_view, AppView::Zones, "Zones"); + ui.selectable_value( + &mut self.current_view, + AppView::Borrowing, + "Borrowing", + ); + ui.selectable_value(&mut self.current_view, AppView::Audits, "Audits"); + ui.selectable_value( + &mut self.current_view, + AppView::Templates, + "Templates", + ); + ui.selectable_value( + &mut self.current_view, + AppView::Suppliers, + "Suppliers", + ); + ui.selectable_value( + &mut self.current_view, + AppView::IssueTracker, + "Issues", + ); + }); + }); + } + + action_triggered + } + + fn handle_logout(&mut self) { + log::info!("Taking myself out"); + + // Logout from API + if let Some(api_client) = &self.api_client { + let _ = api_client.logout(); + } + + // Clear session and reset login screen (do both while holding the lock once) + { + let mut session_manager = self.session_manager.blocking_lock(); + if let Err(e) = session_manager.clear_session() { + log::error!("Failed to clear session: {}", e); + } + + // Reset login screen while we still have the lock + self.login_screen = LoginScreen::new(&session_manager); + } // Lock is dropped here + + // Reset state + self.api_client = None; + self.current_user = None; + self.current_view = AppView::Login; + self.server_status = ServerStatus::Unknown; + } + + /// Force an immediate health check (used when timeout errors detected) + pub fn force_health_check(&mut self) { + self.last_health_check = std::time::Instant::now() - std::time::Duration::from_secs(10); + self.request_health_check(); + } + + fn request_health_check(&mut self) { + if self.api_client.is_none() || self.health_check_in_progress { + return; + } + + if let Some(client) = &self.api_client { + let api_client = client.clone(); + let reauth_needed = self.reauth_needed; + let (tx, rx) = mpsc::channel(); + self.health_check_rx = Some(rx); + self.health_check_in_progress = true; + self.server_status = ServerStatus::Checking; + self.last_health_check = std::time::Instant::now(); + + std::thread::spawn(move || { + let result = Self::run_health_check(api_client, reauth_needed); + let _ = tx.send(result); + }); + } + } + + fn desired_health_interval(&self, predicted_block: bool) -> f32 { + if predicted_block || self.db_offline_latch { + 0.75 + } else if matches!(self.server_status, ServerStatus::Connected) { + 1.5 + } else { + 2.5 + } + } + + fn poll_health_check(&mut self) { + if let Some(rx) = &self.health_check_rx { + match rx.try_recv() { + Ok(result) => { + self.apply_health_result(result); + self.health_check_rx = None; + self.health_check_in_progress = false; + self.last_health_check = std::time::Instant::now(); + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + log::warn!("Health check worker disconnected unexpectedly"); + self.health_check_rx = None; + self.health_check_in_progress = false; + } + } + } + } + + fn apply_health_result(&mut self, result: HealthCheckResult) { + if self.reauth_needed != result.reauth_required { + if self.reauth_needed && !result.reauth_required { + log::info!("Session valid again; clearing re-auth requirement"); + } else if !self.reauth_needed && result.reauth_required { + log::info!("Session invalid/expired; prompting re-auth"); + } + self.reauth_needed = result.reauth_required; + } + + match result.status { + ServerStatus::Disconnected => { + self.db_offline_latch = true; + self.last_timeout_at = Some(std::time::Instant::now()); + self.consecutive_healthy_checks = 0; + } + ServerStatus::Connected => { + self.consecutive_healthy_checks = self.consecutive_healthy_checks.saturating_add(1); + + if self.db_offline_latch { + let timeout_cleared = self + .last_timeout_at + .map(|t| t.elapsed() > std::time::Duration::from_secs(2)) + .unwrap_or(true); + + if timeout_cleared && self.consecutive_healthy_checks >= 2 { + log::info!("Health checks stable; clearing database offline latch"); + self.db_offline_latch = false; + } + } + } + _ => { + self.consecutive_healthy_checks = 0; + } + } + + if self.db_offline_latch { + self.server_status = ServerStatus::Disconnected; + } else { + self.server_status = result.status; + } + } + + fn run_health_check(api_client: ApiClient, mut reauth_needed: bool) -> HealthCheckResult { + let connected = match api_client.check_session_valid() { + Ok(true) => { + reauth_needed = false; + true + } + Ok(false) => { + reauth_needed = true; + true + } + Err(e) => { + log::warn!("Session status check error: {}", e); + false + } + }; + + if connected { + let mut db_disconnected = false; + if let Ok(true) = api_client.health_check() { + if let Ok(info_opt) = api_client.health_info() { + if let Some(info) = info_opt { + let db_down = info.get("database").and_then(|v| v.as_str()) + .map(|s| s.eq_ignore_ascii_case("disconnected")) + .unwrap_or(false) + || info.get("database_connected").and_then(|v| v.as_bool()) + == Some(false) + || info.get("db_connected").and_then(|v| v.as_bool()) + == Some(false) + || info + .get("db") + .and_then(|v| v.as_str()) + .map(|s| s.eq_ignore_ascii_case("down")) + .unwrap_or(false) + || info + .get("database") + .and_then(|v| v.as_str()) + .map(|s| s.eq_ignore_ascii_case("down")) + .unwrap_or(false); + if db_down { + db_disconnected = true; + } + } + } + } + + if db_disconnected { + log::warn!("Database disconnected; treating as offline"); + HealthCheckResult { + status: ServerStatus::Disconnected, + reauth_required: reauth_needed, + } + } else { + HealthCheckResult { + status: ServerStatus::Connected, + reauth_required: reauth_needed, + } + } + } else { + HealthCheckResult { + status: ServerStatus::Disconnected, + reauth_required: reauth_needed, + } + } + } + + fn handle_ribbon_action(&mut self, action: String) { + log::info!("Ribbon action triggered: {}", action); + + // Handle different action types + if action.starts_with("search:") { + let search_query = action.strip_prefix("search:").unwrap_or(""); + log::info!("Search action: {}", search_query); + // TODO: Implement search functionality + } else { + match action.as_str() { + // Dashboard actions + "refresh_dashboard" => { + if let Some(api_client) = &self.api_client { + self.dashboard.refresh_data(api_client); + } + } + "customize_dashboard" => { + log::info!("Customize dashboard - TODO"); + } + + // Inventory actions + "add_item" => { + log::info!("Add item - TODO"); + } + "edit_item" => { + log::info!("Edit item - TODO"); + } + "delete_item" => { + log::info!("Delete item - TODO"); + } + "print_label" => { + log::info!("Print label - TODO"); + } + + // Quick actions + "inventarize_quick" => { + log::info!("Quick inventarize - TODO"); + } + "checkout_checkin" => { + log::info!("Check-out/in - TODO"); + } + "start_room_audit" => { + log::info!("Start room audit - TODO"); + } + "start_spot_check" => { + log::info!("Start spot-check - TODO"); + } + + _ => { + log::info!("Unhandled action: {}", action); + } + } + } + } + + fn show_status_bar(&self, ctx: &egui::Context) { + egui::TopBottomPanel::bottom("status_bar") + .exact_height(24.0) + .show_separator_line(false) + .frame( + egui::Frame::new() + .fill(ctx.style().visuals.window_fill) + .stroke(egui::Stroke::NONE), + ) + .show(ctx, |ui| { + ui.horizontal(|ui| { + // Seqkel inikator + let (icon, text, color) = match self.server_status { + ServerStatus::Connected => ( + "-", + if self.reauth_needed { + "Server Connected • Re-auth required" + } else { + "Server Connected" + }, + egui::Color32::from_rgb(76, 175, 80), + ), + ServerStatus::Disconnected => { + // Check if we detected database timeout recently + let timeout_detected = self.dashboard.has_timeout_error(); + let text = if timeout_detected { + "Database Timeout - Retrying..." + } else { + "Server Disconnected" + }; + ("x", text, egui::Color32::from_rgb(244, 67, 54)) + }, + ServerStatus::Checking => { + ("~", "Checking...", egui::Color32::from_rgb(255, 152, 0)) + } + ServerStatus::Unknown => ( + "??????????? -", + "I don't know maybe connected maybe not ???", + egui::Color32::GRAY, + ), + }; + + ui.label(egui::RichText::new(icon).color(color).size(16.0)); + ui.label(egui::RichText::new(text).color(color).size(12.0)); + + ui.separator(); + + // Server URL + if let Some(client) = &self.api_client { + ui.label( + egui::RichText::new(format!("Server: {}", client.base_url())) + .size(11.0) + .color(egui::Color32::GRAY), + ); + } + + // User info on the right + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(user) = &self.current_user { + ui.label( + egui::RichText::new(format!("User: {}", user.username)) + .size(11.0) + .color(egui::Color32::GRAY), + ); + } + }); + }); + }); + } + + fn show_reconnect_overlay(&self, ctx: &egui::Context) { + let screen_rect = ctx.viewport_rect(); + let visuals = ctx.style().visuals.clone(); + + let dim_color = if visuals.dark_mode { + egui::Color32::from_black_alpha(180) + } else { + egui::Color32::from_white_alpha(200) + }; + + // Dim the entire interface + let layer_id = egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("reconnect_overlay_bg"), + ); + ctx.layer_painter(layer_id) + .rect_filled(screen_rect, 0.0, dim_color); + + // Capture input so underlying widgets don't receive clicks or keypresses + egui::Area::new(egui::Id::new("reconnect_overlay_blocker")) + .order(egui::Order::Foreground) + .movable(false) + .interactable(true) + .fixed_pos(screen_rect.left_top()) + .show(ctx, |ui| { + ui.set_min_size(screen_rect.size()); + ui.allocate_rect(ui.max_rect(), egui::Sense::click_and_drag()); + }); + + let timeout_detected = self.dashboard.has_timeout_error(); + let message = if timeout_detected { + "Database temporarily unavailable. Waiting for heartbeat…" + } else { + "Connection to the backend was lost. Retrying…" + }; + + // Foreground card with spinner and message + egui::Area::new(egui::Id::new("reconnect_overlay_card")) + .order(egui::Order::Foreground) + .movable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0); + ui.set_min_size(egui::vec2(360.0, 200.0)); + egui::Frame::default() + .fill(visuals.panel_fill) + .stroke(egui::Stroke::new(1.0, visuals.weak_text_color())) + .corner_radius(12.0) + .inner_margin(egui::Margin::symmetric(32, 24)) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.heading( + egui::RichText::new("Reconnecting…") + .color(visuals.strong_text_color()) + .size(20.0), + ); + ui.add_space(8.0); + ui.spinner(); + ui.label( + egui::RichText::new(message) + .color(visuals.text_color()) + .size(15.0), + ); + ui.label( + egui::RichText::new( + "All actions are paused until the backend recovers.", + ) + .color(visuals.weak_text_color()) + .size(13.0), + ); + }); + }); + }); + + // Keep spinner animating while offline + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + + fn should_block_interaction(&self) -> bool { + self.api_client.is_some() + && self.current_view != AppView::Login + && (matches!(self.server_status, ServerStatus::Disconnected) + || self.db_offline_latch) + } + + /// Save current filter state before switching views + fn save_filter_state_for_view(&mut self, view: AppView) { + if let Some(ribbon) = &self.ribbon_ui { + // Only save filter state for views that use filters + if matches!( + view, + AppView::Inventory | AppView::Zones | AppView::Borrowing + ) { + self.view_filter_states + .insert(view, ribbon.filter_builder.filter_group.clone()); + } + } + } + + /// Restore filter state when switching to a view + fn restore_filter_state_for_view(&mut self, view: AppView) { + if let Some(ribbon) = &mut self.ribbon_ui { + // Check if we have saved state for this view + if let Some(saved_state) = self.view_filter_states.get(&view) { + ribbon.filter_builder.filter_group = saved_state.clone(); + } else { + // No saved state - clear filters for this view (fresh start) + ribbon.filter_builder.filter_group = + crate::core::components::filter_builder::FilterGroup::new(); + } + } + } +} + +impl eframe::App for BeepZoneApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Detect view changes and save/restore filter state + if let Some(prev_view) = self.previous_view { + if prev_view != self.current_view { + // Save filter state for the view we're leaving + self.save_filter_state_for_view(prev_view); + // Restore filter state for the view we're entering + self.restore_filter_state_for_view(self.current_view); + + // Update available columns for the new view + if let Some(ribbon) = &mut self.ribbon_ui { + match self.current_view { + AppView::Inventory => { + // Ensure Inventory uses asset columns in the FilterBuilder + ribbon.filter_builder.set_columns_for_context("assets"); + } + AppView::Zones => { + ribbon.filter_builder.available_columns = vec![ + ("Any".to_string(), "Any".to_string()), + ("Zone Code".to_string(), "zones.zone_code".to_string()), + ("Zone Name".to_string(), "zones.zone_name".to_string()), + ]; + } + AppView::Borrowing => { + ribbon.filter_builder.available_columns = + crate::ui::borrowing::BorrowingView::get_filter_columns(); + } + _ => {} + } + } + } + } + + // Update previous view for next frame + self.previous_view = Some(self.current_view); + + // Customize background color for light mode + if !ctx.style().visuals.dark_mode { + let mut style = (*ctx.style()).clone(); + style.visuals.panel_fill = egui::Color32::from_rgb(210, 210, 210); + style.visuals.window_fill = egui::Color32::from_rgb(210, 210, 210); + ctx.set_style(style); + } + + // Check for login success + if let Some((server_url, response)) = self.login_success.take() { + self.handle_login_success(server_url, response); + } + + // Process any completed health checks and schedule new ones + self.poll_health_check(); + let predicted_block = self.should_block_interaction(); + let health_interval = self.desired_health_interval(predicted_block); + if self.api_client.is_some() + && !self.health_check_in_progress + && self.last_health_check.elapsed().as_secs_f32() > health_interval + { + self.request_health_check(); + } + + // Show appropriate view + if self.current_view == AppView::Login { + self.login_screen.show(ctx, &mut self.login_success); + } else { + let mut block_interaction = self.should_block_interaction(); + + if let Some(client) = &self.api_client { + if client.take_timeout_signal() { + log::warn!("Backend timeout detected via API client; entering reconnect mode"); + self.server_status = ServerStatus::Disconnected; + self.db_offline_latch = true; + self.last_timeout_at = Some(std::time::Instant::now()); + self.consecutive_healthy_checks = 0; + block_interaction = true; + // Force an immediate health re-check + self.last_health_check = std::time::Instant::now() + - std::time::Duration::from_secs(10); + if !self.health_check_in_progress { + self.request_health_check(); + } + } + } + + // When we're blocked, ensure a health check is queued so we recover ASAP + if block_interaction + && !self.health_check_in_progress + && self.last_health_check.elapsed().as_secs_f32() > 1.0 + { + self.request_health_check(); + } + + self.show_top_bar(ctx, block_interaction); + let ribbon_action = if block_interaction { + None + } else { + self.show_ribbon(ctx) + }; + self.show_status_bar(ctx); + + if !block_interaction { + if let Some(action) = ribbon_action { + self.handle_ribbon_action(action); + } + + egui::CentralPanel::default().show(ctx, |ui| match self.current_view { + AppView::Dashboard => { + self.dashboard.show(ui, self.api_client.as_ref()); + + // Check if dashboard has timeout error and trigger health check + if self.dashboard.has_timeout_error() { + self.force_health_check(); + } + } + AppView::Inventory => { + // Handle FilterBuilder popup BEFORE showing inventory + // This ensures filter changes are processed in the current frame + if let Some(ribbon) = &mut self.ribbon_ui { + let filter_changed = ribbon.filter_builder.show_popup(ctx); + if filter_changed { + ribbon + .checkboxes + .insert("inventory_filter_changed".to_string(), true); + } + } + + self.inventory.show( + ui, + self.api_client.as_ref(), + self.ribbon_ui.as_mut(), + &self.session_manager, + ); + } + AppView::Categories => { + self.categories + .show(ui, self.api_client.as_ref(), self.ribbon_ui.as_mut()); + } + AppView::Zones => { + if let Some(ribbon) = self.ribbon_ui.as_mut() { + // Handle FilterBuilder popup BEFORE showing zones view so changes are applied in the same frame + let filter_changed = ribbon.filter_builder.show_popup(ctx); + if filter_changed { + ribbon + .checkboxes + .insert("zones_filter_changed".to_string(), true); + } + + self.zones.show(ui, self.api_client.as_ref(), ribbon); + + // Handle zone navigation request to inventory + if let Some(zone_code) = self.zones.switch_to_inventory_with_zone.take() { + log::info!("Switching to inventory with zone filter: {}", zone_code); + + // Save current Zones filter state + let zones_filter_state = ribbon.filter_builder.filter_group.clone(); + self.view_filter_states + .insert(AppView::Zones, zones_filter_state); + + // Set zone filter using the correct column name from the JOIN + ribbon.filter_builder.set_single_filter( + "zones.zone_code".to_string(), + crate::core::components::filter_builder::FilterOperator::Is, + zone_code, + ); + + // Switch to inventory view + self.current_view = AppView::Inventory; + ribbon.active_tab = "Inventory".to_string(); + ribbon + .checkboxes + .insert("inventory_filter_changed".to_string(), true); + + // Update previous_view to match so next frame doesn't restore old inventory filters + self.previous_view = Some(AppView::Inventory); + + // Request repaint to ensure the filter is applied on the next frame + ctx.request_repaint(); + } + } else { + // Fallback if no ribbon (shouldn't happen) + log::warn!("No ribbon available for zones view"); + } + } + AppView::Borrowing => { + if let Some(ribbon) = self.ribbon_ui.as_mut() { + // Handle FilterBuilder popup + let filter_changed = ribbon.filter_builder.show_popup(ctx); + if filter_changed { + ribbon + .checkboxes + .insert("borrowing_filter_changed".to_string(), true); + } + + self.borrowing + .show(ctx, ui, self.api_client.as_ref(), ribbon); + + // Handle borrower navigation request to inventory + if let Some(borrower_id) = + self.borrowing.switch_to_inventory_with_borrower.take() + { + log::info!( + "Switching to inventory with borrower filter: {}", + borrower_id + ); + + // Save current Borrowing filter state + let borrowing_filter_state = ribbon.filter_builder.filter_group.clone(); + self.view_filter_states + .insert(AppView::Borrowing, borrowing_filter_state); + + // Set borrower filter using the current_borrower_id from assets table + ribbon.filter_builder.set_single_filter( + "assets.current_borrower_id".to_string(), + crate::core::components::filter_builder::FilterOperator::Is, + borrower_id.to_string(), + ); + + // Switch to inventory view + self.current_view = AppView::Inventory; + ribbon.active_tab = "Inventory".to_string(); + ribbon + .checkboxes + .insert("inventory_filter_changed".to_string(), true); + + // Update previous_view to match so next frame doesn't restore old inventory filters + self.previous_view = Some(AppView::Inventory); + + // Request repaint to ensure the filter is applied on the next frame + ctx.request_repaint(); + } + } else { + // Fallback if no ribbon (shouldn't happen) + log::warn!("No ribbon available for borrowing view"); + } + } + AppView::Audits => { + let user_id = self.current_user.as_ref().map(|u| u.id); + self.audits.show(ctx, ui, self.api_client.as_ref(), user_id); + } + AppView::Templates => { + if let Some(ribbon) = self.ribbon_ui.as_mut() { + // Handle FilterBuilder popup BEFORE showing templates view + let filter_changed = ribbon.filter_builder.show_popup(ctx); + if filter_changed { + ribbon + .checkboxes + .insert("templates_filter_changed".to_string(), true); + } + + let flags = self + .templates + .show(ui, self.api_client.as_ref(), Some(ribbon)); + for flag in flags { + ribbon.checkboxes.insert(flag, false); + } + } else { + self.templates.show(ui, self.api_client.as_ref(), None); + } + } + AppView::Suppliers => { + if let Some(ribbon_ui) = self.ribbon_ui.as_mut() { + let flags = self.suppliers.show( + ui, + self.api_client.as_ref(), + Some(&mut *ribbon_ui), + ); + for flag in flags { + ribbon_ui.checkboxes.insert(flag, false); + } + } else { + let _ = self.suppliers.show(ui, self.api_client.as_ref(), None); + } + } + AppView::IssueTracker => { + self.issues.show(ui, self.api_client.as_ref()); + } + AppView::Printers => { + // Render printers dropdown in ribbon if we're on printers tab + if let Some(ribbon) = self.ribbon_ui.as_mut() { + if ribbon.active_tab == "Printers" { + self.printers + .inject_dropdown_into_ribbon(ribbon, &self.session_manager); + } + } + self.printers.render( + ui, + self.api_client.as_ref(), + self.ribbon_ui.as_mut(), + &self.session_manager, + ); + } + AppView::LabelTemplates => { + self.label_templates.render( + ui, + self.api_client.as_ref(), + self.ribbon_ui.as_mut(), + ); + } + AppView::Login => unreachable!(), + }); + } else { + self.show_reconnect_overlay(ctx); + } + } + + // Re-authentication modal when needed (only outside of Login view) + if self.reauth_needed && self.current_view != AppView::Login { + let mut keep_open = true; + egui::Window::new("Session expired") + .collapsible(false) + .resizable(false) + .movable(true) + .open(&mut keep_open) + .show(ctx, |ui| { + ui.label("Your session has expired or is invalid. Reenter your password to continue."); + ui.add_space(8.0); + let mut pw = std::mem::take(&mut self.reauth_password); + let response = ui.add( + egui::TextEdit::singleline(&mut pw) + .password(true) + .hint_text("Password") + .desired_width(260.0), + ); + self.reauth_password = pw; + ui.add_space(8.0); + ui.horizontal(|ui| { + let mut try_login = ui.button("Reauthenticate").clicked(); + // Allow Enter to submit + try_login |= response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if try_login { + if let (Some(client), Some(user)) = (self.api_client.as_mut(), self.current_user.as_ref()) { + // Attempt password login to refresh token + match client.login_password(&user.username, &self.reauth_password) { + Ok(resp) => { + let server_url = client.base_url().to_string(); + self.handle_reauth_success(server_url, resp); + self.reauth_needed = false; + self.reauth_password.clear(); + // Avoid immediate re-flagging by pushing out the next health check + self.last_health_check = std::time::Instant::now(); + } + Err(e) => { + log::error!("Reauth failed: {}", e); + } + } + } + } + if ui.button("Go to Login").clicked() { + self.handle_logout(); + self.reauth_needed = false; + self.reauth_password.clear(); + } + }); + }); + if !keep_open { + // Close button pressed: just dismiss (will reappear on next check if still invalid) + self.reauth_needed = false; + self.reauth_password.clear(); + self.last_health_check = std::time::Instant::now(); + } + } + + // About dialog + if self.show_about { + egui::Window::new("About BeepZone") + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.heading("BeepZone Desktop Client"); + ui.heading("- eGUI EMO Edition"); + ui.label(format!("Version: {}", env!("CARGO_PKG_VERSION"))); + ui.separator(); + ui.label("A crude inventory system meant to run on any potato!"); + ui.label("- Fueled by peanut butter and caffeine"); + ui.label("- Backed by Spaghetti codebase supreme pro plus ultra"); + ui.label("- Running at all thanks to vibe coding and sheer willpower"); + ui.label("- Oles Approved"); + ui.label("- Atleast tries to be a good fucking inventory system!"); + ui.separator(); + ui.label("Made with love (and some hatred) by crt "); + ui.separator(); + if ui.button("Close this goofy ah panel").clicked() { + self.show_about = false; + } + }); + } + } +} 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, + 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, +} diff --git a/src/ui/borrowing.rs b/src/ui/borrowing.rs new file mode 100644 index 0000000..8b1fc93 --- /dev/null +++ b/src/ui/borrowing.rs @@ -0,0 +1,1618 @@ +use eframe::egui; + +use crate::api::ApiClient; +use crate::core::components::form_builder::FormBuilder; +use crate::core::tables::{get_all_loans, get_borrowers_summary}; +use crate::core::workflows::borrow_flow::BorrowFlow; +use crate::core::workflows::return_flow::ReturnFlow; +use crate::core::{ColumnConfig, TableRenderer}; +use crate::core::{EditorField, FieldType}; + +pub struct BorrowingView { + // data + loans: Vec, + borrowers: Vec, + is_loading: bool, + last_error: Option, + + // UI + init_loaded: bool, + show_loans_column_selector: bool, + show_borrowers_column_selector: bool, + + // Table renderers + loans_table: TableRenderer, + borrowers_table: TableRenderer, + + // Workflows + borrow_flow: BorrowFlow, + return_flow: ReturnFlow, + + // Register borrower dialog + show_register_dialog: bool, + new_borrower_name: String, + new_borrower_email: String, + new_borrower_phone: String, + new_borrower_class: String, + new_borrower_role: String, + register_error: Option, + + // Edit borrower dialog (using FormBuilder) + borrower_editor: FormBuilder, + + // Ban/Unban borrower dialog + show_ban_dialog: bool, + show_unban_dialog: bool, + ban_borrower_data: Option, + ban_fine_amount: String, + ban_reason: String, + + // Return item confirm dialog + show_return_confirm_dialog: bool, + return_loan_data: Option, + + // Delete borrower confirm dialog + show_delete_borrower_dialog: bool, + delete_borrower_data: Option, + + // Search and filtering + loans_search: String, + borrowers_search: String, + + // Navigation + pub switch_to_inventory_with_borrower: Option, // borrower_id to filter by +} + +impl BorrowingView { + pub fn new() -> Self { + // Define columns for loans table - ALL columns from the query + let loans_columns = vec![ + ColumnConfig::new("Loan ID", "id").with_width(60.0).hidden(), + ColumnConfig::new("Asset ID", "asset_id") + .with_width(70.0) + .hidden(), + ColumnConfig::new("Borrower ID", "borrower_id") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Tag", "asset_tag").with_width(80.0), + ColumnConfig::new("Name", "name").with_width(200.0), + ColumnConfig::new("Borrower", "borrower_name").with_width(120.0), + ColumnConfig::new("Class", "class_name").with_width(80.0), + ColumnConfig::new("Status", "lending_status").with_width(80.0), + ColumnConfig::new("Checked Out", "checkout_date").with_width(100.0), + ColumnConfig::new("Due", "due_date").with_width(90.0), + ColumnConfig::new("Returned", "return_date").with_width(100.0), + ColumnConfig::new("Notes", "notes") + .with_width(150.0) + .hidden(), + ]; + + // Define columns for borrowers table - with all backend fields + let borrowers_columns = vec![ + ColumnConfig::new("ID", "borrower_id").with_width(60.0), + ColumnConfig::new("Name", "borrower_name").with_width(150.0), + ColumnConfig::new("Email", "email") + .with_width(150.0) + .hidden(), + ColumnConfig::new("Phone", "phone_number") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Class", "class_name").with_width(80.0), + ColumnConfig::new("Role", "role").with_width(80.0).hidden(), + ColumnConfig::new("Active", "active_loans").with_width(60.0), + ColumnConfig::new("Overdue", "overdue_loans").with_width(60.0), + ColumnConfig::new("Banned", "banned").with_width(60.0), + ]; + + Self { + loans: vec![], + borrowers: vec![], + is_loading: false, + last_error: None, + init_loaded: false, + show_loans_column_selector: false, + show_borrowers_column_selector: false, + loans_table: TableRenderer::new() + .with_columns(loans_columns) + .with_default_sort("checkout_date", false), // Sort by checkout date DESC (most recent first) + borrowers_table: TableRenderer::new().with_columns(borrowers_columns), + borrow_flow: BorrowFlow::new(), + return_flow: ReturnFlow::new(), + show_register_dialog: false, + new_borrower_name: String::new(), + new_borrower_email: String::new(), + new_borrower_phone: String::new(), + new_borrower_class: String::new(), + new_borrower_role: String::new(), + register_error: None, + borrower_editor: { + let fields = vec![ + EditorField { + name: "borrower_id".to_string(), + label: "ID".to_string(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "name".to_string(), + label: "Name".to_string(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "email".to_string(), + label: "Email".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "phone_number".to_string(), + label: "Phone".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "class_name".to_string(), + label: "Class/Department".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "role".to_string(), + label: "Role/Type".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "notes".to_string(), + label: "Notes".to_string(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "banned".to_string(), + label: "Banned".to_string(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "unban_fine".to_string(), + label: "Unban Fine".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + ]; + FormBuilder::new("Edit Borrower", fields) + }, + show_ban_dialog: false, + show_unban_dialog: false, + ban_borrower_data: None, + ban_fine_amount: String::new(), + ban_reason: String::new(), + show_return_confirm_dialog: false, + return_loan_data: None, + show_delete_borrower_dialog: false, + delete_borrower_data: None, + loans_search: String::new(), + borrowers_search: String::new(), + switch_to_inventory_with_borrower: None, + } + } + + pub fn get_filter_columns() -> Vec<(String, String)> { + vec![ + ("Any".to_string(), "Any".to_string()), + ("Asset Tag".to_string(), "assets.asset_tag".to_string()), + ("Asset Name".to_string(), "assets.name".to_string()), + ("Borrower Name".to_string(), "borrowers.name".to_string()), + ("Class".to_string(), "borrowers.class_name".to_string()), + ("Status".to_string(), "assets.lending_status".to_string()), + ( + "Checkout Date".to_string(), + "lending_history.checkout_date".to_string(), + ), + ( + "Due Date".to_string(), + "lending_history.due_date".to_string(), + ), + ( + "Return Date".to_string(), + "lending_history.return_date".to_string(), + ), + ] + } + + fn load(&mut self, api: &ApiClient) { + if self.is_loading { + return; + } + self.is_loading = true; + self.last_error = None; + match get_all_loans(api, None) { + Ok(list) => { + self.loans = list; + } + Err(e) => { + self.last_error = Some(e.to_string()); + } + } + if self.last_error.is_none() { + match get_borrowers_summary(api) { + Ok(list) => { + self.borrowers = list; + } + Err(e) => { + self.last_error = Some(e.to_string()); + } + } + } + self.is_loading = false; + self.init_loaded = true; + } + + pub fn show( + &mut self, + ctx: &egui::Context, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon: &mut crate::ui::ribbon::RibbonUI, + ) { + ui.horizontal(|ui| { + ui.heading("Borrowing"); + 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(); + + // Check for filter changes + if ribbon + .checkboxes + .get("borrowing_filter_changed") + .copied() + .unwrap_or(false) + { + ribbon + .checkboxes + .insert("borrowing_filter_changed".to_string(), false); + // For now just note that filters changed - we'll apply them client-side in render + // In the future we could reload with server-side filtering + } + + // Check for ribbon actions + if let Some(api) = api_client { + if ribbon + .checkboxes + .get("borrowing_action_checkout") + .copied() + .unwrap_or(false) + { + self.borrow_flow.open(api); + } + + if ribbon + .checkboxes + .get("borrowing_action_return") + .copied() + .unwrap_or(false) + { + self.return_flow.open(api); + } + + if ribbon + .checkboxes + .get("borrowing_action_register") + .copied() + .unwrap_or(false) + { + self.show_register_dialog = true; + self.register_error = None; + } + + if ribbon + .checkboxes + .get("borrowing_action_refresh") + .copied() + .unwrap_or(false) + { + self.load(api); + } + } + + if !self.init_loaded { + if let Some(api) = api_client { + self.load(api); + } + } + + // Show borrow flow if open + if let Some(api) = api_client { + self.borrow_flow.show(ctx, api); + if self.borrow_flow.take_recent_success() { + self.load(api); + } + } + + // Show return flow if open + if let Some(api) = api_client { + self.return_flow.show(ctx, api); + if self.return_flow.take_recent_success() { + self.load(api); + } + } + + // Show register dialog if open + if self.show_register_dialog { + if let Some(api) = api_client { + self.show_register_borrower_dialog(ctx, api); + } + } + + // Show borrower editor if open + if let Some(api) = api_client { + if let Some(result) = self.borrower_editor.show_editor(ctx) { + if let Some(data) = result { + // Editor returned data - save it + if let Err(e) = self.save_borrower_changes(api, &data) { + log::error!("Failed to save borrower changes: {}", e); + } else { + self.load(api); + } + } + // else: user cancelled + } + } + + // Show ban dialog if open + if self.show_ban_dialog { + if let Some(api) = api_client { + self.show_ban_dialog(ctx, api); + } + } + + // Show unban dialog if open + if self.show_unban_dialog { + if let Some(api) = api_client { + self.show_unban_dialog(ctx, api); + } + } + + // Show return confirm dialog if open + if self.show_return_confirm_dialog { + if let Some(api) = api_client { + self.show_return_confirm_dialog(ctx, api); + } + } + + // Show delete borrower confirm dialog if open + if self.show_delete_borrower_dialog { + if let Some(api) = api_client { + self.show_delete_borrower_dialog(ctx, api); + } + } + + // Wrap entire content in ScrollArea + egui::ScrollArea::vertical().show(ui, |ui| { + // Section 1: Lending history + egui::CollapsingHeader::new("Lending History") + .default_open(true) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.heading("Loans"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Columns").clicked() { + self.show_loans_column_selector = !self.show_loans_column_selector; + } + }); + }); + + // Search and filter controls + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(&mut self.loans_search); + + ui.separator(); + + // Status filters from ribbon + ui.label("Show:"); + ui.checkbox( + ribbon + .checkboxes + .entry("borrowing_show_normal".to_string()) + .or_insert(true), + "Normal", + ); + ui.checkbox( + ribbon + .checkboxes + .entry("borrowing_show_overdue".to_string()) + .or_insert(true), + "Overdue", + ); + ui.checkbox( + ribbon + .checkboxes + .entry("borrowing_show_stolen".to_string()) + .or_insert(true), + "Stolen", + ); + ui.checkbox( + ribbon + .checkboxes + .entry("borrowing_show_returned".to_string()) + .or_insert(false), + "Returned", + ); + }); + + ui.separator(); + self.render_active_loans(ui, ribbon); + }); + + ui.add_space(10.0); + + // Section 2: Borrowers summary + egui::CollapsingHeader::new("Borrowers") + .default_open(true) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.heading("Borrowers"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Columns").clicked() { + self.show_borrowers_column_selector = + !self.show_borrowers_column_selector; + } + }); + }); + + // Search control + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(&mut self.borrowers_search); + }); + + ui.separator(); + self.render_borrowers_table(ui); + }); + }); // End ScrollArea + + // Show column selector windows + if self.show_loans_column_selector { + egui::Window::new("Loans Columns") + .open(&mut self.show_loans_column_selector) + .resizable(true) + .default_width(250.0) + .show(ctx, |ui| { + self.loans_table.show_column_selector(ui, "loans"); + }); + } + + if self.show_borrowers_column_selector { + egui::Window::new("Borrowers Columns") + .open(&mut self.show_borrowers_column_selector) + .resizable(true) + .default_width(250.0) + .show(ctx, |ui| { + self.borrowers_table.show_column_selector(ui, "borrowers"); + }); + } + } + + fn render_active_loans(&mut self, ui: &mut egui::Ui, ribbon: &crate::ui::ribbon::RibbonUI) { + // Get checkbox states + let show_returned = ribbon + .checkboxes + .get("borrowing_show_returned") + .copied() + .unwrap_or(false); + let show_normal = ribbon + .checkboxes + .get("borrowing_show_normal") + .copied() + .unwrap_or(true); + let show_overdue = ribbon + .checkboxes + .get("borrowing_show_overdue") + .copied() + .unwrap_or(true); + let show_stolen = ribbon + .checkboxes + .get("borrowing_show_stolen") + .copied() + .unwrap_or(true); + + // Apply filters + let filtered_loans: Vec = self + .loans + .iter() + .filter(|loan| { + // First apply search filter + if !self.loans_search.is_empty() { + let search_lower = self.loans_search.to_lowercase(); + let asset_tag = loan.get("asset_tag").and_then(|v| v.as_str()).unwrap_or(""); + let asset_name = loan.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let borrower_name = loan + .get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let class_name = loan + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if !(asset_tag.to_lowercase().contains(&search_lower) + || asset_name.to_lowercase().contains(&search_lower) + || borrower_name.to_lowercase().contains(&search_lower) + || class_name.to_lowercase().contains(&search_lower)) + { + return false; + } + } + + // Apply filter builder filters + if !Self::matches_filter_builder(loan, &ribbon.filter_builder) { + return false; + } + + // Check if this loan has been returned + let has_return_date = loan + .get("return_date") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .is_some(); + + // If returned, check the show_returned checkbox + if has_return_date { + return show_returned; + } + + // For active loans, check the lending_status from assets table + let lending_status = loan + .get("lending_status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Check if stolen + if lending_status == "Stolen" || lending_status == "Illegally Handed Out" { + return show_stolen; + } + + // Check if overdue + if let Some(due_date_str) = loan.get("due_date").and_then(|v| v.as_str()) { + let now = chrono::Local::now().format("%Y-%m-%d").to_string(); + if due_date_str < now.as_str() { + return show_overdue; + } + } + + // Otherwise it's a normal active loan (not overdue, not stolen) + show_normal + }) + .cloned() + .collect(); + + // Derive a display status per loan to avoid confusion: + // If a loan has a return_date, always show "Returned" regardless of the current asset status. + // Otherwise, use the existing lending_status value (Overdue, etc. handled by DB). + let mut display_loans: Vec = Vec::with_capacity(filtered_loans.len()); + for loan in &filtered_loans { + let mut row = loan.clone(); + let has_return = row + .get("return_date") + .and_then(|v| v.as_str()) + .map(|s| !s.is_empty()) + .unwrap_or(false); + + if has_return { + row["lending_status"] = serde_json::Value::String("Returned".to_string()); + } + display_loans.push(row); + } + + let prepared_data = self.loans_table.prepare_json_data(&display_loans); + + // Handle loan table events (return item) + let mut return_loan: Option = None; + + struct LoanEventHandler<'a> { + return_action: &'a mut Option, + } + + impl<'a> crate::core::table_renderer::TableEventHandler + for LoanEventHandler<'a> + { + fn on_double_click(&mut self, _item: &serde_json::Value, _row_index: usize) { + // Not used for loans + } + + fn on_context_menu( + &mut self, + ui: &mut egui::Ui, + item: &serde_json::Value, + _row_index: usize, + ) { + // Only show "Return Item" if the loan is active (no return_date) + let has_return_date = item.get("return_date").and_then(|v| v.as_str()).is_some(); + + if !has_return_date { + if ui + .button(format!( + "{} Return Item", + egui_phosphor::regular::ARROW_RIGHT + )) + .clicked() + { + *self.return_action = Some(item.clone()); + ui.close(); + } + } + } + + fn on_selection_changed(&mut self, _selected_indices: &[usize]) { + // Not used for now + } + } + + let mut handler = LoanEventHandler { + return_action: &mut return_loan, + }; + + self.loans_table + .render_json_table(ui, &prepared_data, Some(&mut handler)); + + // Store return action for processing after all rendering + if let Some(loan) = return_loan { + self.return_loan_data = Some(loan); + self.show_return_confirm_dialog = true; + } + } + + /// Client-side filter matching for filter builder conditions + fn matches_filter_builder( + loan: &serde_json::Value, + filter_builder: &crate::core::components::filter_builder::FilterBuilder, + ) -> bool { + use crate::core::components::filter_builder::FilterOperator; + + // If no valid conditions, don't filter + if !filter_builder.filter_group.is_valid() { + return true; + } + + // Check each condition + for condition in &filter_builder.filter_group.conditions { + if !condition.is_valid() { + continue; + } + + // Map the filter column to the actual JSON field name + let field_name = match condition.column.as_str() { + "assets.asset_tag" => "asset_tag", + "assets.name" => "name", + "borrowers.name" => "borrower_name", + "borrowers.class_name" => "class_name", + "assets.lending_status" => "lending_status", + "lending_history.checkout_date" => "checkout_date", + "lending_history.due_date" => "due_date", + "lending_history.return_date" => "return_date", + _ => { + // Fallback: strip table prefix if present + if condition.column.contains('.') { + condition + .column + .split('.') + .last() + .unwrap_or(&condition.column) + } else { + &condition.column + } + } + }; + + let field_value = loan.get(field_name).and_then(|v| v.as_str()).unwrap_or(""); + + // Apply the operator + let matches = match &condition.operator { + FilterOperator::Is => field_value == condition.value, + FilterOperator::IsNot => field_value != condition.value, + FilterOperator::Contains => field_value + .to_lowercase() + .contains(&condition.value.to_lowercase()), + FilterOperator::DoesntContain => !field_value + .to_lowercase() + .contains(&condition.value.to_lowercase()), + FilterOperator::IsNull => field_value.is_empty(), + FilterOperator::IsNotNull => !field_value.is_empty(), + }; + + if !matches { + return false; // For now, treat as AND logic + } + } + + true + } + + fn render_borrowers_table(&mut self, ui: &mut egui::Ui) { + // Apply search filter if set + let filtered_borrowers: Vec = if self.borrowers_search.is_empty() { + self.borrowers.clone() + } else { + let search_lower = self.borrowers_search.to_lowercase(); + self.borrowers + .iter() + .filter(|borrower| { + let name = borrower + .get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let class = borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + name.to_lowercase().contains(&search_lower) + || class.to_lowercase().contains(&search_lower) + }) + .cloned() + .collect() + }; + + let prepared_data = self.borrowers_table.prepare_json_data(&filtered_borrowers); + + // Store actions to perform after rendering (to avoid borrow checker issues) + let mut edit_borrower: Option = None; + let mut ban_borrower: Option = None; + let mut unban_borrower: Option = None; + let mut delete_borrower: Option = None; + let mut show_items_for_borrower: Option = None; + + // Create event handler for context menu + struct BorrowerEventHandler<'a> { + edit_action: &'a mut Option, + ban_action: &'a mut Option, + unban_action: &'a mut Option, + delete_action: &'a mut Option, + show_items_action: &'a mut Option, + } + + impl<'a> crate::core::TableEventHandler for BorrowerEventHandler<'a> { + fn on_double_click(&mut self, item: &serde_json::Value, _row_index: usize) { + // Open edit dialog on double-click + *self.edit_action = Some(item.clone()); + } + + fn on_context_menu( + &mut self, + ui: &mut egui::Ui, + item: &serde_json::Value, + _row_index: usize, + ) { + let is_banned = item + .get("banned") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let borrower_id = item.get("borrower_id").and_then(|v| v.as_i64()); + + if ui + .button(format!("{} Edit Borrower", egui_phosphor::regular::PENCIL)) + .clicked() + { + *self.edit_action = Some(item.clone()); + ui.close(); + } + + if let Some(id) = borrower_id { + if ui + .button(format!( + "{} Show Items Borrowed to this User", + egui_phosphor::regular::PACKAGE + )) + .clicked() + { + *self.show_items_action = Some(id); + ui.close(); + } + } + + ui.separator(); + + if is_banned { + if ui + .button(format!( + "{} Unban Borrower", + egui_phosphor::regular::CHECK_CIRCLE + )) + .clicked() + { + *self.unban_action = Some(item.clone()); + ui.close(); + } + } else { + if ui + .button(format!("{} Ban Borrower", egui_phosphor::regular::PROHIBIT)) + .clicked() + { + *self.ban_action = Some(item.clone()); + ui.close(); + } + } + + ui.separator(); + + if ui + .button(format!("{} Delete Borrower", egui_phosphor::regular::TRASH)) + .clicked() + { + *self.delete_action = Some(item.clone()); + ui.close(); + } + } + + fn on_selection_changed(&mut self, _selected_indices: &[usize]) { + // Not used for now + } + } + + let mut handler = BorrowerEventHandler { + edit_action: &mut edit_borrower, + ban_action: &mut ban_borrower, + unban_action: &mut unban_borrower, + delete_action: &mut delete_borrower, + show_items_action: &mut show_items_for_borrower, + }; + + self.borrowers_table + .render_json_table(ui, &prepared_data, Some(&mut handler)); + + // Process actions after rendering + if let Some(borrower) = edit_borrower { + self.open_edit_borrower_dialog(borrower); + } + if let Some(borrower) = ban_borrower { + self.open_ban_dialog(borrower); + } + if let Some(borrower) = unban_borrower { + self.open_unban_dialog(borrower); + } + if let Some(borrower) = delete_borrower { + self.delete_borrower_data = Some(borrower); + self.show_delete_borrower_dialog = true; + } + if let Some(borrower_id) = show_items_for_borrower { + // Set the flag to switch to inventory with this borrower filter + self.switch_to_inventory_with_borrower = Some(borrower_id); + } + } + + fn show_register_borrower_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) { + egui::Window::new("Register New Borrower") + .collapsible(false) + .resizable(false) + .default_width(400.0) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + + if let Some(err) = &self.register_error { + ui.colored_label(egui::Color32::RED, err); + ui.separator(); + } + + ui.horizontal(|ui| { + ui.label("Name:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_name) + .hint_text("Full name"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Email:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_email) + .hint_text("email@example.com"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Phone:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_phone) + .hint_text("Phone number"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Class:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_class) + .hint_text("Class or department"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Role:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_role) + .hint_text("Student, Staff, etc."), + ); + }); + + ui.add_space(10.0); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Register").clicked() { + if self.new_borrower_name.trim().is_empty() { + self.register_error = Some("Name is required".to_string()); + } else { + match self.register_borrower(api_client) { + Ok(_) => { + // Success - close dialog and reload data + self.show_register_dialog = false; + self.clear_register_form(); + self.load(api_client); + } + Err(e) => { + self.register_error = Some(e.to_string()); + } + } + } + } + + if ui.button("Cancel").clicked() { + self.show_register_dialog = false; + self.clear_register_form(); + } + }); + }); + }); + } + + fn register_borrower(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let mut borrower_data = serde_json::json!({ + "name": self.new_borrower_name.trim(), + "banned": false, + }); + + if !self.new_borrower_email.is_empty() { + borrower_data["email"] = + serde_json::Value::String(self.new_borrower_email.trim().to_string()); + } + + if !self.new_borrower_phone.is_empty() { + borrower_data["phone_number"] = + serde_json::Value::String(self.new_borrower_phone.trim().to_string()); + } + + if !self.new_borrower_class.is_empty() { + borrower_data["class_name"] = + serde_json::Value::String(self.new_borrower_class.trim().to_string()); + } + + if !self.new_borrower_role.is_empty() { + borrower_data["role"] = + serde_json::Value::String(self.new_borrower_role.trim().to_string()); + } + + let request = QueryRequest { + action: "insert".to_string(), + table: "borrowers".to_string(), + columns: None, + r#where: None, + data: Some(borrower_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to register borrower".to_string()))); + } + + Ok(()) + } + + fn clear_register_form(&mut self) { + self.new_borrower_name.clear(); + self.new_borrower_email.clear(); + self.new_borrower_phone.clear(); + self.new_borrower_class.clear(); + self.new_borrower_role.clear(); + self.register_error = None; + } + + // Edit borrower dialog methods + fn open_edit_borrower_dialog(&mut self, borrower: serde_json::Value) { + // The summary doesn't have all fields, so we'll populate what we have + // and the editor will show empty fields for missing data + let mut editor_data = serde_json::Map::new(); + + // Map the summary fields to editor fields + if let Some(id) = borrower.get("borrower_id") { + editor_data.insert("borrower_id".to_string(), id.clone()); + editor_data.insert("id".to_string(), id.clone()); // Also set 'id' for WHERE clause + } + if let Some(name) = borrower.get("borrower_name") { + editor_data.insert("name".to_string(), name.clone()); + } + if let Some(email) = borrower.get("email") { + if !email.is_null() && email.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("email".to_string(), email.clone()); + } + } + if let Some(phone) = borrower.get("phone_number") { + if !phone.is_null() && phone.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("phone_number".to_string(), phone.clone()); + } + } + if let Some(class) = borrower.get("class_name") { + if !class.is_null() && class.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("class_name".to_string(), class.clone()); + } + } + if let Some(role) = borrower.get("role") { + if !role.is_null() && role.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("role".to_string(), role.clone()); + } + } + if let Some(notes) = borrower.get("notes") { + if !notes.is_null() && notes.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("notes".to_string(), notes.clone()); + } + } + if let Some(banned) = borrower.get("banned") { + editor_data.insert("banned".to_string(), banned.clone()); + } + if let Some(unban_fine) = borrower.get("unban_fine") { + if !unban_fine.is_null() { + editor_data.insert("unban_fine".to_string(), unban_fine.clone()); + } + } + + // Open the editor with the borrower data + let value = serde_json::Value::Object(editor_data); + self.borrower_editor.open(&value); + } + + fn save_borrower_changes( + &self, + api_client: &ApiClient, + diff: &serde_json::Map, + ) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + // Extract borrower ID from the diff (editor includes it as __editor_item_id) + let borrower_id = diff + .get("__editor_item_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) + .or_else(|| diff.get("borrower_id").and_then(|v| v.as_i64())) + .or_else(|| diff.get("id").and_then(|v| v.as_i64())) + .ok_or_else(|| anyhow::anyhow!("Invalid borrower ID"))?; + + // Build update data from the diff (exclude editor metadata) + let mut update_data = serde_json::Map::new(); + for (key, value) in diff.iter() { + if !key.starts_with("__editor_") && key != "borrower_id" && key != "id" { + update_data.insert(key.clone(), value.clone()); + } + } + + if update_data.is_empty() { + return Ok(()); // Nothing to update + } + + let request = QueryRequest { + action: "update".to_string(), + table: "borrowers".to_string(), + data: Some(serde_json::Value::Object(update_data)), + r#where: Some(serde_json::json!({"id": borrower_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to update borrower".to_string()))); + } + + Ok(()) + } + + // Ban/Unban dialog methods + fn open_ban_dialog(&mut self, borrower: serde_json::Value) { + self.ban_borrower_data = Some(borrower); + self.show_ban_dialog = true; + self.ban_fine_amount.clear(); + self.ban_reason.clear(); + } + + fn open_unban_dialog(&mut self, borrower: serde_json::Value) { + self.ban_borrower_data = Some(borrower); + self.show_unban_dialog = true; + } + + fn show_ban_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) { + let mut keep_open = true; + let mut confirmed = false; + let mut cancelled = false; + + let borrower_name = self + .ban_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + egui::Window::new("Ban Borrower") + .collapsible(false) + .resizable(false) + .default_width(400.0) + .open(&mut keep_open) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + + ui.label( + egui::RichText::new(format!( + "⚠ Are you sure you want to ban '{}'?", + borrower_name + )) + .color(egui::Color32::from_rgb(255, 152, 0)) + .strong(), + ); + + ui.add_space(10.0); + + ui.horizontal(|ui| { + ui.label("Fine Amount ($):"); + ui.text_edit_singleline(&mut self.ban_fine_amount); + }); + ui.label( + egui::RichText::new("(Optional: leave empty for no fine)") + .small() + .color(ui.visuals().weak_text_color()), + ); + + ui.add_space(5.0); + + ui.label("Reason:"); + ui.text_edit_multiline(&mut self.ban_reason); + ui.label( + egui::RichText::new("(Optional: reason for banning)") + .small() + .color(ui.visuals().weak_text_color()), + ); + + ui.add_space(10.0); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Confirm Ban").clicked() { + confirmed = true; + } + + if ui.button("Cancel").clicked() { + cancelled = true; + } + }); + }); + }); + + if confirmed { + match self.ban_borrower(api_client) { + Ok(_) => { + self.show_ban_dialog = false; + self.ban_borrower_data = None; + self.load(api_client); + } + Err(e) => { + log::error!("Failed to ban borrower: {}", e); + } + } + } + + if cancelled || !keep_open { + self.show_ban_dialog = false; + self.ban_borrower_data = None; + } + } + + fn show_unban_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) { + let mut keep_open = true; + let mut confirmed = false; + let mut cancelled = false; + + let borrower_name = self + .ban_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + egui::Window::new("Unban Borrower") + .collapsible(false) + .resizable(false) + .default_width(400.0) + .open(&mut keep_open) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + + ui.label( + egui::RichText::new(format!( + "Are you sure you want to unban '{}'?", + borrower_name + )) + .color(egui::Color32::from_rgb(76, 175, 80)) + .strong(), + ); + + ui.add_space(10.0); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Confirm Unban").clicked() { + confirmed = true; + } + + if ui.button("Cancel").clicked() { + cancelled = true; + } + }); + }); + }); + + if confirmed { + match self.unban_borrower(api_client) { + Ok(_) => { + self.show_unban_dialog = false; + self.ban_borrower_data = None; + self.load(api_client); + } + Err(e) => { + log::error!("Failed to unban borrower: {}", e); + } + } + } + + if cancelled || !keep_open { + self.show_unban_dialog = false; + self.ban_borrower_data = None; + } + } + + fn show_return_confirm_dialog(&mut self, _ctx: &egui::Context, api_client: &ApiClient) { + // Replace the basic confirm dialog with the full Return Flow, pre-selecting the loan + if let Some(loan) = self.return_loan_data.clone() { + // Open the full-featured return flow and jump to confirmation + self.return_flow.open(api_client); + self.return_flow.selected_loan = Some(loan); + self.return_flow.current_step = + crate::core::workflows::return_flow::ReturnStep::Confirm; + } + // Close the legacy confirm dialog path + self.show_return_confirm_dialog = false; + self.return_loan_data = None; + } + + fn show_delete_borrower_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) { + let mut keep_open = true; + let mut confirmed = false; + let mut cancelled = false; + + let borrower_name = self + .delete_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + egui::Window::new("Delete Borrower") + .collapsible(false) + .resizable(false) + .default_width(400.0) + .open(&mut keep_open) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + + ui.label( + egui::RichText::new(format!( + "Are you sure you want to delete '{}'?", + borrower_name + )) + .color(egui::Color32::RED) + .strong(), + ); + + ui.add_space(10.0); + + ui.label( + egui::RichText::new("This action cannot be undone!") + .color(egui::Color32::RED) + .small(), + ); + + ui.add_space(10.0); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Confirm Delete").clicked() { + confirmed = true; + } + + if ui.button("Cancel").clicked() { + cancelled = true; + } + }); + }); + }); + + if confirmed { + match self.delete_borrower(api_client) { + Ok(_) => { + self.show_delete_borrower_dialog = false; + self.delete_borrower_data = None; + self.load(api_client); + } + Err(e) => { + log::error!("Failed to delete borrower: {}", e); + self.last_error = Some(format!("Delete failed: {}", e)); + } + } + } + + if cancelled || !keep_open { + self.show_delete_borrower_dialog = false; + self.delete_borrower_data = None; + } + } + + fn ban_borrower(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let borrower_id = self + .ban_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid borrower ID"))?; + + let mut update_data = serde_json::json!({ + "banned": true, + }); + + // Add unban fine amount if provided + if !self.ban_fine_amount.trim().is_empty() { + if let Ok(fine) = self.ban_fine_amount.trim().parse::() { + update_data["unban_fine"] = serde_json::Value::Number( + serde_json::Number::from_f64(fine).unwrap_or(serde_json::Number::from(0)), + ); + } + } + + // Add reason to notes if provided + if !self.ban_reason.trim().is_empty() { + update_data["notes"] = serde_json::Value::String(self.ban_reason.trim().to_string()); + } + + let request = QueryRequest { + action: "update".to_string(), + table: "borrowers".to_string(), + data: Some(update_data), + r#where: Some(serde_json::json!({"id": borrower_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to ban borrower".to_string()))); + } + + Ok(()) + } + + fn unban_borrower(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let borrower_id = self + .ban_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid borrower ID"))?; + + let update_data = serde_json::json!({ + "banned": false, + "unban_fine": 0.0, + }); + + let request = QueryRequest { + action: "update".to_string(), + table: "borrowers".to_string(), + data: Some(update_data), + r#where: Some(serde_json::json!({"id": borrower_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to unban borrower".to_string()))); + } + + Ok(()) + } + + #[allow(dead_code)] + fn process_return(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let loan_id = self + .return_loan_data + .as_ref() + .and_then(|l| l.get("id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid loan ID"))?; + + let asset_id = self + .return_loan_data + .as_ref() + .and_then(|l| l.get("asset_id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid asset ID"))?; + + let return_date = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + // Update lending_history to set return_date + let update_data = serde_json::json!({ + "return_date": return_date + }); + + let request = QueryRequest { + action: "update".to_string(), + table: "lending_history".to_string(), + data: Some(update_data), + r#where: Some(serde_json::json!({"id": loan_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to update loan record".to_string()))); + } + + // Update asset status to "Available" + let asset_update = serde_json::json!({ + "lending_status": "Available" + }); + + let asset_request = QueryRequest { + action: "update".to_string(), + table: "assets".to_string(), + data: Some(asset_update), + r#where: Some(serde_json::json!({"id": asset_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let asset_response = api_client.query(&asset_request)?; + + if !asset_response.success { + return Err(anyhow::anyhow!(asset_response + .error + .unwrap_or_else(|| "Failed to update asset status".to_string()))); + } + + Ok(()) + } + + fn delete_borrower(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let borrower_id = self + .delete_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid borrower ID"))?; + + let request = QueryRequest { + action: "delete".to_string(), + table: "borrowers".to_string(), + data: None, + r#where: Some(serde_json::json!({"id": borrower_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to delete borrower".to_string()))); + } + + Ok(()) + } +} diff --git a/src/ui/categories.rs b/src/ui/categories.rs new file mode 100644 index 0000000..3b119e5 --- /dev/null +++ b/src/ui/categories.rs @@ -0,0 +1,892 @@ +use crate::api::ApiClient; +use crate::core::components::form_builder::FormBuilder; +use crate::core::components::interactions::ConfirmDialog; +use crate::core::table_renderer::{ColumnConfig, TableEventHandler, TableRenderer}; +use crate::core::tables::get_categories; +use crate::core::{EditorField, FieldType}; +use crate::ui::ribbon::RibbonUI; +use eframe::egui; +use serde_json::Value; + +pub struct CategoriesView { + categories: Vec, + is_loading: bool, + last_error: Option, + initial_load_done: bool, + load_attempted: bool, // New field to track if we've tried loading + + // Editor dialogs + edit_dialog: FormBuilder, + add_dialog: FormBuilder, + delete_dialog: ConfirmDialog, + + // Pending operations + pending_delete_ids: Vec, // Changed from Option to Vec for bulk delete support + pending_edit_ids: Vec, // Changed from Option to Vec for bulk edit support + + // Table rendering + table_renderer: crate::core::table_renderer::TableRenderer, +} + +impl CategoriesView { + pub fn new() -> Self { + let edit_dialog = Self::create_edit_dialog(); + let add_dialog = Self::create_placeholder_add_dialog(); + + // Define columns for categories table - code before name as requested + let columns = vec![ + ColumnConfig::new("ID", "id").with_width(60.0).hidden(), + ColumnConfig::new("Category Code", "category_code").with_width(120.0), + ColumnConfig::new("Category Name", "category_name").with_width(200.0), + ColumnConfig::new("Description", "category_description").with_width(300.0), + ColumnConfig::new("Parent ID", "parent_id") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Parent Category", "parent_category_name").with_width(150.0), + ]; + + Self { + categories: Vec::new(), + is_loading: false, + last_error: None, + initial_load_done: false, + load_attempted: false, + edit_dialog, + add_dialog, + delete_dialog: ConfirmDialog::new( + "Delete Category", + "Are you sure you want to delete this category? This will affect all assets using this category.", + ), + pending_delete_ids: Vec::new(), + pending_edit_ids: Vec::new(), + table_renderer: TableRenderer::new() + .with_columns(columns) + .with_default_sort("category_code", true) // Sort by category code alphabetically + .with_search_fields(vec![ + "category_name".to_string(), + "category_code".to_string(), + "category_description".to_string(), + "parent_category_name".to_string(), + ]), + } + } + + fn create_edit_dialog() -> FormBuilder { + FormBuilder::new( + "Edit Category", + vec![ + EditorField { + name: "id".into(), + label: "ID".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "category_name".into(), + label: "Category Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "category_code".into(), + label: "Category Code".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "category_description".into(), + label: "Description".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "parent_id".into(), + label: "Parent Category".into(), + field_type: FieldType::Text, // TODO: Make this a dropdown with other categories + required: false, + read_only: false, + }, + ], + ) + } + + fn create_placeholder_add_dialog() -> FormBuilder { + FormBuilder::new( + "Add Category", + vec![ + EditorField { + name: "category_name".into(), + label: "Category Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "category_code".into(), + label: "Category Code".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "category_description".into(), + label: "Description".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "parent_id".into(), + label: "Parent Category".into(), + field_type: FieldType::Text, // Will be updated to dropdown when opened + required: false, + read_only: false, + }, + ], + ) + } + + fn create_add_dialog_with_options(&self) -> FormBuilder { + let category_options = self.create_category_dropdown_options(None); + + FormBuilder::new( + "Add Category", + vec![ + EditorField { + name: "category_name".into(), + label: "Category Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "category_code".into(), + label: "Category Code".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "category_description".into(), + label: "Description".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "parent_id".into(), + label: "Parent Category".into(), + field_type: FieldType::Dropdown(category_options), + required: false, + read_only: false, + }, + ], + ) + } + + fn load_categories(&mut self, api_client: &ApiClient) { + // Don't start a new load if we're already loading + if self.is_loading { + return; + } + + self.is_loading = true; + self.last_error = None; + self.load_attempted = true; + + match get_categories(api_client, Some(200)) { + Ok(categories) => { + self.categories = categories; + self.initial_load_done = true; + log::info!( + "Categories loaded successfully: {} items", + self.categories.len() + ); + } + Err(e) => { + let error_msg = format!("Error loading categories: {}", e); + log::error!("{}", error_msg); + self.last_error = Some(error_msg); + } + } + + self.is_loading = false; + } + + /// Get selected category IDs for bulk operations (works with filtered view) + fn get_selected_ids(&self) -> Vec { + let filtered_data = self.table_renderer.prepare_json_data(&self.categories); + let mut ids = Vec::new(); + for &row_idx in &self.table_renderer.selection.selected_rows { + // prepared_data contains tuples of (original_index, &Value) + if let Some((_orig_idx, category)) = filtered_data.get(row_idx) { + if let Some(id) = category.get("id").and_then(|v| v.as_i64()) { + ids.push(id); + } + } + } + ids + } + + /// Sanitize form data for categories before sending to the API. + /// - Removes internal editor fields prefixed with __editor_ + /// - Converts empty-string parent_id to JSON null + /// - Coerces numeric parent_id strings to numbers + fn sanitize_category_map( + form_data: &serde_json::Map, + ) -> serde_json::Map { + let mut out = serde_json::Map::new(); + for (k, v) in form_data.iter() { + // Skip internal editor fields + if k.starts_with("__editor_") { + continue; + } + + if k == "parent_id" { + // parent_id might be sent as "" for None. Convert to null. + if v.is_null() { + out.insert(k.clone(), Value::Null); + continue; + } + + if let Some(s) = v.as_str() { + let s_trim = s.trim(); + if s_trim.is_empty() { + out.insert(k.clone(), Value::Null); + continue; + } + // Try parse integer + if let Ok(n) = s_trim.parse::() { + out.insert(k.clone(), Value::Number((n).into())); + continue; + } + // Fallback: keep as string + out.insert(k.clone(), Value::String(s_trim.to_string())); + continue; + } + + // If it's already a number, keep it + if v.is_i64() || v.is_u64() || v.is_f64() { + out.insert(k.clone(), v.clone()); + continue; + } + + // Anything else -> keep as-is + out.insert(k.clone(), v.clone()); + continue; + } + + // For everything else, just copy through + out.insert(k.clone(), v.clone()); + } + out + } + + fn create_category( + &mut self, + api_client: &ApiClient, + form_data: &serde_json::Map, + ) { + // Sanitize and coerce form data (convert empty parent_id -> null, remove internal fields) + let sanitized = Self::sanitize_category_map(form_data); + let values = serde_json::Value::Object(sanitized); + + match api_client.insert("categories", values) { + Ok(resp) if resp.success => { + log::info!("Category created successfully"); + self.load_categories(api_client); // Reload to get fresh data + } + Ok(resp) => { + let error_msg = format!("Create failed: {:?}", resp.error); + log::error!("{}", error_msg); + self.last_error = Some(error_msg); + } + Err(e) => { + let error_msg = format!("Create error: {}", e); + log::error!("{}", error_msg); + self.last_error = Some(error_msg); + } + } + } + + fn update_category( + &mut self, + api_client: &ApiClient, + category_id: i64, + form_data: &serde_json::Map, + ) { + // Sanitize form data (remove internal fields, coerce parent_id). Also ensure we don't send id. + let mut filtered_data = Self::sanitize_category_map(form_data); + filtered_data.remove("id"); + + // Convert form data to JSON object + let values = serde_json::Value::Object(filtered_data); + let where_clause = serde_json::json!({"id": category_id}); + + match api_client.update("categories", values, where_clause) { + Ok(resp) if resp.success => { + log::info!("Category updated successfully"); + self.load_categories(api_client); // Reload to get fresh data + } + Ok(resp) => { + let error_msg = format!("Update failed: {:?}", resp.error); + log::error!("{}", error_msg); + + // Check for foreign key constraint errors + if let Some(err_str) = resp.error.as_ref() { + if err_str.contains("foreign key constraint") { + self.last_error = Some( + "Cannot update category: Invalid parent category reference." + .to_string(), + ); + } else { + self.last_error = Some(error_msg); + } + } else { + self.last_error = Some(error_msg); + } + } + Err(e) => { + let error_msg = format!("Update error: {}", e); + log::error!("{}", error_msg); + self.last_error = Some(error_msg); + } + } + } + + fn delete_category(&mut self, api_client: &ApiClient, category_id: i64) { + let where_clause = serde_json::json!({"id": category_id}); + match api_client.delete("categories", where_clause) { + Ok(resp) if resp.success => { + log::info!("Category deleted successfully"); + self.load_categories(api_client); // Reload to get fresh data + } + Ok(resp) => { + let error_msg = format!("Delete failed: {:?}", resp.error); + log::error!("{}", error_msg); + + // Check for foreign key constraint errors and provide user-friendly message + if let Some(err_str) = resp.error.as_ref() { + if err_str.contains("foreign key constraint") + || err_str.contains("Cannot delete or update a parent row") + { + self.last_error = Some( + "Cannot delete category: It is being used by other categories as their parent, or by assets. \ + Please reassign dependent items first.".to_string() + ); + } else { + self.last_error = Some(error_msg); + } + } else { + self.last_error = Some(error_msg); + } + } + Err(e) => { + let error_msg = format!("Delete error: {}", e); + log::error!("{}", error_msg); + + // Check for foreign key constraint in error message + let err_lower = error_msg.to_lowercase(); + if err_lower.contains("foreign key") || err_lower.contains("constraint") { + self.last_error = Some( + "Cannot delete category: It is being used by other categories as their parent, or by assets. \ + Please reassign dependent items first.".to_string() + ); + } else { + self.last_error = Some(error_msg); + } + } + } + } + + pub fn show( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon: Option<&mut RibbonUI>, + ) -> Vec { + let mut flags_to_clear = Vec::new(); + + // Handle context menu actions and double-click + if let Some(item) = ui.ctx().data_mut(|d| { + d.remove_temp::(egui::Id::new("cat_double_click_edit")) + }) { + self.open_editor_with(&item); + ui.ctx().request_repaint(); + } + + if let Some(item) = ui.ctx().data_mut(|d| { + d.remove_temp::(egui::Id::new("cat_context_menu_edit")) + }) { + self.open_editor_with(&item); + ui.ctx().request_repaint(); + } + + if let Some(item) = ui.ctx().data_mut(|d| { + d.remove_temp::(egui::Id::new("cat_context_menu_delete")) + }) { + let name = item + .get("category_name") + .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.pending_delete_ids = vec![id]; // Changed to vector + self.delete_dialog.open(name, id.to_string()); + ui.ctx().request_repaint(); + } + + // Auto-load on first show, but only try once unless user explicitly requests retry + if !self.initial_load_done && !self.is_loading && !self.load_attempted { + if let Some(client) = api_client { + log::info!("Categories view never loaded, triggering initial auto-load"); + self.load_categories(client); + } + } + + // Extract search query and handle ribbon actions + let search_query = if let Some(ribbon) = ribbon.as_ref() { + let query = ribbon + .search_texts + .get("categories_search") + .filter(|s| !s.trim().is_empty()) + .map(|s| s.as_str()); + + // Handle ribbon actions + if ribbon + .checkboxes + .get("categories_refresh") + .copied() + .unwrap_or(false) + { + if let Some(client) = api_client { + // Reset error state and allow fresh load + self.last_error = None; + self.load_categories(client); + } + flags_to_clear.push("categories_refresh".to_string()); + } + + if ribbon + .checkboxes + .get("categories_add") + .copied() + .unwrap_or(false) + { + // Create a new add dialog with current category options + self.add_dialog = self.create_add_dialog_with_options(); + self.add_dialog.open(&serde_json::json!({})); // Open with empty data + flags_to_clear.push("categories_add".to_string()); + } + + if ribbon + .checkboxes + .get("categories_edit") + .copied() + .unwrap_or(false) + { + // Get selected category IDs + let selected_ids = self.get_selected_ids(); + + if !selected_ids.is_empty() { + // For edit, only edit the first selected category (bulk edit of categories is complex) + if let Some(&first_id) = selected_ids.first() { + // Clone the category to avoid borrowing issues + let category = self + .categories + .iter() + .find(|c| c.get("id").and_then(|v| v.as_i64()) == Some(first_id)) + .cloned(); + + if let Some(cat) = category { + self.open_editor_with(&cat); + } + } + } else { + log::warn!("Edit requested but no categories selected"); + } + flags_to_clear.push("categories_edit".to_string()); + } + + if ribbon + .checkboxes + .get("categories_delete") + .copied() + .unwrap_or(false) + { + // Get selected category IDs for bulk delete + let selected_ids = self.get_selected_ids(); + + if !selected_ids.is_empty() { + self.pending_delete_ids = selected_ids.clone(); + let count = selected_ids.len(); + + // Show dialog with appropriate message for single or multiple deletes + let message = + if count == 1 { + // Get the category name for single delete + if let Some(category) = self.categories.iter().find(|c| { + c.get("id").and_then(|v| v.as_i64()) == Some(selected_ids[0]) + }) { + category + .get("category_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string() + } else { + "Unknown".to_string() + } + } else { + format!("{} categories", count) + }; + + self.delete_dialog + .open(message, format!("IDs: {:?}", selected_ids)); + } else { + log::warn!("Delete requested but no categories selected"); + } + flags_to_clear.push("categories_delete".to_string()); + } + + query + } else { + None + }; + + // Top toolbar + ui.horizontal(|ui| { + ui.heading("Categories"); + + if self.is_loading { + ui.spinner(); + ui.label("Loading..."); + } else { + ui.label(format!("{} categories", self.categories.len())); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("➕ Add Category").clicked() { + self.add_dialog.open_new(None); + } + + if ui.button("Refresh").clicked() { + if let Some(client) = api_client { + // Reset error state and allow fresh load + self.last_error = None; + self.load_categories(client); + } + } + }); + }); + + ui.separator(); + + // Error display with retry option + if let Some(error) = &self.last_error { + ui.colored_label(egui::Color32::RED, format!("Error: {}", error)); + ui.horizontal(|ui| { + if ui.button("Try Again").clicked() { + if let Some(client) = api_client { + // Reset state and try loading again + self.load_attempted = false; + self.initial_load_done = false; + self.load_categories(client); + } + } + if ui.button("Clear Error").clicked() { + self.last_error = None; + } + }); + ui.separator(); + } + + // Categories table + if !self.is_loading && !self.categories.is_empty() { + self.render_table(ui, search_query); + } else if !self.is_loading { + ui.centered_and_justified(|ui| { + ui.label("No categories found. Click 'Add Category' to create one."); + }); + } + + // Handle dialogs + if let Some(api_client) = api_client { + // Add dialog + if let Some(result) = self.add_dialog.show_editor(ui.ctx()) { + if let Some(category_data) = result { + log::info!("Creating new category: {:?}", category_data); + self.create_category(api_client, &category_data); + } + } + + // Edit dialog + if let Some(result) = self.edit_dialog.show_editor(ui.ctx()) { + if let Some(category_data) = result { + // Support bulk edit: if pending_edit_ids is empty, try to get ID from dialog + let ids_to_edit: Vec = if !self.pending_edit_ids.is_empty() { + std::mem::take(&mut self.pending_edit_ids) + } else { + // Single edit from dialog - extract ID from __editor_item_id or category data + category_data + .get("__editor_item_id") + .and_then(|v| v.as_str()) + .or_else(|| category_data.get("id").and_then(|v| v.as_str())) + .and_then(|s| s.parse::().ok()) + .map(|id| vec![id]) + .unwrap_or_default() + }; + + for category_id in ids_to_edit { + log::info!("Updating category {}: {:?}", category_id, category_data); + self.update_category(api_client, category_id, &category_data); + } + } + } + + // Delete dialog - support bulk delete + if let Some(confirmed) = self.delete_dialog.show_dialog(ui.ctx()) { + log::info!( + "Delete dialog result: confirmed={}, pending_delete_ids={:?}", + confirmed, + self.pending_delete_ids + ); + if confirmed && !self.pending_delete_ids.is_empty() { + // Clone the IDs to avoid borrowing issues + let ids_to_delete = self.pending_delete_ids.clone(); + for category_id in ids_to_delete { + log::info!("Deleting category: {}", category_id); + self.delete_category(api_client, category_id); + } + } + self.pending_delete_ids.clear(); + } + } + + flags_to_clear + } + + fn render_table(&mut self, ui: &mut egui::Ui, search_query: Option<&str>) { + // Apply search query to TableRenderer (clear if empty) + match search_query { + Some(query) => self.table_renderer.set_search_query(query.to_string()), + None => self.table_renderer.set_search_query(String::new()), // Clear search when empty + } + + // Prepare sorted/filtered data + let prepared_data = self.table_renderer.prepare_json_data(&self.categories); + + // Create temporary event handler for deferred actions + let mut deferred_actions = Vec::new(); + let mut event_handler = TempCategoriesEventHandler { + deferred_actions: &mut deferred_actions, + }; + + // Render the table with TableRenderer + self.table_renderer + .render_json_table(ui, &prepared_data, Some(&mut event_handler)); + + // Process deferred actions + for action in deferred_actions { + match action { + DeferredCategoryAction::DoubleClick(category) => { + self.open_editor_with(&category); + } + DeferredCategoryAction::ContextEdit(category) => { + self.open_editor_with(&category); + } + DeferredCategoryAction::ContextDelete(category) => { + let name = category + .get("category_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let id = category.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); + self.pending_delete_ids = vec![id]; // Changed to vector + self.delete_dialog.open(name, id.to_string()); + } + DeferredCategoryAction::ContextClone(category) => { + // Prepare Add dialog with up-to-date dropdown options + self.add_dialog = self.create_add_dialog_with_options(); + + // Use the shared helper to clear ID/code and suffix the name + let cloned = crate::core::components::prepare_cloned_value( + &category, + &["id", "category_code"], + Some("category_name"), + Some(""), + ); + + self.add_dialog.title = "Add Category".to_string(); + self.add_dialog.open(&cloned); + } + } + } + } + + fn create_category_dropdown_options(&self, exclude_id: Option) -> Vec<(String, String)> { + let mut options = vec![("".to_string(), "None (Root Category)".to_string())]; + + for category in &self.categories { + if let Some(id) = category.get("id").and_then(|v| v.as_i64()) { + // Exclude the current category to prevent circular references + if let Some(exclude) = exclude_id { + if id == exclude { + continue; + } + } + + let name = category + .get("category_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let code = category + .get("category_code") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let display_name = if code.is_empty() { + name + } else { + format!("{} - {}", code, name) + }; + + options.push((id.to_string(), display_name)); + } + } + + options + } + + fn create_edit_dialog_with_options(&self, exclude_id: Option) -> FormBuilder { + let category_options = self.create_category_dropdown_options(exclude_id); + + FormBuilder::new( + "Edit Category", + vec![ + EditorField { + name: "id".into(), + label: "ID".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "category_name".into(), + label: "Category Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "category_code".into(), + label: "Category Code".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "category_description".into(), + label: "Description".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "parent_id".into(), + label: "Parent Category".into(), + field_type: FieldType::Dropdown(category_options), + required: false, + read_only: false, + }, + ], + ) + } + + fn open_editor_with(&mut self, item: &serde_json::Value) { + let category_id = item.get("id").and_then(|v| v.as_i64()); + + // Clear pending_edit_ids since we're opening a single-item editor + // The ID will be extracted from the dialog data when saving + self.pending_edit_ids.clear(); + + // Create a new editor with current category options (excluding this category) + self.edit_dialog = self.create_edit_dialog_with_options(category_id); + self.edit_dialog.open(item); + } +} + +// Deferred actions for categories table +#[derive(Debug)] +enum DeferredCategoryAction { + DoubleClick(Value), + ContextEdit(Value), + ContextDelete(Value), + ContextClone(Value), +} + +// Temporary event handler that collects actions for later processing +struct TempCategoriesEventHandler<'a> { + deferred_actions: &'a mut Vec, +} + +impl<'a> TableEventHandler for TempCategoriesEventHandler<'a> { + fn on_double_click(&mut self, item: &Value, _row_index: usize) { + log::info!( + "Double-click detected on category: {:?}", + item.get("category_name") + ); + self.deferred_actions + .push(DeferredCategoryAction::DoubleClick(item.clone())); + } + + fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) { + if ui + .button(format!("{} Edit", egui_phosphor::regular::PENCIL)) + .clicked() + { + log::info!( + "Context menu edit clicked for category: {:?}", + item.get("category_name") + ); + self.deferred_actions + .push(DeferredCategoryAction::ContextEdit(item.clone())); + ui.close(); + } + ui.separator(); + if ui + .button(format!("{} Clone Category", egui_phosphor::regular::COPY)) + .clicked() + { + log::info!( + "Context menu clone clicked for category: {:?}", + item.get("category_name") + ); + self.deferred_actions + .push(DeferredCategoryAction::ContextClone(item.clone())); + ui.close(); + } + ui.separator(); + if ui + .button(format!("{} Delete", egui_phosphor::regular::TRASH)) + .clicked() + { + log::info!( + "Context menu delete clicked for category: {:?}", + item.get("category_name") + ); + self.deferred_actions + .push(DeferredCategoryAction::ContextDelete(item.clone())); + ui.close(); + } + } + + fn on_selection_changed(&mut self, _selected_indices: &[usize]) { + // Selection handling is managed by the main CategoriesView + // We don't need to do anything here for now + } +} diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs new file mode 100644 index 0000000..3a40c97 --- /dev/null +++ b/src/ui/dashboard.rs @@ -0,0 +1,384 @@ +use eframe::egui; +use egui_extras::{Column, TableBuilder}; + +use crate::api::ApiClient; +use crate::core::{fetch_dashboard_stats, get_asset_changes, get_issue_changes}; +use crate::models::DashboardStats; + +fn format_date_short(date_str: &str) -> String { + // Parse ISO format like "2024-10-17T01:05:14Z" and return "01:05 17/10/24" + if let Some(parts) = date_str.split('T').next() { + if let Some(time_part) = date_str.split('T').nth(1) { + let time = &time_part[..5]; // HH:MM + if let Some((y, rest)) = parts.split_once('-') { + if let Some((m, d)) = rest.split_once('-') { + let year_short = y.chars().skip(2).collect::(); + return format!("{} {}/{}/{}", time, d, m, year_short); + } + } + } + } + date_str.to_string() +} + +pub struct DashboardView { + stats: DashboardStats, + is_loading: bool, + last_error: Option, + data_loaded: bool, + asset_changes: Vec, + issue_changes: Vec, +} + +impl DashboardView { + pub fn new() -> Self { + Self { + stats: DashboardStats::default(), + is_loading: false, + last_error: None, + data_loaded: false, + asset_changes: Vec::new(), + issue_changes: Vec::new(), + } + } + + pub fn refresh_data(&mut self, api_client: &ApiClient) { + self.is_loading = true; + self.last_error = None; + + // Fetch dashboard stats using core module + log::info!("Refreshing dashboard data..."); + match fetch_dashboard_stats(api_client) { + Ok(stats) => { + log::info!( + "Dashboard stats loaded: {} total assets", + stats.total_assets + ); + self.stats = stats; + + // Load recent changes using core module + self.asset_changes = get_asset_changes(api_client, 15).unwrap_or_default(); + self.issue_changes = get_issue_changes(api_client, 12).unwrap_or_default(); + + self.is_loading = false; + self.data_loaded = true; + } + Err(err) => { + log::error!("Failed to load dashboard stats: {}", err); + self.last_error = Some(format!("Failed to load stats: {}", err)); + self.is_loading = false; + } + } + } + + /// Check if the last error was a database timeout + pub fn has_timeout_error(&self) -> bool { + if let Some(error) = &self.last_error { + error.contains("Database temporarily unavailable") + } else { + false + } + } + + pub fn show(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + // Auto-load data on first show + if !self.data_loaded && !self.is_loading { + if let Some(client) = api_client { + self.refresh_data(client); + } + } + + ui.heading("Dashboard"); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Refresh").clicked() { + if let Some(client) = api_client { + self.refresh_data(client); + } + } + + if self.is_loading { + ui.spinner(); + ui.label("Loading..."); + } + }); + + ui.add_space(12.0); + + // Error display + if let Some(error) = &self.last_error { + ui.label(format!("Error: {}", error)); + ui.add_space(8.0); + } + + // Stats cards - using horizontal layout with equal widths and padding + ui.horizontal(|ui| { + let available_width = ui.available_width(); + let side_padding = 20.0; // Equal padding on both sides + let spacing = 16.0; + let frame_margin = 16.0 * 2.0; // inner_margin on both sides + let stroke_width = 1.0 * 2.0; // stroke on both sides + let total_card_overhead = frame_margin + stroke_width; + + // Calculate card content width accounting for frame overhead and side padding + let usable_width = available_width - (side_padding * 2.0); + let card_width = ((usable_width - (spacing * 2.0)) / 3.0) - total_card_overhead; + + // Add left padding + ui.add_space(side_padding); + + self.show_stat_card( + ui, + "Total Assets", + self.stats.total_assets, + egui::Color32::from_rgb(33, 150, 243), + card_width, + ); + ui.add_space(spacing); + self.show_stat_card( + ui, + "Okay Items", + self.stats.okay_items, + egui::Color32::from_rgb(76, 175, 80), + card_width, + ); + ui.add_space(spacing); + self.show_stat_card( + ui, + "Attention", + self.stats.attention_items, + egui::Color32::from_rgb(244, 67, 54), + card_width, + ); + + // Add right padding (this will naturally happen with the remaining space) + ui.add_space(side_padding); + }); + + ui.add_space(24.0); + + // Recent changes tables side-by-side, fill remaining height + let full_h = ui.available_height(); + ui.horizontal(|ui| { + let spacing = 16.0; + let available = ui.available_width(); + let half = (available - spacing) / 2.0; + + // Left column: Asset changes + ui.allocate_ui_with_layout( + egui::vec2(half, full_h), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.set_width(half); + let col_w = ui.available_width(); + ui.set_max_width(col_w); + + ui.heading("Recent Asset Changes"); + ui.separator(); + ui.add_space(8.0); + + if self.asset_changes.is_empty() { + ui.label("No recent asset changes"); + } else { + ui.push_id("asset_changes_table", |ui| { + let col_w = ui.available_width(); + ui.set_width(col_w); + + // Set table body height based on remaining space + let body_h = (ui.available_height() - 36.0).max(180.0); + let table = TableBuilder::new(ui) + .striped(true) + .resizable(false) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::remainder()) + .column(Column::exact(140.0)) + .column(Column::exact(120.0)) + .column(Column::exact(120.0)) + .min_scrolled_height(body_h); + + table + .header(20.0, |mut header| { + header.col(|ui| { + ui.strong("Asset"); + }); + header.col(|ui| { + ui.strong("Change"); + }); + header.col(|ui| { + ui.strong("Date"); + }); + header.col(|ui| { + ui.strong("User"); + }); + }) + .body(|mut body| { + for change in &self.asset_changes { + let asset = change + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let summary = change + .get("changes") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let date_raw = change + .get("date") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let date = format_date_short(date_raw); + let user = change + .get("user") + .and_then(|v| v.as_str()) + .unwrap_or("System"); + + body.row(24.0, |mut row| { + row.col(|ui| { + ui.add(egui::Label::new(asset).truncate()); + }); + row.col(|ui| { + let label = egui::Label::new(summary).truncate(); + ui.add(label).on_hover_text(summary); + }); + row.col(|ui| { + ui.add(egui::Label::new(&date).truncate()); + }); + row.col(|ui| { + ui.add(egui::Label::new(user).truncate()); + }); + }); + } + }); + }); + } + }, + ); + + ui.add_space(spacing); + + // Right column: Issue changes + ui.allocate_ui_with_layout( + egui::vec2(half, full_h), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.set_width(half); + let col_w = ui.available_width(); + ui.set_max_width(col_w); + + ui.heading("Recent Issue Updates"); + ui.separator(); + ui.add_space(8.0); + + if self.issue_changes.is_empty() { + ui.label("No recent issue updates"); + } else { + ui.push_id("issue_changes_table", |ui| { + let col_w = ui.available_width(); + ui.set_width(col_w); + + let body_h = (ui.available_height() - 36.0).max(180.0); + let table = TableBuilder::new(ui) + .striped(true) + .resizable(false) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::remainder()) + .column(Column::exact(140.0)) + .column(Column::exact(120.0)) + .column(Column::exact(120.0)) + .min_scrolled_height(body_h); + + table + .header(20.0, |mut header| { + header.col(|ui| { + ui.strong("Issue"); + }); + header.col(|ui| { + ui.strong("Change"); + }); + header.col(|ui| { + ui.strong("Date"); + }); + header.col(|ui| { + ui.strong("User"); + }); + }) + .body(|mut body| { + for change in &self.issue_changes { + let issue = change + .get("issue") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let summary = change + .get("changes") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let date_raw = change + .get("date") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let date = format_date_short(date_raw); + let user = change + .get("user") + .and_then(|v| v.as_str()) + .unwrap_or("System"); + + body.row(24.0, |mut row| { + row.col(|ui| { + ui.add(egui::Label::new(issue).truncate()); + }); + row.col(|ui| { + let label = egui::Label::new(summary).truncate(); + ui.add(label).on_hover_text(summary); + }); + row.col(|ui| { + ui.add(egui::Label::new(&date).truncate()); + }); + row.col(|ui| { + ui.add(egui::Label::new(user).truncate()); + }); + }); + } + }); + }); + } + }, + ); + }); + } + + fn show_stat_card( + &self, + ui: &mut egui::Ui, + label: &str, + value: T, + color: egui::Color32, + width: f32, + ) { + // Use default widget background - adapts to light/dark mode automatically + egui::Frame::default() + .corner_radius(8.0) + .inner_margin(16.0) + .fill(ui.visuals().widgets.noninteractive.weak_bg_fill) + .stroke(egui::Stroke::new(1.5, color)) + .show(ui, |ui| { + ui.set_min_width(width); + ui.set_max_width(width); + ui.set_min_height(100.0); + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new(label).size(14.0).color(color)); + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!("{}", value)) + .size(32.0) + .strong(), + ); + }); + }); + } +} + +impl Default for DashboardView { + fn default() -> Self { + Self::new() + } +} diff --git a/src/ui/inventory.rs b/src/ui/inventory.rs new file mode 100644 index 0000000..67fac93 --- /dev/null +++ b/src/ui/inventory.rs @@ -0,0 +1,1933 @@ +use crate::api::ApiClient; +use crate::core::components::help::{show_help_window, HelpWindowOptions}; +use crate::core::workflows::borrow_flow::{BorrowFlow, BorrowStep}; +use crate::core::workflows::return_flow::{ReturnFlow, ReturnStep}; +use crate::core::{ + components::form_builder::FormBuilder, components::interactions::ConfirmDialog, + workflows::AddFromTemplateWorkflow, AssetFieldBuilder, AssetOperations, ColumnConfig, + DataLoader, LoadingState, TableEventHandler, TableRenderer, +}; +use eframe::egui; +use egui_commonmark::CommonMarkCache; +use serde_json::Value; +use std::collections::HashMap; + +pub struct InventoryView { + // Data + assets: Vec, + loading_state: LoadingState, + + // Table and UI components + table_renderer: TableRenderer, + show_column_panel: bool, + + // Filter state tracking + last_show_retired_state: bool, + last_item_lookup: String, + + // Dialogs + delete_dialog: ConfirmDialog, + edit_dialog: FormBuilder, + add_dialog: FormBuilder, + advanced_edit_dialog: FormBuilder, + print_dialog: Option, + show_print_dialog: bool, + + // Bulk action state + pending_delete_ids: Vec, + pending_edit_ids: Vec, + is_bulk_edit: bool, + + // Workflows + add_from_template_workflow: AddFromTemplateWorkflow, + borrow_flow: BorrowFlow, + return_flow: ReturnFlow, + + // Help + show_help: bool, + help_cache: CommonMarkCache, +} + +impl InventoryView { + pub fn new() -> Self { + // Define all available columns from the assets table schema + let columns = vec![ + ColumnConfig::new("ID", "id").with_width(60.0).hidden(), + ColumnConfig::new("Asset Tag", "asset_tag").with_width(120.0), + ColumnConfig::new("Numeric ID", "asset_numeric_id") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Type", "asset_type").with_width(60.0), + ColumnConfig::new("Name", "name").with_width(180.0), + ColumnConfig::new("Category", "category_name").with_width(90.0), + ColumnConfig::new("Manufacturer", "manufacturer").with_width(100.0), + ColumnConfig::new("Model", "model").with_width(100.0), + ColumnConfig::new("Serial Number", "serial_number") + .with_width(130.0) + .hidden(), + ColumnConfig::new("Zone", "zone_code").with_width(80.0), + ColumnConfig::new("Label Template", "label_template_name") + .with_width(160.0) + .hidden(), + ColumnConfig::new("Label Template ID", "label_template_id") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Zone Plus", "zone_plus") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Zone Note", "zone_note") + .with_width(150.0) + .hidden(), + ColumnConfig::new("Status", "status").with_width(80.0), + ColumnConfig::new("Last Audit", "last_audit") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Last Audit Status", "last_audit_status") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Price", "price") + .with_width(90.0) + .hidden(), + ColumnConfig::new("Purchase Date", "purchase_date") + .with_width(110.0) + .hidden(), + ColumnConfig::new("Warranty Until", "warranty_until") + .with_width(110.0) + .hidden(), + ColumnConfig::new("Expiry Date", "expiry_date") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Qty Available", "quantity_available") + .with_width(90.0) + .hidden(), + ColumnConfig::new("Qty Total", "quantity_total") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Qty Used", "quantity_used") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Supplier", "supplier_name") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Lendable", "lendable") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Min Role", "minimum_role_for_lending") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Lending Status", "lending_status").with_width(70.0), + ColumnConfig::new("Current Borrower", "current_borrower_name") + .with_width(130.0) + .hidden(), + ColumnConfig::new("Due Date", "due_date") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Previous Borrower", "previous_borrower_name") + .with_width(130.0) + .hidden(), + ColumnConfig::new("No Scan", "no_scan") + .with_width(70.0) + .hidden(), + ColumnConfig::new("Notes", "notes") + .with_width(200.0) + .hidden(), + ColumnConfig::new("Created Date", "created_date") + .with_width(140.0) + .hidden(), + ColumnConfig::new("Created By", "created_by_username") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Last Modified", "last_modified_date").with_width(70.0), // Visible by default + ColumnConfig::new("Modified By", "last_modified_by_username") + .with_width(100.0) + .hidden(), + ]; + + Self { + assets: Vec::new(), + loading_state: LoadingState::new(), + table_renderer: TableRenderer::new() + .with_columns(columns) + .with_default_sort("last_modified_date", false), // Sort by last modified, newest first + show_column_panel: false, + last_show_retired_state: true, // Default to showing retired items (matches ribbon default) + last_item_lookup: String::new(), + delete_dialog: ConfirmDialog::new( + "Delete Asset", + "Are you sure you want to delete this asset?", + ), + edit_dialog: FormBuilder::new("Edit Asset", vec![]), + add_dialog: FormBuilder::new("Add Asset", vec![]), + advanced_edit_dialog: FormBuilder::new("Advanced Edit Asset", vec![]), + print_dialog: None, + show_print_dialog: false, + pending_delete_ids: Vec::new(), + pending_edit_ids: Vec::new(), + is_bulk_edit: false, + add_from_template_workflow: AddFromTemplateWorkflow::new(), + borrow_flow: BorrowFlow::new(), + return_flow: ReturnFlow::new(), + show_help: false, + help_cache: CommonMarkCache::default(), + } + } + + /// Load assets from the API + fn load_assets( + &mut self, + api_client: &ApiClient, + limit: Option, + where_clause: Option, + filter: Option, + ) { + self.loading_state.start_loading(); + + match DataLoader::load_assets(api_client, limit, where_clause, filter) { + Ok(assets) => { + self.assets = assets; + // Enrich borrower/due_date columns from lending_history for accuracy + self.enrich_loans_for_visible_assets(api_client); + self.loading_state.finish_success(); + } + Err(e) => { + self.loading_state.finish_error(e); + } + } + } + + /// Load assets with retired filter applied + fn load_assets_with_filter( + &mut self, + api_client: &ApiClient, + limit: Option, + show_retired: bool, + ) { + let filter = if show_retired { + None // Show all items including retired + } else { + // Filter out retired items: WHERE status != 'Retired' + Some(serde_json::json!({ + "and": [ + { + "column": "assets.status", + "op": "!=", + "value": "Retired" + } + ] + })) + }; + + self.load_assets(api_client, limit, None, filter); + } + + /// Enrich current/previous borrower and due_date using active and recent loans + fn enrich_loans_for_visible_assets(&mut self, api_client: &ApiClient) { + // Collect visible asset IDs + let mut ids: Vec = Vec::new(); + for asset in &self.assets { + if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) { + ids.push(id); + } + } + if ids.is_empty() { + return; + } + + // Build active loans map: asset_id -> (borrower_name, due_date) + let mut active_map: HashMap, Option)> = HashMap::new(); + if let Ok(active_loans) = crate::core::get_active_loans(api_client, None) { + for row in active_loans { + let aid = row.get("asset_id").and_then(|v| v.as_i64()); + if let Some(asset_id) = aid { + let borrower_name = row + .get("borrower_name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let due = row + .get("due_date") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + active_map.insert(asset_id, (borrower_name, due)); + } + } + } + + // Build recent returns map: asset_id -> borrower_name (most recent return) + let mut recent_return_map: HashMap = HashMap::new(); + if let Ok(recent_returns) = + crate::core::get_recent_returns_for_assets(api_client, &ids, Some(1), None) + { + for row in recent_returns { + if let Some(asset_id) = row.get("asset_id").and_then(|v| v.as_i64()) { + if let Some(name) = row.get("borrower_name").and_then(|v| v.as_str()) { + // Only set if not already set (keep most recent as we sorted desc server-side) + recent_return_map + .entry(asset_id) + .or_insert_with(|| name.to_string()); + } + } + } + } + + // Apply enrichment to assets + for asset in &mut self.assets { + let aid = asset.get("id").and_then(|v| v.as_i64()); + if let Some(asset_id) = aid { + // Current borrower and due_date from active loan (authoritative) + if let Some((borrower_name_opt, due_opt)) = active_map.get(&asset_id) { + if let Some(obj) = asset.as_object_mut() { + if let Some(bname) = borrower_name_opt { + obj.insert( + "current_borrower_name".to_string(), + Value::String(bname.clone()), + ); + } + if let Some(due) = due_opt { + obj.insert("due_date".to_string(), Value::String(due.clone())); + } + } + } + + // Previous borrower from most recent returned loan (only when not currently borrowed) + if !active_map.contains_key(&asset_id) { + if let Some(prev_name) = recent_return_map.get(&asset_id) { + if let Some(obj) = asset.as_object_mut() { + obj.insert( + "previous_borrower_name".to_string(), + Value::String(prev_name.clone()), + ); + } + } + } + } + } + } + + /// Get selected asset IDs for bulk operations (works with filtered view) + fn get_selected_ids(&self) -> Vec { + let filtered_data = self.table_renderer.prepare_json_data(&self.assets); + let mut ids = Vec::new(); + for &row_idx in &self.table_renderer.selection.selected_rows { + if let Some((_, asset)) = filtered_data.get(row_idx) { + if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) { + ids.push(id); + } else if let Some(s) = asset.get("id").and_then(|v| v.as_str()) { + if let Ok(n) = s.parse::() { + ids.push(n); + } + } + } + } + ids + } + + /// Find an asset by ID + fn find_asset_by_id(&self, id: i64) -> Option { + AssetOperations::find_by_id(&self.assets, id, |asset| { + asset.get("id").and_then(|v| v.as_i64()) + }) + } + + /// Prepare field configurations for different dialog types + fn prepare_advanced_edit_fields(&mut self, api_client: &ApiClient) { + self.advanced_edit_dialog = AssetFieldBuilder::create_advanced_edit_dialog(api_client); + } + + fn prepare_easy_edit_fields(&mut self, api_client: &ApiClient) { + self.edit_dialog = AssetFieldBuilder::create_easy_edit_dialog(api_client); + } + + fn prepare_add_asset_editor(&mut self, api_client: &ApiClient) { + self.add_dialog = AssetFieldBuilder::create_add_dialog_with_preset(api_client); + } + + /// Helper method to open easy edit dialog with specific asset + fn open_easy_edit_with(&mut self, item: &serde_json::Value, api_client: Option<&ApiClient>) { + log::info!("=== OPENING EASY EDIT ==="); + log::info!("Asset data: {:?}", item); + let asset_id = item.get("id").and_then(|v| v.as_i64()).or_else(|| { + item.get("id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + }); + log::info!("Extracted asset ID: {:?}", asset_id); + + if let Some(client) = api_client { + self.prepare_easy_edit_fields(client); + } + self.edit_dialog.title = "Easy Edit Asset".to_string(); + self.edit_dialog.open(item); + + log::info!( + "After opening, dialog item_id: {:?}", + self.edit_dialog.item_id + ); + } + + /// Perform item lookup based on asset tag or numeric ID + fn perform_item_lookup( + &mut self, + lookup_text: &str, + api_client: Option<&ApiClient>, + limit: Option, + ) { + if lookup_text != self.last_item_lookup { + self.last_item_lookup = lookup_text.to_string(); + + if !lookup_text.is_empty() { + if let Some(client) = api_client { + // Build filter with OR and LIKE for asset_tag OR asset_numeric_id + let filter = serde_json::json!({ + "or": [ + { + "column": "assets.asset_tag", + "op": "like", + "value": format!("%{}%", lookup_text) + }, + { + "column": "assets.asset_numeric_id", + "op": "like", + "value": format!("%{}%", lookup_text) + } + ] + }); + self.load_assets(client, limit, None, Some(filter)); + } + } else { + // Clear search when lookup is empty + if let Some(client) = api_client { + self.load_assets(client, limit, None, None); + } + } + } + } + + /// Apply updates using the extracted operations module + fn apply_updates( + &mut self, + api: &ApiClient, + updated: serde_json::Map, + limit: Option, + ) { + let assets = self.assets.clone(); + let easy_id = self.edit_dialog.item_id.clone(); + let advanced_id = self.advanced_edit_dialog.item_id.clone(); + + AssetOperations::apply_updates( + api, + updated, + &mut self.pending_edit_ids, + easy_id.as_deref(), + advanced_id.as_deref(), + |id| { + AssetOperations::find_by_id(&assets, id, |asset| { + asset.get("id").and_then(|v| v.as_i64()) + }) + }, + limit, + |api_client, limit| { + match DataLoader::load_assets(api_client, limit, None, None) { + Ok(_) => { /* Assets will be reloaded after this call */ } + Err(e) => log::error!("Failed to reload assets: {}", e), + } + }, + ); + + // Reload assets after update + match DataLoader::load_assets(api, limit, None, None) { + Ok(assets) => { + self.assets = assets; + } + Err(e) => log::error!("Failed to reload assets after update: {}", e), + } + + // Reset selection state after updates + self.is_bulk_edit = false; + self.table_renderer.selection.clear_selection(); + } + + fn render_table_with_events( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + session_manager: &std::sync::Arc>, + ) { + // We need to work around Rust's borrowing rules here + // First, get the data we need + let assets_clone = self.assets.clone(); + let prepared_data = self.table_renderer.prepare_json_data(&assets_clone); + + // Create a temporary event handler that stores actions for later processing + let mut deferred_actions: Vec = Vec::new(); + let mut temp_handler = TempInventoryEventHandler { + deferred_actions: &mut deferred_actions, + }; + + // Render table with the temporary event handler + self.table_renderer + .render_json_table(ui, &prepared_data, Some(&mut temp_handler)); + + // Process the deferred actions + self.process_temp_deferred_actions(deferred_actions, api_client, session_manager); + } + + fn process_temp_deferred_actions( + &mut self, + actions: Vec, + api_client: Option<&ApiClient>, + session_manager: &std::sync::Arc>, + ) { + for action in actions { + match action { + DeferredAction::DoubleClick(asset) => { + log::info!( + "Processing double-click edit for asset: {:?}", + asset.get("name") + ); + self.open_easy_edit_with(&asset, api_client); + } + DeferredAction::ContextClone(asset) => { + log::info!( + "Processing context menu clone for asset: {:?}", + asset.get("name") + ); + if let Some(client) = api_client { + // Use full add dialog so all fields are available when cloning + self.add_dialog = + crate::core::asset_fields::AssetFieldBuilder::create_full_add_dialog( + client, + ); + } + // Prepare cloned payload using shared helper + let cloned = crate::core::components::prepare_cloned_value( + &asset, + &[ + "id", + "asset_numeric_id", + "created_date", + "created_at", + "last_modified_date", + "last_modified", + "last_modified_by", + "last_modified_by_username", + "current_borrower_name", + "previous_borrower_name", + "last_audit", + "last_audit_status", + "due_date", + ], + Some("name"), + Some(""), + ); + self.add_dialog.title = "Add Asset".to_string(); + if let Some(obj) = cloned.as_object() { + // Use open_new so all preset fields are treated as new values and will be saved + self.add_dialog.open_new(Some(obj)); + } else { + self.add_dialog.open(&cloned); + } + } + DeferredAction::ContextEdit(asset) => { + log::info!( + "Processing context menu edit for asset: {:?}", + asset.get("name") + ); + self.open_easy_edit_with(&asset, api_client); + } + DeferredAction::ContextAdvancedEdit(asset) => { + log::info!( + "Processing context menu advanced edit for asset: {:?}", + asset.get("name") + ); + if let Some(client) = api_client { + self.prepare_advanced_edit_fields(client); + } + self.advanced_edit_dialog.open(&asset); + } + DeferredAction::ContextDelete(asset) => { + if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) { + log::info!("Processing context menu delete for asset ID: {}", id); + self.pending_delete_ids = vec![id]; + let name = asset + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + self.delete_dialog.open(name, id.to_string()); + } + } + DeferredAction::ContextLend(asset) => { + log::info!( + "Opening borrow flow from inventory for asset: {:?}", + asset.get("name") + ); + if let Some(client) = api_client { + // Open flow, then preselect this asset and skip to borrower step + self.borrow_flow.open(client); + self.borrow_flow.selected_asset = Some(asset.clone()); + self.borrow_flow.current_step = BorrowStep::SelectBorrower; + } + } + DeferredAction::ContextReturn(asset) => { + log::info!( + "Opening return flow from inventory for asset: {:?}", + asset.get("name") + ); + if let Some(client) = api_client { + self.return_flow.open(client); + // Try to preselect the matching active loan by asset_id or asset_tag + let asset_id = asset.get("id").and_then(|v| v.as_i64()); + let asset_tag = asset.get("asset_tag").and_then(|v| v.as_str()); + if let Some(loan) = self + .return_flow + .active_loans + .iter() + .find(|loan| { + let loan_asset_id = loan.get("asset_id").and_then(|v| v.as_i64()); + let loan_tag = loan.get("asset_tag").and_then(|v| v.as_str()); + (asset_id.is_some() && loan_asset_id == asset_id) + || (asset_tag.is_some() && loan_tag == asset_tag) + }) + .cloned() + { + self.return_flow.selected_loan = Some(loan); + self.return_flow.current_step = ReturnStep::Confirm; + } + } + } + DeferredAction::ContextPrintLabel(asset) => { + log::info!("Processing print label for asset: {:?}", asset.get("name")); + self.open_print_dialog(&asset, api_client, false, session_manager); + } + DeferredAction::ContextAdvancedPrint(asset) => { + log::info!( + "Processing advanced print for asset: {:?}", + asset.get("name") + ); + self.open_print_dialog(&asset, api_client, true, session_manager); + } + } + } + } + + /// Insert new asset and return its DB id if available + fn insert_new_asset( + &mut self, + client: &ApiClient, + limit: Option, + mut data: serde_json::Map, + ) -> Option { + AssetOperations::preprocess_quick_adds(client, &mut data); + AssetOperations::insert_new_asset(client, data, limit, |api_client, limit| { + self.load_assets(api_client, limit, None, None) + }) + } + + /// Open print dialog for an asset + fn open_print_dialog( + &mut self, + asset: &Value, + api_client: Option<&ApiClient>, + force_advanced: bool, + session_manager: &std::sync::Arc>, + ) { + use std::collections::HashMap; + + // Extract asset data as strings for template rendering + let mut asset_data = HashMap::new(); + if let Some(obj) = asset.as_object() { + for (key, value) in obj { + let value_str = match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + _ => serde_json::to_string(value).unwrap_or_default(), + }; + asset_data.insert(key.clone(), value_str); + } + } + + // Get label template ID from asset + let label_template_id = asset.get("label_template_id").and_then(|v| v.as_i64()); + + // Get default and last-used printer from session + let (default_printer_id, last_printer_id) = { + let guard = session_manager.blocking_lock(); + let default_id = guard.get_default_printer_id(); + let last_printer = guard.get_last_print_preferences(); + (default_id, last_printer) + }; + + // Smart logic: if not forcing advanced AND both default printer and template are set, print directly + if !force_advanced && default_printer_id.is_some() && label_template_id.is_some() { + // Print directly without dialog + log::info!("Printing directly with default printer and template"); + if let Some(client) = api_client { + if let (Some(printer_id), Some(template_id)) = + (default_printer_id, label_template_id) + { + self.execute_print(client, printer_id, template_id, &asset_data); + } + } + } else { + // Show dialog + let mut dialog = crate::core::print::PrintDialog::new(asset_data); + dialog = dialog.with_defaults(default_printer_id, label_template_id, last_printer_id); + + if let Some(client) = api_client { + if let Err(e) = dialog.load_data(client) { + log::error!("Failed to load print dialog data: {}", e); + } + } + + self.print_dialog = Some(dialog); + self.show_print_dialog = true; + } + } + + /// Execute print job via print dialog + fn execute_print( + &mut self, + api_client: &ApiClient, + printer_id: i64, + template_id: i64, + asset_data: &HashMap, + ) { + log::info!( + "Executing print: printer_id={}, template_id={}", + printer_id, + template_id + ); + + // Create a print dialog with the options and execute + // The dialog handles all printer settings loading, JSON parsing, and printing + let mut dialog = crate::core::print::PrintDialog::new(asset_data.clone()).with_defaults( + Some(printer_id), + Some(template_id), + None, + ); + + match dialog.execute_print(api_client) { + Ok(_) => { + log::info!("Successfully printed label"); + self.log_print_history(api_client, asset_data, printer_id, template_id, "Success"); + } + Err(e) => { + log::error!("Failed to print label: {}", e); + self.log_print_history( + api_client, + asset_data, + printer_id, + template_id, + &format!("Error: {}", e), + ); + } + } + } + + fn log_print_history( + &self, + _api_client: &ApiClient, + _asset_data: &HashMap, + _printer_id: i64, + _template_id: i64, + status: &str, + ) { + // Print history logging disabled - backend doesn't support raw SQL queries + // and using insert() requires __editor_item_id column which is a log table issue + // TODO: Either add raw SQL support to backend or create a dedicated /print-history endpoint + log::debug!("Print job status: {}", status); + } + + pub fn show( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon_ui: Option<&mut crate::ui::ribbon::RibbonUI>, + session_manager: &std::sync::Arc>, + ) { + // Handle initial load if needed - but ONLY if we've never loaded before + // Don't auto-load if assets are empty due to filters returning 0 results + // Also skip auto-load if there's a pending filter change (which will load filtered results) + let has_pending_filter = ribbon_ui + .as_ref() + .map(|r| { + *r.checkboxes + .get("inventory_filter_changed") + .unwrap_or(&false) + }) + .unwrap_or(false); + + if self.assets.is_empty() + && !self.loading_state.is_loading + && self.loading_state.last_error.is_none() + && self.loading_state.last_load_time.is_none() // Only auto-load if we've never attempted a load + && !has_pending_filter + // Don't auto-load if a filter is about to be applied + { + if let Some(client) = api_client { + log::info!("Inventory view never loaded, triggering initial auto-load"); + // Respect retired filter state from ribbon during auto-load + let show_retired = ribbon_ui + .as_ref() + .map(|r| *r.checkboxes.get("show_retired").unwrap_or(&true)) + .unwrap_or(true); + self.load_assets_with_filter(client, Some(100), show_retired); + } + } + + // Render content and get flags to clear + let flags_to_clear = self.render_content( + ui, + api_client, + Some(100), + ribbon_ui.as_ref().map(|r| &**r), + session_manager, + ); + + // Clear the flags after processing + if let Some(ribbon) = ribbon_ui { + for flag in flags_to_clear { + ribbon.checkboxes.insert(flag, false); + } + } + } + + fn render_content( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + limit: Option, + ribbon_ui: Option<&crate::ui::ribbon::RibbonUI>, + session_manager: &std::sync::Arc>, + ) -> Vec { + // Handle ribbon actions first + let flags_to_clear = if let Some(ribbon) = ribbon_ui { + self.handle_ribbon_actions(ribbon, api_client, session_manager) + } else { + Vec::new() + }; + + // Top toolbar with search and actions + ui.horizontal(|ui| { + ui.label("Search:"); + let mut search_changed = false; + ui.add_enabled_ui(!self.loading_state.is_loading, |ui| { + search_changed = ui + .text_edit_singleline(&mut self.table_renderer.search_query) + .changed(); + }); + + ui.separator(); + + // Action buttons + if let Some(client) = api_client { + if ui.button("Refresh").clicked() { + // Clear any previous error state when user explicitly refreshes + self.loading_state.last_error = None; + + // Respect current filters when refreshing + if let Some(ribbon) = ribbon_ui { + let show_retired = ribbon + .checkboxes + .get("show_retired") + .copied() + .unwrap_or(true); + let user_filter = ribbon.filter_builder.get_filter_json("assets"); + let combined_filter = self.combine_filters(show_retired, user_filter); + self.load_assets(client, limit, None, combined_filter); + } else { + self.load_assets(client, limit, None, None); + } + } + + if ui.button("➕ Add Asset").clicked() { + self.prepare_add_asset_editor(client); + } + + // Show selection count but no buttons (use right-click instead) + let selected_count = self.table_renderer.selection.get_selected_count(); + if selected_count > 0 { + ui.label(format!("{} selected", selected_count)); + } + } + + ui.separator(); + + if ui.button("⚙ Columns").clicked() { + self.show_column_panel = !self.show_column_panel; + } + }); + + ui.separator(); + + // Show loading state + if self.loading_state.is_loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Loading assets..."); + }); + } + + // Show errors + if let Some(error) = self.loading_state.get_error() { + ui.colored_label(egui::Color32::RED, format!("Error: {}", error)); + } + + // Column configuration panel + if self.show_column_panel { + egui::Window::new("Column Configuration") + .open(&mut self.show_column_panel) + .resizable(true) + .movable(true) + .default_width(350.0) + .min_width(300.0) + .max_width(500.0) + .max_height(600.0) + .default_pos([200.0, 150.0]) + .show(ui.ctx(), |ui| { + ui.label("Show/Hide Columns:"); + ui.separator(); + + // Scrollable area for columns + egui::ScrollArea::vertical() + .max_height(450.0) + .show(ui, |ui| { + // Use columns layout to make better use of width while keeping groups intact + ui.columns(2, |columns| { + // Left column + columns[0].group(|ui| { + ui.strong("Basic Information"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "name" | "asset_tag" | "asset_type" | "status" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[0].add_space(5.0); + + columns[0].group(|ui| { + ui.strong("Location & Status"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "zone_code" + | "zone_plus" + | "zone_note" + | "lending_status" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[0].add_space(5.0); + + columns[0].group(|ui| { + ui.strong("Quantities & Lending"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "quantity_available" + | "quantity_total" + | "quantity_used" + | "lendable" + | "minimum_role_for_lending" + | "current_borrower_name" + | "due_date" + | "previous_borrower_name" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + // Right column + columns[1].group(|ui| { + ui.strong("Classification"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "category_name" + | "manufacturer" + | "model" + | "serial_number" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[1].add_space(5.0); + + columns[1].group(|ui| { + ui.strong("Financial & Dates"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "price" + | "purchase_date" + | "warranty_until" + | "expiry_date" + | "last_audit" + | "last_audit_status" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[1].add_space(5.0); + + columns[1].group(|ui| { + ui.strong("Metadata & Other"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "id" | "asset_numeric_id" + | "supplier_name" + | "no_scan" + | "notes" + | "created_date" + | "created_by_username" + | "last_modified_date" + | "last_modified_by_username" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + }); + }); + + ui.separator(); + ui.columns(3, |columns| { + if columns[0].button("Show All").clicked() { + for column in &mut self.table_renderer.columns { + column.visible = true; + } + } + if columns[1].button("Hide All").clicked() { + for column in &mut self.table_renderer.columns { + column.visible = false; + } + } + if columns[2].button("Reset to Default").clicked() { + // Reset to default visibility + for column in &mut self.table_renderer.columns { + column.visible = matches!( + column.field.as_str(), + "asset_tag" + | "asset_type" + | "name" + | "category_name" + | "manufacturer" + | "model" + | "zone_code" + | "status" + | "lending_status" + | "last_modified_date" + ); + } + } + }); + }); + } + + // Render table with event handling + self.render_table_with_events(ui, api_client, session_manager); + + // Handle dialogs + self.handle_dialogs(ui, api_client, limit, session_manager); + + // Process deferred actions from table events + self.process_deferred_actions(ui, api_client, limit, session_manager); + + flags_to_clear + } + + fn handle_dialogs( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + limit: Option, + session_manager: &std::sync::Arc>, + ) { + // Delete confirmation dialog + if let Some(confirmed) = self.delete_dialog.show_dialog(ui.ctx()) { + log::info!( + "Delete dialog result: confirmed={}, pending_delete_ids={:?}", + confirmed, + self.pending_delete_ids + ); + if confirmed && !self.pending_delete_ids.is_empty() { + if let Some(client) = api_client { + for id in &self.pending_delete_ids { + let where_clause = serde_json::json!({"id": id}); + log::info!( + "Sending DELETE for asset id {} with where {:?}", + id, + where_clause + ); + match client.delete("assets", where_clause) { + Ok(resp) => { + if resp.success { + let deleted = resp.data.unwrap_or(0); + log::info!( + "Delete success for asset {} ({} row(s) affected)", + id, + deleted + ); + } else { + log::error!( + "Server rejected delete for asset {}: {:?}", + id, + resp.error + ); + } + } + Err(e) => { + log::error!("Failed to delete asset {}: {}", id, e); + } + } + } + // Reload after attempting deletes + self.load_assets(client, limit, None, None); + } else { + log::error!("No API client available for delete operation"); + } + self.pending_delete_ids.clear(); + } + } + + // Edit dialogs + if let Some(Some(updated)) = self.edit_dialog.show_editor(ui.ctx()) { + if let Some(client) = api_client { + self.apply_updates(client, updated, limit); + } + } + + if let Some(Some(updated)) = self.advanced_edit_dialog.show_editor(ui.ctx()) { + if let Some(client) = api_client { + self.apply_updates(client, updated, limit); + } + } + + if let Some(Some(mut new_data)) = self.add_dialog.show_editor(ui.ctx()) { + if let Some(client) = api_client { + // Check if user requested label printing after add + let print_after_add = new_data + .get("print_label") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Preflight: enforce asset_tag uniqueness if provided + if let Some(tag) = new_data.get("asset_tag").and_then(|v| v.as_str()) { + let tag = tag.trim(); + if !tag.is_empty() { + let where_clause = serde_json::json!({ "asset_tag": tag }); + if let Ok(resp) = client.select( + "assets", + Some(vec!["id".into()]), + Some(where_clause), + None, + Some(1), + ) { + if resp.success { + if let Some(rows) = resp.data { + if !rows.is_empty() { + // Tag already exists; reopen editor and require change + log::warn!("Asset tag '{}' already exists; prompting user to change it", tag); + // Put back the print flag and reopen the dialog with current values + self.add_dialog.title = "Add Asset".to_string(); + self.add_dialog + .open(&serde_json::Value::Object(new_data.clone())); + return; // Don't proceed to insert or print + } + } + } else { + log::error!("Tag uniqueness check failed: {:?}", resp.error); + } + } + } + } + + // Prepare asset data snapshot for printing (before filtering removes fields) + let mut print_snapshot = new_data.clone(); + // Remove the UI-only flag so it doesn't get sent to the server + new_data.remove("print_label"); + + // Insert the asset and capture DB id + let inserted_id = self.insert_new_asset(client, limit, new_data); + + // If requested, trigger printing with smart defaults: + // - If default printer and label_template_id are set -> print directly + // - Otherwise open the print dialog to ask what to do + if print_after_add { + if let Some(id) = inserted_id { + let id_val = Value::Number(id.into()); + // Expose id under both `id` and `asset_id` so label templates can pick it up + print_snapshot.insert("id".to_string(), id_val.clone()); + print_snapshot.insert("asset_id".to_string(), id_val); + // Try to also include asset_numeric_id from the freshly reloaded assets + if let Some(row) = self + .assets + .iter() + .find(|r| r.get("id").and_then(|v| v.as_i64()) == Some(id)) + { + if let Some(n) = row.get("asset_numeric_id").and_then(|v| v.as_i64()) { + print_snapshot.insert( + "asset_numeric_id".to_string(), + Value::Number(n.into()), + ); + } + } + } + let asset_val = Value::Object(print_snapshot); + self.open_print_dialog(&asset_val, api_client, false, session_manager); + } + } + } + + // Workflow handling + if let Some(client) = api_client { + // Handle add from template workflow + if let Some(asset_data) = self.add_from_template_workflow.show(ui, client) { + if let Value::Object(mut map) = asset_data { + // Extract optional print flag before insert + let print_after_add = map + .get("print_label") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Keep a snapshot for printing (includes label_template_id etc.) + let mut print_snapshot = map.clone(); + // Remove UI-only field so it doesn't get sent to server + map.remove("print_label"); + + // Insert the asset and capture DB id + let inserted_id = self.insert_new_asset(client, limit, map); + + // If requested, perform printing via smart defaults + if print_after_add { + if let Some(id) = inserted_id { + let id_val = Value::Number(id.into()); + print_snapshot.insert("id".to_string(), id_val.clone()); + print_snapshot.insert("asset_id".to_string(), id_val); + // Try to also include asset_numeric_id from the freshly reloaded assets + if let Some(row) = self + .assets + .iter() + .find(|r| r.get("id").and_then(|v| v.as_i64()) == Some(id)) + { + if let Some(n) = + row.get("asset_numeric_id").and_then(|v| v.as_i64()) + { + print_snapshot.insert( + "asset_numeric_id".to_string(), + Value::Number(n.into()), + ); + } + } + } + let asset_val = Value::Object(print_snapshot); + self.open_print_dialog(&asset_val, api_client, false, session_manager); + } + } + } + + // Show help window if requested + if self.show_help { + const HELP_TEXT: &str = r#"# Inventory Management + +## Quick Actions +- **Add Asset**: Create new assets from templates or blank forms +- **Edit Asset**: Modify existing asset details +- **Delete Asset**: Remove assets (requires confirmation) +- **Print Label**: Generate and print asset labels + +## Borrowing/Lending +- **Borrow**: Check out assets to users +- **Return**: Check assets back in + +## Filtering +- Use **Show Retired** to include/exclude retired assets +- **Item Lookup** searches across multiple fields +- Click **Filters** to build advanced queries + +## Tips +- Double-click a row to quick-edit +- Right-click for context menu options +- Use Ctrl+Click to select multiple items +"#; + show_help_window( + ui.ctx(), + &mut self.help_cache, + "inventory_help", + "Inventory Help", + HELP_TEXT, + &mut self.show_help, + HelpWindowOptions::default(), + ); + } + + // Show borrow/return flows if open and reload when they complete successfully + self.borrow_flow.show(ui.ctx(), client); + if self.borrow_flow.take_recent_success() { + self.load_assets(client, limit, None, None); + } + + if self.return_flow.show(ui.ctx(), client) { + // still open + } else if self.return_flow.success_message.is_some() { + self.load_assets(client, limit, None, None); + } + } + + // Print dialog + if self.show_print_dialog { + let mut should_clear_dialog = false; + let mut completed_options: Option = None; + let mut completed_asset_data: Option> = None; + + if let Some(dialog) = self.print_dialog.as_mut() { + let mut open = self.show_print_dialog; + let completed = dialog.show(ui.ctx(), &mut open, api_client); + self.show_print_dialog = open; + + if completed { + completed_options = Some(dialog.options().clone()); + completed_asset_data = Some(dialog.asset_data().clone()); + should_clear_dialog = true; + } else if !self.show_print_dialog { + // Dialog was closed without completing the print job + should_clear_dialog = true; + } + } else { + self.show_print_dialog = false; + } + + if should_clear_dialog { + self.print_dialog = None; + } + + if let (Some(options), Some(asset_data)) = (completed_options, completed_asset_data) { + if let (Some(printer_id), Some(template_id)) = + (options.printer_id, options.label_template_id) + { + if let Some(client) = api_client { + self.log_print_history( + client, + &asset_data, + printer_id, + template_id, + "Success", + ); + } + } + } + } + } + + fn process_deferred_actions( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + _limit: Option, + session_manager: &std::sync::Arc>, + ) { + // Handle double-click edit + if let Some(asset) = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("double_click_edit"))) + { + log::info!( + "Processing double-click edit for asset: {:?}", + asset.get("name") + ); + self.open_easy_edit_with(&asset, api_client); + } + + // Handle context menu actions + if let Some(asset) = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("context_menu_edit"))) + { + log::info!( + "Processing context menu edit for asset: {:?}", + asset.get("name") + ); + self.open_easy_edit_with(&asset, api_client); + } + + if let Some(asset) = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("context_menu_edit_adv"))) + { + log::info!( + "Processing context menu advanced edit for asset: {:?}", + asset.get("name") + ); + if let Some(client) = api_client { + self.prepare_advanced_edit_fields(client); + } + self.advanced_edit_dialog.open(&asset); + } + + if let Some(asset) = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("context_menu_delete"))) + { + if let Some(id) = asset.get("id").and_then(|v| v.as_i64()) { + log::info!("Processing context menu delete for asset ID: {}", id); + self.pending_delete_ids = vec![id]; + self.delete_dialog.show = true; + } + } + + // Handle print label actions + if let Some(asset) = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("context_menu_print"))) + { + log::info!( + "Processing context menu print for asset: {:?}", + asset.get("name") + ); + self.open_print_dialog(&asset, api_client, false, session_manager); + } + + if let Some(asset) = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("context_menu_print_adv"))) + { + log::info!( + "Processing context menu advanced print for asset: {:?}", + asset.get("name") + ); + self.open_print_dialog(&asset, api_client, true, session_manager); + } + } + + /// Handle ribbon checkbox actions (main integration point) + /// Returns a list of flags that should be cleared after processing + fn handle_ribbon_actions( + &mut self, + ribbon: &crate::ui::ribbon::RibbonUI, + api_client: Option<&ApiClient>, + session_manager: &std::sync::Arc>, + ) -> Vec { + let mut flags_to_clear = Vec::new(); + + // Handle help button + if *ribbon + .checkboxes + .get("inventory_show_help") + .unwrap_or(&false) + { + flags_to_clear.push("inventory_show_help".to_string()); + self.show_help = true; + } + + // Handle limit settings first + let limit = if *ribbon + .checkboxes + .get("inventory_no_limit") + .unwrap_or(&false) + { + None + } else { + Some(*ribbon.number_fields.get("inventory_limit").unwrap_or(&100)) + }; + + // Check if retired filter state changed and trigger refresh if needed + let show_retired = *ribbon.checkboxes.get("show_retired").unwrap_or(&true); + if self.last_show_retired_state != show_retired { + self.last_show_retired_state = show_retired; + if let Some(client) = api_client { + self.loading_state.last_error = None; + self.load_assets_with_filter(client, limit, show_retired); + return flags_to_clear; // Early return to avoid duplicate loading + } + } + + // Handle filter builder changes + if *ribbon + .checkboxes + .get("inventory_filter_changed") + .unwrap_or(&false) + { + flags_to_clear.push("inventory_filter_changed".to_string()); + if let Some(client) = api_client { + self.loading_state.last_error = None; + + // Get current show_retired setting + let show_retired = ribbon + .checkboxes + .get("show_retired") + .copied() + .unwrap_or(true); + + // Get user-defined filters from FilterBuilder + let user_filter = ribbon.filter_builder.get_filter_json("assets"); + + // Combine retired filter with user filters + let combined_filter = self.combine_filters(show_retired, user_filter); + + // Debug: Log the filter to see what we're getting + if let Some(ref cf) = combined_filter { + log::info!("Combined filter: {:?}", cf); + } else { + log::info!("No filter conditions (showing all assets)"); + } + + self.load_assets(client, limit, None, combined_filter); + return flags_to_clear; // Early return to avoid duplicate loading + } + } + + let selected_ids = self.get_selected_ids(); + + if *ribbon + .checkboxes + .get("inventory_action_add") + .unwrap_or(&false) + { + if let Some(client) = api_client { + self.prepare_add_asset_editor(client); + } else { + let mut preset = serde_json::Map::new(); + preset.insert( + "asset_type".to_string(), + serde_json::Value::String("N".to_string()), + ); + preset.insert( + "status".to_string(), + serde_json::Value::String("Good".to_string()), + ); + self.add_dialog.title = "Add Asset".to_string(); + self.add_dialog.open_new(Some(&preset)); + } + } + + if *ribbon + .checkboxes + .get("inventory_action_delete") + .unwrap_or(&false) + { + if !selected_ids.is_empty() { + self.pending_delete_ids = selected_ids.clone(); + if selected_ids.len() == 1 { + if let Some(asset) = self.find_asset_by_id(selected_ids[0]) { + let name = asset + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + self.delete_dialog.open(name, selected_ids[0].to_string()); + } + } else { + self.delete_dialog.title = "Delete Assets".to_string(); + self.delete_dialog.message = format!( + "Are you sure you want to delete {} selected assets?", + selected_ids.len() + ); + self.delete_dialog.open( + format!("Multiple items ({} selected)", selected_ids.len()), + "multiple".to_string(), + ); + } + } + } + + if *ribbon + .checkboxes + .get("inventory_action_edit_easy") + .unwrap_or(&false) + { + if !selected_ids.is_empty() { + self.pending_edit_ids = selected_ids.clone(); + self.is_bulk_edit = selected_ids.len() > 1; + if let Some(client) = api_client { + self.prepare_easy_edit_fields(client); + } + if selected_ids.len() == 1 { + if let Some(asset) = self.find_asset_by_id(selected_ids[0]) { + self.edit_dialog.open(&asset); + } + } else { + self.edit_dialog.open_new(None); + } + } + } + + if *ribbon + .checkboxes + .get("inventory_action_edit_adv") + .unwrap_or(&false) + { + if !selected_ids.is_empty() { + self.pending_edit_ids = selected_ids.clone(); + self.is_bulk_edit = selected_ids.len() > 1; + if let Some(client) = api_client { + self.prepare_advanced_edit_fields(client); + } + if selected_ids.len() == 1 { + if let Some(asset) = self.find_asset_by_id(selected_ids[0]) { + self.advanced_edit_dialog.open(&asset); + } + } else { + self.advanced_edit_dialog.open_new(None); + } + } + } + + if *ribbon + .checkboxes + .get("inventory_action_print_label") + .unwrap_or(&false) + { + if !selected_ids.is_empty() && selected_ids.len() == 1 { + if let Some(asset) = self.find_asset_by_id(selected_ids[0]) { + log::info!("Print label requested for asset: {:?}", asset.get("name")); + // Check if Alt is held for advanced print (ctx not available here, so just default) + self.open_print_dialog(&asset, api_client, false, session_manager); + } + } else if !selected_ids.is_empty() { + log::warn!("Bulk label printing not yet implemented"); + } + } + + // Handle item lookup + if *ribbon + .checkboxes + .get("item_lookup_trigger") + .unwrap_or(&false) + { + if let Some(lookup_text) = ribbon.search_texts.get("item_lookup") { + self.perform_item_lookup(lookup_text, api_client, limit); + } + } + + // Handle limit refresh trigger (when limit changes or refresh is needed) + if *ribbon + .checkboxes + .get("inventory_limit_refresh_trigger") + .unwrap_or(&false) + { + if let Some(client) = api_client { + // Clear any previous error state when refreshing via ribbon + self.loading_state.last_error = None; + self.load_assets(client, limit, None, None); + } + } + + // Handle workflow actions + if *ribbon + .checkboxes + .get("inventory_add_from_template_single") + .unwrap_or(&false) + { + flags_to_clear.push("inventory_add_from_template_single".to_string()); + if let Some(client) = api_client { + self.add_from_template_workflow.start_single_mode(client); + } + } + + if *ribbon + .checkboxes + .get("inventory_add_from_template_multiple") + .unwrap_or(&false) + { + flags_to_clear.push("inventory_add_from_template_multiple".to_string()); + if let Some(client) = api_client { + self.add_from_template_workflow.start_multiple_mode(client); + } + } + + flags_to_clear + } + + /// Combine retired filter with user-defined filters + fn combine_filters(&self, show_retired: bool, user_filter: Option) -> Option { + if show_retired && user_filter.is_none() { + // No filtering needed + return None; + } + + let mut conditions = Vec::new(); + + // Add retired filter if needed + if !show_retired { + conditions.push(serde_json::json!({ + "column": "assets.status", + "op": "!=", + "value": "Retired" + })); + } + + // Add user filter conditions (sanitized for inventory joins) + if let Some(mut filter) = user_filter { + // Map columns from other views (e.g., borrowers.*) to inventory's JOIN aliases + self.sanitize_filter_for_inventory(&mut filter); + if let Some(and_array) = filter.get("and").and_then(|v| v.as_array()) { + conditions.extend(and_array.iter().cloned()); + } else if let Some(_or_array) = filter.get("or").and_then(|v| v.as_array()) { + // Wrap OR conditions to maintain precedence + conditions.push(filter); + } else { + // Single condition + conditions.push(filter); + } + } + + // Return appropriate filter structure + match conditions.len() { + 0 => None, + 1 => { + if let Some(mut only) = conditions.into_iter().next() { + // Final pass to sanitize single-condition filters + self.sanitize_filter_for_inventory(&mut only); + Some(only) + } else { + None + } + } + _ => { + let mut combined = serde_json::json!({ + "and": conditions + }); + self.sanitize_filter_for_inventory(&mut combined); + Some(combined) + } + } + } + + /// Rewrite filter column names to match inventory JOIN aliases + fn sanitize_filter_for_inventory(&self, filter: &mut Value) { + fn rewrite_column(col: &str) -> String { + match col { + // Borrowing view columns → inventory aliases + "borrowers.name" => "current_borrower.name".to_string(), + "borrowers.class_name" => "current_borrower.class_name".to_string(), + // Fallback: leave unchanged + _ => col.to_string(), + } + } + + match filter { + Value::Object(map) => { + // If this object has a `column`, rewrite it + if let Some(Value::String(col)) = map.get_mut("column") { + let new_col = rewrite_column(col); + *col = new_col; + } + // Recurse into possible logical groups + if let Some(Value::Array(arr)) = map.get_mut("and") { + for v in arr.iter_mut() { + self.sanitize_filter_for_inventory(v); + } + } + if let Some(Value::Array(arr)) = map.get_mut("or") { + for v in arr.iter_mut() { + self.sanitize_filter_for_inventory(v); + } + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + self.sanitize_filter_for_inventory(v); + } + } + _ => {} + } + } +} + +impl Default for InventoryView { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone)] +enum DeferredAction { + DoubleClick(Value), + ContextEdit(Value), + ContextAdvancedEdit(Value), + ContextDelete(Value), + ContextLend(Value), + ContextReturn(Value), + ContextPrintLabel(Value), + ContextAdvancedPrint(Value), + ContextClone(Value), +} + +// Temporary event handler that collects actions for later processing +struct TempInventoryEventHandler<'a> { + deferred_actions: &'a mut Vec, +} + +impl<'a> TableEventHandler for TempInventoryEventHandler<'a> { + fn on_double_click(&mut self, item: &Value, _row_index: usize) { + log::info!("Double-click detected on asset: {:?}", item.get("name")); + self.deferred_actions + .push(DeferredAction::DoubleClick(item.clone())); + } + + fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) { + if ui + .button(format!("{} Edit", egui_phosphor::regular::PENCIL)) + .clicked() + { + log::info!( + "Context menu edit clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextEdit(item.clone())); + ui.close(); + } + + if ui + .button(format!("{} Clone Asset", egui_phosphor::regular::COPY)) + .clicked() + { + log::info!( + "Context menu clone clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextClone(item.clone())); + ui.close(); + } + + if ui + .button(format!("{} Advanced Edit", egui_phosphor::regular::GEAR)) + .clicked() + { + log::info!( + "Context menu advanced edit clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextAdvancedEdit(item.clone())); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Print Label", egui_phosphor::regular::PRINTER)) + .clicked() + { + log::info!( + "Context menu print label clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextPrintLabel(item.clone())); + ui.close(); + } + + if ui + .button(format!( + "{} Advanced Print", + egui_phosphor::regular::PRINTER + )) + .clicked() + { + log::info!( + "Context menu advanced print clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextAdvancedPrint(item.clone())); + ui.close(); + } + + ui.separator(); + + // Lend/Return options for lendable assets + let lendable = match item.get("lendable") { + Some(serde_json::Value::Bool(b)) => *b, + Some(serde_json::Value::Number(n)) => n.as_i64() == Some(1) || n.as_u64() == Some(1), + Some(serde_json::Value::String(s)) => { + let s = s.to_lowercase(); + s == "true" || s == "1" || s == "yes" || s == "y" + } + _ => false, + }; + // Only act when we have an explicit lending_status; blank usually means non-lendable or unmanaged + let status = item + .get("lending_status") + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .unwrap_or(""); + + if lendable && !status.is_empty() { + if status == "Available" { + if ui + .button(format!("{} Lend Item", egui_phosphor::regular::ARROW_LEFT)) + .clicked() + { + log::info!( + "Context menu lend clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextLend(item.clone())); + ui.close(); + } + } else if matches!( + status, + "Borrowed" | "Overdue" | "Stolen" | "Illegally Handed Out" | "Deployed" + ) { + if ui + .button(format!( + "{} Return Item", + egui_phosphor::regular::ARROW_RIGHT + )) + .clicked() + { + log::info!( + "Context menu return clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextReturn(item.clone())); + ui.close(); + } + } + ui.separator(); + } + + if ui + .button(format!("{} Delete", egui_phosphor::regular::TRASH)) + .clicked() + { + log::info!( + "Context menu delete clicked for asset: {:?}", + item.get("name") + ); + self.deferred_actions + .push(DeferredAction::ContextDelete(item.clone())); + ui.close(); + } + } + + fn on_selection_changed(&mut self, selected_indices: &[usize]) { + log::debug!("Selection changed: {:?}", selected_indices); + } +} 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, + is_loading: bool, + last_error: Option, + init_loaded: bool, + // Cached summary stats (avoid recomputing every frame) + summary_by_status: Vec<(String, i32)>, + summary_by_severity: Vec<(String, i32)>, + // Columns & selector + columns: Vec, + show_column_panel: bool, + // Selection & interactions + selected_row: Option, + last_click_time: Option, + last_click_row: Option, + selected_rows: HashSet, + selection_anchor: Option, + // Dialogs + delete_dialog: ConfirmDialog, + edit_dialog: FormBuilder, + // Track ids for operations + edit_current_id: Option, + delete_current_id: Option, +} + +impl IssuesView { + pub fn new() -> Self { + let columns = vec![ + ColumnConfig { + name: "Title".into(), + field: "title".into(), + visible: true, + }, + ColumnConfig { + name: "Status".into(), + field: "status".into(), + visible: true, + }, + ColumnConfig { + name: "Severity".into(), + field: "severity".into(), + visible: true, + }, + ColumnConfig { + name: "Priority".into(), + field: "priority".into(), + visible: true, + }, + ColumnConfig { + name: "Asset".into(), + field: "asset_label".into(), + visible: true, + }, + ColumnConfig { + name: "Borrower".into(), + field: "borrower_name".into(), + visible: false, + }, + ColumnConfig { + name: "Assigned To".into(), + field: "assigned_to_name".into(), + visible: false, + }, + ColumnConfig { + name: "Auto".into(), + field: "auto_detected".into(), + visible: false, + }, + ColumnConfig { + name: "Trigger".into(), + field: "detection_trigger".into(), + visible: false, + }, + ColumnConfig { + name: "Updated".into(), + field: "updated_at".into(), + visible: true, + }, + ColumnConfig { + name: "Created".into(), + field: "created_at".into(), + visible: false, + }, + ColumnConfig { + name: "Resolved".into(), + field: "resolved_date".into(), + visible: false, + }, + ColumnConfig { + name: "Description".into(), + field: "description".into(), + visible: false, + }, + ColumnConfig { + name: "Solution".into(), + field: "solution".into(), + visible: false, + }, + ColumnConfig { + name: "Solution+".into(), + field: "solution_plus".into(), + visible: false, + }, + ColumnConfig { + name: "Replacement".into(), + field: "replacement_asset_id".into(), + visible: false, + }, + ColumnConfig { + name: "Cost".into(), + field: "cost".into(), + visible: false, + }, + ColumnConfig { + name: "Notes".into(), + field: "notes".into(), + visible: false, + }, + ColumnConfig { + name: "ID".into(), + field: "id".into(), + visible: false, + }, + ]; + Self { + rows: vec![], + is_loading: false, + last_error: None, + init_loaded: false, + summary_by_status: Vec::new(), + summary_by_severity: Vec::new(), + columns, + show_column_panel: false, + selected_row: None, + last_click_time: None, + last_click_row: None, + selected_rows: HashSet::new(), + selection_anchor: None, + delete_dialog: ConfirmDialog::new( + "Delete Issue", + "Are you sure you want to delete this issue?", + ), + edit_dialog: FormBuilder::new( + "Edit Issue", + vec![ + EditorField { + name: "title".into(), + label: "Title".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "status".into(), + label: "Status".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "severity".into(), + label: "Severity".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "priority".into(), + label: "Priority".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "description".into(), + label: "Description".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "solution".into(), + label: "Solution".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ], + ), + edit_current_id: None, + delete_current_id: None, + } + } + + fn load(&mut self, api: &ApiClient) { + if self.is_loading { + return; + } + self.is_loading = true; + self.last_error = None; + match get_issues(api, Some(200)) { + Ok(mut list) => { + // Build asset label + for row in &mut list { + if let Some(obj) = row.as_object_mut() { + let asset = match ( + obj.get("asset_tag").and_then(|v| v.as_str()), + obj.get("asset_name").and_then(|v| v.as_str()), + ) { + (Some(tag), Some(name)) if !tag.is_empty() => { + format!("{} ({})", name, tag) + } + (_, Some(name)) => name.to_string(), + _ => "-".to_string(), + }; + obj.insert("asset_label".into(), serde_json::json!(asset)); + } + } + self.rows = list; + } + Err(e) => self.last_error = Some(e.to_string()), + } + self.is_loading = false; + self.init_loaded = true; + self.recompute_summary(); + } + + pub fn show(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + ui.horizontal(|ui| { + ui.heading("Issues"); + if self.is_loading { + ui.spinner(); + ui.label("Loading..."); + } + if let Some(err) = &self.last_error { + ui.colored_label(egui::Color32::RED, err); + if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + } else if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + ui.separator(); + let button_text = if self.show_column_panel { + "Hide Column Selector" + } else { + "Show Column Selector" + }; + if ui.button(button_text).clicked() { + self.show_column_panel = !self.show_column_panel; + } + }); + ui.separator(); + + if !self.init_loaded { + if let Some(api) = api_client { + self.load(api); + } + } + + // Column selector + if self.show_column_panel { + self.show_columns_window(ui); + } + + // Summary chips (cached) + self.render_summary(ui); + + let visible_columns: Vec = + self.columns.iter().filter(|c| c.visible).cloned().collect(); + self.render_table(ui, &visible_columns); + + // Process selection/dialog events + let ctx = ui.ctx(); + // inline selection now, nothing to fetch here + if let Some(row_idx) = + ctx.data_mut(|d| d.remove_temp::(egui::Id::new("iss_double_click_idx"))) + { + self.selected_row = Some(row_idx); + self.last_click_row = None; + self.last_click_time = None; + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::(egui::Id::new("iss_double_click_edit")) + }) { + self.open_editor_with(&item); + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::(egui::Id::new("iss_context_menu_edit")) + }) { + self.open_editor_with(&item); + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::(egui::Id::new("iss_context_menu_delete")) + }) { + let title = item + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let id = item.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); + self.delete_current_id = Some(id); + self.delete_dialog.open(title, id.to_string()); + ctx.request_repaint(); + } + + if let Some(confirmed) = self.delete_dialog.show_dialog(ctx) { + if confirmed { + if let (Some(api), Some(id)) = (api_client, self.delete_current_id) { + let where_clause = serde_json::json!({"id": id}); + match api.delete("issue_tracker", where_clause) { + Ok(resp) if resp.success => { + self.load(api); + } + Ok(resp) => { + self.last_error = Some(format!("Delete failed: {:?}", resp.error)); + } + Err(e) => { + self.last_error = Some(format!("Delete error: {}", e)); + } + } + } + } + } + + if let Some(result) = self.edit_dialog.show_editor(ctx) { + if let Some(updated) = result { + if let (Some(api), Some(id)) = (api_client, self.edit_current_id) { + let values = serde_json::Value::Object(updated); + let where_clause = serde_json::json!({"id": id}); + match api.update("issue_tracker", values, where_clause) { + Ok(resp) if resp.success => { + self.load(api); + } + Ok(resp) => { + self.last_error = Some(format!("Update failed: {:?}", resp.error)); + } + Err(e) => { + self.last_error = Some(format!("Update error: {}", e)); + } + } + } + } + } + } + + fn render_summary(&self, ui: &mut egui::Ui) { + ui.horizontal_wrapped(|ui| { + for (status, n) in &self.summary_by_status { + issues_chip(ui, format!("{}: {}", status, n), color_for_status(status)); + } + ui.separator(); + for (sev, n) in &self.summary_by_severity { + issues_chip(ui, format!("{}: {}", sev, n), color_for_severity(sev)); + } + }); + ui.add_space(6.0); + } + + fn recompute_summary(&mut self) { + use std::collections::HashMap; + let mut by_status: HashMap = HashMap::new(); + let mut by_sev: HashMap = HashMap::new(); + for r in &self.rows { + let status = r + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let sev = r + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + *by_status.entry(status).or_insert(0) += 1; + *by_sev.entry(sev).or_insert(0) += 1; + } + // Stable order for status + let status_order = [ + "Open", + "In Progress", + "On Hold", + "Resolved", + "Closed", + "Unknown", + ]; + let mut status_vec: Vec<(String, i32)> = by_status.into_iter().collect(); + status_vec.sort_by(|a, b| { + let ia = status_order + .iter() + .position(|s| s.eq_ignore_ascii_case(&a.0)) + .unwrap_or(status_order.len()); + let ib = status_order + .iter() + .position(|s| s.eq_ignore_ascii_case(&b.0)) + .unwrap_or(status_order.len()); + ia.cmp(&ib) + .then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())) + }); + // Stable order for severity + let sev_order = ["Critical", "High", "Medium", "Low", "Unknown"]; + let mut sev_vec: Vec<(String, i32)> = by_sev.into_iter().collect(); + sev_vec.sort_by(|a, b| { + let ia = sev_order + .iter() + .position(|s| s.eq_ignore_ascii_case(&a.0)) + .unwrap_or(sev_order.len()); + let ib = sev_order + .iter() + .position(|s| s.eq_ignore_ascii_case(&b.0)) + .unwrap_or(sev_order.len()); + ia.cmp(&ib) + .then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())) + }); + self.summary_by_status = status_vec; + self.summary_by_severity = sev_vec; + } + + fn show_columns_window(&mut self, ui: &egui::Ui) { + let ctx = ui.ctx(); + let screen_rect = ctx.input(|i| { + i.viewport().inner_rect.unwrap_or(egui::Rect::from_min_max( + egui::pos2(0.0, 0.0), + egui::pos2(1920.0, 1080.0), + )) + }); + let max_w = (screen_rect.width() - 20.0).max(220.0); + let max_h = (screen_rect.height() - 100.0).max(200.0); + + egui::Window::new("Column Selector") + .collapsible(false) + .resizable(true) + .default_width(260.0) + .default_height(360.0) + .anchor(egui::Align2::RIGHT_TOP, [-10.0, 90.0]) + .open(&mut self.show_column_panel) + .min_size(egui::vec2(220.0, 200.0)) + .max_size(egui::vec2(max_w, max_h)) + .frame(egui::Frame { + fill: egui::Color32::from_rgb(30, 30, 30), + stroke: egui::Stroke::new(1.0, egui::Color32::from_gray(60)), + inner_margin: egui::Margin::from(10.0), + outer_margin: egui::Margin::ZERO, + corner_radius: 6.0.into(), + shadow: egui::epaint::Shadow::NONE, + }) + .show(ctx, |ui| { + ui.heading("Columns"); + ui.separator(); + ui.add_space(8.0); + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + for column in &mut self.columns { + ui.checkbox(&mut column.visible, &column.name); + } + }); + ui.add_space(8.0); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Show All").clicked() { + for col in &mut self.columns { + col.visible = true; + } + } + if ui.button("Hide All").clicked() { + for col in &mut self.columns { + col.visible = false; + } + } + }); + }); + } + + fn render_table(&mut self, ui: &mut egui::Ui, visible_columns: &Vec) { + use egui_extras::{Column, TableBuilder}; + let mut table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)); + // Add checkbox column first, then the rest + table = table.column(Column::initial(28.0)); + for _ in 0..visible_columns.len() { + table = table.column(Column::remainder()); + } + table + .header(22.0, |mut header| { + // Select-all checkbox + header.col(|ui| { + let all_selected = self + .rows + .iter() + .enumerate() + .all(|(i, _)| self.selected_rows.contains(&i)); + let mut chk = all_selected; + if ui + .checkbox(&mut chk, "") + .on_hover_text("Select All") + .clicked() + { + if chk { + self.selected_rows = (0..self.rows.len()).collect(); + } else { + self.selected_rows.clear(); + } + } + }); + for col in visible_columns { + header.col(|ui| { + ui.strong(&col.name); + }); + } + }) + .body(|mut body| { + for (idx, r) in self.rows.iter().enumerate() { + let r_clone = r.clone(); + let is_selected = self.selected_rows.contains(&idx); + body.row(20.0, |mut row| { + if is_selected { + row.set_selected(true); + } + // Checkbox cell + row.col(|ui| { + let mut checked = self.selected_rows.contains(&idx); + let resp = ui.checkbox(&mut checked, ""); + if resp.changed() { + let mods = ui.input(|i| i.modifiers); + if mods.shift { + let anchor = self.selection_anchor.unwrap_or(idx); + let (a, b) = if anchor <= idx { + (anchor, idx) + } else { + (idx, anchor) + }; + for i in a..=b { + if checked { + self.selected_rows.insert(i); + } else { + self.selected_rows.remove(&i); + } + } + } else if mods.command || mods.ctrl { + if checked { + self.selected_rows.insert(idx); + } else { + self.selected_rows.remove(&idx); + } + self.selection_anchor = Some(idx); + } else { + self.selected_rows.clear(); + if checked { + self.selected_rows.insert(idx); + self.selection_anchor = Some(idx); + } + } + } + }); + // Data columns + let mut combined: Option = None; + for col in visible_columns { + row.col(|ui| { + let resp = render_issue_cell(ui, &r_clone, &col.field); + combined = Some(match combined.take() { + Some(p) => p.union(resp), + None => resp, + }); + }); + } + let mut row_resp = row.response(); + if let Some(c) = combined { + row_resp = row_resp.union(c); + } + if row_resp.clicked() { + let now = std::time::Instant::now(); + let dbl = if let (Some(t), Some(rw)) = + (self.last_click_time, self.last_click_row) + { + rw == idx && now.duration_since(t).as_millis() < 500 + } else { + false + }; + if dbl { + row_resp.ctx.data_mut(|d| { + d.insert_temp(egui::Id::new("iss_double_click_idx"), idx); + d.insert_temp( + egui::Id::new("iss_double_click_edit"), + r_clone.clone(), + ); + }); + } else { + // Multi-select on row click + let mods = row_resp.ctx.input(|i| i.modifiers); + if mods.shift { + let anchor = self.selection_anchor.unwrap_or(idx); + let (a, b) = if anchor <= idx { + (anchor, idx) + } else { + (idx, anchor) + }; + for i in a..=b { + self.selected_rows.insert(i); + } + } else if mods.command || mods.ctrl { + if self.selected_rows.contains(&idx) { + self.selected_rows.remove(&idx); + } else { + self.selected_rows.insert(idx); + } + self.selection_anchor = Some(idx); + } else { + self.selected_rows.clear(); + self.selected_rows.insert(idx); + self.selection_anchor = Some(idx); + } + self.last_click_time = Some(now); + self.last_click_row = Some(idx); + } + } + row_resp.context_menu(|ui| { + if ui + .button(format!("{} Edit", egui_phosphor::regular::PENCIL)) + .clicked() + { + ui.ctx().data_mut(|d| { + d.insert_temp( + egui::Id::new("iss_context_menu_edit"), + r_clone.clone(), + ) + }); + ui.close(); + } + ui.separator(); + if ui + .button(format!("{} Delete", egui_phosphor::regular::TRASH)) + .clicked() + { + ui.ctx().data_mut(|d| { + d.insert_temp( + egui::Id::new("iss_context_menu_delete"), + r_clone.clone(), + ) + }); + ui.close(); + } + }); + }); + } + }); + } + + fn open_editor_with(&mut self, item: &serde_json::Value) { + self.edit_current_id = item.get("id").and_then(|v| v.as_i64()); + self.edit_dialog.open(item); + } +} + +fn issues_chip(ui: &mut egui::Ui, text: String, color: egui::Color32) { + egui::Frame::new() + .fill(color.linear_multiply(0.14)) + .corner_radius(6.0) + .inner_margin(egui::Margin { + left: 8_i8, + right: 8_i8, + top: 4_i8, + bottom: 4_i8, + }) + .show(ui, |ui| { + ui.label(egui::RichText::new(text).color(color)); + }); +} + +fn color_for_status(status: &str) -> egui::Color32 { + match status.to_lowercase().as_str() { + "open" => egui::Color32::from_rgb(244, 67, 54), + "in progress" => egui::Color32::from_rgb(255, 152, 0), + "on hold" => egui::Color32::from_rgb(121, 85, 72), + "resolved" => egui::Color32::from_rgb(76, 175, 80), + "closed" => egui::Color32::from_rgb(96, 125, 139), + _ => egui::Color32::GRAY, + } +} + +fn color_for_severity(sev: &str) -> egui::Color32 { + match sev.to_lowercase().as_str() { + "critical" => egui::Color32::from_rgb(244, 67, 54), + "high" => egui::Color32::from_rgb(255, 152, 0), + "medium" => egui::Color32::from_rgb(66, 165, 245), + "low" => egui::Color32::from_rgb(158, 158, 158), + _ => egui::Color32::GRAY, + } +} + +fn label_trunc(ui: &mut egui::Ui, text: &str, max: usize) -> egui::Response { + if text.len() > max { + let short = format!("{}…", &text[..max]); + ui.label(short).on_hover_ui(|ui| { + ui.label(text); + }) + } else { + ui.label(text) + } +} + +fn render_issue_cell(ui: &mut egui::Ui, row: &serde_json::Value, field: &str) -> egui::Response { + let t = |k: &str| row.get(k).and_then(|v| v.as_str()).unwrap_or(""); + match field { + "id" => ui.label( + row.get("id") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string(), + ), + "title" => label_trunc(ui, t("title"), 60), + "status" => ui.label(t("status")), + "severity" => ui.label(t("severity")), + "priority" => ui.label(t("priority")), + "asset_label" => ui.label(t("asset_label")), + "borrower_name" => ui.label(t("borrower_name")), + "assigned_to_name" => ui.label(t("assigned_to_name")), + "auto_detected" => ui.label( + if row + .get("auto_detected") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + "Yes" + } else { + "No" + }, + ), + "detection_trigger" => label_trunc(ui, t("detection_trigger"), 40), + "updated_at" => ui.label(t("updated_at")), + "created_at" => ui.label(t("created_at")), + "resolved_date" => ui.label(t("resolved_date")), + "description" => label_trunc(ui, t("description"), 80), + "solution" => label_trunc(ui, t("solution"), 80), + "solution_plus" => label_trunc(ui, t("solution_plus"), 80), + "replacement_asset_id" => ui.label( + row.get("replacement_asset_id") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string(), + ), + "cost" => ui.label(match row.get("cost") { + Some(serde_json::Value::Number(n)) => n.to_string(), + Some(serde_json::Value::String(s)) => s.clone(), + _ => String::new(), + }), + "notes" => label_trunc(ui, t("notes"), 80), + other => ui.label(format!("{}", other)), + } +} diff --git a/src/ui/label_templates.rs b/src/ui/label_templates.rs new file mode 100644 index 0000000..fbe373e --- /dev/null +++ b/src/ui/label_templates.rs @@ -0,0 +1,607 @@ +use crate::api::ApiClient; +use crate::core::components::form_builder::FormBuilder; +use crate::core::components::interactions::ConfirmDialog; +use crate::core::{ColumnConfig, TableEventHandler, TableRenderer}; +use crate::core::{EditorField, FieldType}; +use crate::ui::ribbon::RibbonUI; +use eframe::egui; +use serde_json::Value; + +pub struct LabelTemplatesView { + templates: Vec, + is_loading: bool, + last_error: Option, + initial_load_done: bool, + + // Table renderer + table_renderer: TableRenderer, + + // Editor dialogs + edit_dialog: FormBuilder, + add_dialog: FormBuilder, + delete_dialog: ConfirmDialog, + + // Pending operations + pending_delete_id: Option, + pending_edit_id: Option, +} + +impl LabelTemplatesView { + pub fn new() -> Self { + let edit_dialog = Self::create_edit_dialog(); + let add_dialog = Self::create_add_dialog(); + + // Define columns for label_templates table + let columns = vec![ + ColumnConfig::new("ID", "id").with_width(60.0).hidden(), + ColumnConfig::new("Template Code", "template_code").with_width(150.0), + ColumnConfig::new("Template Name", "template_name").with_width(200.0), + ColumnConfig::new("Layout JSON", "layout_json") + .with_width(250.0) + .hidden(), + ]; + + Self { + templates: Vec::new(), + is_loading: false, + last_error: None, + initial_load_done: false, + table_renderer: TableRenderer::new() + .with_columns(columns) + .with_default_sort("template_name", true) + .with_search_fields(vec![ + "template_code".to_string(), + "template_name".to_string(), + ]), + edit_dialog, + add_dialog, + delete_dialog: ConfirmDialog::new( + "Delete Label Template", + "Are you sure you want to delete this label template?", + ), + pending_delete_id: None, + pending_edit_id: None, + } + } + + fn create_edit_dialog() -> FormBuilder { + FormBuilder::new( + "Edit Label Template", + vec![ + EditorField { + name: "id".into(), + label: "ID".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "template_code".into(), + label: "Template Code".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "template_name".into(), + label: "Template Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "layout_json".into(), + label: "Layout JSON".into(), + field_type: FieldType::MultilineText, + required: true, + read_only: false, + }, + ], + ) + } + + fn create_add_dialog() -> FormBuilder { + FormBuilder::new( + "Add Label Template", + vec![ + EditorField { + name: "template_code".into(), + label: "Template Code".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "template_name".into(), + label: "Template Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "layout_json".into(), + label: "Layout JSON".into(), + field_type: FieldType::MultilineText, + required: true, + read_only: false, + }, + ], + ) + } + + fn ensure_loaded(&mut self, api_client: Option<&ApiClient>) { + if self.is_loading || self.initial_load_done { + return; + } + if let Some(client) = api_client { + self.load_templates(client); + } + } + + fn load_templates(&mut self, api_client: &ApiClient) { + use crate::core::tables::get_label_templates; + + self.is_loading = true; + self.last_error = None; + match get_label_templates(api_client) { + Ok(list) => { + self.templates = list; + self.is_loading = false; + self.initial_load_done = true; + } + Err(e) => { + self.last_error = Some(e.to_string()); + self.is_loading = false; + self.initial_load_done = true; + } + } + } + + pub fn render( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon_ui: Option<&mut RibbonUI>, + ) { + self.ensure_loaded(api_client); + + // Get search query from ribbon first (before mutable borrow) + let search_query = ribbon_ui + .as_ref() + .and_then(|r| r.search_texts.get("labels_search")) + .map(|s| s.clone()) + .unwrap_or_default(); + + // Apply search to table renderer + self.table_renderer.search_query = search_query; + + // Handle ribbon actions + if let Some(ribbon) = ribbon_ui { + if ribbon + .checkboxes + .get("labels_action_add") + .copied() + .unwrap_or(false) + { + // Provide helpful default layout JSON template matching database schema + let layout_json = r##"{ + "version": "1.0", + "background": "#FFFFFF", + "elements": [ + { + "type": "text", + "field": "{{asset_tag}}", + "x": 5, + "y": 10, + "fontSize": 14, + "fontWeight": "bold", + "fontFamily": "Arial" + }, + { + "type": "text", + "field": "{{name}}", + "x": 5, + "y": 28, + "fontSize": 10, + "fontFamily": "Arial" + }, + { + "type": "qrcode", + "field": "{{asset_tag}}", + "x": 5, + "y": 50, + "size": 40 + } + ] +}"##; + let default_data = serde_json::json!({ + "layout_json": layout_json + }); + self.add_dialog.open(&default_data); + } + if ribbon + .checkboxes + .get("labels_action_refresh") + .copied() + .unwrap_or(false) + { + if let Some(client) = api_client { + self.load_templates(client); + } + } + } + + // Error message + let mut clear_error = false; + if let Some(err) = &self.last_error { + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::RED, format!("Error: {}", err)); + if ui.button("Close").clicked() { + clear_error = true; + } + }); + ui.separator(); + } + if clear_error { + self.last_error = None; + } + + // Loading indicator + if self.is_loading { + ui.spinner(); + ui.label("Loading label templates..."); + return; + } + + // Render table with event handling + self.render_table_with_events(ui, api_client); + + // Handle dialogs + self.handle_dialogs(ui, api_client); + + // Process deferred actions from context menus + self.process_deferred_actions(ui, api_client); + } + + fn render_table_with_events(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + let templates_clone = self.templates.clone(); + let prepared_data = self.table_renderer.prepare_json_data(&templates_clone); + + let mut deferred_actions: Vec = Vec::new(); + let mut temp_handler = TempTemplatesEventHandler { + api_client, + deferred_actions: &mut deferred_actions, + }; + + self.table_renderer + .render_json_table(ui, &prepared_data, Some(&mut temp_handler)); + + self.process_temp_deferred_actions(deferred_actions, api_client); + } + + fn process_temp_deferred_actions( + &mut self, + actions: Vec, + _api_client: Option<&ApiClient>, + ) { + for action in actions { + match action { + DeferredAction::DoubleClick(template) => { + log::info!( + "Processing double-click edit for template: {:?}", + template.get("template_name") + ); + self.edit_dialog.open(&template); + if let Some(id) = template.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + DeferredAction::ContextEdit(template) => { + log::info!( + "Processing context menu edit for template: {:?}", + template.get("template_name") + ); + self.edit_dialog.open(&template); + if let Some(id) = template.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + DeferredAction::ContextDelete(template) => { + let name = template + .get("template_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + let id = template.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); + log::info!("Processing context menu delete for template: {}", name); + self.pending_delete_id = Some(id); + self.delete_dialog.open(name.to_string(), id.to_string()); + } + DeferredAction::ContextClone(template) => { + log::info!( + "Processing context menu clone for template: {:?}", + template.get("template_name") + ); + // Build payload for Add dialog using shared helper + let mut cloned = crate::core::components::prepare_cloned_value( + &template, + &["id", "template_code"], + Some("template_name"), + Some(""), + ); + // Ensure layout_json is a string for the editor + if let Some(obj) = cloned.as_object_mut() { + if let Some(v) = template.get("layout_json") { + let as_string = if let Some(s) = v.as_str() { + s.to_string() + } else { + serde_json::to_string_pretty(v).unwrap_or_else(|_| "{}".to_string()) + }; + obj.insert( + "layout_json".to_string(), + serde_json::Value::String(as_string), + ); + } + } + self.add_dialog.title = "Add Label Template".to_string(); + self.add_dialog.open(&cloned); + } + } + } + } + + fn handle_dialogs(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + // Delete confirmation dialog + if let Some(confirmed) = self.delete_dialog.show_dialog(ui.ctx()) { + if confirmed { + if let (Some(id), Some(client)) = (self.pending_delete_id, api_client) { + let where_clause = serde_json::json!({"id": id}); + match client.delete("label_templates", where_clause) { + Ok(resp) => { + if resp.success { + log::info!("Label template {} deleted successfully", id); + self.load_templates(client); + } else { + self.last_error = Some(format!("Delete failed: {:?}", resp.error)); + log::error!("Delete failed: {:?}", resp.error); + } + } + Err(e) => { + self.last_error = Some(format!("Failed to delete template: {}", e)); + log::error!("Failed to delete template: {}", e); + } + } + } + self.pending_delete_id = None; + } + } + + // Edit dialog + if let Some(Some(updated)) = self.edit_dialog.show_editor(ui.ctx()) { + if let (Some(id), Some(client)) = (self.pending_edit_id, api_client) { + let where_clause = serde_json::json!({"id": id}); + let mut to_update = updated; + // Remove editor metadata + let mut meta_keys: Vec = to_update + .keys() + .filter(|k| k.starts_with("__editor_")) + .cloned() + .collect(); + // Also remove __editor_item_id specifically + if to_update.contains_key("__editor_item_id") { + meta_keys.push("__editor_item_id".to_string()); + } + for k in meta_keys { + to_update.remove(&k); + } + // Send layout_json as actual JSON object + if let Some(val) = to_update.get_mut("layout_json") { + if let Some(s) = val.as_str() { + match serde_json::from_str::(s) { + Ok(json_val) => { + // Send as actual JSON object, not string + *val = json_val; + } + Err(e) => { + self.last_error = Some(format!("Layout JSON is invalid: {}", e)); + return; + } + } + } + } + match client.update( + "label_templates", + serde_json::Value::Object(to_update.clone()), + where_clause, + ) { + Ok(resp) => { + if resp.success { + log::info!("Label template {} updated successfully", id); + self.load_templates(client); + } else { + self.last_error = Some(format!("Update failed: {:?}", resp.error)); + log::error!("Update failed: {:?}", resp.error); + } + } + Err(e) => { + self.last_error = Some(format!("Failed to update template: {}", e)); + log::error!("Failed to update template: {}", e); + } + } + self.pending_edit_id = None; + } + } + + // Add dialog + if let Some(Some(new_data)) = self.add_dialog.show_editor(ui.ctx()) { + if let Some(client) = api_client { + let mut payload = new_data; + // Strip any editor metadata that may have leaked in + let meta_strip: Vec = payload + .keys() + .filter(|k| k.starts_with("__editor_")) + .cloned() + .collect(); + for k in meta_strip { + payload.remove(&k); + } + // Send layout_json as actual JSON object + if let Some(val) = payload.get_mut("layout_json") { + if let Some(s) = val.as_str() { + match serde_json::from_str::(s) { + Ok(json_val) => { + // Send as actual JSON object, not string + *val = json_val; + } + Err(e) => { + self.last_error = Some(format!("Layout JSON is invalid: {}", e)); + return; + } + } + } + } + match client.insert("label_templates", serde_json::Value::Object(payload)) { + Ok(resp) => { + if resp.success { + log::info!("Label template added successfully"); + self.load_templates(client); + } else { + self.last_error = Some(format!("Insert failed: {:?}", resp.error)); + log::error!("Insert failed: {:?}", resp.error); + } + } + Err(e) => { + self.last_error = Some(format!("Failed to add template: {}", e)); + log::error!("Failed to add template: {}", e); + } + } + } + } + } + + fn process_deferred_actions(&mut self, ui: &mut egui::Ui, _api_client: Option<&ApiClient>) { + // Handle double-click edit + if let Some(template) = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("label_double_click_edit"))) + { + log::info!( + "Processing double-click edit for template: {:?}", + template.get("template_name") + ); + self.edit_dialog.open(&template); + if let Some(id) = template.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + + // Handle context menu actions + if let Some(template) = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("label_context_menu_edit"))) + { + log::info!( + "Processing context menu edit for template: {:?}", + template.get("template_name") + ); + self.edit_dialog.open(&template); + if let Some(id) = template.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + + if let Some(template) = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("label_context_menu_delete"))) + { + let name = template + .get("template_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + let id = template.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); + log::info!("Processing context menu delete for template: {}", name); + self.pending_delete_id = Some(id); + self.delete_dialog.open(name.to_string(), id.to_string()); + } + } +} + +impl Default for LabelTemplatesView { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone)] +enum DeferredAction { + DoubleClick(Value), + ContextEdit(Value), + ContextDelete(Value), + ContextClone(Value), +} + +// Temporary event handler that collects actions for later processing +struct TempTemplatesEventHandler<'a> { + #[allow(dead_code)] + api_client: Option<&'a ApiClient>, + deferred_actions: &'a mut Vec, +} + +impl<'a> TableEventHandler for TempTemplatesEventHandler<'a> { + fn on_double_click(&mut self, item: &Value, _row_index: usize) { + log::info!( + "Double-click detected on template: {:?}", + item.get("template_name") + ); + self.deferred_actions + .push(DeferredAction::DoubleClick(item.clone())); + } + + fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) { + if ui + .button(format!("{} Edit Template", egui_phosphor::regular::PENCIL)) + .clicked() + { + log::info!( + "Context menu edit clicked for template: {:?}", + item.get("template_name") + ); + self.deferred_actions + .push(DeferredAction::ContextEdit(item.clone())); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Clone Template", egui_phosphor::regular::COPY)) + .clicked() + { + log::info!( + "Context menu clone clicked for template: {:?}", + item.get("template_name") + ); + self.deferred_actions + .push(DeferredAction::ContextClone(item.clone())); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Delete Template", egui_phosphor::regular::TRASH)) + .clicked() + { + log::info!( + "Context menu delete clicked for template: {:?}", + item.get("template_name") + ); + self.deferred_actions + .push(DeferredAction::ContextDelete(item.clone())); + ui.close(); + } + } + + fn on_selection_changed(&mut self, selected_indices: &[usize]) { + log::debug!("Template selection changed: {:?}", selected_indices); + } +} diff --git a/src/ui/login.rs b/src/ui/login.rs new file mode 100644 index 0000000..8a85418 --- /dev/null +++ b/src/ui/login.rs @@ -0,0 +1,272 @@ +use eframe::egui; +use std::sync::mpsc::Receiver; + +use crate::api::ApiClient; +use crate::models::LoginResponse; +use crate::session::SessionManager; + +pub struct LoginScreen { + server_url: String, + username: String, + password: String, + + remember_server: bool, + remember_username: bool, + + error_message: Option, + is_logging_in: bool, + + // For async operations + login_receiver: Option>>, +} + +impl LoginScreen { + pub fn new(session_manager: &SessionManager) -> Self { + let server_url = session_manager + .get_saved_server_url() + .unwrap_or_else(|| "http://localhost:5777".to_string()); // Reminder to myself : Fucking remove this before release + + let username = session_manager.get_saved_username().unwrap_or_default(); + + let remember_username = !username.is_empty(); + + Self { + server_url, + username, + password: String::new(), + remember_server: true, + remember_username, + error_message: None, + is_logging_in: false, + login_receiver: None, + } + } + + pub fn show(&mut self, ctx: &egui::Context, on_success: &mut Option<(String, LoginResponse)>) { + // Check if we have a login result from async operation + if let Some(receiver) = &self.login_receiver { + match receiver.try_recv() { + Ok(result) => { + log::info!("UI thread: Received login result!"); + self.is_logging_in = false; + self.login_receiver = None; + + match result { + Ok((server_url, login_response)) => { + log::info!("UI thread: Login successful, setting on_success"); + *on_success = Some((server_url, login_response)); + } + Err(err) => { + log::error!("UI thread: Login failed: {} tried suicide yet maybe that actually works?", err); + self.error_message = Some(err); + } + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + // Still waiting, request repaint to check again + ctx.request_repaint(); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + log::error!("UI thread: Channel disconnected!"); + self.is_logging_in = false; + self.login_receiver = None; + self.error_message = Some("Connection error".to_string()); + } + } + } + + egui::CentralPanel::default().show(ctx, |ui| { + // Center the login form both horizontally and vertically bruh + let available_size = ui.available_size(); + let panel_width = available_size.x.min(500.0); + + ui.allocate_ui_with_layout( + available_size, + egui::Layout::top_down(egui::Align::Center), + |ui| { + // Add vertical spacing to center + ui.add_space(available_size.y * 0.15); + + // Logo/Title + ui.heading(egui::RichText::new("BeepZone Login").size(48.0).strong()); + ui.label( + egui::RichText::new(format!( + "BeepZone Desktop Client eGUI EMO Edition - v{}", + env!("CARGO_PKG_VERSION") + )) + .size(18.0), + ); + + ui.add_space(30.0); + + // Login form + egui::Frame::new() + .fill(ctx.style().visuals.window_fill) + .stroke(ctx.style().visuals.window_stroke) + .corner_radius(12.0) + .inner_margin(32.0) + .show(ui, |ui| { + ui.set_width(panel_width * 0.8); + + // Server URL + ui.horizontal(|ui: &mut egui::Ui| { + ui.label("BeepZone Sekel API URL:"); + ui.add_space(10.0); + }); + ui.text_edit_singleline(&mut self.server_url); + ui.add_space(8.0); + + // Username field + ui.label("Username:"); + ui.text_edit_singleline(&mut self.username); + ui.add_space(8.0); + + // Password field + ui.label("Password:"); + let password_response = ui + .add(egui::TextEdit::singleline(&mut self.password).password(true)); + + // Enter key to submit + if password_response.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + { + self.do_login(); + } + + ui.add_space(12.0); + + // Remember options + ui.checkbox(&mut self.remember_server, "Remember Sekel URL"); + ui.checkbox(&mut self.remember_username, "Remember Username"); + + ui.add_space(16.0); + + // Error message + if let Some(error) = &self.error_message { + ui.label(format!("Error: {}", error)); + ui.add_space(12.0); + } + + // Login button + ui.add_enabled_ui(!self.is_logging_in, |ui| { + let button_text = if self.is_logging_in { + "Logging in..." + } else { + "Login" + }; + + if ui + .add_sized( + [ui.available_width(), 40.0], + egui::Button::new(button_text), + ) + .clicked() + { + self.do_login(); + } + }); + + if self.is_logging_in { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Connecting to server..."); + }); + } + }); + + ui.add_space(20.0); + ui.label( + egui::RichText::new("- The only based Sigma Inventory System -") + .size(12.0) + .color(egui::Color32::GRAY), + ); + + // Debug info + if self.is_logging_in { + ui.add_space(10.0); + ui.label( + egui::RichText::new(format!("Connecting to: {}", self.server_url)) + .size(10.0) + .color(egui::Color32::GRAY), + ); + } + }, + ); + }); + } + + fn do_login(&mut self) { + // Validate inputs + if self.server_url.trim().is_empty() { + self.error_message = + Some("Server URL is required!? Perhaps enter it you know?".to_string()); + return; + } + + if self.username.trim().is_empty() || self.password.trim().is_empty() { + self.error_message = Some( + "Username and password are required!? Please enter both you know?".to_string(), + ); + return; + } + + self.error_message = None; + self.is_logging_in = true; + + // Clone data for background thread + let server_url = self.server_url.clone(); + let username = self.username.clone(); + let password = self.password.clone(); + + log::info!("Trying to sign in as : {}", username); + + // Create channel for communication + let (tx, rx) = std::sync::mpsc::channel(); + self.login_receiver = Some(rx); + + // Spawn background thread to perform login + std::thread::spawn(move || { + log::info!("Background thread: Connecting to {}", server_url); + let result = match ApiClient::new(server_url.clone()) { + Ok(client) => { + log::info!("Background thread: API client created, attempting login..."); + match client.login_password(&username, &password) { + Ok(response) => { + log::info!( + "Background thread: Got response, success={}", + response.success + ); + if response.success { + log::info!( + "Login successfulf for user: {}", + response.user.username + ); + Ok((server_url, response)) + } else { + let error = "Login failed gay credentials".to_string(); + log::error!("{}", error); + Err(error) + } + } + Err(e) => { + let error = format!("Notwork error: {}", e); + log::error!("{}", error); + Err(error) + } + } + } + Err(e) => { + let error = format!("Failed at connecting to server somehow: {}", e); + log::error!("{}", error); + Err(error) + } + }; + + log::info!("Background thread: Sending result back to UI"); + if let Err(e) = tx.send(result) { + log::error!("Background thread: Failed to send result: {:?}", e); + } + }); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..f173749 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,14 @@ +pub mod app; +pub mod audits; +pub mod borrowing; +pub mod categories; +pub mod dashboard; +pub mod inventory; +pub mod issues; +pub mod label_templates; +pub mod login; +pub mod printers; +pub mod ribbon; +pub mod suppliers; +pub mod templates; +pub mod zones; diff --git a/src/ui/printers.rs b/src/ui/printers.rs new file mode 100644 index 0000000..bafc445 --- /dev/null +++ b/src/ui/printers.rs @@ -0,0 +1,943 @@ +use crate::api::ApiClient; +use crate::core::components::form_builder::FormBuilder; +use crate::core::components::interactions::ConfirmDialog; +use crate::core::{ColumnConfig, TableEventHandler, TableRenderer}; +use crate::core::{EditorField, FieldType}; +use crate::ui::ribbon::RibbonUI; +use eframe::egui; +use serde_json::Value; + +const SYSTEM_PRINTER_SETTINGS_TEMPLATE: &str = r#"{ + "paper_size": "Custom", + "orientation": "landscape", + "margins": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "color": false, + "quality": "high", + "copies": 1, + "duplex": false, + "center": "both", + "center_disabled": false, + "scale_mode": "fit", + "scale_factor": 1.0, + "custom_width_mm": 50.8, + "custom_height_mm": 76.2, + "printer_name": null, + "show_dialog_if_unfound": true +}"#; + +const PDF_PRINTER_SETTINGS_TEMPLATE: &str = SYSTEM_PRINTER_SETTINGS_TEMPLATE; + +const SYSTEM_PRINTER_JSON_HELP: &str = r#"# System printer JSON + +Use this payload when registering the `System` printer plugin. Leave fields out to fall back to BeepZone's legacy sizing. + +## Core fields +- `paper_size` *(string)* — Named stock such as `A4`, `Letter`, `A5`, or `Custom`. +- `orientation` *(string)* — Either `portrait` or `landscape`. Selecting `landscape` rotates the page 90°; any custom width/height you supply are interpreted in the stock's natural (portrait) orientation and the app flips them automatically while printing. +- `margins` *(object in millimetres)* — Trim space on each edge with `top`, `right`, `bottom`, `left` properties. +- `scale_mode` *(string)* — Scaling behavior: `fit` (proportional fit), `fit-x` (fit width), `fit-y` (fit height), `max-both`, `max-x`, `max-y`, or `manual`. +- `scale_factor` *(number ≥ 0)* — Manual multiplier applied according to scale_mode. +- `duplex`, `color`, `quality` *(optional)* — Mirrors the underlying OS print options. +- `copies` *(number)* — Number of copies to print. +- `custom_width_mm` / `custom_height_mm` *(numbers)* — Provide both to describe bespoke media using the printer's normal portrait orientation. + +## Layout control +- `center` *("none" | "horizontal" | "vertical" | "both" | null)* — Centers content when not disabled. +- `center_disabled` *(bool)* — When `true`, ignores the `center` setting while keeping the last chosen mode for later. + +## Direct print (optional) +- `printer_name` *(string | null)* — If set, the System plugin will attempt to print directly to this OS printer by name. +- `show_dialog_if_unfound` *(bool, default: true)* — When `true` (or omitted) and the named printer can't be resolved, a lightweight popup chooser appears. Set to `false` to skip the chooser and only open the PDF viewer. +- `compatibility_mode` *(bool, default: false)* — When `true`, sends NO CUPS job options at all - only the raw PDF. Use this for severely broken printer filters (e.g., Kyocera network printers with crashing filters). The printer will use its default settings. + +## Examples + +### Custom Label Printer (e.g., ZQ510) +```json +{ + "paper_size": "Custom", + "orientation": "landscape", + "margins": { + "top": 0.0, + "right": 0.0, + "bottom": 0.0, + "left": 0.0 + }, + "scale_mode": "fit", + "scale_factor": 1.0, + "color": false, + "quality": "high", + "copies": 1, + "duplex": false, + "custom_width_mm": 50.8, + "custom_height_mm": 76.2, + "center": "both", + "center_disabled": false, + "printer_name": "ZQ510", + "show_dialog_if_unfound": true +} +``` + +### Standard A4 Office Printer +```json +{ + "paper_size": "A4", + "orientation": "portrait", + "margins": { + "top": 12.7, + "right": 12.7, + "bottom": 12.7, + "left": 12.7 + }, + "scale_mode": "fit", + "scale_factor": 1.0, + "color": true, + "quality": "high", + "copies": 1, + "duplex": false, + "center": "both", + "center_disabled": false, + "printer_name": "HP LaserJet Pro", + "show_dialog_if_unfound": true +} +``` +"#; + +const PDF_PRINTER_JSON_HELP: &str = r#"# PDF export JSON + +The PDF plugin understands the same shape as the System printer. Use the optional flags only when you want the enhanced layout controls; otherwise omit them for the classic renderer settings. + +## Typical usage +- Provide `paper_size` / `orientation` or include `custom_width_mm` + `custom_height_mm` for bespoke sheets. Enter the measurements in the stock's natural portrait orientation; landscape output is handled automatically. +- Reuse the `margins` block from your system printers so labels line up identically. +- `scale_mode`, `scale_factor`, `center`, `center_disabled` behave exactly the same as the System plugin. +- The exported file path is still chosen through the PDF save dialog; these settings only influence page geometry. + +## Available scale modes +- `fit` — Proportionally fit the design within the printable area +- `fit-x` — Fit to page width only +- `fit-y` — Fit to page height only +- `max-both` — Maximum size that fits both dimensions +- `max-x` — Maximum width scaling +- `max-y` — Maximum height scaling +- `manual` — Use exact `scale_factor` value + +## Example + +```json +{ + "paper_size": "Letter", + "orientation": "portrait", + "margins": { "top": 5.0, "right": 5.0, "bottom": 5.0, "left": 5.0 }, + "scale_mode": "manual", + "scale_factor": 0.92, + "center": "horizontal", + "center_disabled": false +} +``` +"#; + +pub struct PrintersView { + printers: Vec, + is_loading: bool, + last_error: Option, + initial_load_done: bool, + + // Table renderer + table_renderer: TableRenderer, + + // Editor dialogs + edit_dialog: FormBuilder, + add_dialog: FormBuilder, + delete_dialog: ConfirmDialog, + + // Pending operations + pending_delete_id: Option, + pending_edit_id: Option, + + // Navigation + pub switch_to_print_history: bool, + + // Track last selected plugin to detect changes + last_add_dialog_plugin: Option, +} + +impl PrintersView { + pub fn new() -> Self { + let edit_dialog = Self::create_edit_dialog(); + let add_dialog = Self::create_add_dialog(); + + // Define columns for printer_settings table + let columns = vec![ + ColumnConfig::new("ID", "id").with_width(60.0).hidden(), + ColumnConfig::new("Printer Name", "printer_name").with_width(150.0), + ColumnConfig::new("Description", "description").with_width(200.0), + ColumnConfig::new("Plugin", "printer_plugin").with_width(100.0), + ColumnConfig::new("Log Prints", "log").with_width(90.0), + ColumnConfig::new("Use for Reports", "can_be_used_for_reports").with_width(120.0), + ColumnConfig::new("Min Power Level", "min_powerlevel_to_use").with_width(110.0), + ColumnConfig::new("Settings JSON", "printer_settings") + .with_width(150.0) + .hidden(), + ]; + + Self { + printers: Vec::new(), + is_loading: false, + last_error: None, + initial_load_done: false, + table_renderer: TableRenderer::new() + .with_columns(columns) + .with_default_sort("printer_name", true) + .with_search_fields(vec![ + "printer_name".to_string(), + "description".to_string(), + "printer_plugin".to_string(), + ]), + edit_dialog, + add_dialog, + delete_dialog: ConfirmDialog::new( + "Delete Printer", + "Are you sure you want to delete this printer configuration?", + ), + pending_delete_id: None, + pending_edit_id: None, + switch_to_print_history: false, + last_add_dialog_plugin: None, + } + } + + fn plugin_help_text(plugin: &str) -> Option<&'static str> { + match plugin { + "System" => Some(SYSTEM_PRINTER_JSON_HELP), + "PDF" => Some(PDF_PRINTER_JSON_HELP), + _ => None, + } + } + + fn apply_plugin_help(editor: &mut FormBuilder, plugin: Option<&str>) { + if let Some(plugin) = plugin { + if let Some(help) = Self::plugin_help_text(plugin) { + editor.form_help_text = Some(help.to_string()); + return; + } + } + editor.form_help_text = None; + } + + fn create_edit_dialog() -> FormBuilder { + let plugin_options = vec![ + ("Ptouch".to_string(), "Brother P-Touch".to_string()), + ("Brother".to_string(), "Brother (Generic)".to_string()), + ("Zebra".to_string(), "Zebra".to_string()), + ("System".to_string(), "System Printer".to_string()), + ("PDF".to_string(), "PDF Export".to_string()), + ("Network".to_string(), "Network Printer".to_string()), + ("Custom".to_string(), "Custom".to_string()), + ]; + + FormBuilder::new( + "Edit Printer", + vec![ + EditorField { + name: "id".into(), + label: "ID".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "printer_name".into(), + label: "Printer Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "description".into(), + label: "Description".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "printer_plugin".into(), + label: "Printer Plugin".into(), + field_type: FieldType::Dropdown(plugin_options.clone()), + required: true, + read_only: false, + }, + EditorField { + name: "log".into(), + label: "Log Print Jobs".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "can_be_used_for_reports".into(), + label: "Can Print Reports".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "min_powerlevel_to_use".into(), + label: "Minimum Power Level".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "printer_settings".into(), + label: "Printer Settings Required".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ], + ) + } + + fn create_add_dialog() -> FormBuilder { + let plugin_options = vec![ + ("Ptouch".to_string(), "Brother P-Touch".to_string()), + ("Brother".to_string(), "Brother (Generic)".to_string()), + ("Zebra".to_string(), "Zebra".to_string()), + ("System".to_string(), "System Printer".to_string()), + ("PDF".to_string(), "PDF Export".to_string()), + ("Network".to_string(), "Network Printer".to_string()), + ("Custom".to_string(), "Custom".to_string()), + ]; + + FormBuilder::new( + "Add Printer", + vec![ + EditorField { + name: "printer_name".into(), + label: "Printer Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "description".into(), + label: "Description".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "printer_plugin".into(), + label: "Printer Plugin".into(), + field_type: FieldType::Dropdown(plugin_options), + required: true, + read_only: false, + }, + EditorField { + name: "log".into(), + label: "Log Print Jobs".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "can_be_used_for_reports".into(), + label: "Can Print Reports".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "min_powerlevel_to_use".into(), + label: "Minimum Power Level".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "printer_settings".into(), + label: "Printer Settings (JSON)".into(), + field_type: FieldType::MultilineText, + required: true, + read_only: false, + }, + ], + ) + } + + fn ensure_loaded(&mut self, api_client: Option<&ApiClient>) { + if self.is_loading || self.initial_load_done { + return; + } + if let Some(client) = api_client { + self.load_printers(client); + } + } + + fn load_printers(&mut self, api_client: &ApiClient) { + use crate::core::tables::get_printers; + + self.is_loading = true; + self.last_error = None; + match get_printers(api_client) { + Ok(list) => { + self.printers = list; + self.is_loading = false; + self.initial_load_done = true; + } + Err(e) => { + self.last_error = Some(e.to_string()); + self.is_loading = false; + self.initial_load_done = true; + } + } + } + + pub fn render( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon_ui: Option<&mut RibbonUI>, + session_manager: &std::sync::Arc>, + ) { + self.ensure_loaded(api_client); + + // Get search query from ribbon first (before mutable borrow) + let search_query = ribbon_ui + .as_ref() + .and_then(|r| r.search_texts.get("printers_search")) + .map(|s| s.clone()) + .unwrap_or_default(); + + // Apply search to table renderer + self.table_renderer.search_query = search_query; + + // Handle ribbon actions and default printer dropdown + if let Some(ribbon) = ribbon_ui { + if ribbon + .checkboxes + .get("printers_action_add") + .copied() + .unwrap_or(false) + { + // Provide default values - printer_settings will get plugin-specific template + let default_data = serde_json::json!({ + "printer_settings": "{}", + "log": true, + "can_be_used_for_reports": false, + "min_powerlevel_to_use": "0" + }); + self.add_dialog.open(&default_data); + } + if ribbon + .checkboxes + .get("printers_action_refresh") + .copied() + .unwrap_or(false) + { + if let Some(client) = api_client { + self.load_printers(client); + } + } + if ribbon + .checkboxes + .get("printers_view_print_history") + .copied() + .unwrap_or(false) + { + self.switch_to_print_history = true; + } + + // Handle default printer dropdown (will be rendered in Settings group) + // Store selected printer ID change flag + if ribbon + .checkboxes + .get("printers_default_changed") + .copied() + .unwrap_or(false) + { + if let Some(printer_id_str) = ribbon.search_texts.get("printers_default_id") { + if printer_id_str.is_empty() { + // Clear default printer + if let Ok(mut session) = session_manager.try_lock() { + if let Err(e) = session.update_default_printer(None) { + log::error!("Failed to clear default printer: {}", e); + } else { + log::info!("Default printer cleared"); + } + } + } else if let Ok(printer_id) = printer_id_str.parse::() { + // Set default printer + if let Ok(mut session) = session_manager.try_lock() { + if let Err(e) = session.update_default_printer(Some(printer_id)) { + log::error!("Failed to update default printer: {}", e); + } else { + log::info!("Default printer set to ID: {}", printer_id); + } + } + } + } + } + } + + // Error message + let mut clear_error = false; + if let Some(err) = &self.last_error { + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::RED, format!("Error: {}", err)); + if ui.button("Close").clicked() { + clear_error = true; + } + }); + ui.separator(); + } + if clear_error { + self.last_error = None; + } + + // Loading indicator + if self.is_loading { + ui.spinner(); + ui.label("Loading printers..."); + return; + } + + // Render table with event handling + self.render_table_with_events(ui, api_client); + + // Handle dialogs + self.handle_dialogs(ui, api_client); + + // Process deferred actions from context menus + self.process_deferred_actions(ui, api_client); + } + + /// Called before rendering to inject printer dropdown data into ribbon + pub fn inject_dropdown_into_ribbon( + &self, + ribbon_ui: &mut RibbonUI, + session_manager: &std::sync::Arc>, + ) { + // Try to get current default printer ID without blocking (avoid Tokio panic) + let current_default = session_manager + .try_lock() + .ok() + .and_then(|s| s.get_default_printer_id()); + + // Store current default for ribbon rendering + if let Some(id) = current_default { + ribbon_ui + .search_texts + .insert("_printers_current_default".to_string(), id.to_string()); + } else { + ribbon_ui + .search_texts + .insert("_printers_current_default".to_string(), "".to_string()); + } + + // Store printer list as JSON string for ribbon to parse + let printers_json = serde_json::to_string(&self.printers).unwrap_or_default(); + ribbon_ui + .search_texts + .insert("_printers_list".to_string(), printers_json); + } + + fn render_table_with_events(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + let printers_clone = self.printers.clone(); + let prepared_data = self.table_renderer.prepare_json_data(&printers_clone); + + let mut deferred_actions: Vec = Vec::new(); + let mut temp_handler = TempPrintersEventHandler { + api_client, + deferred_actions: &mut deferred_actions, + }; + + self.table_renderer + .render_json_table(ui, &prepared_data, Some(&mut temp_handler)); + + self.process_temp_deferred_actions(deferred_actions, api_client); + } + + fn process_temp_deferred_actions( + &mut self, + actions: Vec, + _api_client: Option<&ApiClient>, + ) { + for action in actions { + match action { + DeferredAction::DoubleClick(printer) => { + log::info!( + "Processing double-click edit for printer: {:?}", + printer.get("printer_name") + ); + self.edit_dialog.open(&printer); + if let Some(id) = printer.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + DeferredAction::ContextEdit(printer) => { + log::info!( + "Processing context menu edit for printer: {:?}", + printer.get("printer_name") + ); + self.edit_dialog.open(&printer); + if let Some(id) = printer.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + DeferredAction::ContextDelete(printer) => { + let name = printer + .get("printer_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + let id = printer.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); + log::info!("Processing context menu delete for printer: {}", name); + self.pending_delete_id = Some(id); + self.delete_dialog.open(name.to_string(), id.to_string()); + } + DeferredAction::ContextClone(printer) => { + log::info!( + "Processing context menu clone for printer: {:?}", + printer.get("printer_name") + ); + let mut cloned = crate::core::components::prepare_cloned_value( + &printer, + &["id"], + Some("printer_name"), + Some(""), + ); + if let Some(obj) = cloned.as_object_mut() { + if let Some(ps) = obj.get("printer_settings") { + let as_str = if ps.is_string() { + ps.as_str().unwrap_or("{}").to_string() + } else { + serde_json::to_string_pretty(ps) + .unwrap_or_else(|_| "{}".to_string()) + }; + obj.insert( + "printer_settings".to_string(), + serde_json::Value::String(as_str), + ); + } + self.add_dialog.open_new(Some(obj)); + } + } + } + } + } + + fn handle_dialogs(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) { + // BEFORE showing add dialog, check if printer_plugin changed and auto-populate printer_settings + if self.add_dialog.show { + let current_plugin = self + .add_dialog + .data + .get("printer_plugin") + .map(|s| s.clone()); + + // Detect if plugin changed to "System" + if current_plugin != self.last_add_dialog_plugin { + if let Some(ref plugin) = current_plugin { + let template = match plugin.as_str() { + "System" => Some(SYSTEM_PRINTER_SETTINGS_TEMPLATE), + "PDF" => Some(PDF_PRINTER_SETTINGS_TEMPLATE), + _ => None, + }; + + if let Some(template) = template { + let current_settings = self + .add_dialog + .data + .get("printer_settings") + .map(|s| s.as_str()) + .unwrap_or("{}"); + + if current_settings.trim().is_empty() || current_settings.trim() == "{}" { + self.add_dialog + .data + .insert("printer_settings".to_string(), template.to_string()); + } + } + } + self.last_add_dialog_plugin = current_plugin.clone(); + } + + Self::apply_plugin_help(&mut self.add_dialog, current_plugin.as_deref()); + } else { + // Reset tracking when dialog closes + self.last_add_dialog_plugin = None; + self.add_dialog.form_help_text = None; + } + + if self.edit_dialog.show { + let edit_plugin = self + .edit_dialog + .data + .get("printer_plugin") + .map(|s| s.clone()); + Self::apply_plugin_help(&mut self.edit_dialog, edit_plugin.as_deref()); + } else { + self.edit_dialog.form_help_text = None; + } + + // Delete confirmation dialog + if let Some(confirmed) = self.delete_dialog.show_dialog(ui.ctx()) { + if confirmed { + if let (Some(id), Some(client)) = (self.pending_delete_id, api_client) { + let where_clause = serde_json::json!({"id": id}); + match client.delete("printer_settings", where_clause) { + Ok(_) => { + log::info!("Printer {} deleted successfully", id); + self.load_printers(client); + } + Err(e) => { + self.last_error = Some(format!("Failed to delete printer: {}", e)); + log::error!("Failed to delete printer: {}", e); + } + } + } + self.pending_delete_id = None; + } + } + + // Edit dialog + if let Some(Some(updated)) = self.edit_dialog.show_editor(ui.ctx()) { + if let (Some(id), Some(client)) = (self.pending_edit_id, api_client) { + let where_clause = serde_json::json!({"id": id}); + // Ensure printer_settings field is valid JSON and send as JSON object + let mut to_update = updated; + // Remove generic editor metadata keys (avoid backend invalid column errors) + let mut meta_keys: Vec = to_update + .keys() + .filter(|k| k.starts_with("__editor_")) + .cloned() + .collect(); + // Also remove __editor_item_id specifically + if to_update.contains_key("__editor_item_id") { + meta_keys.push("__editor_item_id".to_string()); + } + for k in meta_keys { + to_update.remove(&k); + } + if let Some(val) = to_update.get_mut("printer_settings") { + if let Some(s) = val.as_str() { + match serde_json::from_str::(s) { + Ok(json_val) => { + // Send as actual JSON object, not base64 string + *val = json_val; + } + Err(e) => { + self.last_error = + Some(format!("Printer Settings JSON is invalid: {}", e)); + return; + } + } + } + } + match client.update( + "printer_settings", + serde_json::Value::Object(to_update.clone()), + where_clause, + ) { + Ok(resp) => { + if resp.success { + log::info!("Printer {} updated successfully", id); + self.load_printers(client); + } else { + self.last_error = Some(format!("Update failed: {:?}", resp.error)); + log::error!("Update failed: {:?}", resp.error); + } + } + Err(e) => { + self.last_error = Some(format!("Failed to update printer: {}", e)); + log::error!("Failed to update printer: {}", e); + } + } + self.pending_edit_id = None; + } + } + + // Add dialog + if let Some(Some(new_data)) = self.add_dialog.show_editor(ui.ctx()) { + if let Some(client) = api_client { + // Parse printer_settings JSON and send as JSON object + let mut payload = new_data; + // Strip any editor metadata that may have leaked in + let meta_strip: Vec = payload + .keys() + .filter(|k| k.starts_with("__editor_")) + .cloned() + .collect(); + for k in meta_strip { + payload.remove(&k); + } + if let Some(val) = payload.get_mut("printer_settings") { + if let Some(s) = val.as_str() { + match serde_json::from_str::(s) { + Ok(json_val) => { + // Send as actual JSON object, not base64 string + *val = json_val; + } + Err(e) => { + self.last_error = + Some(format!("Printer Settings JSON is invalid: {}", e)); + return; + } + } + } + } + match client.insert( + "printer_settings", + serde_json::Value::Object(payload.clone()), + ) { + Ok(resp) => { + if resp.success { + log::info!("Printer added successfully"); + self.load_printers(client); + } else { + self.last_error = Some(format!("Insert failed: {:?}", resp.error)); + log::error!("Insert failed: {:?}", resp.error); + } + } + Err(e) => { + self.last_error = Some(format!("Failed to add printer: {}", e)); + log::error!("Failed to add printer: {}", e); + } + } + } + } + } + + fn process_deferred_actions(&mut self, ui: &mut egui::Ui, _api_client: Option<&ApiClient>) { + // Handle double-click edit + if let Some(printer) = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("printer_double_click_edit"))) + { + log::info!( + "Processing double-click edit for printer: {:?}", + printer.get("printer_name") + ); + self.edit_dialog.open(&printer); + if let Some(id) = printer.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + + // Handle context menu actions + if let Some(printer) = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("printer_context_menu_edit"))) + { + log::info!( + "Processing context menu edit for printer: {:?}", + printer.get("printer_name") + ); + self.edit_dialog.open(&printer); + if let Some(id) = printer.get("id").and_then(|v| v.as_i64()) { + self.pending_edit_id = Some(id); + } + } + + if let Some(printer) = ui + .ctx() + .data_mut(|d| d.remove_temp::(egui::Id::new("printer_context_menu_delete"))) + { + let name = printer + .get("printer_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + let id = printer.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); + log::info!("Processing context menu delete for printer: {}", name); + self.pending_delete_id = Some(id); + self.delete_dialog.open(name.to_string(), id.to_string()); + } + } +} + +impl Default for PrintersView { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone)] +enum DeferredAction { + DoubleClick(Value), + ContextEdit(Value), + ContextDelete(Value), + ContextClone(Value), +} + +// Temporary event handler that collects actions for later processing +struct TempPrintersEventHandler<'a> { + #[allow(dead_code)] + api_client: Option<&'a ApiClient>, + deferred_actions: &'a mut Vec, +} + +impl<'a> TableEventHandler for TempPrintersEventHandler<'a> { + fn on_double_click(&mut self, item: &Value, _row_index: usize) { + log::info!( + "Double-click detected on printer: {:?}", + item.get("printer_name") + ); + self.deferred_actions + .push(DeferredAction::DoubleClick(item.clone())); + } + + fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) { + if ui + .button(format!("{} Edit Printer", egui_phosphor::regular::PENCIL)) + .clicked() + { + log::info!( + "Context menu edit clicked for printer: {:?}", + item.get("printer_name") + ); + self.deferred_actions + .push(DeferredAction::ContextEdit(item.clone())); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Clone Printer", egui_phosphor::regular::COPY)) + .clicked() + { + log::info!( + "Context menu clone clicked for printer: {:?}", + item.get("printer_name") + ); + self.deferred_actions + .push(DeferredAction::ContextClone(item.clone())); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Delete Printer", egui_phosphor::regular::TRASH)) + .clicked() + { + log::info!( + "Context menu delete clicked for printer: {:?}", + item.get("printer_name") + ); + self.deferred_actions + .push(DeferredAction::ContextDelete(item.clone())); + ui.close(); + } + } + + fn on_selection_changed(&mut self, selected_indices: &[usize]) { + log::debug!("Printer selection changed: {:?}", selected_indices); + } +} diff --git a/src/ui/ribbon.rs b/src/ui/ribbon.rs new file mode 100644 index 0000000..0da355f --- /dev/null +++ b/src/ui/ribbon.rs @@ -0,0 +1,1056 @@ +use crate::core::components::filter_builder::FilterBuilder; +use eframe::egui; +use egui_phosphor::variants::regular as icons; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +pub struct RibbonUI { + pub active_tab: String, + pub search_texts: HashMap, + pub checkboxes: HashMap, + pub number_fields: HashMap, + pub filter_builder: FilterBuilder, +} + +impl Default for RibbonUI { + fn default() -> Self { + let mut number_fields = HashMap::new(); + number_fields.insert("inventory_limit".to_string(), 100); + number_fields.insert("templates_limit".to_string(), 200); + + Self { + active_tab: "Dashboard".to_string(), + search_texts: HashMap::new(), + checkboxes: HashMap::new(), + number_fields, + filter_builder: FilterBuilder::new(), + } + } +} + +impl RibbonUI { + pub fn preferred_height(&self) -> f32 { + 135.0 + } + + pub fn get_active_view(&self) -> Option { + Some(self.active_tab.clone()) + } + + pub fn show(&mut self, _ctx: &egui::Context, ui: &mut egui::Ui) -> Option { + // Clear one-shot trigger flags from previous frame so clicks only fire once + // NOTE: inventory_filter_changed and templates_filter_changed are NOT cleared here - they're cleared by their views after processing + for key in [ + "item_lookup_trigger", + "inventory_limit_refresh_trigger", + // Inventory Actions + "inventory_action_add", + "inventory_action_delete", + "inventory_action_edit_easy", + "inventory_action_edit_adv", + "inventory_action_print_label", + // Inventory Quick Actions + "inventory_quick_inventarize_room", + "inventory_quick_add_multiple_from_template", + // Templates + "templates_limit_changed", + "templates_action_new", + "templates_action_edit", + "templates_action_delete", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.vertical(|ui| { + // Tab headers row + ui.horizontal(|ui| { + let tabs = vec![ + "Dashboard", + "Inventory", + "Categories", + "Zones", + "Borrowing", + "Audits", + "Suppliers", + "Issues", + "Printers", + "Label Templates", + "Item Templates", + ]; + for tab in &tabs { + if ui.selectable_label(self.active_tab == *tab, *tab).clicked() { + self.active_tab = tab.to_string(); + // Update filter columns based on the active tab + match *tab { + "Zones" => self.filter_builder.set_columns_for_context("zones"), + "Inventory" => self.filter_builder.set_columns_for_context("assets"), + _ => {} + } + } + } + }); + + ui.separator(); + + // Content area with fixed height I dont even know what this here fucking does tbh + let ribbon_height = 90.0; + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), ribbon_height), + egui::Layout::left_to_right(egui::Align::Min), + |ui| { + egui::ScrollArea::horizontal().show(ui, |ui| { + ui.horizontal(|ui| match self.active_tab.as_str() { + "Dashboard" => self.show_dashboard_tab(ui, ribbon_height), + "Inventory" => self.show_inventory_tab(ui, ribbon_height), + "Categories" => self.show_categories_tab(ui, ribbon_height), + "Zones" => self.show_zones_tab(ui, ribbon_height), + "Borrowing" => self.show_borrowing_tab(ui, ribbon_height), + "Audits" => self.show_audits_tab(ui, ribbon_height), + "Item Templates" => self.show_templates_tab(ui, ribbon_height), + "Suppliers" => self.show_suppliers_tab(ui, ribbon_height), + "Issues" => self.show_issues_tab(ui, ribbon_height), + "Printers" => self.show_printers_tab(ui, ribbon_height), + "Label Templates" => self.show_label_templates_tab(ui, ribbon_height), + _ => {} + }); + }); + }, + ); + }); + None + } + + /// Render a 2-row grid of actions with consistent column widths and set checkbox triggers by key. + fn render_actions_grid_with_keys( + &mut self, + ui: &mut egui::Ui, + grid_id: &str, + items: &[(String, &str)], + ) { + let rows: usize = 2; + if items.is_empty() { + return; + } + let cols: usize = (items.len() + rows - 1) / rows; + + let pad_x = ui.style().spacing.button_padding.x; + let row_height: f32 = 24.0; + let mut col_widths = vec![0.0f32; cols]; + for col in 0..cols { + for row in 0..rows { + let idx = col * rows + row; + if idx < items.len() { + let text = &items[idx].0; + let text_width = 8.0 * text.len() as f32; + col_widths[col] = col_widths[col].max(text_width + pad_x * 2.0); + } + } + } + + egui::Grid::new(grid_id) + .num_columns(cols) + .spacing(egui::vec2(8.0, 8.0)) + .show(ui, |ui| { + for row in 0..rows { + for col in 0..cols { + let idx = col * rows + row; + if idx < items.len() { + let (label, key) = &items[idx]; + let w = col_widths[col]; + if ui + .add_sized([w, row_height], egui::Button::new(label.clone())) + .clicked() + { + self.checkboxes.insert(key.to_string(), true); + } + } else { + ui.allocate_space(egui::vec2(col_widths[col], row_height)); + } + } + ui.end_row(); + } + }); + } + + fn show_dashboard_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("View"); + egui::ScrollArea::vertical() + .id_salt("dashboard_view_scroll") + .show(ui, |ui| if ui.button("Refresh").clicked() {}); + }); + }); + // Stats section removed for now + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Widgets"); + egui::ScrollArea::vertical() + .id_salt("dashboard_widgets_scroll") + .show(ui, |ui| { + ui.checkbox( + self.checkboxes + .entry("show_pie".to_string()) + .or_insert(true), + "Show Pie Chart", + ); + ui.checkbox( + self.checkboxes + .entry("show_timeline".to_string()) + .or_insert(true), + "Show Timeline", + ); + }); + }); + }); + } + + fn show_inventory_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + // 2x2 grid: Add, Edit, Remove, Print Label (Edit supports Alt for Advanced) + let labels = [ + &format!("{} {}", icons::PLUS, "Add Item"), + &format!("{} {}", icons::PENCIL, "Edit"), + &format!("{} {}", icons::TRASH, "Remove"), + &format!("{} {}", icons::PRINTER, "Print Label"), + ]; // 4 labels -> 2x2 grid + let rows: usize = 2; + let cols: usize = 2; + let row_height: f32 = 24.0; + let pad_x = ui.style().spacing.button_padding.x; + // Compute column widths based on text + let mut col_widths = vec![0.0f32; cols]; + for col in 0..cols { + for row in 0..rows { + let idx = col * rows + row; + let text = labels[idx]; + let text_width = 8.0 * text.len() as f32; + col_widths[col] = col_widths[col].max(text_width + pad_x * 2.0); + } + } + egui::Grid::new("inventory_actions_grid") + .num_columns(cols) + .spacing(egui::vec2(8.0, 8.0)) + .show(ui, |ui| { + for row in 0..rows { + for col in 0..cols { + let idx = col * rows + row; + let w = col_widths[col]; + let button = egui::Button::new(labels[idx]); + let clicked = ui.add_sized([w, row_height], button).clicked(); + if clicked { + // If user holds Alt while clicking Edit, trigger Advanced Edit instead of Easy + let alt_held = ui.input(|i| i.modifiers.alt); + match idx { + 0 => { + self.checkboxes + .insert("inventory_action_add".to_string(), true); + } + 1 => { + if alt_held { + self.checkboxes.insert( + "inventory_action_edit_adv".to_string(), + true, + ); + } else { + self.checkboxes.insert( + "inventory_action_edit_easy".to_string(), + true, + ); + } + } + 2 => { + self.checkboxes.insert( + "inventory_action_delete".to_string(), + true, + ); + } + 3 => { + self.checkboxes.insert( + "inventory_action_print_label".to_string(), + true, + ); + } + _ => {} + } + } + } + ui.end_row(); + } + }); + }); + }); + // Import/Export hidden for now (planned-only) + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.set_width(200.0); // Reduced width for compact design + ui.vertical(|ui| { + ui.label("Filter"); + egui::ScrollArea::vertical() + .id_salt("inventory_filter_scroll") + .show(ui, |ui| { + // Compact FilterBuilder with popup + let filter_changed = self.filter_builder.show_compact(ui); + if filter_changed { + // Set trigger for filter application + self.checkboxes + .insert("inventory_filter_changed".to_string(), true); + } + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("View"); + egui::ScrollArea::vertical() + .id_salt("inventory_view_scroll") + .show(ui, |ui| { + let old_show_retired = + self.checkboxes.get("show_retired").copied().unwrap_or(true); + let mut show_retired = old_show_retired; + ui.checkbox(&mut show_retired, "Show Retired"); + if show_retired != old_show_retired { + self.checkboxes + .insert("show_retired".to_string(), show_retired); + self.checkboxes + .insert("inventory_filter_changed".to_string(), true); + } + if ui.button("❓ Help").clicked() { + self.checkboxes + .insert("inventory_show_help".to_string(), true); + } + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Quick Actions"); + egui::ScrollArea::vertical() + .id_salt("inventory_quick_actions_scroll") + .show(ui, |ui| { + // Ensure both controls share the same visual width while stacking vertically + let pad_x = ui.style().spacing.button_padding.x; + let w1 = 8.0 * "Inventarize Room".len() as f32 + pad_x * 2.0; + let w2 = 8.0 * "Add from...".len() as f32 + pad_x * 2.0; + let w = w1.max(w2).max(130.0); + + if ui + .add_sized([w, 24.0], egui::Button::new("Inventarize Room")) + .clicked() + { + self.checkboxes + .insert("inventory_quick_inventarize_room".to_string(), true); + } + + // Use a fixed-width ComboBox as a dropdown to ensure equal width + egui::ComboBox::from_id_salt("inventory_add_from_combo") + .width(w) + .selected_text("Add from...") + .show_ui(ui, |ui| { + if ui.selectable_label(false, "Add from Template").clicked() { + self.checkboxes.insert( + "inventory_add_from_template_single".to_string(), + true, + ); + } + if ui + .selectable_label(false, "Add Multiple from Template") + .clicked() + { + self.checkboxes.insert( + "inventory_add_from_template_multiple".to_string(), + true, + ); + } + if ui + .selectable_label(false, "Add using Another Item") + .clicked() + { + self.checkboxes + .insert("inventory_add_from_item_single".to_string(), true); + } + if ui + .selectable_label(false, "Add Multiple from Another Item") + .clicked() + { + self.checkboxes.insert( + "inventory_add_from_item_multiple".to_string(), + true, + ); + } + }); + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Query"); + egui::ScrollArea::vertical() + .id_salt("inventory_query_scroll") + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Limit:"); + let limit = self + .number_fields + .entry("inventory_limit".to_string()) + .or_insert(100); + let mut limit_str = limit.to_string(); + let response = ui.add_sized( + [60.0, 20.0], + egui::TextEdit::singleline(&mut limit_str), + ); + + // Trigger refresh on Enter or when value changes + let enter_pressed = response.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if response.changed() || enter_pressed { + if let Ok(val) = limit_str.parse::() { + *limit = val; + if enter_pressed { + // Set trigger for inventory refresh + let trigger = self + .checkboxes + .entry("inventory_limit_refresh_trigger".to_string()) + .or_insert(false); + *trigger = true; + } + } + } + }); + + let no_limit_changed = ui + .checkbox( + self.checkboxes + .entry("inventory_no_limit".to_string()) + .or_insert(false), + "Maximum", + ) + .changed(); + if no_limit_changed { + // Trigger refresh when no limit checkbox changes + let trigger = self + .checkboxes + .entry("inventory_limit_refresh_trigger".to_string()) + .or_insert(false); + *trigger = true; + } + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Lookup"); + egui::ScrollArea::vertical() + .id_salt("inventory_lookup_scroll") + .show(ui, |ui| { + ui.label("Item Lookup:"); + ui.horizontal(|ui| { + let lookup_text = self + .search_texts + .entry("item_lookup".to_string()) + .or_insert_with(String::new); + let response = ui.add_sized( + [120.0, 30.0], + egui::TextEdit::singleline(lookup_text).hint_text("Tag or ID"), + ); + + // Trigger search on Enter key + let enter_pressed = response.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)); + + if ui.button("Search").clicked() || enter_pressed { + // Set trigger flag for inventory view to detect + let trigger = self + .checkboxes + .entry("item_lookup_trigger".to_string()) + .or_insert(false); + *trigger = true; + } + }); + }); + }); + }); + } + + fn show_categories_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + // Clear one-shot action triggers from previous frame + for key in [ + "categories_refresh", + "categories_add", + "categories_edit", + "categories_delete", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + // Match Inventory layout: [Add, Edit, Delete, Refresh] (Refresh where Inventory has Print) + let items = vec![ + ( + format!("{} {}", icons::PLUS, "Add Category"), + "categories_add", + ), + ( + format!("{} {}", icons::PENCIL, "Edit Category"), + "categories_edit", + ), + ( + format!("{} {}", icons::TRASH, "Delete Category"), + "categories_delete", + ), + ( + format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"), + "categories_refresh", + ), + ]; + self.render_actions_grid_with_keys(ui, "categories_actions_grid", &items); + }); + }); + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Search"); + egui::ScrollArea::vertical() + .id_salt("categories_filter_scroll") + .show(ui, |ui| { + let search_text = self + .search_texts + .entry("categories_search".to_string()) + .or_insert_with(String::new); + ui.text_edit_singleline(search_text); + }); + }); + }); + } + + fn show_zones_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + // Clear one-shot action triggers from previous frame + for key in [ + "zones_action_add", + "zones_action_edit", + "zones_action_remove", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ( + format!("{} {}", icons::PLUS, "Add Zone"), + "zones_action_add", + ), + ( + format!("{} {}", icons::PENCIL, "Edit Zone"), + "zones_action_edit", + ), + ]; + self.render_actions_grid_with_keys(ui, "zones_actions_grid", &items); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.set_width(200.0); // Reduced width for compact design + ui.vertical(|ui| { + ui.label("Filter"); + egui::ScrollArea::vertical() + .id_salt("zones_filter_scroll") + .show(ui, |ui| { + // Compact FilterBuilder with popup (same as Inventory) + let filter_changed = self.filter_builder.show_compact(ui); + if filter_changed { + // Set trigger for filter application + self.checkboxes + .insert("zones_filter_changed".to_string(), true); + } + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("View"); + egui::ScrollArea::vertical() + .id_salt("zones_view_scroll") + .show(ui, |ui| { + // Use new key `zones_show_empty` but mirror to legacy key for compatibility + let show_empty = self + .checkboxes + .get("zones_show_empty") + .copied() + .unwrap_or(true); + let mut local = show_empty; + if ui.checkbox(&mut local, "Show Empty").changed() { + self.checkboxes + .insert("zones_show_empty".to_string(), local); + self.checkboxes + .insert("show_empty_zones".to_string(), local); + } + ui.checkbox( + self.checkboxes + .entry("zones_show_items".to_string()) + .or_insert(true), + "Show Items in Zones", + ); + }); + }); + }); + } + + fn show_borrowing_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + // Clear one-shot action triggers from previous frame + for key in [ + "borrowing_action_checkout", + "borrowing_action_return", + "borrowing_action_register", + "borrowing_action_refresh", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ( + format!("{} {}", icons::ARROW_LEFT, "Check Out"), + "borrowing_action_checkout", + ), + ( + format!("{} {}", icons::ARROW_RIGHT, "Return"), + "borrowing_action_return", + ), + ( + format!("{} {}", icons::PENCIL, "Register"), + "borrowing_action_register", + ), + ( + format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"), + "borrowing_action_refresh", + ), + ]; + self.render_actions_grid_with_keys(ui, "borrowing_actions_grid", &items); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.set_width(200.0); + ui.vertical(|ui| { + ui.label("Filter"); + egui::ScrollArea::vertical() + .id_salt("borrowing_filter_scroll") + .show(ui, |ui| { + // Compact FilterBuilder with popup + let filter_changed = self.filter_builder.show_compact(ui); + if filter_changed { + // Set trigger for filter application + self.checkboxes + .insert("borrowing_filter_changed".to_string(), true); + } + }); + }); + }); + } + + fn show_audits_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ("New Audit".to_string(), "audits_action_new"), + ("View Audit".to_string(), "audits_action_view"), + ("Export Report".to_string(), "audits_action_export"), + ]; + self.render_actions_grid_with_keys(ui, "audits_actions_grid", &items); + }); + }); + } + + fn show_templates_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + // Clear one-shot action triggers from previous frame + for key in [ + "templates_action_new", + "templates_action_edit", + "templates_action_delete", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ( + format!("{} {}", icons::PLUS, "Add Template"), + "templates_action_new", + ), + ( + format!("{} {}", icons::PENCIL, "Edit Template"), + "templates_action_edit", + ), + ]; + self.render_actions_grid_with_keys(ui, "templates_actions_grid", &items); + }); + }); + // Import/Export removed for now + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.set_width(200.0); // Reduced width for compact design + ui.vertical(|ui| { + ui.label("Filter"); + egui::ScrollArea::vertical() + .id_salt("templates_filter_scroll") + .show(ui, |ui| { + // Compact FilterBuilder with popup + let filter_changed = self.filter_builder.show_compact(ui); + if filter_changed { + // Set trigger for filter application + self.checkboxes + .insert("templates_filter_changed".to_string(), true); + } + }); + }); + }); + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Query"); + egui::ScrollArea::vertical() + .id_salt("templates_query_scroll") + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Limit:"); + let limit = self + .number_fields + .entry("templates_limit".to_string()) + .or_insert(200); + let mut limit_str = limit.to_string(); + let response = ui.add_sized( + [60.0, 20.0], + egui::TextEdit::singleline(&mut limit_str), + ); + + // Trigger refresh on Enter or when value changes + let enter_pressed = response.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if response.changed() || enter_pressed { + if let Ok(val) = limit_str.parse::() { + *limit = val; + self.checkboxes + .insert("templates_limit_changed".to_string(), true); + } + } + }); + }); + }); + }); + } + + fn show_issues_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ("Report Issue".to_string(), "issues_action_report"), + ("View Issue".to_string(), "issues_action_view"), + ("Resolve Issue".to_string(), "issues_action_resolve"), + ]; + self.render_actions_grid_with_keys(ui, "issues_actions_grid", &items); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Filter"); + egui::ScrollArea::vertical() + .id_salt("issues_filter_scroll") + .show(ui, |ui| { + let search_text = self + .search_texts + .entry("issue_search".to_string()) + .or_insert_with(String::new); + ui.text_edit_singleline(search_text); + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("View"); + egui::ScrollArea::vertical() + .id_salt("issues_view_scroll") + .show(ui, |ui| { + ui.checkbox( + self.checkboxes + .entry("show_resolved".to_string()) + .or_insert(false), + "Show Resolved", + ); + ui.checkbox( + self.checkboxes + .entry("show_high_priority".to_string()) + .or_insert(true), + "High Priority Only", + ); + }); + }); + }); + } + + fn show_suppliers_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + for key in [ + "suppliers_action_new", + "suppliers_action_edit", + "suppliers_action_delete", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ( + format!("{} {}", icons::PLUS, "New Supplier"), + "suppliers_action_new", + ), + ( + format!("{} {}", icons::PENCIL, "Edit Supplier"), + "suppliers_action_edit", + ), + ]; + self.render_actions_grid_with_keys(ui, "suppliers_actions_grid", &items); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Search"); + egui::ScrollArea::vertical() + .id_salt("suppliers_filter_scroll") + .show(ui, |ui| { + let search_text = self + .search_texts + .entry("supplier_search".to_string()) + .or_insert_with(String::new); + ui.text_edit_singleline(search_text); + }); + }); + }); + } + + pub fn show_printers_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + // Clear one-shot action triggers from previous frame + for key in [ + "printers_action_add", + "printers_action_refresh", + "printers_view_print_history", + "printers_default_changed", + ] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ( + format!("{} {}", icons::PLUS, "Add Printer"), + "printers_action_add", + ), + ( + format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"), + "printers_action_refresh", + ), + ]; + self.render_actions_grid_with_keys(ui, "printers_actions_grid", &items); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Settings"); + egui::ScrollArea::vertical() + .id_salt("printers_settings_scroll") + .show(ui, |ui| { + ui.label("Default Printer:"); + + // Get printer data injected by PrintersView + let printers_json = self + .search_texts + .get("_printers_list") + .cloned() + .unwrap_or_default(); + let current_default_str = self + .search_texts + .get("_printers_current_default") + .cloned() + .unwrap_or_default(); + let current_default = if current_default_str.is_empty() { + None + } else { + current_default_str.parse::().ok() + }; + + if printers_json.is_empty() { + ui.label("(Loading...)"); + } else { + // Parse printer list + let printers: Vec = + serde_json::from_str(&printers_json).unwrap_or_default(); + + let current_name = if let Some(id) = current_default { + printers + .iter() + .find(|p| p.get("id").and_then(|v| v.as_i64()) == Some(id)) + .and_then(|p| p.get("printer_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string() + } else { + "None".to_string() + }; + + egui::ComboBox::from_id_salt("ribbon_default_printer_selector") + .selected_text(¤t_name) + .show_ui(ui, |ui| { + // None option + if ui + .selectable_label(current_default.is_none(), "None") + .clicked() + { + self.search_texts.insert( + "printers_default_id".to_string(), + "".to_string(), + ); + self.checkboxes + .insert("printers_default_changed".to_string(), true); + } + + // Printer options + for printer in &printers { + if let (Some(id), Some(name)) = ( + printer.get("id").and_then(|v| v.as_i64()), + printer.get("printer_name").and_then(|v| v.as_str()), + ) { + let is_selected = current_default == Some(id); + if ui.selectable_label(is_selected, name).clicked() { + self.search_texts.insert( + "printers_default_id".to_string(), + id.to_string(), + ); + self.checkboxes.insert( + "printers_default_changed".to_string(), + true, + ); + } + } + } + }); + } + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("View"); + egui::ScrollArea::vertical() + .id_salt("printers_view_scroll") + .show(ui, |ui| { + if ui + .button(format!( + "{} {}", + icons::ARROWS_CLOCKWISE, + "View Print History" + )) + .clicked() + { + self.checkboxes + .insert("printers_view_print_history".to_string(), true); + } + }); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Search"); + egui::ScrollArea::vertical() + .id_salt("printers_search_scroll") + .show(ui, |ui| { + let search_text = self + .search_texts + .entry("printers_search".to_string()) + .or_insert_with(String::new); + ui.text_edit_singleline(search_text); + }); + }); + }); + } + + fn show_label_templates_tab(&mut self, ui: &mut egui::Ui, ribbon_height: f32) { + // Clear one-shot action triggers from previous frame + for key in ["labels_action_add", "labels_action_refresh"] { + self.checkboxes.insert(key.to_string(), false); + } + + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Actions"); + let items = vec![ + ( + format!("{} {}", icons::PLUS, "Add Template"), + "labels_action_add", + ), + ( + format!("{} {}", icons::ARROWS_CLOCKWISE, "Refresh"), + "labels_action_refresh", + ), + ]; + self.render_actions_grid_with_keys(ui, "labels_actions_grid", &items); + }); + }); + ui.group(|ui| { + ui.set_height(ribbon_height); + ui.vertical(|ui| { + ui.label("Search"); + egui::ScrollArea::vertical() + .id_salt("labels_search_scroll") + .show(ui, |ui| { + let search_text = self + .search_texts + .entry("labels_search".to_string()) + .or_insert_with(String::new); + ui.text_edit_singleline(search_text); + }); + }); + }); + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RibbonConfig {} diff --git a/src/ui/suppliers.rs b/src/ui/suppliers.rs new file mode 100644 index 0000000..ce7679f --- /dev/null +++ b/src/ui/suppliers.rs @@ -0,0 +1,802 @@ +use eframe::egui; +use std::collections::HashSet; + +use crate::api::ApiClient; +use crate::core::components::form_builder::FormBuilder; +use crate::core::components::interactions::ConfirmDialog; +use crate::core::counters::count_entities; +use crate::core::tables::get_suppliers; +use crate::core::{EditorField, FieldType}; +use crate::ui::ribbon::RibbonUI; + +#[derive(Clone)] +struct ColumnConfig { + name: String, + field: String, + visible: bool, +} + +pub struct SuppliersView { + rows: Vec, + display_rows: Vec, + is_loading: bool, + last_error: Option, + init_loaded: bool, + // Columns & selector + columns: Vec, + show_column_panel: bool, + // Selection & interactions + selected_row: Option, + last_click_time: Option, + last_click_row: Option, + selected_rows: HashSet, + selection_anchor: Option, + // Dialogs + delete_dialog: ConfirmDialog, + edit_dialog: FormBuilder, + add_dialog: FormBuilder, + // Track ids for operations + edit_current_id: Option, + delete_current_id: Option, +} + +impl SuppliersView { + pub fn new() -> Self { + let columns = vec![ + ColumnConfig { + name: "Name".into(), + field: "name".into(), + visible: true, + }, + ColumnConfig { + name: "Contact".into(), + field: "contact".into(), + visible: true, + }, + ColumnConfig { + name: "Email".into(), + field: "email".into(), + visible: true, + }, + ColumnConfig { + name: "Phone".into(), + field: "phone".into(), + visible: true, + }, + ColumnConfig { + name: "Website".into(), + field: "website".into(), + visible: true, + }, + ColumnConfig { + name: "Items".into(), + field: "items_count".into(), + visible: true, + }, + // Hidden by default + ColumnConfig { + name: "Notes".into(), + field: "notes".into(), + visible: false, + }, + ColumnConfig { + name: "Created".into(), + field: "created_at".into(), + visible: false, + }, + ColumnConfig { + name: "ID".into(), + field: "id".into(), + visible: false, + }, + ]; + Self { + rows: vec![], + display_rows: vec![], + is_loading: false, + last_error: None, + init_loaded: false, + 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 Supplier", + "Are you sure you want to delete this supplier?", + ), + edit_dialog: FormBuilder::new( + "Edit Supplier", + vec![ + EditorField { + name: "name".into(), + label: "Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "contact".into(), + label: "Contact".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "email".into(), + label: "Email".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "phone".into(), + label: "Phone".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "website".into(), + label: "Website".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "notes".into(), + label: "Notes".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ], + ), + add_dialog: FormBuilder::new( + "Add Supplier", + vec![ + EditorField { + name: "name".into(), + label: "Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "contact".into(), + label: "Contact".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "email".into(), + label: "Email".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "phone".into(), + label: "Phone".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "website".into(), + label: "Website".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "notes".into(), + label: "Notes".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_suppliers(api, Some(200)) { + Ok(mut list) => { + // Compute items_count per supplier + for row in &mut list { + if let Some(id) = row.get("id").and_then(|v| v.as_i64()) { + let where_clause = serde_json::json!({"supplier_id": id}); + let count = count_entities(api, "assets", Some(where_clause)).unwrap_or(0); + row.as_object_mut().map(|o| { + o.insert("items_count".into(), serde_json::json!(count)); + }); + } + } + self.rows = list; + self.display_rows = self.rows.clone(); + } + Err(e) => self.last_error = Some(e.to_string()), + } + self.is_loading = false; + self.init_loaded = true; + } + + pub fn show( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon: Option<&mut RibbonUI>, + ) -> Vec { + let mut flags_to_clear = Vec::new(); + + // Apply simple search from ribbon (by name/code/contact/email/phone/website) + if let Some(ribbon) = ribbon.as_ref() { + let term = ribbon + .search_texts + .get("supplier_search") + .cloned() + .unwrap_or_default(); + let tl = term.to_lowercase(); + if tl.is_empty() { + self.display_rows = self.rows.clone(); + } else { + self.display_rows = self + .rows + .iter() + .filter(|row| { + let fields = ["name", "contact", "email", "phone", "website"]; + fields.iter().any(|f| { + row.get(*f) + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase().contains(&tl)) + .unwrap_or(false) + }) + }) + .cloned() + .collect(); + } + } + + if let Some(ribbon) = ribbon.as_ref() { + // Handle ribbon actions before rendering + if ribbon + .checkboxes + .get("suppliers_action_new") + .copied() + .unwrap_or(false) + { + self.add_dialog.open_new(None); + flags_to_clear.push("suppliers_action_new".to_string()); + } + + if ribbon + .checkboxes + .get("suppliers_action_edit") + .copied() + .unwrap_or(false) + { + if let Some(selected) = self.first_selected_supplier() { + self.open_editor_with(&selected); + } else { + log::warn!("Ribbon edit triggered but no supplier selected"); + } + flags_to_clear.push("suppliers_action_edit".to_string()); + } + + if ribbon + .checkboxes + .get("suppliers_action_delete") + .copied() + .unwrap_or(false) + { + if let Some((name, id)) = self.first_selected_supplier_id_name() { + self.delete_current_id = Some(id); + self.delete_dialog.open(name, id.to_string()); + } else { + log::warn!("Ribbon delete triggered but no supplier selected"); + } + flags_to_clear.push("suppliers_action_delete".to_string()); + } + } + + ui.horizontal(|ui| { + ui.heading("Suppliers"); + 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 window + if self.show_column_panel { + 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; + } + } + }); + }); + } + + // compute visible columns and render table + let visible_columns: Vec = + self.columns.iter().filter(|c| c.visible).cloned().collect(); + self.render_table(ui, &visible_columns); + + // Process selection and dialog events + let ctx = ui.ctx(); + // selection handled inline via checkbox/row clicks + if let Some(row_idx) = + ctx.data_mut(|d| d.remove_temp::(egui::Id::new("sup_double_click_idx"))) + { + self.selected_row = Some(row_idx); + self.last_click_row = None; + self.last_click_time = None; + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::(egui::Id::new("sup_double_click_edit")) + }) { + self.open_editor_with(&item); + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::(egui::Id::new("sup_context_menu_edit")) + }) { + self.open_editor_with(&item); + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::(egui::Id::new("sup_context_menu_clone")) + }) { + // Prepare cloned payload: clear id, suffix name, keep other fields + let cloned = crate::core::components::prepare_cloned_value( + &item, + &["id"], + Some("name"), + Some(""), + ); + if let Some(obj) = cloned.as_object() { + self.add_dialog.open_new(Some(obj)); + } + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::(egui::Id::new("sup_context_menu_delete")) + }) { + let name = item + .get("name") + .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(name, id.to_string()); + ctx.request_repaint(); + } + + // Handle delete confirmation dialog + 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("suppliers", 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)); + } + } + } + } + } + + // Handle edit dialog save + 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 mut updated = updated; + strip_editor_metadata(&mut updated); + let values = serde_json::Value::Object(updated); + let where_clause = serde_json::json!({"id": id}); + match api.update("suppliers", 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)); + } + } + } + } + } + + // Handle add dialog (clone -> insert) + self.handle_add_dialog(ui.ctx(), api_client); + + flags_to_clear + } + + fn render_table(&mut self, ui: &mut egui::Ui, visible_columns: &Vec) { + use egui_extras::{Column, TableBuilder}; + let mut table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)); + // Add selection checkbox column first + 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 + .display_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.display_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.display_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 column + 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); + } + } + } + }); + let mut combined_cell_response: Option = None; + for col in visible_columns { + row.col(|ui| { + let resp = render_supplier_cell(ui, &r_clone, &col.field); + combined_cell_response = + Some(match combined_cell_response.take() { + Some(prev) => prev.union(resp), + None => resp, + }); + }); + } + let mut row_resp = row.response(); + if let Some(cell_r) = combined_cell_response { + row_resp = row_resp.union(cell_r); + } + if row_resp.clicked() { + let now = std::time::Instant::now(); + let is_double_click = if let (Some(last_time), Some(last_row)) = + (self.last_click_time, self.last_click_row) + { + last_row == idx && now.duration_since(last_time).as_millis() < 500 + } else { + false + }; + if is_double_click { + row_resp.ctx.data_mut(|d| { + d.insert_temp(egui::Id::new("sup_double_click_idx"), idx); + d.insert_temp( + egui::Id::new("sup_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("sup_context_menu_edit"), + r_clone.clone(), + ) + }); + ui.close(); + } + if ui + .button(format!("{} Clone", egui_phosphor::regular::COPY)) + .clicked() + { + ui.ctx().data_mut(|d| { + d.insert_temp( + egui::Id::new("sup_context_menu_clone"), + 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("sup_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 first_selected_supplier(&self) -> Option { + self.first_selected_index() + .and_then(|idx| self.display_rows.get(idx).cloned()) + } + + fn first_selected_supplier_id_name(&self) -> Option<(String, i64)> { + self.first_selected_index().and_then(|idx| { + self.display_rows.get(idx).and_then(|row| { + let name = row + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let id = row.get("id").and_then(|v| v.as_i64())?; + Some((name, id)) + }) + }) + } + + fn first_selected_index(&self) -> Option { + if self.selected_rows.is_empty() { + None + } else { + self.selected_rows.iter().copied().min() + } + } +} + +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_supplier_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(), + ), + "name" => ui.label(t("name")), + "contact" => ui.label(t("contact")), + "email" => ui.label(t("email")), + "phone" => ui.label(t("phone")), + "website" => ui.label(t("website")), + "notes" => label_trunc(ui, t("notes"), 60), + "created_at" => ui.label(t("created_at")), + "items_count" => ui.label( + row.get("items_count") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string(), + ), + other => ui.label(format!("{}", other)), + } +} + +// Handle add dialog save and insert +impl SuppliersView { + fn handle_add_dialog(&mut self, ctx: &egui::Context, api_client: Option<&ApiClient>) { + if let Some(Some(mut new_data)) = self.add_dialog.show_editor(ctx) { + if let Some(api) = api_client { + strip_editor_metadata(&mut new_data); + match api.insert("suppliers", serde_json::Value::Object(new_data)) { + Ok(resp) if resp.success => { + self.load(api); + } + Ok(resp) => { + self.last_error = Some(format!("Insert failed: {:?}", resp.error)); + } + Err(e) => { + self.last_error = Some(format!("Insert error: {}", e)); + } + } + } + } + } +} + +fn strip_editor_metadata(map: &mut serde_json::Map) { + let meta_keys: Vec = map + .keys() + .filter(|k| k.starts_with("__editor_")) + .cloned() + .collect(); + for key in meta_keys { + map.remove(&key); + } +} diff --git a/src/ui/templates.rs b/src/ui/templates.rs new file mode 100644 index 0000000..9fd46a5 --- /dev/null +++ b/src/ui/templates.rs @@ -0,0 +1,1113 @@ +use crate::api::ApiClient; +use crate::core::asset_fields::AssetDropdownOptions; +use crate::core::components::form_builder::FormBuilder; +use crate::core::tables::get_templates; +use crate::core::{ColumnConfig, LoadingState, TableRenderer}; +use crate::core::{EditorField, FieldType}; +use eframe::egui; + +pub struct TemplatesView { + templates: Vec, + loading_state: LoadingState, + table_renderer: TableRenderer, + show_column_panel: bool, + edit_dialog: FormBuilder, + pending_delete_ids: Vec, +} + +impl TemplatesView { + pub fn new() -> Self { + let columns = vec![ + ColumnConfig::new("ID", "id").with_width(60.0).hidden(), + ColumnConfig::new("Template Code", "template_code").with_width(120.0), + ColumnConfig::new("Name", "name").with_width(200.0), + ColumnConfig::new("Asset Type", "asset_type").with_width(80.0), + ColumnConfig::new("Description", "description").with_width(250.0), + ColumnConfig::new("Asset Tag Generation String", "asset_tag_generation_string") + .with_width(200.0), + ColumnConfig::new("Label Template", "label_template_name") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Label Template ID", "label_template_id") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Audit Task", "audit_task_name") + .with_width(140.0) + .hidden(), + ColumnConfig::new("Audit Task ID", "audit_task_id") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Category", "category_name").with_width(120.0), + ColumnConfig::new("Manufacturer", "manufacturer") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Model", "model") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Zone", "zone_name") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Zone Code", "zone_code") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Zone+", "zone_plus") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Zone Note", "zone_note") + .with_width(150.0) + .hidden(), + ColumnConfig::new("Status", "status") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Price", "price") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Purchase Date", "purchase_date") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Purchase Now?", "purchase_date_now") + .with_width(110.0) + .hidden(), + ColumnConfig::new("Warranty Until", "warranty_until") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Warranty Auto?", "warranty_auto") + .with_width(110.0) + .hidden(), + ColumnConfig::new("Warranty Amount", "warranty_auto_amount") + .with_width(110.0) + .hidden(), + ColumnConfig::new("Warranty Unit", "warranty_auto_unit") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Expiry Date", "expiry_date") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Expiry Auto?", "expiry_auto") + .with_width(100.0) + .hidden(), + ColumnConfig::new("Expiry Amount", "expiry_auto_amount") + .with_width(110.0) + .hidden(), + ColumnConfig::new("Expiry Unit", "expiry_auto_unit") + .with_width(90.0) + .hidden(), + ColumnConfig::new("Qty Total", "quantity_total") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Qty Used", "quantity_used") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Supplier", "supplier_name") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Lendable", "lendable") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Lending Status", "lending_status") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Min Role", "minimum_role_for_lending") + .with_width(80.0) + .hidden(), + ColumnConfig::new("No Scan", "no_scan") + .with_width(70.0) + .hidden(), + ColumnConfig::new("Notes", "notes") + .with_width(200.0) + .hidden(), + ColumnConfig::new("Active", "active") + .with_width(70.0) + .hidden(), + ColumnConfig::new("Created Date", "created_at") + .with_width(140.0) + .hidden(), + ]; + Self { + templates: Vec::new(), + loading_state: LoadingState::new(), + table_renderer: TableRenderer::new() + .with_columns(columns) + .with_default_sort("created_at", false), + show_column_panel: false, + edit_dialog: FormBuilder::new("Template Editor", vec![]), + pending_delete_ids: Vec::new(), + } + } + + fn prepare_template_edit_fields(&mut self, api_client: &ApiClient) { + let options = AssetDropdownOptions::new(api_client); + + let fields: Vec = vec![ + // Basic identifiers + EditorField { + name: "template_code".into(), + label: "Template Code".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "name".into(), + label: "Template Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + // Asset tag generation + EditorField { + name: "asset_tag_generation_string".into(), + label: "Asset Tag Generation String".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + // Type / status + EditorField { + name: "asset_type".into(), + label: "Asset Type".into(), + field_type: FieldType::Dropdown({ + let mut asset_type_opts = vec![("".to_string(), "-- None --".to_string())]; + asset_type_opts.extend(options.asset_types.clone()); + asset_type_opts + }), + required: false, + read_only: false, + }, + EditorField { + name: "status".into(), + label: "Default Status".into(), + field_type: FieldType::Dropdown({ + let mut status_opts = vec![("".to_string(), "-- None --".to_string())]; + status_opts.extend(options.status_options.clone()); + status_opts + }), + required: false, + read_only: false, + }, + // Zone and zone-plus + EditorField { + name: "zone_id".into(), + label: "Default Zone".into(), + field_type: FieldType::Dropdown({ + let mut zone_opts = vec![("".to_string(), "-- None --".to_string())]; + zone_opts.extend(options.zone_options.clone()); + zone_opts + }), + required: false, + read_only: false, + }, + EditorField { + name: "zone_plus".into(), + label: "Zone+".into(), + field_type: FieldType::Dropdown({ + let mut zone_plus_opts = vec![("".to_string(), "-- None --".to_string())]; + zone_plus_opts.extend(options.zone_plus_options.clone()); + zone_plus_opts + }), + required: false, + read_only: false, + }, + // No-scan option + EditorField { + name: "no_scan".into(), + label: "No Scan".into(), + field_type: FieldType::Dropdown(options.no_scan_options.clone()), + required: false, + read_only: false, + }, + // Purchase / warranty / expiry + EditorField { + name: "purchase_date".into(), + label: "Purchase Date".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + EditorField { + name: "purchase_date_now".into(), + label: "Use current date (Purchase)".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "warranty_until".into(), + label: "Warranty Until".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + EditorField { + name: "warranty_auto".into(), + label: "Auto-calc Warranty".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "warranty_auto_amount".into(), + label: "Warranty Auto Amount".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "warranty_auto_unit".into(), + label: "Warranty Auto Unit".into(), + field_type: FieldType::Dropdown(vec![ + ("days".to_string(), "Days".to_string()), + ("years".to_string(), "Years".to_string()), + ]), + required: false, + read_only: false, + }, + EditorField { + name: "expiry_date".into(), + label: "Expiry Date".into(), + field_type: FieldType::Date, + required: false, + read_only: false, + }, + EditorField { + name: "expiry_auto".into(), + label: "Auto-calc Expiry".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "expiry_auto_amount".into(), + label: "Expiry Auto Amount".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "expiry_auto_unit".into(), + label: "Expiry Auto Unit".into(), + field_type: FieldType::Dropdown(vec![ + ("days".to_string(), "Days".to_string()), + ("years".to_string(), "Years".to_string()), + ]), + required: false, + read_only: false, + }, + // Financial / lending / supplier + EditorField { + name: "price".into(), + label: "Price".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "lendable".into(), + label: "Lendable".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "lending_status".into(), + label: "Lending Status".into(), + field_type: FieldType::Dropdown({ + let mut lending_status_opts = vec![("".to_string(), "-- None --".to_string())]; + lending_status_opts.extend(options.lending_status_options.clone()); + lending_status_opts + }), + required: false, + read_only: false, + }, + EditorField { + name: "supplier_id".into(), + label: "Supplier".into(), + field_type: FieldType::Dropdown({ + let mut supplier_opts = vec![("".to_string(), "-- None --".to_string())]; + supplier_opts.extend(options.supplier_options.clone()); + supplier_opts + }), + required: false, + read_only: false, + }, + // Label template + EditorField { + name: "label_template_id".into(), + label: "Label Template".into(), + field_type: FieldType::Dropdown({ + let mut label_template_opts = vec![("".to_string(), "-- None --".to_string())]; + label_template_opts.extend(options.label_template_options.clone()); + label_template_opts + }), + required: false, + read_only: false, + }, + EditorField { + name: "audit_task_id".into(), + label: "Default Audit Task".into(), + field_type: FieldType::Dropdown(options.audit_task_options.clone()), + required: false, + read_only: false, + }, + // Defaults for created assets + EditorField { + name: "category_id".into(), + label: "Default Category".into(), + field_type: FieldType::Dropdown({ + let mut category_opts = vec![("".to_string(), "-- None --".to_string())]; + category_opts.extend(options.category_options.clone()); + category_opts + }), + required: false, + read_only: false, + }, + EditorField { + name: "manufacturer".into(), + label: "Default Manufacturer".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "model".into(), + label: "Default Model".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: "additional_fields_json".into(), + label: "Additional Fields (JSON)".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ]; + + self.edit_dialog = FormBuilder::new("Template Editor", fields); + } + + fn parse_additional_fields_input( + raw: Option, + ) -> Result, String> { + match raw { + Some(serde_json::Value::String(s)) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + Ok(Some(serde_json::Value::Null)) + } else { + serde_json::from_str::(trimmed) + .map(Some) + .map_err(|e| e.to_string()) + } + } + Some(serde_json::Value::Null) => Ok(Some(serde_json::Value::Null)), + Some(other) => Ok(Some(other)), + None => Ok(None), + } + } + + fn load_templates(&mut self, api_client: &ApiClient, limit: Option) { + self.loading_state.start_loading(); + + match get_templates(api_client, limit) { + Ok(templates) => { + self.templates = templates; + self.loading_state.finish_success(); + } + Err(e) => { + self.loading_state.finish_error(e.to_string()); + } + } + } + + pub fn show( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon: Option<&mut crate::ui::ribbon::RibbonUI>, + ) -> Vec { + let mut flags_to_clear = Vec::new(); + + // Get limit from ribbon + let limit = ribbon + .as_ref() + .and_then(|r| r.number_fields.get("templates_limit")) + .copied() + .or(Some(200)); + + // Top toolbar + ui.horizontal(|ui| { + ui.heading("Templates"); + + if self.loading_state.is_loading { + ui.spinner(); + ui.label("Loading..."); + } + + if let Some(err) = &self.loading_state.last_error { + ui.colored_label(egui::Color32::RED, err); + if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.loading_state.last_error = None; + self.load_templates(api, limit); + } + } + } else if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load_templates(api, limit); + } + } + + ui.separator(); + + if ui.button("Columns").clicked() { + self.show_column_panel = !self.show_column_panel; + } + }); + + ui.separator(); + + // Auto-load on first view + if self.templates.is_empty() + && !self.loading_state.is_loading + && self.loading_state.last_error.is_none() + && self.loading_state.last_load_time.is_none() + { + if let Some(api) = api_client { + log::info!("Templates view never loaded, triggering initial auto-load"); + self.load_templates(api, limit); + } + } + + // Handle ribbon events + if let Some(ribbon) = ribbon.as_ref() { + // Handle filter changes + if *ribbon + .checkboxes + .get("templates_filter_changed") + .unwrap_or(&false) + { + flags_to_clear.push("templates_filter_changed".to_string()); + if let Some(client) = api_client { + self.loading_state.last_error = None; + + // Get user-defined filters from FilterBuilder + let user_filter = ribbon.filter_builder.get_filter_json("templates"); + + // Debug: Log the filter to see what we're getting + if let Some(ref cf) = user_filter { + log::info!("Template filter: {:?}", cf); + } else { + log::info!("No filter conditions (showing all templates)"); + } + + self.load_templates(client, limit); + return flags_to_clear; // Early return to avoid duplicate loading + } + } + + // Handle limit changes + if *ribbon + .checkboxes + .get("templates_limit_changed") + .unwrap_or(&false) + { + flags_to_clear.push("templates_limit_changed".to_string()); + if let Some(client) = api_client { + self.loading_state.last_error = None; + self.load_templates(client, limit); + } + } + + // Handle ribbon actions + if *ribbon + .checkboxes + .get("templates_action_new") + .unwrap_or(&false) + { + flags_to_clear.push("templates_action_new".to_string()); + + // Prepare dynamic dropdown fields before opening dialog + if let Some(client) = api_client { + self.prepare_template_edit_fields(client); + } + + // Open new template dialog with empty data (comprehensive fields for templates) + let empty_template = serde_json::json!({ + "id": "", + "template_code": "", + "name": "", + "asset_type": "", + "asset_tag_generation_string": "", + "description": "", + "additional_fields": null, + "additional_fields_json": "{}", + "category_id": "", + "manufacturer": "", + "model": "", + "zone_id": "", + "zone_plus": "", + "status": "", + "price": "", + "warranty_until": "", + "expiry_date": "", + "quantity_total": "", + "quantity_used": "", + "supplier_id": "", + "label_template_id": "", + "audit_task_id": "", + "lendable": false, + "minimum_role_for_lending": "", + "no_scan": "", + "notes": "", + "active": false + }); + self.edit_dialog.title = "Add New Template".to_string(); + self.open_edit_template_dialog(empty_template); + } + + if *ribbon + .checkboxes + .get("templates_action_edit") + .unwrap_or(&false) + { + flags_to_clear.push("templates_action_edit".to_string()); + // TODO: Implement edit selected template (requires selection tracking) + log::info!( + "Edit Template clicked (requires table selection - use double-click for now)" + ); + } + + if *ribbon + .checkboxes + .get("templates_action_delete") + .unwrap_or(&false) + { + flags_to_clear.push("templates_action_delete".to_string()); + // TODO: Implement delete selected templates (requires selection tracking) + log::info!( + "Delete Template clicked (requires table selection - use right-click for now)" + ); + } + } + + // Render table with event handler + let mut edit_template: Option = None; + let mut delete_template: Option = None; + let mut clone_template: Option = None; + + struct TemplateEventHandler<'a> { + edit_action: &'a mut Option, + delete_action: &'a mut Option, + clone_action: &'a mut Option, + } + + impl<'a> crate::core::table_renderer::TableEventHandler + for TemplateEventHandler<'a> + { + fn on_double_click(&mut self, item: &serde_json::Value, _row_index: usize) { + *self.edit_action = Some(item.clone()); + } + + fn on_context_menu( + &mut self, + ui: &mut egui::Ui, + item: &serde_json::Value, + _row_index: usize, + ) { + if ui + .button(format!("{} Edit Template", egui_phosphor::regular::PENCIL)) + .clicked() + { + *self.edit_action = Some(item.clone()); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Clone Template", egui_phosphor::regular::COPY)) + .clicked() + { + *self.clone_action = Some(item.clone()); + ui.close(); + } + + ui.separator(); + + if ui + .button(format!("{} Delete Template", egui_phosphor::regular::TRASH)) + .clicked() + { + *self.delete_action = Some(item.clone()); + ui.close(); + } + } + + fn on_selection_changed(&mut self, _selected_indices: &[usize]) { + // Not used for now + } + } + + let mut handler = TemplateEventHandler { + edit_action: &mut edit_template, + delete_action: &mut delete_template, + clone_action: &mut clone_template, + }; + + let prepared_data = self.table_renderer.prepare_json_data(&self.templates); + self.table_renderer + .render_json_table(ui, &prepared_data, Some(&mut handler)); + + // Process actions after rendering + if let Some(template) = edit_template { + // Prepare dynamic dropdown fields before opening dialog + if let Some(client) = api_client { + self.prepare_template_edit_fields(client); + } + self.open_edit_template_dialog(template); + } + if let Some(template) = delete_template { + if let Some(id) = template.get("id").and_then(|v| v.as_i64()) { + self.pending_delete_ids.push(id); + } + } + + // Handle clone action: open Add New dialog pre-filled with selected template values + if let Some(template) = clone_template { + // Prepare dynamic dropdown fields before opening dialog + if let Some(client) = api_client { + self.prepare_template_edit_fields(client); + } + + // Use shared clone helper to prepare new-item payload + let cloned = crate::core::components::prepare_cloned_value( + &template, + &["id", "template_code"], + Some("name"), + Some(""), + ); + + self.edit_dialog.title = "Add New Template".to_string(); + self.open_edit_template_dialog(cloned); + } + + // Show column selector if open + if self.show_column_panel { + egui::Window::new("Column Configuration") + .open(&mut self.show_column_panel) + .resizable(true) + .movable(true) + .default_width(350.0) + .min_width(300.0) + .max_width(500.0) + .max_height(600.0) + .default_pos([200.0, 150.0]) + .show(ui.ctx(), |ui| { + ui.label("Show/Hide Columns:"); + ui.separator(); + + // Scrollable area for columns + egui::ScrollArea::vertical() + .max_height(450.0) + .show(ui, |ui| { + // Use columns layout to make better use of width while keeping groups intact + ui.columns(2, |columns| { + // Left column + columns[0].group(|ui| { + ui.strong("Basic Information"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "template_code" + | "name" + | "asset_type" + | "description" + | "asset_tag_generation_string" + | "label_template_name" + | "label_template_id" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[0].add_space(5.0); + + columns[0].group(|ui| { + ui.strong("Classification"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "category_name" | "manufacturer" | "model" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[0].add_space(5.0); + + columns[0].group(|ui| { + ui.strong("Location & Status"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "zone_name" + | "zone_code" + | "zone_plus" + | "zone_note" + | "status" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + // Right column + columns[1].group(|ui| { + ui.strong("Financial, Dates & Auto-Calc"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "price" + | "purchase_date" + | "purchase_date_now" + | "warranty_until" + | "warranty_auto" + | "warranty_auto_amount" + | "warranty_auto_unit" + | "expiry_date" + | "expiry_auto" + | "expiry_auto_amount" + | "expiry_auto_unit" + | "created_at" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[1].add_space(5.0); + + columns[1].group(|ui| { + ui.strong("Quantities & Lending"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "quantity_total" + | "quantity_used" + | "lendable" + | "lending_status" + | "minimum_role_for_lending" + | "no_scan" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + + columns[1].add_space(5.0); + + columns[1].group(|ui| { + ui.strong("Metadata & Other"); + ui.separator(); + for column in &mut self.table_renderer.columns { + if matches!( + column.field.as_str(), + "id" | "supplier_name" | "notes" | "active" + ) { + ui.checkbox(&mut column.visible, &column.name); + } + } + }); + }); + }); + + ui.separator(); + ui.columns(3, |columns| { + if columns[0].button("Show All").clicked() { + for column in &mut self.table_renderer.columns { + column.visible = true; + } + } + if columns[1].button("Hide All").clicked() { + for column in &mut self.table_renderer.columns { + column.visible = false; + } + } + if columns[2].button("Reset to Default").clicked() { + // Reset to default visibility (matching the initial setup) + for column in &mut self.table_renderer.columns { + column.visible = matches!( + column.field.as_str(), + "template_code" + | "name" + | "asset_type" + | "description" + | "category_name" + ); + } + } + }); + }); + } + + // Handle pending deletes + if !self.pending_delete_ids.is_empty() { + if let Some(api) = api_client { + let ids_to_delete = self.pending_delete_ids.clone(); + self.pending_delete_ids.clear(); + + for id in ids_to_delete { + let where_clause = serde_json::json!({"id": id}); + match api.delete("templates", where_clause) { + Ok(resp) if resp.success => { + log::info!("Template {} deleted successfully", id); + } + Ok(resp) => { + self.loading_state.last_error = + Some(format!("Delete failed: {:?}", resp.error)); + } + Err(e) => { + self.loading_state.last_error = Some(format!("Delete error: {}", e)); + } + } + } + + // Reload after deletes + self.load_templates(api, limit); + } + } + + // Handle edit dialog save + let ctx = ui.ctx(); + if let Some(result) = self.edit_dialog.show_editor(ctx) { + log::info!( + "🎯 Templates received editor result: {:?}", + result.is_some() + ); + if let Some(updated) = result { + log::info!( + "🎯 Processing template save with data keys: {:?}", + updated.keys().collect::>() + ); + if let Some(api) = api_client { + let mut id_from_updated: Option = None; + if let Some(id_val) = updated.get("id") { + log::info!("Raw id_val for template save: {:?}", id_val); + id_from_updated = if let Some(s) = id_val.as_str() { + if s.trim().is_empty() { + None + } else { + s.trim().parse::().ok() + } + } else { + id_val.as_i64() + }; + } else if let Some(meta_id_val) = updated.get("__editor_item_id") { + log::info!( + "No 'id' in diff; checking __editor_item_id: {:?}", + meta_id_val + ); + id_from_updated = match meta_id_val { + serde_json::Value::String(s) => { + let s = s.trim(); + if s.is_empty() { + None + } else { + s.parse::().ok() + } + } + serde_json::Value::Number(n) => n.as_i64(), + _ => None, + }; + } + if let Some(id) = id_from_updated { + log::info!("Entering UPDATE template path for id {}", id); + let mut cleaned = updated.clone(); + cleaned.remove("__editor_item_id"); + + let additional_fields_update = match Self::parse_additional_fields_input( + cleaned.remove("additional_fields_json"), + ) { + Ok(val) => val, + Err(err) => { + let msg = format!("Additional Fields JSON is invalid: {}", err); + log::error!("{}", msg); + self.loading_state.last_error = Some(msg); + return flags_to_clear; + } + }; + + // Filter empty strings to NULL for UPDATE too + let mut filtered_values = serde_json::Map::new(); + for (key, value) in cleaned.iter() { + if key.starts_with("__editor_") { + continue; + } + match value { + serde_json::Value::String(s) if s.trim().is_empty() => { + filtered_values.insert(key.clone(), serde_json::Value::Null); + } + _ => { + filtered_values.insert(key.clone(), value.clone()); + } + } + } + if let Some(val) = additional_fields_update { + filtered_values.insert("additional_fields".to_string(), val); + } + let values = serde_json::Value::Object(filtered_values); + let where_clause = serde_json::json!({"id": id}); + log::info!( + "Sending UPDATE request: values={:?} where={:?}", + values, + where_clause + ); + match api.update("templates", values, where_clause) { + Ok(resp) if resp.success => { + log::info!("Template {} updated successfully", id); + self.load_templates(api, limit); + } + Ok(resp) => { + let err = format!("Update failed: {:?}", resp.error); + log::error!("{}", err); + self.loading_state.last_error = Some(err); + } + Err(e) => { + let err = format!("Update error: {}", e); + log::error!("{}", err); + self.loading_state.last_error = Some(err); + } + } + } else { + log::info!("🆕 Entering INSERT template path (no valid ID detected)"); + let mut values = updated.clone(); + values.remove("id"); + values.remove("__editor_item_id"); + + let additional_fields_insert = match Self::parse_additional_fields_input( + values.remove("additional_fields_json"), + ) { + Ok(val) => val, + Err(err) => { + let msg = format!("Additional Fields JSON is invalid: {}", err); + log::error!("{}", msg); + self.loading_state.last_error = Some(msg); + return flags_to_clear; + } + }; + let mut filtered_values = serde_json::Map::new(); + for (key, value) in values.iter() { + if key.starts_with("__editor_") { + continue; + } + match value { + serde_json::Value::String(s) if s.trim().is_empty() => { + filtered_values.insert(key.clone(), serde_json::Value::Null); + } + _ => { + filtered_values.insert(key.clone(), value.clone()); + } + } + } + if let Some(val) = additional_fields_insert { + filtered_values.insert("additional_fields".to_string(), val); + } + let values = serde_json::Value::Object(filtered_values); + log::info!("➡️ Sending INSERT request for template: {:?}", values); + match api.insert("templates", values) { + Ok(resp) if resp.success => { + log::info!( + "✅ New template created successfully (id={:?})", + resp.data + ); + self.load_templates(api, limit); + } + Ok(resp) => { + let error_msg = format!("Insert failed: {:?}", resp.error); + log::error!("Template insert failed: {}", error_msg); + self.loading_state.last_error = Some(error_msg); + } + Err(e) => { + let error_msg = format!("Insert error: {}", e); + log::error!("Template insert error: {}", error_msg); + self.loading_state.last_error = Some(error_msg); + } + } + } + } + } + } + + flags_to_clear + } + + fn open_edit_template_dialog(&mut self, mut template: serde_json::Value) { + // Determine whether we are creating a new template (no ID or empty/zero ID) + let is_new = match template.get("id") { + Some(serde_json::Value::String(s)) => s.trim().is_empty(), + Some(serde_json::Value::Number(n)) => n.as_i64().map(|id| id <= 0).unwrap_or(true), + Some(serde_json::Value::Null) | None => true, + _ => false, + }; + + self.edit_dialog.title = if is_new { + "Add New Template".to_string() + } else { + "Edit Template".to_string() + }; + + if let Some(obj) = template.as_object_mut() { + let pretty_json = if let Some(existing) = + obj.get("additional_fields_json").and_then(|v| v.as_str()) + { + existing.to_string() + } else { + match obj.get("additional_fields") { + Some(serde_json::Value::Null) | None => String::new(), + Some(serde_json::Value::String(s)) => s.clone(), + Some(value) => { + serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()) + } + } + }; + obj.insert( + "additional_fields_json".to_string(), + serde_json::Value::String(pretty_json), + ); + } + + // Debug log to check the template data being passed to editor + log::info!( + "Template data for editor: {}", + serde_json::to_string_pretty(&template) + .unwrap_or_else(|_| "failed to serialize".to_string()) + ); + + if is_new { + // Use open_new so cloned templates keep their preset values when saved + if let Some(obj) = template.as_object() { + self.edit_dialog.open_new(Some(obj)); + } else { + self.edit_dialog.open_new(None); + } + } else { + self.edit_dialog.open(&template); + } + } +} diff --git a/src/ui/zones.rs b/src/ui/zones.rs new file mode 100644 index 0000000..d331642 --- /dev/null +++ b/src/ui/zones.rs @@ -0,0 +1,990 @@ +use eframe::egui; +use std::collections::{HashMap, HashSet}; + +use crate::core::components::form_builder::FormBuilder; +use crate::core::components::interactions::ConfirmDialog; +use crate::core::{EditorField, FieldType}; + +use crate::api::ApiClient; +use crate::core::tables::get_assets_in_zone; + +pub struct ZonesView { + zones: Vec, + is_loading: bool, + last_error: Option, + // UI state + show_items: bool, + search_query: String, + // Cache: assets per zone id + zone_assets: HashMap>, + // Request guards + initial_load_done: bool, + zone_assets_attempted: HashSet, + zone_assets_failed: HashSet, + // Editor dialogs for zones + edit_dialog: FormBuilder, + add_dialog: FormBuilder, + delete_dialog: ConfirmDialog, + // Pending operation + pending_delete_id: Option, + pending_parent_id: Option, // For "Add Child Zone" + // Navigation request + pub switch_to_inventory_with_zone: Option, // zone_code to filter by + // Print dialog + print_dialog: Option, + show_print_dialog: bool, + force_expand_state: Option, +} + +impl ZonesView { + pub fn new() -> Self { + // Create basic editors first, they'll be updated with dropdowns when API client is available + let edit_dialog = Self::create_edit_dialog(Vec::new()); + let add_dialog = Self::create_add_dialog(Vec::new()); + + Self { + zones: Vec::new(), + is_loading: false, + last_error: None, + show_items: true, + search_query: String::new(), + zone_assets: HashMap::new(), + initial_load_done: false, + zone_assets_attempted: HashSet::new(), + zone_assets_failed: HashSet::new(), + edit_dialog, + add_dialog, + delete_dialog: ConfirmDialog::new("Delete Zone", "Are you sure you want to delete this zone? All items in this zone will lose their zone assignment."), + pending_delete_id: None, + pending_parent_id: None, + switch_to_inventory_with_zone: None, + print_dialog: None, + show_print_dialog: false, + force_expand_state: None, + } + } + + /// Check if a zone or any of its descendants have items + fn zone_or_descendants_have_items( + &self, + zone_id: i32, + all_zones: &[serde_json::Value], + ) -> bool { + // Check if this zone has items + if self + .zone_assets + .get(&zone_id) + .map(|assets| !assets.is_empty()) + .unwrap_or(false) + { + return true; + } + + // Check if any children have items (recursively) + for z in all_zones { + if let Some(parent_id) = z + .get("parent_id") + .and_then(|v| v.as_i64()) + .map(|v| v as i32) + { + if parent_id == zone_id { + if let Some(child_id) = z.get("id").and_then(|v| v.as_i64()).map(|v| v as i32) { + if self.zone_or_descendants_have_items(child_id, all_zones) { + return true; + } + } + } + } + } + + false + } + + /// Create edit dialog with zone options for parent selection + fn create_edit_dialog(parent_options: Vec<(String, String)>) -> FormBuilder { + let zone_type_options = vec![ + ("Building".to_string(), "Building".to_string()), + ("Floor".to_string(), "Floor".to_string()), + ("Room".to_string(), "Room".to_string()), + ("Storage Area".to_string(), "Storage Area".to_string()), + ]; + + FormBuilder::new( + "Edit Zone", + vec![ + EditorField { + name: "id".into(), + label: "ID".into(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "mini_code".into(), + label: "Mini Code".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "zone_code".into(), + label: "Full Zone Code".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "zone_name".into(), + label: "Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "zone_type".into(), + label: "Type".into(), + field_type: FieldType::Dropdown(zone_type_options.clone()), + required: false, + read_only: false, + }, + EditorField { + name: "parent_id".into(), + label: "Parent Zone".into(), + field_type: FieldType::Dropdown(parent_options.clone()), + required: false, + read_only: false, + }, + EditorField { + name: "include_in_parent".into(), + label: "Include in Parent".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "audit_timeout_minutes".into(), + label: "Audit Timeout (minutes)".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "zone_notes".into(), + label: "Notes".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ], + ) + } + + /// Create add dialog with zone options for parent selection + fn create_add_dialog(parent_options: Vec<(String, String)>) -> FormBuilder { + let zone_type_options = vec![ + ("Building".to_string(), "Building".to_string()), + ("Floor".to_string(), "Floor".to_string()), + ("Room".to_string(), "Room".to_string()), + ("Storage Area".to_string(), "Storage Area".to_string()), + ]; + + FormBuilder::new( + "Add Zone", + vec![ + EditorField { + name: "mini_code".into(), + label: "Mini Code".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "zone_code".into(), + label: "Full Zone Code".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "zone_name".into(), + label: "Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "zone_type".into(), + label: "Type".into(), + field_type: FieldType::Dropdown(zone_type_options), + required: false, + read_only: false, + }, + EditorField { + name: "parent_id".into(), + label: "Parent Zone".into(), + field_type: FieldType::Dropdown(parent_options), + required: false, + read_only: false, + }, + EditorField { + name: "include_in_parent".into(), + label: "Include in Parent".into(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "audit_timeout_minutes".into(), + label: "Audit Timeout (minutes)".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "zone_notes".into(), + label: "Notes".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ], + ) + } + + /// Update editor dialogs with current zone list for parent dropdown + fn update_editor_dropdowns(&mut self) { + let parent_options: Vec<(String, String)> = + std::iter::once(("".to_string(), "(None)".to_string())) + .chain(self.zones.iter().filter_map(|z| { + let id = z.get("id")?.as_i64()?.to_string(); + let code = z.get("zone_code")?.as_str()?; + let name = z.get("name").or_else(|| z.get("zone_name"))?.as_str()?; + Some((id, format!("{} - {}", code, name))) + })) + .collect(); + + self.edit_dialog = Self::create_edit_dialog(parent_options.clone()); + self.add_dialog = Self::create_add_dialog(parent_options); + } + + fn ensure_loaded(&mut self, api_client: Option<&ApiClient>) { + if self.is_loading || self.initial_load_done { + return; + } + if let Some(client) = api_client { + self.load_zones(client, None); + } + } + + /// Load zones with optional filter + fn load_zones(&mut self, api_client: &ApiClient, filter: Option) { + use crate::core::tables::get_all_zones_with_filter; + + self.is_loading = true; + self.last_error = None; + match get_all_zones_with_filter(api_client, filter) { + Ok(list) => { + self.zones = list; + self.is_loading = false; + self.initial_load_done = true; + // Update editor dropdowns with new zone list + self.update_editor_dropdowns(); + } + Err(e) => { + self.last_error = Some(e.to_string()); + self.is_loading = false; + self.initial_load_done = true; + } + } + } + + /// Refresh zones data from the API + pub fn refresh(&mut self, api_client: Option<&ApiClient>) { + self.zones.clear(); + self.is_loading = false; + self.initial_load_done = false; + self.zone_assets.clear(); + self.zone_assets_attempted.clear(); + self.zone_assets_failed.clear(); + self.last_error = None; + self.ensure_loaded(api_client); + } + + pub fn show( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon: &mut crate::ui::ribbon::RibbonUI, + ) { + self.ensure_loaded(api_client); + + // Handle filter changes from FilterBuilder + if ribbon + .checkboxes + .get("zones_filter_changed") + .copied() + .unwrap_or(false) + { + ribbon + .checkboxes + .insert("zones_filter_changed".to_string(), false); + if let Some(client) = api_client { + self.last_error = None; + + // Get user-defined filters from FilterBuilder + let user_filter = ribbon.filter_builder.get_filter_json("zones"); + + // Debug: Log the filter to see what we're getting + if let Some(ref cf) = user_filter { + log::info!("Zones filter: {:?}", cf); + } else { + log::info!("No filter conditions (showing all zones)"); + } + + self.load_zones(client, user_filter); + } + } + + // Handle ribbon actions + if ribbon + .checkboxes + .get("zones_action_add") + .copied() + .unwrap_or(false) + { + self.update_editor_dropdowns(); + self.add_dialog.open_new(None); + } + if ribbon + .checkboxes + .get("zones_action_edit") + .copied() + .unwrap_or(false) + { + // Edit needs a selected zone - will be handled via context menu + log::info!("Edit zone clicked - use right-click context menu on a zone"); + } + if ribbon + .checkboxes + .get("zones_action_remove") + .copied() + .unwrap_or(false) + { + // Remove needs a selected zone - will be handled via context menu + log::info!("Remove zone clicked - use right-click context menu on a zone"); + } + + // Update show_items from ribbon + self.show_items = ribbon + .checkboxes + .get("zones_show_items") + .copied() + .unwrap_or(false); + + // Get show_empty preference from ribbon + let show_empty = ribbon + .checkboxes + .get("zones_show_empty") + .copied() + .unwrap_or(true); + + ui.horizontal(|ui| { + ui.heading("Zones"); + if self.is_loading { + ui.spinner(); + ui.label("Loading zones..."); + } + if let Some(err) = &self.last_error { + ui.colored_label(egui::Color32::RED, err); + if ui.button("Refresh").clicked() { + self.refresh(api_client); + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Collapse All").clicked() { + self.set_all_open(false, ui.ctx()); + } + if ui.button("Expand All").clicked() { + self.set_all_open(true, ui.ctx()); + } + if ui.button("➕ Add Zone").clicked() { + self.update_editor_dropdowns(); + self.add_dialog.open_new(None); + } + }); + }); + + // Search bar + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(&mut self.search_query); + if ui.button("Clear").clicked() { + self.search_query.clear(); + } + }); + + ui.separator(); + + // If there was an error loading zones, stop here until user refreshes + if self.last_error.is_some() { + return; + } + if self.zones.is_empty() { + return; + } + + // Default: expand all once on first successful load + // Filter zones by search query (case-insensitive) + let search_lower = self.search_query.to_lowercase(); + let filtered_zones: Vec = self + .zones + .iter() + .filter(|z| { + // Apply search filter + if !search_lower.is_empty() { + let code_match = z + .get("zone_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase().contains(&search_lower)) + .unwrap_or(false); + let mini_match = z + .get("mini_code") + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase().contains(&search_lower)) + .unwrap_or(false); + let name_match = z + .get("name") + .or_else(|| z.get("zone_name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase().contains(&search_lower)) + .unwrap_or(false); + if !code_match && !mini_match && !name_match { + return false; + } + } + + // Apply empty filter + if !show_empty { + let zone_id = z.get("id").and_then(|v| v.as_i64()).map(|v| v as i32); + if let Some(id) = zone_id { + // Only show zones that have items or have descendants with items + if !self.zone_or_descendants_have_items(id, &self.zones) { + return false; + } + } + } + + true + }) + .cloned() + .collect(); + + // Build parent -> children map + let mut children: HashMap, Vec> = HashMap::new(); + for z in &filtered_zones { + let parent = z + .get("parent_id") + .and_then(|v| v.as_i64()) + .map(|v| v as i32); + children.entry(parent).or_default().push(z.clone()); + } + // Sort children lists by zone_code + for list in children.values_mut() { + list.sort_by(|a, b| { + a.get("zone_code") + .and_then(|v| v.as_str()) + .cmp(&b.get("zone_code").and_then(|v| v.as_str())) + }); + } + + // Render roots (parent = None) + if let Some(roots) = children.get(&None) { + egui::ScrollArea::both() + .auto_shrink([false; 2]) + .show(ui, |ui| { + for root in roots { + self.render_zone_node(ui, root, &children, api_client); + } + }); + } + + // Clear the one-shot expand/collapse request after applying it + if self.force_expand_state.is_some() { + self.force_expand_state = None; + } + + // Show zone editor dialogs + if let Some(result) = self.edit_dialog.show_editor(ui.ctx()) { + if let Some(updated) = result { + log::info!("Zone updated: {:?}", updated); + if let Some(client) = api_client { + self.update_zone(client, &updated); + } + } + } + + if let Some(result) = self.add_dialog.show_editor(ui.ctx()) { + if let Some(new_zone) = result { + log::info!("Zone added: {:?}", new_zone); + if let Some(client) = api_client { + self.create_zone(client, &new_zone); + } + } + } + + // Show delete confirmation dialog + if let Some(should_delete) = self.delete_dialog.show_dialog(ui.ctx()) { + if should_delete { + if let Some(zone_id) = self.pending_delete_id { + log::info!("Deleting zone ID: {}", zone_id); + if let Some(client) = api_client { + self.delete_zone(client, zone_id); + } + } + } + self.pending_delete_id = None; + } + + // Show print dialog + if let Some(print_dialog) = &mut self.print_dialog { + let print_complete = + print_dialog.show(ui.ctx(), &mut self.show_print_dialog, api_client); + if print_complete || !self.show_print_dialog { + self.print_dialog = None; + } + } + } + + fn render_zone_node( + &mut self, + ui: &mut egui::Ui, + zone: &serde_json::Value, + children: &HashMap, Vec>, + api_client: Option<&ApiClient>, + ) { + let id = zone.get("id").and_then(|v| v.as_i64()).unwrap_or_default() as i32; + let code = zone + .get("zone_code") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let mini = zone.get("mini_code").and_then(|v| v.as_str()).unwrap_or(""); + let name = zone.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let label = if mini.is_empty() { + format!("{} — {}", code, name) + } else { + // Subtle mini code display in parentheses + format!("{} — {} ({})", code, name, mini) + }; + + let mut header = egui::CollapsingHeader::new(label.clone()) + .id_salt(("zone", id)) + .default_open(true); + + if let Some(force) = self.force_expand_state { + let ctx = ui.ctx(); + let id_key = egui::Id::new(label.clone()).with(("zone", id)); + let mut state = egui::collapsing_header::CollapsingState::load_with_default_open( + ctx, id_key, force, + ); + state.set_open(force); + state.store(ctx); + + header = header.open(Some(force)); + } + + let resp = header.show(ui, |ui| { + // Children zones + if let Some(kids) = children.get(&Some(id)) { + ui.indent(egui::Id::new(("zone_indent", id)), |ui| { + for child in kids { + self.render_zone_node(ui, child, children, api_client); + } + }); + } + + // Optional: items in this zone + if self.show_items { + ui.indent(egui::Id::new(("zone_items", id)), |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + // Load from cache or fetch + if !self.zone_assets.contains_key(&id) + && !self.zone_assets_attempted.contains(&id) + && !self.zone_assets_failed.contains(&id) + { + self.zone_assets_attempted.insert(id); + if let Some(client) = api_client { + match get_assets_in_zone(client, id, Some(200)) { + Ok(list) => { + self.zone_assets.insert(id, list); + } + Err(e) => { + self.zone_assets_failed.insert(id); + ui.colored_label( + egui::Color32::RED, + format!("Failed to load items: {}", e), + ); + } + } + } + } + if self.zone_assets_failed.contains(&id) && !self.zone_assets.contains_key(&id) + { + ui.colored_label( + egui::Color32::RED, + "(items failed to load – use Refresh at top)", + ); + } + if let Some(items) = self.zone_assets.get(&id) { + for a in items { + let tag = a.get("asset_tag").and_then(|v| v.as_str()).unwrap_or("?"); + let nm = a.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let status = a.get("status").and_then(|v| v.as_str()).unwrap_or(""); + ui.label(format!("• [{}] {} ({})", tag, nm, status)); + } + if items.is_empty() { + ui.label("(no items)"); + } + } + }); + } + }); + + // Add context menu to header for editing + resp.header_response.context_menu(|ui| { + if ui + .button(format!("{} Edit Zone", egui_phosphor::regular::PENCIL)) + .clicked() + { + // Update dropdowns with current zone list + self.update_editor_dropdowns(); + + // Map the zone data to match the editor field names + if let Some(zone_obj) = zone.as_object() { + let mut zone_for_editor = zone_obj.clone(); + + // The data comes in with "name" (aliased from zone_name), but editor expects "zone_name" + if let Some(name_value) = zone_for_editor.remove("name") { + zone_for_editor.insert("zone_name".to_string(), name_value); + } + + // Convert integer fields to strings if they're numbers + if let Some(audit_timeout) = zone_for_editor.get("audit_timeout_minutes") { + if let Some(num) = audit_timeout.as_i64() { + zone_for_editor.insert( + "audit_timeout_minutes".to_string(), + serde_json::json!(num.to_string()), + ); + } + } + + self.edit_dialog + .open(&serde_json::Value::Object(zone_for_editor)); + ui.close(); + } else { + log::error!("Zone data is not an object, cannot edit"); + } + } + if ui + .button(format!("{} Delete Zone", egui_phosphor::regular::TRASH)) + .clicked() + { + self.pending_delete_id = Some(id); + self.delete_dialog + .open(format!("{} - {}", code, name), id.to_string()); + ui.close(); + } + ui.separator(); + if ui + .button(format!("{} Add Child Zone", egui_phosphor::regular::PLUS)) + .clicked() + { + // Open add dialog with parent_id pre-filled + self.pending_parent_id = Some(id); + self.update_editor_dropdowns(); + + // Create initial data with parent_id + let mut initial_data = serde_json::Map::new(); + initial_data.insert("parent_id".to_string(), serde_json::json!(id.to_string())); + + self.add_dialog.open_new(Some(&initial_data)); + ui.close(); + } + if ui + .button(format!( + "{} Show Items in this Zone", + egui_phosphor::regular::PACKAGE + )) + .clicked() + { + // Set the flag to switch to inventory with this zone filter + self.switch_to_inventory_with_zone = Some(code.to_string()); + ui.close(); + } + if ui + .button(format!( + "{} Print Zone Label", + egui_phosphor::regular::PRINTER + )) + .clicked() + { + // Prepare zone data for printing + let mut print_data = std::collections::HashMap::new(); + print_data.insert("zone_code".to_string(), code.to_string()); + print_data.insert("zone_name".to_string(), name.to_string()); + + // Extract additional fields from zone JSON + if let Some(zone_type) = zone.get("zone_type").and_then(|v| v.as_str()) { + print_data.insert("zone_type".to_string(), zone_type.to_string()); + } + if let Some(zone_barcode) = zone.get("zone_barcode").and_then(|v| v.as_str()) { + print_data.insert("zone_barcode".to_string(), zone_barcode.to_string()); + } + + // Create new print dialog with zone data + self.print_dialog = Some(crate::core::print::PrintDialog::new(print_data)); + self.show_print_dialog = true; + ui.close(); + } + ui.separator(); + if ui + .button(format!("{} Clone Zone", egui_phosphor::regular::COPY)) + .clicked() + { + // Open add dialog prefilled with cloned values + self.update_editor_dropdowns(); + + // Start from original zone object + let mut clone_map = zone.as_object().cloned().unwrap_or_default(); + + // Editor expects zone_name instead of name + if let Some(name_val) = clone_map.remove("name") { + clone_map.insert("zone_name".to_string(), name_val); + } + + // Clear identifiers and codes that must be unique + clone_map.remove("id"); + clone_map.insert( + "zone_code".to_string(), + serde_json::Value::String(String::new()), + ); + // Mini code is required but typically unique — leave empty to force user choice + clone_map.insert( + "mini_code".to_string(), + serde_json::Value::String(String::new()), + ); + + // Convert parent_id to string for dropdown if present + if let Some(p) = clone_map.get("parent_id").cloned() { + let as_str = match p { + serde_json::Value::Number(n) => { + n.as_i64().map(|i| i.to_string()).unwrap_or_default() + } + serde_json::Value::String(s) => s, + _ => String::new(), + }; + clone_map.insert("parent_id".to_string(), serde_json::Value::String(as_str)); + } + + // Ensure audit_timeout_minutes is string + if let Some(a) = clone_map.get("audit_timeout_minutes").cloned() { + if let Some(num) = a.as_i64() { + clone_map.insert( + "audit_timeout_minutes".to_string(), + serde_json::json!(num.to_string()), + ); + } + } + + // Suffix the name to indicate copy + if let Some(serde_json::Value::String(nm)) = clone_map.get_mut("zone_name") { + nm.push_str(""); + } + + // Open prefilled Add dialog + self.add_dialog.open_new(Some(&clone_map)); + ui.close(); + } + }); + } + + /// Create a new zone via API + fn create_zone( + &mut self, + api_client: &ApiClient, + zone_data: &serde_json::Map, + ) { + use crate::models::QueryRequest; + + // Clean the data - remove empty parent_id and convert audit_timeout_minutes to integer + let mut clean_data = zone_data.clone(); + + if let Some(parent_id) = clean_data.get("parent_id") { + if parent_id.as_str() == Some("") || parent_id.is_null() { + clean_data.remove("parent_id"); + } + } + + // Do not auto-generate full zone_code here; use user-provided value as-is for now. + + // Convert audit_timeout_minutes string to integer if present and not empty + if let Some(audit_timeout) = clean_data.get("audit_timeout_minutes") { + if let Some(s) = audit_timeout.as_str() { + if !s.is_empty() { + if let Ok(num) = s.parse::() { + clean_data + .insert("audit_timeout_minutes".to_string(), serde_json::json!(num)); + } + } else { + clean_data.remove("audit_timeout_minutes"); + } + } + } + + let request = QueryRequest { + action: "insert".to_string(), + table: "zones".to_string(), + columns: None, + data: Some(serde_json::Value::Object(clean_data)), + r#where: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + self.refresh(Some(api_client)); + } else { + log::error!("Failed to create zone: {:?}", response.error); + } + } + Err(e) => { + log::error!("Error creating zone: {}", e); + } + } + } + + /// Update an existing zone via API + fn update_zone( + &mut self, + api_client: &ApiClient, + zone_data: &serde_json::Map, + ) { + use crate::models::QueryRequest; + + // Try to get ID from various possible locations + let zone_id = zone_data + .get("id") + .and_then(|v| { + v.as_i64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }) + .or_else(|| { + // Check for __editor_item_id which is set by the editor for read-only ID fields + zone_data + .get("__editor_item_id") + .and_then(|v| v.as_str().and_then(|s| s.parse().ok())) + }) + .unwrap_or_else(|| { + log::error!("Zone update attempted without ID"); + 0 + }); + + // Create a clean data object without the editor metadata and without the id field + let mut clean_data = zone_data.clone(); + clean_data.remove("__editor_item_id"); + clean_data.remove("id"); // Don't include id in the update data, only in WHERE + + // Remove empty parent_id + if let Some(parent_id) = clean_data.get("parent_id") { + if parent_id.as_str() == Some("") || parent_id.is_null() { + clean_data.remove("parent_id"); + } + } + + // Do not auto-generate full zone_code during updates either; keep user value intact. + + // Convert audit_timeout_minutes string to integer if present and not empty + if let Some(audit_timeout) = clean_data.get("audit_timeout_minutes") { + if let Some(s) = audit_timeout.as_str() { + if !s.is_empty() { + if let Ok(num) = s.parse::() { + clean_data + .insert("audit_timeout_minutes".to_string(), serde_json::json!(num)); + } + } else { + clean_data.remove("audit_timeout_minutes"); + } + } + } + + let request = QueryRequest { + action: "update".to_string(), + table: "zones".to_string(), + columns: None, + data: Some(serde_json::Value::Object(clean_data)), + r#where: Some(serde_json::json!({ + "id": zone_id + })), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + self.refresh(Some(api_client)); + } else { + log::error!("Failed to update zone: {:?}", response.error); + } + } + Err(e) => { + log::error!("Error updating zone: {}", e); + } + } + } + + /// Delete a zone via API + fn delete_zone(&mut self, api_client: &ApiClient, zone_id: i32) { + use crate::models::QueryRequest; + + let request = QueryRequest { + action: "delete".to_string(), + table: "zones".to_string(), + columns: None, + data: None, + r#where: Some(serde_json::json!({ + "id": zone_id + })), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + self.refresh(Some(api_client)); + } else { + log::error!("Failed to delete zone: {:?}", response.error); + } + } + Err(e) => { + log::error!("Error deleting zone: {}", e); + } + } + } + + fn set_all_open(&mut self, open: bool, ctx: &egui::Context) { + self.force_expand_state = Some(open); + ctx.request_repaint(); + } +} -- cgit v1.2.3-70-g09d2