aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
committerUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
commit8323fdd73272a2882781aba3c499ba0be3dff2a6 (patch)
treeffbf86473933e69cfaeef30d5c6ea7e5b494856c /src
committing to insanityHEADmaster
Diffstat (limited to 'src')
-rw-r--r--src/api.rs636
-rw-r--r--src/assets/app-icon/AppIcon.icnsbin0 -> 389899 bytes
-rw-r--r--src/assets/app-icon/AppIcon.pngbin0 -> 179552 bytes
-rw-r--r--src/config.rs43
-rw-r--r--src/core/components/clone.rs69
-rw-r--r--src/core/components/filter_builder.rs698
-rw-r--r--src/core/components/form_builder.rs371
-rw-r--r--src/core/components/help.rs66
-rw-r--r--src/core/components/interactions.rs225
-rw-r--r--src/core/components/mod.rs12
-rw-r--r--src/core/components/stats.rs57
-rw-r--r--src/core/data/asset_fields.rs1008
-rw-r--r--src/core/data/counters.rs43
-rw-r--r--src/core/data/data_loader.rs99
-rw-r--r--src/core/data/mod.rs8
-rw-r--r--src/core/mod.rs26
-rw-r--r--src/core/operations/asset_operations.rs613
-rw-r--r--src/core/operations/mod.rs4
-rw-r--r--src/core/print/mod.rs15
-rw-r--r--src/core/print/parsing.rs219
-rw-r--r--src/core/print/plugins/mod.rs2
-rw-r--r--src/core/print/plugins/pdf.rs27
-rw-r--r--src/core/print/plugins/system.rs49
-rw-r--r--src/core/print/printer_manager.rs228
-rw-r--r--src/core/print/renderer.rs1537
-rw-r--r--src/core/print/ui/mod.rs3
-rw-r--r--src/core/print/ui/print_dialog.rs999
-rw-r--r--src/core/table_renderer.rs739
-rw-r--r--src/core/tables.rs1570
-rw-r--r--src/core/utils/mod.rs4
-rw-r--r--src/core/utils/search.rs135
-rw-r--r--src/core/workflows/add_from_template.rs1488
-rw-r--r--src/core/workflows/audit.rs1719
-rw-r--r--src/core/workflows/borrow_flow.rs1450
-rw-r--r--src/core/workflows/mod.rs9
-rw-r--r--src/core/workflows/return_flow.rs924
-rw-r--r--src/main.rs106
-rw-r--r--src/models.rs274
-rw-r--r--src/session.rs161
-rw-r--r--src/ui/app.rs1268
-rw-r--r--src/ui/audits.rs898
-rw-r--r--src/ui/borrowing.rs1618
-rw-r--r--src/ui/categories.rs892
-rw-r--r--src/ui/dashboard.rs384
-rw-r--r--src/ui/inventory.rs1933
-rw-r--r--src/ui/issues.rs773
-rw-r--r--src/ui/label_templates.rs607
-rw-r--r--src/ui/login.rs272
-rw-r--r--src/ui/mod.rs14
-rw-r--r--src/ui/printers.rs943
-rw-r--r--src/ui/ribbon.rs1056
-rw-r--r--src/ui/suppliers.rs802
-rw-r--r--src/ui/templates.rs1113
-rw-r--r--src/ui/zones.rs990
54 files changed, 29199 insertions, 0 deletions
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<String>,
+ db_timeout_flag: Arc<AtomicBool>,
+}
+
+impl ApiClient {
+ /// Create a new API client
+ pub fn new(base_url: String) -> Result<Self> {
+ 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<String>) {
+ if Self::is_database_timeout_error(error) {
+ self.flag_timeout_signal();
+ }
+ }
+
+ fn send_request(
+ &self,
+ builder: reqwest::blocking::RequestBuilder,
+ context_msg: &'static str,
+ ) -> Result<Response> {
+ 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<bool> {
+ 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<Option<serde_json::Value>> {
+ 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::<serde_json::Value>(&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<String>) -> 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<LoginResponse> {
+ 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<ApiResponse<LoginResponse>> {
+ 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<LoginResponse> =
+ 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<ApiResponse<LoginResponse>> {
+ 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<LoginResponse> =
+ response.json().context("Failed to parse login response")?;
+ self.observe_response_error(&result.error);
+
+ Ok(result)
+ }
+
+ /// Logout current session
+ pub fn logout(&self) -> Result<ApiResponse<()>> {
+ 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<ApiResponse<SessionStatus>> {
+ 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<SessionStatus> = 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<bool> {
+ 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::<serde_json::Value>(&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<SessionStatus>
+ if let Ok(parsed) = serde_json::from_str::<ApiResponse<SessionStatus>>(&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<ApiResponse<PermissionsResponse>> {
+ 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<PermissionsResponse> = response.json()?;
+ self.observe_response_error(&result.error);
+ Ok(result)
+ }
+
+ /// Get user preferences
+ #[allow(dead_code)]
+ pub fn get_preferences(
+ &self,
+ user_id: Option<i32>,
+ ) -> Result<ApiResponse<PreferencesResponse>> {
+ 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<PreferencesResponse> = 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<i32>,
+ ) -> Result<ApiResponse<PreferencesResponse>> {
+ 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<PreferencesResponse> = response.json()?;
+ self.observe_response_error(&result.error);
+ Ok(result)
+ }
+
+ // Query Methods
+
+ /// Execute a generic query
+ pub fn query(&self, request: &QueryRequest) -> Result<ApiResponse<serde_json::Value>> {
+ 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::Value> = 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<Vec<String>>,
+ where_clause: Option<serde_json::Value>,
+ order_by: Option<Vec<OrderBy>>,
+ limit: Option<u32>,
+ ) -> Result<ApiResponse<Vec<serde_json::Value>>> {
+ 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::Value> = 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<Vec<String>>,
+ where_clause: Option<serde_json::Value>,
+ filter: Option<serde_json::Value>,
+ order_by: Option<Vec<OrderBy>>,
+ limit: Option<u32>,
+ joins: Option<Vec<Join>>,
+ ) -> Result<ApiResponse<Vec<serde_json::Value>>> {
+ 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::Value> = 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<ApiResponse<i32>> {
+ 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<ApiResponse<u32>> {
+ 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<ApiResponse<u32>> {
+ 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<serde_json::Value>,
+ ) -> Result<ApiResponse<i32>> {
+ 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<reqwest::blocking::RequestBuilder> {
+ 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
--- /dev/null
+++ b/src/assets/app-icon/AppIcon.icns
Binary files differ
diff --git a/src/assets/app-icon/AppIcon.png b/src/assets/app-icon/AppIcon.png
new file mode 100644
index 0000000..62dcc94
--- /dev/null
+++ b/src/assets/app-icon/AppIcon.png
Binary files 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<String, Value>) {
+ // Remove __editor_* keys
+ let keys: Vec<String> = 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<String, Value>, 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<String, Value>, 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<FilterOperator> {
+ 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<LogicalOperator> {
+ 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<FilterCondition>,
+ pub logical_operators: Vec<LogicalOperator>,
+}
+
+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<Value> {
+ 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<Value> {
+ 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<EditorField>,
+ pub data: HashMap<String, String>, // Store as strings for form editing
+ pub original_data: serde_json::Map<String, Value>, // Store original JSON data
+ pub show: bool,
+ pub item_id: Option<String>,
+ pub is_new: bool,
+ field_help: HashMap<String, String>,
+ pub form_help_text: Option<String>,
+ pub show_form_help: bool,
+ help_cache: CommonMarkCache,
+}
+
+impl FormBuilder {
+ pub fn new(title: impl Into<String>, fields: Vec<EditorField>) -> 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<String>) -> 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<String, Value>>) {
+ 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<Option<serde_json::Map<String, Value>>> {
+ 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::<i64>() {
+ Value::Number(n.into())
+ } else if let Ok(n) = v.parse::<f64>() {
+ 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<String>) -> Self {
+ Self {
+ label: label.into(),
+ hint: String::new(),
+ value: String::new(),
+ multiline: false,
+ }
+ }
+
+ pub fn hint(mut self, hint: impl Into<String>) -> 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<String>,
+ pub item_id: Option<String>,
+ pub show: bool,
+ pub is_dangerous: bool,
+ pub confirm_text: String,
+ pub cancel_text: String,
+ pub input_fields: Vec<ConfirmInputField>,
+}
+
+impl ConfirmDialog {
+ pub fn new(title: impl Into<String>, message: impl Into<String>) -> 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<String>, id: impl Into<String>) -> 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<String>) -> Self {
+ self.confirm_text = text.into();
+ self
+ }
+
+ #[allow(dead_code)]
+ pub fn cancel_text(mut self, text: impl Into<String>) -> 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<String>, id: impl Into<String>) {
+ 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<String, String> {
+ 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<bool> {
+ 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<DashboardStats> {
+ 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<EditorField> = 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<EditorField> = 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<String> {
+ 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<Value>,
+) -> Result<i32> {
+ 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<String>,
+ pub last_load_time: Option<std::time::Instant>,
+}
+
+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<String>) {
+ 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<u32>,
+ where_clause: Option<Value>,
+ filter: Option<Value>,
+ ) -> Result<Vec<Value>, 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<T>(
+ api: &ApiClient,
+ updated: Map<String, Value>,
+ pending_edit_ids: &mut Vec<i64>,
+ easy_dialog_item_id: Option<&str>,
+ advanced_dialog_item_id: Option<&str>,
+ find_asset_fn: impl Fn(i64) -> Option<T>,
+ limit: Option<u32>,
+ reload_fn: impl FnOnce(&ApiClient, Option<u32>),
+ ) where
+ T: serde::Serialize,
+ {
+ let ids: Vec<i64> = 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::<i64>() {
+ // 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<String, Value>) {
+ // 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::<i64>().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::<i64>() {
+ 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<String, Value>) -> Map<String, Value> {
+ // 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::<i64>() {
+ 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::<f64>() {
+ 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<String, Value>) {
+ 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<String, Value>) {
+ 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::<Vec<_>>());
+ 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<String, Value>,
+ limit: Option<u32>,
+ reload_fn: impl FnOnce(&ApiClient, Option<u32>),
+ ) -> Option<i64> {
+ 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<T>(
+ collection: &[T],
+ id: i64,
+ id_extractor: impl Fn(&T) -> Option<i64>,
+ ) -> Option<T>
+ 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<T>(
+ collection: &[T],
+ selected_rows: &std::collections::HashSet<usize>,
+ id_extractor: impl Fn(&T) -> Option<i64>,
+ ) -> Vec<i64> {
+ 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<Value> {
+ 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<CenterMode>,
+ #[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<f32>,
+ #[serde(default)]
+ pub custom_height_mm: Option<f32>,
+ // New optional direct-print fields
+ #[serde(default)]
+ pub printer_name: Option<String>,
+ #[serde(default)]
+ pub show_dialog_if_unfound: Option<bool>,
+ #[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<T>(value: &Value) -> Result<T>
+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<LabelLayout> {
+ parse_flexible_json(layout_json_value)
+}
+
+pub fn parse_printer_settings(settings_value: &Value) -> Result<PrinterSettings> {
+ 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<Self> {
+ 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<PathBuf> {
+ 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<PrinterInfo>,
+ 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<String> {
+ 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<String> {
+ 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<Mutex<PrinterManager>>);
+
+impl SharedPrinterManager {
+ pub fn new() -> Self {
+ Self(Arc::new(Mutex::new(PrinterManager::new())))
+ }
+
+ pub fn get_printers(&self) -> Vec<PrinterInfo> {
+ 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<String>,
+ #[serde(default)]
+ pub space: LayoutSpace,
+ #[serde(default)]
+ pub elements: Vec<LabelElement>,
+}
+
+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<String>,
+ #[serde(rename = "fontFamily", default)]
+ font_family: Option<String>,
+ #[serde(rename = "maxWidth", default)]
+ max_width: Option<f32>,
+ #[serde(default)]
+ wrap: Option<bool>,
+ #[serde(default)]
+ color: Option<String>,
+ },
+ QrCode {
+ field: String,
+ x: f32,
+ y: f32,
+ size: f32,
+ #[serde(rename = "showText", default)]
+ show_text: Option<bool>,
+ },
+ Barcode {
+ field: String,
+ x: f32,
+ y: f32,
+ width: f32,
+ height: f32,
+ #[serde(default)]
+ format: Option<String>,
+ #[serde(rename = "showText", default)]
+ show_text: Option<bool>,
+ },
+ DataMatrix {
+ field: String,
+ x: f32,
+ y: f32,
+ size: f32,
+ #[serde(rename = "showText", default)]
+ show_text: Option<bool>,
+ },
+ Rect {
+ x: f32,
+ y: f32,
+ width: f32,
+ height: f32,
+ #[serde(default)]
+ fill: Option<String>,
+ },
+ 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<Self> {
+ 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<String, String>,
+ 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<String, String>,
+ 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<u8> = 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, String>) -> 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<LayoutBounds> {
+ 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<egui::Color32> {
+ 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<String, String>,
+ ) -> 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<String, String>,
+ 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<u8> = 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<usize> = 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<Self> {
+ 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<String, String>,
+ 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<String> = 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<String> {
+ // 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<String> {
+ 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<String> {
+ 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("<svg") {
+ Some(data_uri.to_string())
+ } else {
+ None
+ }
+ }
+ }
+
+ fn rasterize_svg_to_rgba(svg_xml: &str, target_w: u32, target_h: u32) -> Option<Vec<u8>> {
+ 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<i64>,
+ pub printer_name: String,
+ pub label_template_id: Option<i64>,
+ 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<String, String>,
+ printers: Vec<Value>,
+ templates: Vec<Value>,
+ renderer: Option<LabelRenderer>,
+ preview_scale: f32,
+ error_message: Option<String>,
+ loading: bool,
+ // Promise for handling async PDF export
+ pdf_export_promise: Option<Promise<Option<PathBuf>>>,
+ // OS printer fallback popup
+ os_popup_visible: bool,
+ os_printers: Vec<PrinterInfo>,
+ os_selected_index: usize,
+ os_print_path: Option<PathBuf>,
+ os_error_message: Option<String>,
+ os_base_settings: Option<PrinterSettings>,
+ os_renderer: Option<LabelRenderer>,
+ 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<String, String>) -> 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<i64>,
+ label_template_id: Option<i64>,
+ last_printer_id: Option<i64>,
+ ) -> 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 { &current })
+ .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<String, String> {
+ &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<bool> {
+ 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<bool, String> {
+ // 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<PathBuf> {
+ 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<String>, field: impl Into<String>) -> 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<String>,
+ 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<usize>,
+ pub selection_anchor: Option<usize>,
+ pub last_click_time: Option<std::time::Instant>,
+ pub last_click_row: Option<usize>,
+}
+
+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<usize> {
+ 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<T> {
+ 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<ColumnConfig>,
+ pub sort_config: SortConfig,
+ pub selection: SelectionManager,
+ pub search_query: String,
+ pub search_fields: Vec<String>,
+}
+
+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<ColumnConfig>) -> 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<String>) -> 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<Value>>,
+ ) -> 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<egui::Response> = 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<serde_json::Value> {
+ 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::<serde_json::Value>(&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::<String>() + &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<Vec<serde_json::Value>> {
+ 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::<Vec<_>>())
+ );
+ }
+
+ 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<Vec<serde_json::Value>> {
+ 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::<serde_json::Value>(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::<serde_json::Value>(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::<String>() + c.as_str(),
+ None => String::new(),
+ }
+}
+
+/// Get issues with useful labels
+pub fn get_issues(api_client: &ApiClient, limit: Option<u32>) -> Result<Vec<serde_json::Value>> {
+ 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<u32>,
+ where_clause: Option<serde_json::Value>,
+ filter: Option<serde_json::Value>,
+) -> Result<Vec<serde_json::Value>> {
+ 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<serde_json::Value>,
+) -> Result<Vec<serde_json::Value>> {
+ 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<u32>,
+) -> Result<Vec<serde_json::Value>> {
+ 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<Option<Value>> {
+ 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::<Value>(&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<Option<Value>> {
+ 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::<i64>() {
+ 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<Option<Value>> {
+ 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<u32>) -> Result<Vec<Value>> {
+ 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::<Value>(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<u32>,
+) -> Result<Vec<serde_json::Value>> {
+ // 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<u32>) -> Result<Vec<serde_json::Value>> {
+ // 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<u32>,
+ overall_limit: Option<u32>,
+) -> Result<Vec<serde_json::Value>> {
+ 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<i64, u32> = 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<Vec<serde_json::Value>> {
+ // 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<i64, (i32, i32)> = 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<u32>,
+) -> Result<Vec<serde_json::Value>> {
+ 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<u32>,
+) -> Result<Vec<serde_json::Value>> {
+ 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<u32>) -> Result<Vec<serde_json::Value>> {
+ 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::<Value>(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<u32>) -> Result<Vec<serde_json::Value>> {
+ 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<Vec<serde_json::Value>> {
+ 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<Vec<serde_json::Value>> {
+ 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<u32>,
+) -> Result<Vec<serde_json::Value>> {
+ 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<Value> {
+ 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<T>(data: &[T], predicate: impl Fn(&T) -> bool) -> Vec<T>
+ 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<Value> {
+ 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<Value> {
+ 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<Value> {
+ 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<Value> {
+ 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<Value> {
+ 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<T>(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<FormBuilder>,
+ /// Current selected template data
+ selected_template: Option<Value>,
+ /// 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<AssetTagConfirmation>,
+ /// 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<String>,
+}
+
+/// Template selector component
+struct TemplateSelector {
+ /// Available templates
+ templates: Vec<Value>,
+ /// Filter text for searching templates
+ filter_text: String,
+ /// Currently selected template index
+ selected_index: Option<usize>,
+ /// Whether the selector dialog is open
+ is_open: bool,
+ /// Loading state
+ is_loading: bool,
+ /// Error message if any
+ error_message: Option<String>,
+}
+
+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<Value> {
+ 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<Value> {
+ 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<String, Vec<String>> {
+ let mut result = generation_string.to_string();
+
+ log::info!(
+ "Available asset_data keys: {:?}",
+ asset_data.as_object().map(|o| o.keys().collect::<Vec<_>>())
+ );
+
+ // 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<String, Vec<String>> {
+ 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<String> {
+ // 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::<i64>().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::<i64>().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<String> {
+ // 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::<i64>().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<std::collections::HashMap<String, String>> {
+ 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<serde_json::Value> {
+ 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<String> {
+ 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::<i64>().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::<i64>().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<Value> {
+ 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<Value> {
+ 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<String>,
+ zone_name: String,
+ _zone_type: Option<String>,
+ audit_timeout_minutes: Option<i64>,
+}
+
+impl ZoneInfo {
+ fn from_value(value: &Value) -> Result<Self> {
+ 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<i64>,
+ asset_tag: String,
+ name: String,
+ _status_before: Option<String>,
+ scan_policy: AuditScanPolicy,
+ audit_task_id: Option<i64>,
+ expected: bool,
+ _expected_zone_id: Option<i64>,
+ _actual_zone_id: Option<i64>,
+ scanned: bool,
+ status_found: String,
+ notes: String,
+ task_responses: Option<Value>,
+ additional_fields: Map<String, Value>,
+ exception_type: Option<String>,
+ exception_details: Option<String>,
+ completed_at: Option<DateTime<Utc>>,
+}
+
+impl AuditAssetState {
+ fn from_value(value: Value, expected_zone_id: Option<i64>, expected: bool) -> Result<Self> {
+ 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<String>,
+ additional_fields: Map<String, Value>,
+ 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<ZoneInfo>,
+ expected_assets: Vec<AuditAssetState>,
+ selected_asset: Option<usize>,
+ scan_input: String,
+ notes: String,
+ audit_name: String,
+ started_at: Option<DateTime<Utc>>,
+ timeout_minutes: Option<i64>,
+ last_error: Option<String>,
+ ask_dialog: ConfirmDialog,
+ pending_ask_index: Option<usize>,
+ cancel_dialog: ConfirmDialog,
+ finalize_dialog: ConfirmDialog,
+ current_task_runner: Option<TaskRunnerState>,
+ cached_tasks: HashMap<i64, AuditTaskDefinition>,
+ has_recent_completion: bool,
+ completion_snapshot: Option<AuditCompletion>,
+ user_id: Option<i64>,
+ pending_finalize: Option<PendingFinalizeIntent>,
+}
+
+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<AuditCompletion> {
+ 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(&current_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<usize> {
+ 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(&current_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<AuditTaskStep>,
+ index_by_step: HashMap<i64, usize>,
+}
+
+impl AuditTaskDefinition {
+ fn from_value(value: Value) -> Result<Self> {
+ 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::<Value>(&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::<Value>(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<AuditTaskStep> = 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<i64> {
+ if let Some(idx) = self.index_by_step.get(&current_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<String>,
+ #[serde(default)]
+ actions: HashMap<String, AuditTaskAction>,
+}
+
+#[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<i64>,
+ #[serde(default)]
+ set_status: Option<String>,
+ #[serde(default)]
+ set_additional_fields: Option<HashMap<String, String>>,
+ #[serde(default)]
+ end_audit: Option<bool>,
+}
+
+#[derive(Debug, Clone)]
+struct AuditTaskRunner {
+ definition: AuditTaskDefinition,
+ current_step: i64,
+ responses: Vec<TaskResponseEntry>,
+ is_open: bool,
+ user_input: String,
+ asset_label: String,
+ collected_fields: Map<String, Value>,
+ status_override: Option<String>,
+}
+
+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<AuditTaskOutcome> {
+ if !self.is_open {
+ return None;
+ }
+
+ let mut keep_open = self.is_open;
+ let mut completed: Option<AuditTaskOutcome> = 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<String>,
+ ) -> Option<AuditTaskOutcome> {
+ 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<Value>,
+ pub selected_asset: Option<Value>,
+ pub asset_search: String,
+ pub asset_loading: bool,
+
+ // Step 2: Borrower Selection
+ pub borrower_selection: BorrowerSelection,
+ pub registered_borrowers: Vec<Value>,
+ pub banned_borrowers: Vec<Value>,
+ 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<u32>,
+ 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<String>,
+ pub success_message: Option<String>,
+ 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<i64> {
+ 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<Value>,
+ pub selected_loan: Option<Value>,
+ 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<String>,
+ pub success_message: Option<String>,
+ 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<i64>,
+ ) -> 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<T> {
+ pub success: bool,
+ pub data: Option<T>,
+ pub error: Option<String>,
+ pub message: Option<String>,
+}
+
+/// Format backend error payloads into a readable string.
+pub fn api_error_detail(error: &Option<String>) -> 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<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub password: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub pin: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub login_string: Option<String>,
+}
+
+#[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<UserInfo>,
+ pub expires_at: Option<DateTime<Utc>>,
+ pub message: Option<String>,
+}
+
+// ============================================================================
+// 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<String>,
+}
+
+// ============================================================================
+// 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<i32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[serde(rename = "preferences")]
+ pub preferences: Option<serde_json::Value>,
+}
+
+#[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<Vec<String>>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ #[serde(rename = "data")]
+ pub data: Option<serde_json::Value>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub r#where: Option<serde_json::Value>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub filter: Option<serde_json::Value>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub order_by: Option<Vec<OrderBy>>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub limit: Option<u32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub offset: Option<u32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub joins: Option<Vec<Join>>,
+}
+
+#[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<i32>,
+ pub asset_tag: String,
+ pub asset_numeric_id: Option<i32>,
+ pub asset_type: String, // "N", "T", "C"
+ pub name: String,
+ pub description: Option<String>,
+ pub category_id: Option<i32>,
+ pub zone_id: Option<i32>,
+ pub zone_plus: Option<String>, // "Exact", "Clarify", "Deployed"
+ pub zone_note: Option<String>,
+ pub manufacturer: Option<String>,
+ pub model: Option<String>,
+ pub serial_number: Option<String>,
+ pub status: String, // "Good", "Faulty", "Scrapped", "Missing"
+ pub price: Option<f64>,
+ pub purchase_date: Option<String>,
+ pub warranty_expiry: Option<String>,
+ pub supplier_id: Option<i32>,
+ pub lendable: bool,
+ pub lending_status: Option<String>, // "Available", "Borrowed", "Deployed", "Overdue"
+ pub asset_image: Option<String>,
+ pub notes: Option<String>,
+ pub created_by: Option<i32>,
+ pub created_date: Option<DateTime<Utc>>,
+ pub last_modified_by: Option<i32>,
+ pub last_modified_date: Option<DateTime<Utc>>,
+}
+
+// ============================================================================
+// Borrower Models
+// ============================================================================
+
+#[allow(dead_code)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Borrower {
+ pub id: Option<i32>,
+ pub borrower_code: String,
+ pub name: String,
+ pub email: Option<String>,
+ pub phone: Option<String>,
+ pub borrower_type: String, // "Student", "Faculty", "Staff", "External"
+ pub department: Option<String>,
+ pub banned: bool,
+ pub unban_fine: Option<f64>,
+ pub ban_reason: Option<String>,
+ pub notes: Option<String>,
+}
+
+// ============================================================================
+// Category & Zone Models
+// ============================================================================
+
+#[allow(dead_code)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Category {
+ pub id: Option<i32>,
+ pub category_code: String,
+ pub name: String,
+ pub description: Option<String>,
+}
+
+#[allow(dead_code)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Zone {
+ pub id: Option<i32>,
+ pub zone_code: String,
+ pub name: String,
+ pub parent_zone_id: Option<i32>,
+ pub level: i32,
+ pub description: Option<String>,
+}
+
+// ============================================================================
+// Lending History
+// ============================================================================
+
+#[allow(dead_code)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LendingHistory {
+ pub id: Option<i32>,
+ pub asset_id: i32,
+ pub borrower_id: i32,
+ pub checkout_date: DateTime<Utc>,
+ pub due_date: String,
+ pub return_date: Option<DateTime<Utc>>,
+ pub status: String, // "Active", "Returned", "Overdue", "Lost"
+ pub checkout_condition: Option<String>,
+ pub return_condition: Option<String>,
+ pub notes: Option<String>,
+ pub checked_out_by: Option<i32>,
+ pub checked_in_by: Option<i32>,
+}
+
+// ============================================================================
+// Issue Tracker
+// ============================================================================
+
+#[allow(dead_code)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Issue {
+ pub id: Option<i32>,
+ pub issue_type: String, // "Asset Issue", "Borrower Issue", "System Issue"
+ pub asset_id: Option<i32>,
+ pub borrower_id: Option<i32>,
+ pub title: String,
+ pub description: Option<String>,
+ 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<String>,
+ pub solution_plus: Option<String>,
+ pub auto_detected: bool,
+ pub detection_trigger: Option<String>,
+ pub reported_by: Option<i32>,
+ pub reported_date: Option<DateTime<Utc>>,
+ pub resolved_by: Option<i32>,
+ pub resolved_date: Option<DateTime<Utc>>,
+}
+
+// ============================================================================
+// 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<String>,
+ #[serde(default)]
+ pub default_printer_id: Option<i64>,
+ /// Remember last-used printer (may differ from default_printer_id if user overrides per-print)
+ #[serde(default)]
+ pub last_printer_id: Option<i64>,
+}
+
+/// Manages user session and credentials
+pub struct SessionManager {
+ config_path: PathBuf,
+ current_session: Option<SessionData>,
+}
+
+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<String> {
+ 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<String> {
+ 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<i64>) -> 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<i64> {
+ 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<i64> {
+ 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<Mutex<SessionManager>>,
+ api_client: Option<ApiClient>,
+
+ // Current view state
+ current_view: AppView,
+ previous_view: Option<AppView>,
+ current_user: Option<UserInfo>,
+
+ // Per-view filter state storage
+ view_filter_states: HashMap<AppView, crate::core::components::filter_builder::FilterGroup>,
+
+ // 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<RibbonUI>,
+
+ // Configuration
+ #[allow(dead_code)]
+ app_config: Option<AppConfig>,
+
+ // 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<mpsc::Receiver<HealthCheckResult>>,
+ // Re-authentication prompt state
+ reauth_needed: bool,
+ reauth_password: String,
+
+ // Database outage tracking
+ db_offline_latch: bool,
+ last_timeout_at: Option<std::time::Instant>,
+ 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<Mutex<SessionManager>>,
+ ) -> 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<String> {
+ 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<Value>,
+ logs: Vec<Value>,
+ tasks: Vec<Value>,
+ is_loading: bool,
+ last_error: Option<String>,
+ init_loaded: bool,
+ workflow: AuditWorkflow,
+ zone_code_input: String,
+ start_error: Option<String>,
+ start_success: Option<String>,
+ audits_table: TableRenderer,
+ logs_table: TableRenderer,
+ tasks_table: TableRenderer,
+ tasks_loading: bool,
+ task_error: Option<String>,
+ task_success: Option<String>,
+ task_delete_dialog: ConfirmDialog,
+ pending_task_delete_id: Option<i64>,
+ pending_task_delete_name: Option<String>,
+ task_editor: AuditTaskEditor,
+}
+
+impl AuditsView {
+ pub fn new() -> Self {
+ let audit_columns = Self::build_audit_columns();
+ let log_columns = Self::build_log_columns();
+
+ Self {
+ audits: vec![],
+ logs: vec![],
+ tasks: vec![],
+ is_loading: false,
+ last_error: None,
+ init_loaded: false,
+ workflow: AuditWorkflow::new(),
+ zone_code_input: String::new(),
+ start_error: None,
+ start_success: None,
+ audits_table: TableRenderer::new()
+ .with_columns(audit_columns)
+ .with_default_sort("completed_at", false),
+ logs_table: TableRenderer::new()
+ .with_columns(log_columns)
+ .with_default_sort("audit_date", false),
+ tasks_table: TableRenderer::new()
+ .with_columns(Self::build_task_columns())
+ .with_default_sort("updated_at", false)
+ .with_search_fields(vec!["task_name".into(), "sequence_preview".into()]),
+ tasks_loading: false,
+ task_error: None,
+ task_success: None,
+ task_delete_dialog: ConfirmDialog::new(
+ "Delete Audit Task",
+ "Are you sure you want to delete this audit task? This cannot be undone.",
+ )
+ .confirm_text("Delete Task")
+ .dangerous(true),
+ pending_task_delete_id: None,
+ pending_task_delete_name: None,
+ task_editor: AuditTaskEditor::new(),
+ }
+ }
+
+ fn load(&mut self, api: &ApiClient) {
+ if self.is_loading {
+ return;
+ }
+
+ self.is_loading = true;
+ self.tasks_loading = true;
+ self.last_error = None;
+
+ match get_recent_audits(api, Some(50)) {
+ Ok(rows) => {
+ self.audits = rows;
+ self.audits_table.selection.clear_selection();
+ }
+ Err(err) => {
+ self.last_error = Some(err.to_string());
+ }
+ }
+
+ if self.last_error.is_none() {
+ match get_recent_audit_logs(api, Some(200)) {
+ Ok(rows) => {
+ self.logs = rows;
+ self.logs_table.selection.clear_selection();
+ }
+ Err(err) => {
+ self.last_error = Some(err.to_string());
+ }
+ }
+ }
+
+ if self.last_error.is_none() {
+ match get_audit_tasks(api, Some(200)) {
+ Ok(rows) => {
+ self.tasks = rows;
+ self.tasks_table.selection.clear_selection();
+ }
+ Err(err) => {
+ self.last_error = Some(err.to_string());
+ }
+ }
+ }
+
+ self.is_loading = false;
+ self.tasks_loading = false;
+ self.init_loaded = true;
+ }
+
+ fn render_launch_controls(
+ &mut self,
+ ui: &mut egui::Ui,
+ api: &ApiClient,
+ current_user_id: Option<i32>,
+ ) {
+ egui::Frame::group(ui.style())
+ .fill(ui.style().visuals.extreme_bg_color)
+ .inner_margin(egui::Margin {
+ left: 12,
+ right: 12,
+ top: 2,
+ bottom: 2,
+ })
+ .corner_radius(8.0)
+ .show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ let control_height = ui.spacing().interact_size.y;
+ let needs_error_margin = self.start_error.is_some();
+ let needs_progress_msg = self.workflow.is_active();
+
+ if !needs_error_margin {
+ let extra = if needs_progress_msg { 16.0 } else { 8.0 };
+ ui.set_max_height(control_height + extra);
+ }
+
+ if self.workflow.is_active() {
+ ui.colored_label(
+ egui::Color32::from_rgb(66, 133, 244),
+ "Audit in progress. Continue in the workflow window.",
+ );
+ }
+
+ ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
+ let btn_w: f32 = 140.0;
+
+ ui.label("Zone Code:");
+
+ // Compute input width based on remaining space after two fixed-width buttons
+ let spacing = ui.spacing().item_spacing.x;
+ let remaining = ui.available_width();
+ let reserve_for_buttons = btn_w * 2.0 + spacing * 2.0;
+ let input_w = (remaining - reserve_for_buttons).max(200.0);
+
+ let text_resp = ui.add(
+ egui::TextEdit::singleline(&mut self.zone_code_input)
+ .hint_text("ZONE-ABC")
+ .desired_width(input_w),
+ );
+
+ let disable_new = self.workflow.is_active();
+ let start_zone_clicked_button = ui
+ .add_enabled(
+ !disable_new,
+ egui::Button::new("Start Zone Audit").min_size(egui::vec2(btn_w, 0.0)),
+ )
+ .clicked();
+
+ let start_spot_clicked = ui
+ .add_enabled(
+ !disable_new,
+ egui::Button::new("Start Spot Check").min_size(egui::vec2(btn_w, 0.0)),
+ )
+ .clicked();
+
+ let start_zone_pressed_enter =
+ text_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
+ let start_zone_clicked = start_zone_clicked_button || start_zone_pressed_enter;
+
+ if start_zone_clicked {
+ if let Some(user_id) = current_user_id {
+ let code = self.zone_code_input.trim();
+ if code.is_empty() {
+ self.start_error =
+ Some("Enter a zone code to start an audit".to_string());
+ self.start_success = None;
+ } else {
+ match self.workflow.start_zone_audit(api, code, user_id as i64) {
+ Ok(()) => {
+ self.start_error = None;
+ self.start_success =
+ Some(format!("Zone audit started for {}", code));
+ self.zone_code_input.clear();
+ }
+ Err(err) => {
+ self.start_error = Some(err.to_string());
+ self.start_success = None;
+ }
+ }
+ }
+ } else {
+ self.start_error =
+ Some("You must be logged in to start an audit".to_string());
+ self.start_success = None;
+ }
+ }
+
+ if start_spot_clicked {
+ if let Some(user_id) = current_user_id {
+ self.workflow.start_spot_check(user_id as i64);
+ self.start_error = None;
+ self.start_success = Some("Spot check started".to_string());
+ } else {
+ self.start_error =
+ Some("You must be logged in to start a spot check".to_string());
+ self.start_success = None;
+ }
+ }
+ });
+
+ if let Some(err) = &self.start_error {
+ ui.add_space(4.0);
+ ui.colored_label(egui::Color32::from_rgb(244, 67, 54), err);
+ }
+
+ if self.workflow.is_active() {
+ if let Some(msg) = &self.start_success {
+ ui.add_space(4.0);
+ ui.colored_label(egui::Color32::from_rgb(76, 175, 80), msg);
+ }
+ }
+ });
+ }
+
+ fn build_audit_columns() -> Vec<ColumnConfig> {
+ vec![
+ ColumnConfig::new("ID", "id").with_width(60.0),
+ ColumnConfig::new("Type", "audit_type").with_width(90.0),
+ ColumnConfig::new("Zone", "zone_display").with_width(140.0),
+ ColumnConfig::new("Audit Name", "audit_name").with_width(160.0),
+ ColumnConfig::new("Started By", "started_by_name").with_width(140.0),
+ ColumnConfig::new("Started At", "started_at").with_width(150.0),
+ ColumnConfig::new("Completed At", "completed_at").with_width(150.0),
+ ColumnConfig::new("Status", "status").with_width(110.0),
+ ColumnConfig::new("Timeout (min)", "timeout_minutes").with_width(110.0),
+ ColumnConfig::new("Issues", "issues_summary").with_width(220.0),
+ ColumnConfig::new("Expected", "assets_expected").with_width(90.0),
+ ColumnConfig::new("Found", "assets_found").with_width(90.0),
+ ColumnConfig::new("Notes", "notes").with_width(200.0),
+ ColumnConfig::new("Cancelled Reason", "cancelled_reason").with_width(220.0),
+ ]
+ }
+
+ fn build_log_columns() -> Vec<ColumnConfig> {
+ vec![
+ ColumnConfig::new("ID", "id").with_width(60.0),
+ ColumnConfig::new("Audit ID", "physical_audit_id").with_width(80.0),
+ ColumnConfig::new("Asset", "asset_display").with_width(160.0),
+ ColumnConfig::new("Audit Date", "audit_date").with_width(140.0),
+ ColumnConfig::new("Audited By", "audited_by_name").with_width(140.0),
+ ColumnConfig::new("Status Found", "status_found").with_width(110.0),
+ ColumnConfig::new("Task ID", "audit_task_id").with_width(80.0),
+ ColumnConfig::new("Task Responses", "task_responses_text").with_width(240.0),
+ ColumnConfig::new("Exception", "exception_type").with_width(120.0),
+ ColumnConfig::new("Details", "exception_details").with_width(220.0),
+ ColumnConfig::new("Found Zone", "found_zone_display").with_width(160.0),
+ ColumnConfig::new("Action", "auditor_action").with_width(140.0),
+ ColumnConfig::new("Notes", "notes").with_width(200.0),
+ ]
+ }
+
+ fn build_task_columns() -> Vec<ColumnConfig> {
+ vec![
+ ColumnConfig::new("ID", "id").with_width(60.0),
+ ColumnConfig::new("Task Name", "task_name").with_width(180.0),
+ ColumnConfig::new("Step Count", "step_count").with_width(90.0),
+ ColumnConfig::new("Sequence Preview", "sequence_preview").with_width(280.0),
+ ColumnConfig::new("Created", "created_at").with_width(150.0),
+ ColumnConfig::new("Updated", "updated_at").with_width(150.0),
+ ]
+ }
+ pub fn show(
+ &mut self,
+ ctx: &egui::Context,
+ ui: &mut egui::Ui,
+ api_client: Option<&ApiClient>,
+ current_user_id: Option<i32>,
+ ) {
+ egui::ScrollArea::vertical()
+ .auto_shrink([false; 2])
+ .show(ui, |ui| {
+ ui.set_width(ui.available_width());
+
+ ui.horizontal(|ui| {
+ ui.heading("Audits");
+ if self.is_loading {
+ ui.spinner();
+ ui.label("Loading...");
+ }
+ if let Some(err) = &self.last_error {
+ ui.colored_label(egui::Color32::RED, err);
+ if ui.button("Refresh").clicked() {
+ if let Some(api) = api_client {
+ self.load(api);
+ }
+ }
+ } else if ui.button("Refresh").clicked() {
+ if let Some(api) = api_client {
+ self.load(api);
+ }
+ }
+ if let Some(msg) = &self.start_success {
+ if !self.workflow.is_active() {
+ ui.colored_label(egui::Color32::from_rgb(76, 175, 80), msg);
+ }
+ }
+ });
+ ui.separator();
+
+ if let Some(api) = api_client {
+ self.render_launch_controls(ui, api, current_user_id);
+ }
+
+ if !self.init_loaded {
+ if let Some(api) = api_client {
+ self.load(api);
+ }
+ }
+
+ if let Some(msg) = &self.start_success {
+ if !self.workflow.is_active() {
+ ui.add_space(6.0);
+ ui.colored_label(egui::Color32::from_rgb(76, 175, 80), msg);
+ }
+ }
+
+ self.render_summary(ui);
+
+ egui::CollapsingHeader::new("Recent Audits")
+ .default_open(true)
+ .show(ui, |ui| {
+ self.render_audits_table(ui);
+ });
+
+ ui.add_space(10.0);
+
+ egui::CollapsingHeader::new("Audit Logs")
+ .default_open(true)
+ .show(ui, |ui| {
+ self.render_logs_table(ui);
+ });
+
+ ui.add_space(10.0);
+
+ egui::CollapsingHeader::new("Audit Task Library")
+ .default_open(false)
+ .show(ui, |ui| {
+ self.render_tasks_section(ui, api_client);
+ });
+ });
+
+ if let Some(result) = self.task_editor.show(ctx) {
+ if let Some(api) = api_client {
+ self.save_task(api, result);
+ } else {
+ self.task_error =
+ Some("No API client available; cannot save audit task changes.".to_string());
+ }
+ }
+
+ if let Some(decision) = self.task_delete_dialog.show_dialog(ctx) {
+ if decision {
+ if let (Some(api), Some(id)) = (api_client, self.pending_task_delete_id) {
+ self.delete_task(api, id);
+ } else {
+ self.task_error =
+ Some("No API client available; cannot delete audit tasks.".to_string());
+ }
+ } else {
+ self.pending_task_delete_id = None;
+ self.pending_task_delete_name = None;
+ }
+ }
+
+ if let Some(api) = api_client {
+ if self.workflow.show(ctx, api) {
+ // Window stays open, nothing else to do here.
+ }
+ if let Some(completion) = self.workflow.take_recent_completion() {
+ self.load(api);
+ let banner = match completion.status.as_str() {
+ "cancelled" => "Audit cancelled".to_string(),
+ "all-good" => "Audit completed successfully".to_string(),
+ other => format!("Audit finished with status: {}", other),
+ };
+ self.start_success = Some(banner);
+ }
+ }
+ }
+
+ fn render_summary(&self, ui: &mut egui::Ui) {
+ // derive counts from loaded audits
+ let total = self.audits.len() as i64;
+ let mut in_progress = 0;
+ let mut attention = 0;
+ let mut timeout = 0;
+ let mut cancelled = 0;
+ let mut all_good = 0;
+ for a in &self.audits {
+ match a.get("status").and_then(|v| v.as_str()).unwrap_or("") {
+ "in-progress" => in_progress += 1,
+ "attention" => attention += 1,
+ "timeout" => timeout += 1,
+ "cancelled" => cancelled += 1,
+ "all-good" => all_good += 1,
+ _ => {}
+ }
+ }
+ ui.horizontal_wrapped(|ui| {
+ ui.label(egui::RichText::new(format!("Total: {}", total)).strong());
+ ui.separator();
+ chip(
+ ui,
+ format!("In progress: {}", in_progress),
+ egui::Color32::from_rgb(66, 133, 244),
+ );
+ chip(
+ ui,
+ format!("Attention: {}", attention),
+ egui::Color32::from_rgb(255, 152, 0),
+ );
+ chip(
+ ui,
+ format!("Timeout: {}", timeout),
+ egui::Color32::from_rgb(244, 67, 54),
+ );
+ chip(
+ ui,
+ format!("Cancelled: {}", cancelled),
+ egui::Color32::from_rgb(158, 158, 158),
+ );
+ chip(
+ ui,
+ format!("All good: {}", all_good),
+ egui::Color32::from_rgb(76, 175, 80),
+ );
+ });
+ ui.add_space(6.0);
+
+ fn chip(ui: &mut egui::Ui, text: String, color: egui::Color32) {
+ egui::Frame::new()
+ .fill(color.linear_multiply(0.14))
+ .corner_radius(6.0)
+ .inner_margin(egui::Margin {
+ left: 8,
+ right: 8,
+ top: 4,
+ bottom: 4,
+ })
+ .show(ui, |ui| {
+ ui.label(egui::RichText::new(text).color(color));
+ });
+ }
+ }
+
+ fn render_audits_table(&mut self, ui: &mut egui::Ui) {
+ if self.audits.is_empty() {
+ ui.label("No recent audits found.");
+ return;
+ }
+
+ let prepared = self.audits_table.prepare_json_data(&self.audits);
+ self.audits_table.render_json_table(ui, &prepared, None);
+ }
+
+ fn render_logs_table(&mut self, ui: &mut egui::Ui) {
+ if self.logs.is_empty() {
+ ui.label("No audit logs found.");
+ return;
+ }
+
+ let prepared = self.logs_table.prepare_json_data(&self.logs);
+ self.logs_table.render_json_table(ui, &prepared, None);
+ }
+
+ fn render_tasks_section(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) {
+ ui.horizontal(|ui| {
+ ui.label("Search:");
+ ui.text_edit_singleline(&mut self.tasks_table.search_query);
+ ui.separator();
+
+ let has_api = api_client.is_some();
+ if ui
+ .add_enabled(has_api, egui::Button::new("New Task"))
+ .clicked()
+ {
+ self.task_error = None;
+ self.task_success = None;
+ self.task_editor.open_new();
+ }
+
+ if ui
+ .add_enabled(has_api, egui::Button::new("Refresh"))
+ .clicked()
+ {
+ if let Some(api) = api_client {
+ self.task_error = None;
+ self.task_success = None;
+ self.refresh_tasks(api);
+ }
+ }
+ });
+
+ if let Some(err) = &self.task_error {
+ ui.add_space(6.0);
+ ui.colored_label(egui::Color32::from_rgb(244, 67, 54), err);
+ }
+
+ if let Some(msg) = &self.task_success {
+ ui.add_space(6.0);
+ ui.colored_label(egui::Color32::from_rgb(76, 175, 80), msg);
+ }
+
+ ui.add_space(6.0);
+
+ if self.tasks_loading {
+ ui.horizontal(|ui| {
+ ui.spinner();
+ ui.label("Loading audit tasks...");
+ });
+ return;
+ }
+
+ if self.tasks.is_empty() {
+ ui.label("No audit tasks found.");
+ return;
+ }
+
+ let prepared = self.tasks_table.prepare_json_data(&self.tasks);
+
+ let mut edit_task: Option<Value> = None;
+ let mut clone_task: Option<Value> = None;
+ let mut delete_task: Option<Value> = None;
+
+ struct TaskEventHandler<'a> {
+ edit_action: &'a mut Option<Value>,
+ clone_action: &'a mut Option<Value>,
+ delete_action: &'a mut Option<Value>,
+ }
+
+ impl<'a> crate::core::table_renderer::TableEventHandler<Value> for TaskEventHandler<'a> {
+ fn on_double_click(&mut self, item: &Value, _row_index: usize) {
+ *self.edit_action = Some(item.clone());
+ }
+
+ fn on_context_menu(&mut self, ui: &mut egui::Ui, item: &Value, _row_index: usize) {
+ if ui
+ .button(format!("{} Edit Task", egui_phosphor::regular::PENCIL))
+ .clicked()
+ {
+ *self.edit_action = Some(item.clone());
+ ui.close();
+ }
+
+ if ui
+ .button(format!("{} Clone Task", egui_phosphor::regular::COPY))
+ .clicked()
+ {
+ *self.clone_action = Some(item.clone());
+ ui.close();
+ }
+
+ ui.separator();
+
+ if ui
+ .button(format!("{} Delete Task", egui_phosphor::regular::TRASH))
+ .clicked()
+ {
+ *self.delete_action = Some(item.clone());
+ ui.close();
+ }
+ }
+
+ fn on_selection_changed(&mut self, _selected_indices: &[usize]) {}
+ }
+
+ let mut handler = TaskEventHandler {
+ edit_action: &mut edit_task,
+ clone_action: &mut clone_task,
+ delete_action: &mut delete_task,
+ };
+
+ self.tasks_table
+ .render_json_table(ui, &prepared, Some(&mut handler));
+
+ if let Some(task) = edit_task {
+ self.task_error = None;
+ self.task_success = None;
+ self.task_editor.open_edit(&task);
+ }
+
+ if let Some(task) = clone_task {
+ self.task_error = None;
+ self.task_success = None;
+ self.task_editor.open_clone(&task);
+ }
+
+ if let Some(task) = delete_task {
+ if let Some(id) = task.get("id").and_then(|v| v.as_i64()) {
+ let name = task
+ .get("task_name")
+ .and_then(|v| v.as_str())
+ .unwrap_or("Unnamed Task")
+ .to_string();
+ self.pending_task_delete_id = Some(id);
+ self.pending_task_delete_name = Some(name.clone());
+ self.task_delete_dialog.open(name, id.to_string());
+ }
+ }
+ }
+
+ fn refresh_tasks(&mut self, api: &ApiClient) {
+ self.tasks_loading = true;
+ match get_audit_tasks(api, Some(200)) {
+ Ok(rows) => {
+ self.tasks = rows;
+ self.tasks_table.selection.clear_selection();
+ self.task_error = None;
+ }
+ Err(err) => {
+ self.task_error = Some(err.to_string());
+ }
+ }
+ self.tasks_loading = false;
+ }
+
+ fn save_task(&mut self, api: &ApiClient, result: AuditTaskEditorResult) {
+ self.task_error = None;
+ self.task_success = None;
+
+ let AuditTaskEditorResult {
+ id,
+ name,
+ sequence,
+ is_new,
+ } = result;
+
+ let mut payload = serde_json::Map::new();
+ payload.insert("task_name".into(), Value::String(name.clone()));
+ payload.insert("json_sequence".into(), sequence);
+ let payload_value = Value::Object(payload);
+
+ if is_new {
+ match api.insert("audit_tasks", payload_value) {
+ Ok(resp) if resp.success => {
+ self.task_success = Some(format!("Created audit task \"{}\".", name));
+ self.refresh_tasks(api);
+ }
+ Ok(resp) => {
+ self.task_error = Some(format!("Insert failed: {:?}", resp.error));
+ }
+ Err(err) => {
+ self.task_error = Some(format!("Insert error: {}", err));
+ }
+ }
+ } else if let Some(task_id) = id {
+ let where_clause = json!({ "id": task_id });
+ match api.update("audit_tasks", payload_value, where_clause) {
+ Ok(resp) if resp.success => {
+ self.task_success = Some(format!("Updated audit task \"{}\".", name));
+ self.refresh_tasks(api);
+ }
+ Ok(resp) => {
+ self.task_error = Some(format!("Update failed: {:?}", resp.error));
+ }
+ Err(err) => {
+ self.task_error = Some(format!("Update error: {}", err));
+ }
+ }
+ } else {
+ self.task_error = Some("Missing task identifier; cannot update.".to_string());
+ }
+ }
+
+ fn delete_task(&mut self, api: &ApiClient, id: i64) {
+ let where_clause = json!({ "id": id });
+ match api.delete("audit_tasks", where_clause) {
+ Ok(resp) if resp.success => {
+ let name = self
+ .pending_task_delete_name
+ .take()
+ .unwrap_or_else(|| format!("Task #{id}"));
+ self.task_success = Some(format!("Deleted audit task \"{}\".", name));
+ self.refresh_tasks(api);
+ }
+ Ok(resp) => {
+ self.task_error = Some(format!("Delete failed: {:?}", resp.error));
+ }
+ Err(err) => {
+ self.task_error = Some(format!("Delete error: {}", err));
+ }
+ }
+ self.pending_task_delete_id = None;
+ self.pending_task_delete_name = None;
+ }
+}
+
+struct AuditTaskEditor {
+ open: bool,
+ is_new: bool,
+ current_id: Option<i64>,
+ task_name: String,
+ sequence_text: String,
+ error: Option<String>,
+}
+
+impl AuditTaskEditor {
+ fn new() -> Self {
+ Self {
+ open: false,
+ is_new: true,
+ current_id: None,
+ task_name: String::new(),
+ sequence_text: "[]".to_string(),
+ error: None,
+ }
+ }
+
+ fn open_new(&mut self) {
+ self.open = true;
+ self.is_new = true;
+ self.current_id = None;
+ self.task_name.clear();
+ self.sequence_text = "[]".to_string();
+ self.error = None;
+ }
+
+ fn open_edit(&mut self, task: &Value) {
+ self.open = true;
+ self.is_new = false;
+ self.current_id = task.get("id").and_then(|v| v.as_i64());
+ self.task_name = task
+ .get("task_name")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ self.sequence_text = task
+ .get("json_sequence")
+ .map(|seq| serde_json::to_string_pretty(seq).unwrap_or_else(|_| seq.to_string()))
+ .unwrap_or_else(|| "[]".to_string());
+ self.error = None;
+ }
+
+ fn open_clone(&mut self, task: &Value) {
+ self.open_edit(task);
+ self.is_new = true;
+ self.current_id = None;
+ if self.task_name.is_empty() {
+ self.task_name = "Copied Task".to_string();
+ } else {
+ self.task_name = format!("{} (Copy)", self.task_name);
+ }
+ }
+
+ fn show(&mut self, ctx: &egui::Context) -> Option<AuditTaskEditorResult> {
+ if !self.open {
+ return None;
+ }
+
+ let mut window_open = true;
+ let mut close_requested = false;
+ let mut outcome: Option<AuditTaskEditorResult> = None;
+ let title = if self.is_new {
+ "New Audit Task"
+ } else {
+ "Edit Audit Task"
+ };
+
+ let root_bounds = ctx.available_rect();
+ let screen_bounds = ctx.input(|i| {
+ i.viewport().inner_rect.unwrap_or(egui::Rect::from_min_size(
+ egui::Pos2::ZERO,
+ egui::vec2(800.0, 600.0),
+ ))
+ });
+ let horizontal_margin = 24.0_f32;
+ let vertical_margin = 24.0_f32;
+
+ let available_max_w = (root_bounds.width() - horizontal_margin).max(420.0_f32);
+ let screen_max_w = (screen_bounds.width() - horizontal_margin).max(420.0_f32);
+ let max_w = available_max_w.min(screen_max_w);
+
+ let available_max_h = (root_bounds.height() - vertical_margin).max(360.0_f32);
+ let screen_max_h = (screen_bounds.height() - vertical_margin).max(360.0_f32);
+ let max_h = available_max_h.min(screen_max_h);
+
+ let default_w = 520.0_f32.clamp(360.0_f32.min(max_w), max_w);
+ let default_h = (root_bounds.height() * 0.6_f32)
+ .max(360.0_f32)
+ .clamp(320.0_f32.min(max_h), max_h);
+ let min_w = max_w.min(380.0_f32).max(320.0_f32.min(max_w));
+ let min_h = max_h.min(340.0_f32).max(300.0_f32.min(max_h));
+
+ egui::Window::new(title)
+ .collapsible(false)
+ .resizable(true)
+ .movable(true)
+ .default_size(egui::vec2(default_w, default_h))
+ .min_size(egui::vec2(min_w, min_h))
+ .max_size(egui::vec2(max_w, max_h))
+ .constrain_to(screen_bounds.shrink2(egui::vec2(12.0_f32, 12.0_f32)))
+ .open(&mut window_open)
+ .show(ctx, |ui| {
+ if let Some(err) = &self.error {
+ ui.colored_label(egui::Color32::from_rgb(244, 67, 54), err);
+ ui.add_space(8.0);
+ }
+
+ let reserved_footer = 72.0_f32;
+ let scroll_height = (ui.available_height() - reserved_footer).max(160.0_f32);
+
+ egui::ScrollArea::vertical()
+ .max_height(scroll_height)
+ .auto_shrink([false, false])
+ .show(ui, |ui| {
+ ui.label("Task Name");
+ ui.text_edit_singleline(&mut self.task_name);
+ ui.add_space(8.0);
+
+ ui.label("JSON Sequence");
+ ui.add(
+ egui::TextEdit::multiline(&mut self.sequence_text)
+ .desired_rows(14)
+ .desired_width(f32::INFINITY),
+ );
+ });
+
+ ui.add_space(12.0);
+ ui.separator();
+ ui.add_space(8.0);
+
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ if ui.button("Save Task").clicked() {
+ let name = self.task_name.trim();
+ if name.is_empty() {
+ self.error = Some("Task name cannot be empty.".to_string());
+ } else {
+ match serde_json::from_str::<Value>(&self.sequence_text) {
+ Ok(sequence) => {
+ self.error = None;
+ outcome = Some(AuditTaskEditorResult {
+ id: self.current_id,
+ name: name.to_string(),
+ sequence,
+ is_new: self.is_new,
+ });
+ close_requested = true;
+ }
+ Err(err) => {
+ self.error = Some(format!("Invalid JSON: {}", err));
+ }
+ }
+ }
+ }
+
+ ui.add_space(12.0);
+
+ if ui.button("Cancel").clicked() {
+ close_requested = true;
+ }
+ });
+ });
+
+ if !window_open || close_requested {
+ self.open = false;
+ }
+
+ outcome
+ }
+}
+
+struct AuditTaskEditorResult {
+ id: Option<i64>,
+ name: String,
+ sequence: Value,
+ is_new: bool,
+}
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<serde_json::Value>,
+ borrowers: Vec<serde_json::Value>,
+ is_loading: bool,
+ last_error: Option<String>,
+
+ // 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<String>,
+
+ // Edit borrower dialog (using FormBuilder)
+ borrower_editor: FormBuilder,
+
+ // Ban/Unban borrower dialog
+ show_ban_dialog: bool,
+ show_unban_dialog: bool,
+ ban_borrower_data: Option<serde_json::Value>,
+ ban_fine_amount: String,
+ ban_reason: String,
+
+ // Return item confirm dialog
+ show_return_confirm_dialog: bool,
+ return_loan_data: Option<serde_json::Value>,
+
+ // Delete borrower confirm dialog
+ show_delete_borrower_dialog: bool,
+ delete_borrower_data: Option<serde_json::Value>,
+
+ // Search and filtering
+ loans_search: String,
+ borrowers_search: String,
+
+ // Navigation
+ pub switch_to_inventory_with_borrower: Option<i64>, // 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<serde_json::Value> = 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<serde_json::Value> = 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<serde_json::Value> = None;
+
+ struct LoanEventHandler<'a> {
+ return_action: &'a mut Option<serde_json::Value>,
+ }
+
+ impl<'a> crate::core::table_renderer::TableEventHandler<serde_json::Value>
+ 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<serde_json::Value> = 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<serde_json::Value> = None;
+ let mut ban_borrower: Option<serde_json::Value> = None;
+ let mut unban_borrower: Option<serde_json::Value> = None;
+ let mut delete_borrower: Option<serde_json::Value> = None;
+ let mut show_items_for_borrower: Option<i64> = None;
+
+ // Create event handler for context menu
+ struct BorrowerEventHandler<'a> {
+ edit_action: &'a mut Option<serde_json::Value>,
+ ban_action: &'a mut Option<serde_json::Value>,
+ unban_action: &'a mut Option<serde_json::Value>,
+ delete_action: &'a mut Option<serde_json::Value>,
+ show_items_action: &'a mut Option<i64>,
+ }
+
+ impl<'a> crate::core::TableEventHandler<serde_json::Value> 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<String, serde_json::Value>,
+ ) -> 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::<i64>().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::<f64>() {
+ 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<serde_json::Value>,
+ is_loading: bool,
+ last_error: Option<String>,
+ 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<i64>, // Changed from Option<i64> to Vec<i64> for bulk delete support
+ pending_edit_ids: Vec<i64>, // Changed from Option<i64> to Vec<i64> 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<i64> {
+ 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<String, Value>,
+ ) -> serde_json::Map<String, Value> {
+ 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::<i64>() {
+ 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<String, Value>,
+ ) {
+ // 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<String, Value>,
+ ) {
+ // 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<String> {
+ 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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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<i64> = 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::<i64>().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<i64>) -> 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<i64>) -> 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<DeferredCategoryAction>,
+}
+
+impl<'a> TableEventHandler<Value> 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::<String>();
+ return format!("{} {}/{}/{}", time, d, m, year_short);
+ }
+ }
+ }
+ }
+ date_str.to_string()
+}
+
+pub struct DashboardView {
+ stats: DashboardStats,
+ is_loading: bool,
+ last_error: Option<String>,
+ data_loaded: bool,
+ asset_changes: Vec<serde_json::Value>,
+ issue_changes: Vec<serde_json::Value>,
+}
+
+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<T: std::fmt::Display>(
+ &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<Value>,
+ 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<crate::core::print::PrintDialog>,
+ show_print_dialog: bool,
+
+ // Bulk action state
+ pending_delete_ids: Vec<i64>,
+ pending_edit_ids: Vec<i64>,
+ 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<u32>,
+ where_clause: Option<Value>,
+ filter: Option<Value>,
+ ) {
+ 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<u32>,
+ 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<i64> = 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<i64, (Option<String>, Option<String>)> = 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<i64, String> = 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<i64> {
+ 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::<i64>() {
+ ids.push(n);
+ }
+ }
+ }
+ }
+ ids
+ }
+
+ /// Find an asset by ID
+ fn find_asset_by_id(&self, id: i64) -> Option<Value> {
+ 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<u32>,
+ ) {
+ 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<String, Value>,
+ limit: Option<u32>,
+ ) {
+ 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<tokio::sync::Mutex<crate::session::SessionManager>>,
+ ) {
+ // 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<DeferredAction> = 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<DeferredAction>,
+ api_client: Option<&ApiClient>,
+ session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>,
+ ) {
+ 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<u32>,
+ mut data: serde_json::Map<String, Value>,
+ ) -> Option<i64> {
+ 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<tokio::sync::Mutex<crate::session::SessionManager>>,
+ ) {
+ 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<String, String>,
+ ) {
+ 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<String, String>,
+ _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<tokio::sync::Mutex<crate::session::SessionManager>>,
+ ) {
+ // 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<u32>,
+ ribbon_ui: Option<&crate::ui::ribbon::RibbonUI>,
+ session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>,
+ ) -> Vec<String> {
+ // 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<u32>,
+ session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>,
+ ) {
+ // 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<crate::core::print::PrintOptions> = None;
+ let mut completed_asset_data: Option<HashMap<String, String>> = 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<u32>,
+ session_manager: &std::sync::Arc<tokio::sync::Mutex<crate::session::SessionManager>>,
+ ) {
+ // Handle double-click edit
+ if let Some(asset) = ui
+ .ctx()
+ .data_mut(|d| d.remove_temp::<Value>(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::<Value>(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::<Value>(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::<Value>(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::<Value>(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::<Value>(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<tokio::sync::Mutex<crate::session::SessionManager>>,
+ ) -> Vec<String> {
+ 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<Value>) -> Option<Value> {
+ 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<DeferredAction>,
+}
+
+impl<'a> TableEventHandler<Value> 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<serde_json::Value>,
+ is_loading: bool,
+ last_error: Option<String>,
+ init_loaded: bool,
+ // Cached summary stats (avoid recomputing every frame)
+ summary_by_status: Vec<(String, i32)>,
+ summary_by_severity: Vec<(String, i32)>,
+ // Columns & selector
+ columns: Vec<ColumnConfig>,
+ show_column_panel: bool,
+ // Selection & interactions
+ selected_row: Option<usize>,
+ last_click_time: Option<std::time::Instant>,
+ last_click_row: Option<usize>,
+ selected_rows: HashSet<usize>,
+ selection_anchor: Option<usize>,
+ // Dialogs
+ delete_dialog: ConfirmDialog,
+ edit_dialog: FormBuilder,
+ // Track ids for operations
+ edit_current_id: Option<i64>,
+ delete_current_id: Option<i64>,
+}
+
+impl IssuesView {
+ pub fn new() -> Self {
+ let columns = vec![
+ ColumnConfig {
+ name: "Title".into(),
+ field: "title".into(),
+ visible: true,
+ },
+ ColumnConfig {
+ name: "Status".into(),
+ field: "status".into(),
+ visible: true,
+ },
+ ColumnConfig {
+ name: "Severity".into(),
+ field: "severity".into(),
+ visible: true,
+ },
+ ColumnConfig {
+ name: "Priority".into(),
+ field: "priority".into(),
+ visible: true,
+ },
+ ColumnConfig {
+ name: "Asset".into(),
+ field: "asset_label".into(),
+ visible: true,
+ },
+ ColumnConfig {
+ name: "Borrower".into(),
+ field: "borrower_name".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Assigned To".into(),
+ field: "assigned_to_name".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Auto".into(),
+ field: "auto_detected".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Trigger".into(),
+ field: "detection_trigger".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Updated".into(),
+ field: "updated_at".into(),
+ visible: true,
+ },
+ ColumnConfig {
+ name: "Created".into(),
+ field: "created_at".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Resolved".into(),
+ field: "resolved_date".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Description".into(),
+ field: "description".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Solution".into(),
+ field: "solution".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Solution+".into(),
+ field: "solution_plus".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Replacement".into(),
+ field: "replacement_asset_id".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Cost".into(),
+ field: "cost".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "Notes".into(),
+ field: "notes".into(),
+ visible: false,
+ },
+ ColumnConfig {
+ name: "ID".into(),
+ field: "id".into(),
+ visible: false,
+ },
+ ];
+ Self {
+ rows: vec![],
+ is_loading: false,
+ last_error: None,
+ init_loaded: false,
+ summary_by_status: Vec::new(),
+ summary_by_severity: Vec::new(),
+ columns,
+ show_column_panel: false,
+ selected_row: None,
+ last_click_time: None,
+ last_click_row: None,
+ selected_rows: HashSet::new(),
+ selection_anchor: None,
+ delete_dialog: ConfirmDialog::new(
+ "Delete Issue",
+ "Are you sure you want to delete this issue?",
+ ),
+ edit_dialog: FormBuilder::new(
+ "Edit Issue",
+ vec![
+ EditorField {
+ name: "title".into(),
+ label: "Title".into(),
+ field_type: FieldType::Text,
+ required: true,
+ read_only: false,
+ },
+ EditorField {
+ name: "status".into(),
+ label: "Status".into(),
+ field_type: FieldType::Text,
+ required: true,
+ read_only: false,
+ },
+ EditorField {
+ name: "severity".into(),
+ label: "Severity".into(),
+ field_type: FieldType::Text,
+ required: false,
+ read_only: false,
+ },
+ EditorField {
+ name: "priority".into(),
+ label: "Priority".into(),
+ field_type: FieldType::Text,
+ required: false,
+ read_only: false,
+ },
+ EditorField {
+ name: "description".into(),
+ label: "Description".into(),
+ field_type: FieldType::MultilineText,
+ required: false,
+ read_only: false,
+ },
+ EditorField {
+ name: "solution".into(),
+ label: "Solution".into(),
+ field_type: FieldType::MultilineText,
+ required: false,
+ read_only: false,
+ },
+ ],
+ ),
+ edit_current_id: None,
+ delete_current_id: None,
+ }
+ }
+
+ fn load(&mut self, api: &ApiClient) {
+ if self.is_loading {
+ return;
+ }
+ self.is_loading = true;
+ self.last_error = None;
+ match get_issues(api, Some(200)) {
+ Ok(mut list) => {
+ // Build asset label
+ for row in &mut list {
+ if let Some(obj) = row.as_object_mut() {
+ let asset = match (
+ obj.get("asset_tag").and_then(|v| v.as_str()),
+ obj.get("asset_name").and_then(|v| v.as_str()),
+ ) {
+ (Some(tag), Some(name)) if !tag.is_empty() => {
+ format!("{} ({})", name, tag)
+ }
+ (_, Some(name)) => name.to_string(),
+ _ => "-".to_string(),
+ };
+ obj.insert("asset_label".into(), serde_json::json!(asset));
+ }
+ }
+ self.rows = list;
+ }
+ Err(e) => self.last_error = Some(e.to_string()),
+ }
+ self.is_loading = false;
+ self.init_loaded = true;
+ self.recompute_summary();
+ }
+
+ pub fn show(&mut self, ui: &mut egui::Ui, api_client: Option<&ApiClient>) {
+ ui.horizontal(|ui| {
+ ui.heading("Issues");
+ if self.is_loading {
+ ui.spinner();
+ ui.label("Loading...");
+ }
+ if let Some(err) = &self.last_error {
+ ui.colored_label(egui::Color32::RED, err);
+ if ui.button("Refresh").clicked() {
+ if let Some(api) = api_client {
+ self.load(api);
+ }
+ }
+ } else if ui.button("Refresh").clicked() {
+ if let Some(api) = api_client {
+ self.load(api);
+ }
+ }
+ ui.separator();
+ let button_text = if self.show_column_panel {
+ "Hide Column Selector"
+ } else {
+ "Show Column Selector"
+ };
+ if ui.button(button_text).clicked() {
+ self.show_column_panel = !self.show_column_panel;
+ }
+ });
+ ui.separator();
+
+ if !self.init_loaded {
+ if let Some(api) = api_client {
+ self.load(api);
+ }
+ }
+
+ // Column selector
+ if self.show_column_panel {
+ self.show_columns_window(ui);
+ }
+
+ // Summary chips (cached)
+ self.render_summary(ui);
+
+ let visible_columns: Vec<ColumnConfig> =
+ self.columns.iter().filter(|c| c.visible).cloned().collect();
+ self.render_table(ui, &visible_columns);
+
+ // Process selection/dialog events
+ let ctx = ui.ctx();
+ // inline selection now, nothing to fetch here
+ if let Some(row_idx) =
+ ctx.data_mut(|d| d.remove_temp::<usize>(egui::Id::new("iss_double_click_idx")))
+ {
+ self.selected_row = Some(row_idx);
+ self.last_click_row = None;
+ self.last_click_time = None;
+ }
+ if let Some(item) = ctx.data_mut(|d| {
+ d.remove_temp::<serde_json::Value>(egui::Id::new("iss_double_click_edit"))
+ }) {
+ self.open_editor_with(&item);
+ ctx.request_repaint();
+ }
+ if let Some(item) = ctx.data_mut(|d| {
+ d.remove_temp::<serde_json::Value>(egui::Id::new("iss_context_menu_edit"))
+ }) {
+ self.open_editor_with(&item);
+ ctx.request_repaint();
+ }
+ if let Some(item) = ctx.data_mut(|d| {
+ d.remove_temp::<serde_json::Value>(egui::Id::new("iss_context_menu_delete"))
+ }) {
+ let title = item
+ .get("title")
+ .and_then(|v| v.as_str())
+ .unwrap_or("Unknown")
+ .to_string();
+ let id = item.get("id").and_then(|v| v.as_i64()).unwrap_or(-1);
+ self.delete_current_id = Some(id);
+ self.delete_dialog.open(title, id.to_string());
+ ctx.request_repaint();
+ }
+
+ if let Some(confirmed) = self.delete_dialog.show_dialog(ctx) {
+ if confirmed {
+ if let (Some(api), Some(id)) = (api_client, self.delete_current_id) {
+ let where_clause = serde_json::json!({"id": id});
+ match api.delete("issue_tracker", where_clause) {
+ Ok(resp) if resp.success => {
+ self.load(api);
+ }
+ Ok(resp) => {
+ self.last_error = Some(format!("Delete failed: {:?}", resp.error));
+ }
+ Err(e) => {
+ self.last_error = Some(format!("Delete error: {}", e));
+ }
+ }
+ }
+ }
+ }
+
+ if let Some(result) = self.edit_dialog.show_editor(ctx) {
+ if let Some(updated) = result {
+ if let (Some(api), Some(id)) = (api_client, self.edit_current_id) {
+ let values = serde_json::Value::Object(updated);
+ let where_clause = serde_json::json!({"id": id});
+ match api.update("issue_tracker", values, where_clause) {
+ Ok(resp) if resp.success => {
+ self.load(api);
+ }
+ Ok(resp) => {
+ self.last_error = Some(format!("Update failed: {:?}", resp.error));
+ }
+ Err(e) => {
+ self.last_error = Some(format!("Update error: {}", e));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fn render_summary(&self, ui: &mut egui::Ui) {
+ ui.horizontal_wrapped(|ui| {
+ for (status, n) in &self.summary_by_status {
+ issues_chip(ui, format!("{}: {}", status, n), color_for_status(status));
+ }
+ ui.separator();
+ for (sev, n) in &self.summary_by_severity {
+ issues_chip(ui, format!("{}: {}", sev, n), color_for_severity(sev));
+ }
+ });
+ ui.add_space(6.0);
+ }
+
+ fn recompute_summary(&mut self) {
+ use std::collections::HashMap;
+ let mut by_status: HashMap<String, i32> = HashMap::new();
+ let mut by_sev: HashMap<String, i32> = HashMap::new();
+ for r in &self.rows {
+ let status = r
+ .get("status")
+ .and_then(|v| v.as_str())
+ .unwrap_or("Unknown")
+ .to_string();
+ let sev = r
+ .get("severity")
+ .and_then(|v| v.as_str())
+ .unwrap_or("Unknown")
+ .to_string();
+ *by_status.entry(status).or_insert(0) += 1;
+ *by_sev.entry(sev).or_insert(0) += 1;
+ }
+ // Stable order for status
+ let status_order = [
+ "Open",
+ "In Progress",
+ "On Hold",
+ "Resolved",
+ "Closed",
+ "Unknown",
+ ];
+ let mut status_vec: Vec<(String, i32)> = by_status.into_iter().collect();
+ status_vec.sort_by(|a, b| {
+ let ia = status_order
+ .iter()
+ .position(|s| s.eq_ignore_ascii_case(&a.0))
+ .unwrap_or(status_order.len());
+ let ib = status_order
+ .iter()
+ .position(|s| s.eq_ignore_ascii_case(&b.0))
+ .unwrap_or(status_order.len());
+ ia.cmp(&ib)
+ .then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
+ });
+ // Stable order for severity
+ let sev_order = ["Critical", "High", "Medium", "Low", "Unknown"];
+ let mut sev_vec: Vec<(String, i32)> = by_sev.into_iter().collect();
+ sev_vec.sort_by(|a, b| {
+ let ia = sev_order
+ .iter()
+ .position(|s| s.eq_ignore_ascii_case(&a.0))
+ .unwrap_or(sev_order.len());
+ let ib = sev_order
+ .iter()
+ .position(|s| s.eq_ignore_ascii_case(&b.0))
+ .unwrap_or(sev_order.len());
+ ia.cmp(&ib)
+ .then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
+ });
+ self.summary_by_status = status_vec;
+ self.summary_by_severity = sev_vec;
+ }
+
+ fn show_columns_window(&mut self, ui: &egui::Ui) {
+ let ctx = ui.ctx();
+ let screen_rect = ctx.input(|i| {
+ i.viewport().inner_rect.unwrap_or(egui::Rect::from_min_max(
+ egui::pos2(0.0, 0.0),
+ egui::pos2(1920.0, 1080.0),
+ ))
+ });
+ let max_w = (screen_rect.width() - 20.0).max(220.0);
+ let max_h = (screen_rect.height() - 100.0).max(200.0);
+
+ egui::Window::new("Column Selector")
+ .collapsible(false)
+ .resizable(true)
+ .default_width(260.0)
+ .default_height(360.0)
+ .anchor(egui::Align2::RIGHT_TOP, [-10.0, 90.0])
+ .open(&mut self.show_column_panel)
+ .min_size(egui::vec2(220.0, 200.0))
+ .max_size(egui::vec2(max_w, max_h))
+ .frame(egui::Frame {
+ fill: egui::Color32::from_rgb(30, 30, 30),
+ stroke: egui::Stroke::new(1.0, egui::Color32::from_gray(60)),
+ inner_margin: egui::Margin::from(10.0),
+ outer_margin: egui::Margin::ZERO,
+ corner_radius: 6.0.into(),
+ shadow: egui::epaint::Shadow::NONE,
+ })
+ .show(ctx, |ui| {
+ ui.heading("Columns");
+ ui.separator();
+ ui.add_space(8.0);
+ egui::ScrollArea::vertical()
+ .auto_shrink([false; 2])
+ .show(ui, |ui| {
+ for column in &mut self.columns {
+ ui.checkbox(&mut column.visible, &column.name);
+ }
+ });
+ ui.add_space(8.0);
+ ui.separator();
+ ui.horizontal(|ui| {
+ if ui.button("Show All").clicked() {
+ for col in &mut self.columns {
+ col.visible = true;
+ }
+ }
+ if ui.button("Hide All").clicked() {
+ for col in &mut self.columns {
+ col.visible = false;
+ }
+ }
+ });
+ });
+ }
+
+ fn render_table(&mut self, ui: &mut egui::Ui, visible_columns: &Vec<ColumnConfig>) {
+ use egui_extras::{Column, TableBuilder};
+ let mut table = TableBuilder::new(ui)
+ .striped(true)
+ .resizable(true)
+ .cell_layout(egui::Layout::left_to_right(egui::Align::Center));
+ // Add checkbox column first, then the rest
+ table = table.column(Column::initial(28.0));
+ for _ in 0..visible_columns.len() {
+ table = table.column(Column::remainder());
+ }
+ table
+ .header(22.0, |mut header| {
+ // Select-all checkbox
+ header.col(|ui| {
+ let all_selected = self
+ .rows
+ .iter()
+ .enumerate()
+ .all(|(i, _)| self.selected_rows.contains(&i));
+ let mut chk = all_selected;
+ if ui
+ .checkbox(&mut chk, "")
+ .on_hover_text("Select All")
+ .clicked()
+ {
+ if chk {
+ self.selected_rows = (0..self.rows.len()).collect();
+ } else {
+ self.selected_rows.clear();
+ }
+ }
+ });
+ for col in visible_columns {
+ header.col(|ui| {
+ ui.strong(&col.name);
+ });
+ }
+ })
+ .body(|mut body| {
+ for (idx, r) in self.rows.iter().enumerate() {
+ let r_clone = r.clone();
+ let is_selected = self.selected_rows.contains(&idx);
+ body.row(20.0, |mut row| {
+ if is_selected {
+ row.set_selected(true);
+ }
+ // Checkbox cell
+ row.col(|ui| {
+ let mut checked = self.selected_rows.contains(&idx);
+ let resp = ui.checkbox(&mut checked, "");
+ if resp.changed() {
+ let mods = ui.input(|i| i.modifiers);
+ if mods.shift {
+ let anchor = self.selection_anchor.unwrap_or(idx);
+ let (a, b) = if anchor <= idx {
+ (anchor, idx)
+ } else {
+ (idx, anchor)
+ };
+ for i in a..=b {
+ if checked {
+ self.selected_rows.insert(i);
+ } else {
+ self.selected_rows.remove(&i);
+ }
+ }
+ } else if mods.command || mods.ctrl {
+ if checked {
+ self.selected_rows.insert(idx);
+ } else {
+ self.selected_rows.remove(&idx);
+ }
+ self.selection_anchor = Some(idx);
+ } else {
+ self.selected_rows.clear();
+ if checked {
+ self.selected_rows.insert(idx);
+ self.selection_anchor = Some(idx);
+ }
+ }
+ }
+ });
+ // Data columns
+ let mut combined: Option<egui::Response> = None;
+ for col in visible_columns {
+ row.col(|ui| {
+ let resp = render_issue_cell(ui, &r_clone, &col.field);
+ combined = Some(match combined.take() {
+ Some(p) => p.union(resp),
+ None => resp,
+ });
+ });
+ }
+ let mut row_resp = row.response();
+ if let Some(c) = combined {
+ row_resp = row_resp.union(c);
+ }
+ if row_resp.clicked() {
+ let now = std::time::Instant::now();
+ let dbl = if let (Some(t), Some(rw)) =
+ (self.last_click_time, self.last_click_row)
+ {
+ rw == idx && now.duration_since(t).as_millis() < 500
+ } else {
+ false
+ };
+ if dbl {
+ row_resp.ctx.data_mut(|d| {
+ d.insert_temp(egui::Id::new("iss_double_click_idx"), idx);
+ d.insert_temp(
+ egui::Id::new("iss_double_click_edit"),
+ r_clone.clone(),
+ );
+ });
+ } else {
+ // Multi-select on row click
+ let mods = row_resp.ctx.input(|i| i.modifiers);
+ if mods.shift {
+ let anchor = self.selection_anchor.unwrap_or(idx);
+ let (a, b) = if anchor <= idx {
+ (anchor, idx)
+ } else {
+ (idx, anchor)
+ };
+ for i in a..=b {
+ self.selected_rows.insert(i);
+ }
+ } else if mods.command || mods.ctrl {
+ if self.selected_rows.contains(&idx) {
+ self.selected_rows.remove(&idx);
+ } else {
+ self.selected_rows.insert(idx);
+ }
+ self.selection_anchor = Some(idx);
+ } else {
+ self.selected_rows.clear();
+ self.selected_rows.insert(idx);
+ self.selection_anchor = Some(idx);
+ }
+ self.last_click_time = Some(now);
+ self.last_click_row = Some(idx);
+ }
+ }
+ row_resp.context_menu(|ui| {
+ if ui
+ .button(format!("{} Edit", egui_phosphor::regular::PENCIL))
+ .clicked()
+ {
+ ui.ctx().data_mut(|d| {
+ d.insert_temp(
+ egui::Id::new("iss_context_menu_edit"),
+ r_clone.clone(),
+ )
+ });
+ ui.close();
+ }
+ ui.separator();
+ if ui
+ .button(format!("{} Delete", egui_phosphor::regular::TRASH))
+ .clicked()
+ {
+ ui.ctx().data_mut(|d| {
+ d.insert_temp(
+ egui::Id::new("iss_context_menu_delete"),
+ r_clone.clone(),
+ )
+ });
+ ui.close();
+ }
+ });
+ });
+ }
+ });
+ }
+
+ fn open_editor_with(&mut self, item: &serde_json::Value) {
+ self.edit_current_id = item.get("id").and_then(|v| v.as_i64());
+ self.edit_dialog.open(item);
+ }
+}
+
+fn issues_chip(ui: &mut egui::Ui, text: String, color: egui::Color32) {
+ egui::Frame::new()
+ .fill(color.linear_multiply(0.14))
+ .corner_radius(6.0)
+ .inner_margin(egui::Margin {
+ left: 8_i8,
+ right: 8_i8,
+ top: 4_i8,
+ bottom: 4_i8,
+ })
+ .show(ui, |ui| {
+ ui.label(egui::RichText::new(text).color(color));
+ });
+}
+
+fn color_for_status(status: &str) -> egui::Color32 {
+ match status.to_lowercase().as_str() {
+ "open" => egui::Color32::from_rgb(244, 67, 54),
+ "in progress" => egui::Color32::from_rgb(255, 152, 0),
+ "on hold" => egui::Color32::from_rgb(121, 85, 72),
+ "resolved" => egui::Color32::from_rgb(76, 175, 80),
+ "closed" => egui::Color32::from_rgb(96, 125, 139),
+ _ => egui::Color32::GRAY,
+ }
+}
+
+fn color_for_severity(sev: &str) -> egui::Color32 {
+ match sev.to_lowercase().as_str() {
+ "critical" => egui::Color32::from_rgb(244, 67, 54),
+ "high" => egui::Color32::from_rgb(255, 152, 0),
+ "medium" => egui::Color32::from_rgb(66, 165, 245),
+ "low" => egui::Color32::from_rgb(158, 158, 158),
+ _ => egui::Color32::GRAY,
+ }
+}
+
+fn label_trunc(ui: &mut egui::Ui, text: &str, max: usize) -> egui::Response {
+ if text.len() > max {
+ let short = format!("{}…", &text[..max]);
+ ui.label(short).on_hover_ui(|ui| {
+ ui.label(text);
+ })
+ } else {
+ ui.label(text)
+ }
+}
+
+fn render_issue_cell(ui: &mut egui::Ui, row: &serde_json::Value, field: &str) -> egui::Response {
+ let t = |k: &str| row.get(k).and_then(|v| v.as_str()).unwrap_or("");
+ match field {
+ "id" => ui.label(
+ row.get("id")
+ .and_then(|v| v.as_i64())
+ .unwrap_or(0)
+ .to_string(),
+ ),
+ "title" => label_trunc(ui, t("title"), 60),
+ "status" => ui.label(t("status")),
+ "severity" => ui.label(t("severity")),
+ "priority" => ui.label(t("priority")),
+ "asset_label" => ui.label(t("asset_label")),
+ "borrower_name" => ui.label(t("borrower_name")),
+ "assigned_to_name" => ui.label(t("assigned_to_name")),
+ "auto_detected" => ui.label(
+ if row
+ .get("auto_detected")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false)
+ {
+ "Yes"
+ } else {
+ "No"
+ },
+ ),
+ "detection_trigger" => label_trunc(ui, t("detection_trigger"), 40),
+ "updated_at" => ui.label(t("updated_at")),
+ "created_at" => ui.label(t("created_at")),
+ "resolved_date" => ui.label(t("resolved_date")),
+ "description" => label_trunc(ui, t("description"), 80),
+ "solution" => label_trunc(ui, t("solution"), 80),
+ "solution_plus" => label_trunc(ui, t("solution_plus"), 80),
+ "replacement_asset_id" => ui.label(
+ row.get("replacement_asset_id")
+ .and_then(|v| v.as_i64())
+ .unwrap_or(0)
+ .to_string(),
+ ),
+ "cost" => ui.label(match row.get("cost") {
+ Some(serde_json::Value::Number(n)) => n.to_string(),
+ Some(serde_json::Value::String(s)) => s.clone(),
+ _ => String::new(),
+ }),
+ "notes" => label_trunc(ui, t("notes"), 80),
+ other => ui.label(format!("{}", other)),
+ }
+}
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<serde_json::Value>,
+ is_loading: bool,
+ last_error: Option<String>,
+ 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<i64>,
+ pending_edit_id: Option<i64>,
+}
+
+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<DeferredAction> = 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<DeferredAction>,
+ _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<String> = 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::<serde_json::Value>(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<String> = 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::<serde_json::Value>(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::<Value>(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::<Value>(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::<Value>(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<DeferredAction>,
+}
+
+impl<'a> TableEventHandler<Value> 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<String>,
+ is_logging_in: bool,
+
+ // For async operations
+ login_receiver: Option<Receiver<Result<(String, LoginResponse), String>>>,
+}
+
+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<serde_json::Value>,
+ is_loading: bool,
+ last_error: Option<String>,
+ 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<i64>,
+ pending_edit_id: Option<i64>,
+
+ // Navigation
+ pub switch_to_print_history: bool,
+
+ // Track last selected plugin to detect changes
+ last_add_dialog_plugin: Option<String>,
+}
+
+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<tokio::sync::Mutex<crate::session::SessionManager>>,
+ ) {
+ 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::<i64>() {
+ // 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<tokio::sync::Mutex<crate::session::SessionManager>>,
+ ) {
+ // 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<DeferredAction> = 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<DeferredAction>,
+ _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<String> = 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::<serde_json::Value>(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<String> = 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::<serde_json::Value>(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::<Value>(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::<Value>(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::<Value>(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<DeferredAction>,
+}
+
+impl<'a> TableEventHandler<Value> 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<String, String>,
+ pub checkboxes: HashMap<String, bool>,
+ pub number_fields: HashMap<String, u32>,
+ 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<String> {
+ Some(self.active_tab.clone())
+ }
+
+ pub fn show(&mut self, _ctx: &egui::Context, ui: &mut egui::Ui) -> Option<String> {
+ // 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::<u32>() {
+ *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::<u32>() {
+ *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::<i64>().ok()
+ };
+
+ if printers_json.is_empty() {
+ ui.label("(Loading...)");
+ } else {
+ // Parse printer list
+ let printers: Vec<serde_json::Value> =
+ 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(&current_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<serde_json::Value>,
+ display_rows: Vec<serde_json::Value>,
+ is_loading: bool,
+ last_error: Option<String>,
+ init_loaded: bool,
+ // Columns & selector
+ columns: Vec<ColumnConfig>,
+ show_column_panel: bool,
+ // Selection & interactions
+ selected_row: Option<usize>,
+ last_click_time: Option<std::time::Instant>,
+ last_click_row: Option<usize>,
+ selected_rows: HashSet<usize>,
+ selection_anchor: Option<usize>,
+ // Dialogs
+ delete_dialog: ConfirmDialog,
+ edit_dialog: FormBuilder,
+ add_dialog: FormBuilder,
+ // Track ids for operations
+ edit_current_id: Option<i64>,
+ delete_current_id: Option<i64>,
+}
+
+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<String> {
+ 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<ColumnConfig> =
+ 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::<usize>(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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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::<serde_json::Value>(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<ColumnConfig>) {
+ use egui_extras::{Column, TableBuilder};
+ let mut table = TableBuilder::new(ui)
+ .striped(true)
+ .resizable(true)
+ .cell_layout(egui::Layout::left_to_right(egui::Align::Center));
+ // Add 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<egui::Response> = 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<serde_json::Value> {
+ 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<usize> {
+ 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<String, serde_json::Value>) {
+ let meta_keys: Vec<String> = 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<serde_json::Value>,
+ loading_state: LoadingState,
+ table_renderer: TableRenderer,
+ show_column_panel: bool,
+ edit_dialog: FormBuilder,
+ pending_delete_ids: Vec<i64>,
+}
+
+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<EditorField> = 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<serde_json::Value>,
+ ) -> Result<Option<serde_json::Value>, 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::<serde_json::Value>(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<u32>) {
+ 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<String> {
+ 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<serde_json::Value> = None;
+ let mut delete_template: Option<serde_json::Value> = None;
+ let mut clone_template: Option<serde_json::Value> = None;
+
+ struct TemplateEventHandler<'a> {
+ edit_action: &'a mut Option<serde_json::Value>,
+ delete_action: &'a mut Option<serde_json::Value>,
+ clone_action: &'a mut Option<serde_json::Value>,
+ }
+
+ impl<'a> crate::core::table_renderer::TableEventHandler<serde_json::Value>
+ 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::<Vec<_>>()
+ );
+ if let Some(api) = api_client {
+ let mut id_from_updated: Option<i64> = 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::<i64>().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::<i64>().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<serde_json::Value>,
+ is_loading: bool,
+ last_error: Option<String>,
+ // UI state
+ show_items: bool,
+ search_query: String,
+ // Cache: assets per zone id
+ zone_assets: HashMap<i32, Vec<serde_json::Value>>,
+ // Request guards
+ initial_load_done: bool,
+ zone_assets_attempted: HashSet<i32>,
+ zone_assets_failed: HashSet<i32>,
+ // Editor dialogs for zones
+ edit_dialog: FormBuilder,
+ add_dialog: FormBuilder,
+ delete_dialog: ConfirmDialog,
+ // Pending operation
+ pending_delete_id: Option<i32>,
+ pending_parent_id: Option<i32>, // For "Add Child Zone"
+ // Navigation request
+ pub switch_to_inventory_with_zone: Option<String>, // zone_code to filter by
+ // Print dialog
+ print_dialog: Option<crate::core::print::PrintDialog>,
+ show_print_dialog: bool,
+ force_expand_state: Option<bool>,
+}
+
+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<serde_json::Value>) {
+ 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<serde_json::Value> = 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<Option<i32>, Vec<serde_json::Value>> = 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<Option<i32>, Vec<serde_json::Value>>,
+ 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<String, serde_json::Value>,
+ ) {
+ 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::<i64>() {
+ 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<String, serde_json::Value>,
+ ) {
+ 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::<i64>() {
+ 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();
+ }
+}