aboutsummaryrefslogtreecommitdiff
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
committing to insanityHEADmaster
-rw-r--r--.gitignore19
-rw-r--r--Cargo.toml70
-rw-r--r--LICENSE1
-rw-r--r--README.md192
-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
58 files changed, 29481 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e924706
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,19 @@
+# Ignore Rust build artifacts
+/target/
+Cargo.lock
+
+# Ignore vscodes bs files
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# Ignore macOS files
+.DS_Store
+
+# Ignore session data
+session.json
+
+# Ignore logs
+*.log
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..c90c155
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,70 @@
+[package]
+name = "beepzone-egui"
+version = "0.0.8"
+edition = "2021"
+authors = ["crt"]
+description = "BeepZone Client eGUI Emo Edition"
+
+[dependencies]
+# Egui my beloved
+egui = "0.33"
+eframe = { version = "0.33", default-features = true, features = ["default_fonts", "persistence"] }
+egui_extras = { version = "0.33", features = ["image"] }
+egui-phosphor = "0.11"
+egui_form = { version = "0.7", features = ["validator_garde"] }
+garde = { version = "0.22", features = ["derive"] }
+egui_commonmark = { version = "0.22", features = ["better_syntax_highlighting"] }
+
+# mmmm notworking stuffs
+reqwest = { version = "0.12", features = ["json", "blocking"] }
+tokio = { version = "1.40", features = ["full"] }
+
+# jayson derulo
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+
+# basics
+chrono = { version = "0.4", features = ["serde"] }
+base64 = "0.21"
+anyhow = "1.0"
+regex = "1.10"
+thiserror = "1.0"
+dirs = "5.0"
+log = "0.4"
+env_logger = "0.11"
+rand = "0.8"
+
+
+# printin (no touchy this was hell)
+# Reverted to printpdf 0.7.0 due to upstream 0.8.x svg2pdf/write-fonts compile issues (unresolved as of 2025-11-09).
+# 0.7.0 provides stable core PDF features we use (images, text positioning) without the failing dependency chain.
+printpdf = "0.7.0"
+lopdf = "0.31"
+qrcodegen = "1.8"
+barcoders = "2.0"
+image = "0.25"
+datamatrix = "0.3"
+
+# svg hell
+usvg = "0.43"
+resvg = { version = "0.43", default-features = false, features = ["text"] }
+tiny-skia = "0.11"
+
+# not sure if all still needed check someday tm
+rfd = "0.15"
+open = "5.3"
+poll-promise = "0.3"
+
+# Printer itself
+printers = "2.2.0"
+
+# more basics
+
+[profile.release]
+opt-level = 3
+lto = true
+strip = true
+codegen-units = 1
+
+[profile.dev]
+opt-level = 1 \ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..a83ef26
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1 @@
+GPL-3.0 \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0ce737f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,192 @@
+# BeepZone Desktop Client (eGUI EMO Edition)
+very very very early beta version atleast fr
+
+desktop client for the BeepZone asset management "system", built with rust + egui (dont ask why egui i forgor).
+
+## Features
+
+### Core funzionality
+- **Asset Management**: Browse, search, add, edit, clone, and delete assets and more to come ...
+- **Borrowing System**: Easy checkout and return workflows with borrower table and lending status "tracking"
+- **Zone Management**: Tree zone view with recursive filtering and item assignment
+- **Label Printing**: flexible label template system with support for QR codes, barcodes, DataMatrix codes, and sofar direct CUPS printer integration (never tested on Fenster, works on Pinguin and Macdonalds. hella buggy and doesnt work with certain Kyocera Net Printers yet)
+- **Issue Tracking**: Report and manage asset issues with status tracking (not yet finished)
+- **Audit System**: Track asset changes and user actions (Todo Implement unexpected Item Zone Actions)
+- **Category Management**: Organize assets by categories and subcats
+- **Supplier Management**: Track suppliers and their contact information
+
+### tehnikal features
+- **Multiple Authentication**: Password, PIN, and RFID/Token login support (Meant for future kiosk usage only password rn implemented)
+- **Session Management**: "Persistent" login sessions with "secure" token storage
+- **Advanced Search**: Realtime filtering across multiple fields with debouncing
+- **Table Rendering**: Sortable, searchable tables with context menus, Filter Builder and row selection
+- **Form Validation**: Built in validation with help system using egui_form + garde
+- **Print System**:
+ - Multiple printer plugins (System, PDF ... more are planed like pTouch and remote printers through API maybe)
+ - Custom label templates with JSON based layout system
+ - Support for custom paper sizes and orientations
+- **cross platform**: should run on all major 3 OS'es (Pinguin, Macdonalds, Fenster)
+- **somewhat lightweight**: it's not fat and should run on any potato !
+
+## disclosure !
+beepzone and its stack were all made as a passion project with initial works starting sometime in late October 2024 (more yappin and details as to why i made this ill add later maybe)
+
+I fully admit that :
+- I have had zero experience in front end developer work when starting this (and still alot of times feel like i kinda dont know what im doing)
+- Have only some bare minimal rust programming skills which i mainly previously only used for small backend type applications
+- Barely document my spaghetti code
+
+Therefore I did use agentic LLM models during this project for the following :
+- Code and Config Comments since I often myself forget what does what or write them in a way only I understand with lots of swearing or just in straight up Swiss-German
+- Loads and loads of bug fixing
+- Placeholders/Templates for Views
+- Initial Structure of the Desktop Clients Code (as a template)
+- Certain features/patches/repetitive work where I was too lazy to do it myself and considered them to be simple work for an agentic LLM model
+
+I hope that by roughly disclosing where I used none human work I can bring more honesty to the table unlike certain projects I've recently seen on github that dont disclose that at all yet the entire repository reeks of AI SLOP which honestly just feels dishonest and stinks (and sometimes even charge money for the product that barely works).
+
+## prereqs
+
+- rust 1.70 or later recommended install from here : [rustup website](https://rustup.rs/)
+- beepzone schema with atleast one admin user served using seckelapi
+
+## Installation
+
+### build from source
+
+```bash
+cd /path/to/beepzone-egui
+cargo build --release
+```
+
+binary will be at `target/release/beepzone-egui`
+
+### run dev build
+
+```sh
+cargo run
+```
+
+### run release build
+
+```sh
+cargo run --release
+```
+
+## project "structure"
+
+```
+beepzone-egui/
+├── Cargo.toml
+├── src/
+│ ├── main.rs # Entry point, font/style configuration
+│ ├── api.rs # API client for backend communication
+│ ├── config.rs # Configuration management
+│ ├── models.rs # Data models and API types
+│ ├── session.rs # Session persistence and management
+│ ├── core/ # Core business logic
+│ │ ├── mod.rs
+│ │ ├── asset_fields.rs # Asset field definitions
+│ │ ├── asset_operations.rs
+│ │ ├── borrow_flow.rs # Multi-step checkout workflow
+│ │ ├── return_flow.rs # Multi-step return workflow
+│ │ ├── counters.rs
+│ │ ├── data_loader.rs
+│ │ ├── editor.rs
+│ │ ├── filter_builder.rs
+│ │ ├── interactions.rs
+│ │ ├── search.rs
+│ │ ├── stats.rs
+│ │ ├── table_renderer.rs
+│ │ ├── tables.rs # Database table queries
+│ │ ├── components/ # Reusable components
+│ │ │ ├── clone.rs
+│ │ │ ├── form_builder.rs
+│ │ │ ├── help.rs
+│ │ │ └── md.rs
+│ │ ├── print/ # Printing system
+│ │ │ ├── mod.rs
+│ │ │ ├── parsing.rs
+│ │ │ ├── printer_manager.rs
+│ │ │ ├── renderer.rs
+│ │ │ ├── plugins/
+│ │ │ └── ui/
+│ │ └── workflows/ # Not yet implemented
+│ └── ui/ # UI views
+│ ├── mod.rs
+│ ├── app.rs # Main application state
+│ ├── audits.rs
+│ ├── borrowing.rs # Borrower management
+│ ├── categories.rs
+│ ├── components.rs # Shared UI components
+│ ├── dashboard.rs
+│ ├── inventory.rs # Asset inventory view
+│ ├── issues.rs
+│ ├── label_templates.rs
+│ ├── login.rs
+│ ├── printers.rs
+│ ├── ribbon.rs # Top navigation ribbon
+│ ├── suppliers.rs
+│ ├── templates.rs # Not yet implemented
+│ └── zones.rs
+└── docs/ # Documentation and examples
+```
+
+## configuration
+
+### session storage
+
+session data should be found here incase you need to delete it:
+- Macdonalds: `~/Library/Application Support/beepzone/session.json`
+- Pinguin: `~/.config/beepzone/session.json`
+- Fenster: `%APPDATA%\beepzone\session.json`
+
+### default settings
+
+- server url: `http://localhost:5777` (until a usable release exists)
+- configurable via the login screen
+
+## development
+
+### adding new views
+
+1. Create module in `src/ui/`
+2. Add to `src/ui/mod.rs`
+3. Add enum variant to `AppView` in `src/ui/app.rs`
+4. Implement in main `update()` match
+
+
+### debugging
+
+enable debug logging:
+```bash
+RUST_LOG=debug cargo run
+```
+
+log levels: `error`, `warn`, `info` (default), `debug`, `trace`
+
+## planned stuff
+
+- make it actuall support backends RBAC lol
+- Finish Audit workflow in regards to items in wrong zones and UI Polishing
+- Reporting and analytics (allow printing paper reports on rooms for non technical people)
+- Global Settings/preferences/adminier
+- Bulk import/export functionality
+- Flag to launch in kiosk mode but thats far from now
+- Asset image display and file attachments (technically supported by backend and schema was simply too lazy)
+- Properly implementing issue view (currently a "design" concept was slapped there by an LLM)
+- Adminier Panel
+- Fixing up of user preference json stuff
+- Dashboard overhaul (more of a concept slapper in place rn)
+- Item Replacement system
+- Item Relationship System
+- Bunch of testing
+- View Item Right Click and Double Click thingy instead of old E-Edit maybe
+- Caching to some extent to make it more wireless terminal capable
+- localization someday maybe
+- less terrible backend connection checking and failsafes
+- more I forgor
+
+## license
+
+beepzone and everything of in its stack is GPL-3.0 i think
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();
+ }
+}