diff options
| author | UMTS at Teleco <crt@teleco.ch> | 2025-12-13 02:51:15 +0100 |
|---|---|---|
| committer | UMTS at Teleco <crt@teleco.ch> | 2025-12-13 02:51:15 +0100 |
| commit | 8323fdd73272a2882781aba3c499ba0be3dff2a6 (patch) | |
| tree | ffbf86473933e69cfaeef30d5c6ea7e5b494856c /src/core | |
Diffstat (limited to 'src/core')
32 files changed, 14416 insertions, 0 deletions
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 { ¤t }) + .show_ui(ui, |ui| { + for (i, p) in self.os_printers.iter().enumerate() { + if ui + .selectable_label(i == self.os_selected_index, &p.name) + .clicked() + { + self.os_selected_index = i; + } + } + }); + } + ui.separator(); + + if let Some(base) = &self.os_base_settings { + let saved_label = format!( + "Use saved ({})", + base.paper_size.as_str() + ); + + egui::ComboBox::from_id_salt("os_size_override") + .selected_text(match self.os_size_override { + PaperSizeOverride::UseSaved => saved_label.clone(), + PaperSizeOverride::A4 => "A4 (210×297 mm)".into(), + PaperSizeOverride::Letter => "Letter (215.9×279.4 mm)".into(), + PaperSizeOverride::Custom => "Custom size".into(), + }) + .show_ui(ui, |ui| { + if ui + .selectable_value( + &mut self.os_size_override, + PaperSizeOverride::UseSaved, + saved_label, + ) + .clicked() + { + self.os_error_message = None; + } + if ui + .selectable_value( + &mut self.os_size_override, + PaperSizeOverride::A4, + "A4 (210×297 mm)", + ) + .clicked() + { + self.os_error_message = None; + } + if ui + .selectable_value( + &mut self.os_size_override, + PaperSizeOverride::Letter, + "Letter (215.9×279.4 mm)", + ) + .clicked() + { + self.os_error_message = None; + } + if ui + .selectable_value( + &mut self.os_size_override, + PaperSizeOverride::Custom, + "Custom size", + ) + .clicked() + { + if base.custom_width_mm.is_some() + && base.custom_height_mm.is_some() + { + let (w, h) = base.get_dimensions_mm(); + self.os_custom_width_mm = w; + self.os_custom_height_mm = h; + } else { + self.os_custom_width_mm = 0.0; + self.os_custom_height_mm = 0.0; + } + self.os_error_message = None; + } + }); + + if matches!(self.os_size_override, PaperSizeOverride::Custom) { + ui.vertical(|ui| { + ui.label("Custom page size (mm)"); + ui.horizontal(|ui| { + ui.label("Width:"); + ui.add( + egui::DragValue::new(&mut self.os_custom_width_mm) + .range(10.0..=600.0) + .suffix(" mm"), + ); + ui.label("Height:"); + ui.add( + egui::DragValue::new(&mut self.os_custom_height_mm) + .range(10.0..=600.0) + .suffix(" mm"), + ); + }); + }); + } + } + + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + self.os_print_path = None; + self.os_base_settings = None; + close_os_popup = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let can_print = !self.os_printers.is_empty() + && self + .os_printers + .get(self.os_selected_index) + .is_some(); + ui.add_enabled_ui(can_print, |ui| { + if ui.button("Print").clicked() { + let selected_name = self + .os_printers + .get(self.os_selected_index) + .map(|p| p.name.clone()); + if let Some(name) = selected_name { + match self.print_via_os_popup(&name) { + Ok(true) => { + self.os_print_path = None; + self.os_base_settings = None; + close_os_popup = true; + print_action_complete = true; + close_dialog = true; + } + Ok(false) => { /* not used: function only returns true on success */ } + Err(e) => { + self.os_error_message = Some(e); + } + } + } + } + }); + }); + }); + }); + // Apply window close state after rendering + if !keep_open_flag { + close_os_popup = true; + } + if close_os_popup { + self.os_popup_visible = false; + self.os_base_settings = None; + } + } + + if close_dialog { + *open = false; + } + + print_action_complete + } + + /// Show options section + fn show_options(&mut self, ui: &mut egui::Ui) { + ui.heading("Print Options"); + ui.add_space(8.0); + + egui::Grid::new("print_options_grid") + .num_columns(2) + .spacing([8.0, 8.0]) + .show(ui, |ui| { + // Printer selection + ui.label("Printer:"); + egui::ComboBox::from_id_salt("printer_select") + .selected_text(&self.options.printer_name) + .width(300.0) + .show_ui(ui, |ui| { + for printer in &self.printers { + let printer_id = printer.get("id").and_then(|v| v.as_i64()); + let printer_name = printer + .get("printer_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + + if ui + .selectable_label( + self.options.printer_id == printer_id, + printer_name, + ) + .clicked() + { + self.options.printer_id = printer_id; + self.options.printer_name = printer_name.to_string(); + // Try to parse printer settings for preview (if provided by the DB row) + if let Some(ps_val) = printer.get("printer_settings") { + match parse_printer_settings(ps_val) { + Ok(ps) => { + self.os_base_settings = Some(ps); + } + Err(e) => { + log::warn!( + "Failed to parse printer_settings for preview: {}", + e + ); + self.os_base_settings = None; + } + } + } else { + self.os_base_settings = None; + } + } + } + }); + ui.end_row(); + + // Template selection + ui.label("Label Template:"); + egui::ComboBox::from_id_salt("template_select") + .selected_text(&self.options.label_template_name) + .width(300.0) + .show_ui(ui, |ui| { + for template in &self.templates { + let template_id = template.get("id").and_then(|v| v.as_i64()); + let template_name = template + .get("template_name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + + if ui + .selectable_label( + self.options.label_template_id == template_id, + template_name, + ) + .clicked() + { + self.options.label_template_id = template_id; + self.options.label_template_name = template_name.to_string(); + + // Update renderer + if let Some(layout_json) = + template.get("layout_json").and_then(|v| v.as_str()) + { + match LabelRenderer::from_json(layout_json) { + Ok(renderer) => { + self.renderer = Some(renderer); + self.error_message = None; + } + Err(e) => { + log::warn!("Failed to parse label layout: {}", e); + self.error_message = + Some(format!("Invalid template: {}", e)); + self.renderer = None; + } + } + } + } + } + }); + ui.end_row(); + + // Number of copies + ui.label("Copies:"); + ui.add(egui::DragValue::new(&mut self.options.copies).range(1..=99)); + ui.end_row(); + }); + } + + /// Show preview section + fn show_preview(&mut self, ui: &mut egui::Ui) { + ui.add_space(8.0); + ui.heading("Preview"); + ui.add_space(8.0); + + // Preview scale control + ui.horizontal(|ui| { + ui.label("Scale:"); + ui.add(egui::Slider::new(&mut self.preview_scale, 2.0..=8.0).suffix("x")); + }); + + ui.add_space(8.0); + + // Render preview + if let Some(renderer) = &self.renderer { + egui::ScrollArea::both() // Enable both horizontal and vertical scrolling + .max_height(300.0) + .auto_shrink([false, false]) // Don't shrink in either direction + .show(ui, |ui| { + egui::Frame::new() + .fill(egui::Color32::from_gray(240)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(200))) + .inner_margin(16.0) + .show(ui, |ui| { + if let Err(e) = renderer.render_preview( + ui, + &self.asset_data, + self.preview_scale, + self.os_base_settings.as_ref(), + ) { + ui.colored_label( + egui::Color32::RED, + format!("Preview error: {}", e), + ); + } + }); + }); + } else { + ui.colored_label( + egui::Color32::from_gray(150), + "Select a label template to see preview", + ); + } + } + + /// Get asset data reference + pub fn asset_data(&self) -> &HashMap<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(¤t_status, true); + } + } + } + + if let Some(task_id) = run_task_clicked { + if let Err(err) = self.launch_task_runner(idx, task_id, api_client) { + self.last_error = Some(err.to_string()); + } + } + } else { + right.label("Select an asset to see details."); + } + + right.add_space(12.0); + right.separator(); + right.add_space(8.0); + right.label("Audit Notes"); + right.add( + egui::TextEdit::multiline(&mut self.notes) + .desired_rows(4) + .desired_width(right.available_width()) + .hint_text("Optional notes for the entire audit"), + ); + }); + } + + fn required_total(&self) -> usize { + self.expected_assets + .iter() + .filter(|a| a.requires_scan()) + .count() + } + + fn completed_total(&self) -> usize { + self.expected_assets + .iter() + .filter(|a| a.requires_scan() && a.scanned) + .count() + } + + fn remaining_required(&self) -> usize { + self.expected_assets + .iter() + .filter(|asset| asset.requires_scan() && !asset.scanned) + .count() + } + + fn next_unresolved_ask(&self) -> Option<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(¤t_status, true); + } + + if !outcome.additional_fields.is_empty() { + asset.additional_fields = outcome.additional_fields.clone(); + } + + let mut payload = Map::new(); + payload.insert("responses".into(), outcome.responses); + if !outcome.additional_fields.is_empty() { + payload.insert( + "additional_fields".into(), + Value::Object(outcome.additional_fields.clone()), + ); + } + asset.task_responses = Some(Value::Object(payload)); + } + } + + fn finalize_audit(&mut self, api_client: &ApiClient, force_missing: bool) -> Result<()> { + let remaining = self.remaining_required(); + if remaining > 0 { + if !force_missing { + return Err(anyhow!( + "Cannot finalize audit. {} required assets still pending.", + remaining + )); + } + + for asset in &mut self.expected_assets { + if asset.requires_scan() && !asset.scanned { + asset.set_status("Missing", true); + asset.exception_type = Some(EXCEPTION_OTHER.to_string()); + if asset.exception_details.is_none() { + asset.exception_details = Some(DEFAULT_MISSING_DETAIL.to_string()); + } + } + } + } + + let user_id = self + .user_id + .ok_or_else(|| anyhow!("Missing current user id for audit session"))?; + let started_at = self + .started_at + .unwrap_or_else(|| Utc::now() - chrono::Duration::minutes(1)); + let completed_at = Utc::now(); + + let required_total = self.required_total(); + let mut found_count = 0; + let mut missing_assets = Vec::new(); + let mut attention_assets = Vec::new(); + let mut exceptions = Vec::new(); + let mut unexpected_assets = Vec::new(); + + for asset in &self.expected_assets { + if asset.expected && asset.requires_scan() { + if asset.status_found != "Missing" { + found_count += 1; + } else { + missing_assets.push(json!({ + "asset_id": asset.asset_id, + "asset_tag": asset.asset_tag, + "name": asset.name, + })); + } + + if asset.status_found != "Good" && asset.status_found != "Missing" { + attention_assets.push(json!({ + "asset_id": asset.asset_id, + "asset_tag": asset.asset_tag, + "name": asset.name, + "status": asset.status_found, + })); + } + } + + if let Some(ref exception) = asset.exception_type { + exceptions.push(json!({ + "asset_id": asset.asset_id, + "asset_tag": asset.asset_tag, + "name": asset.name, + "type": exception, + "details": asset.exception_details, + })); + } + + if !asset.expected { + unexpected_assets.push(json!({ + "asset_id": asset.asset_id, + "asset_tag": asset.asset_tag, + "name": asset.name, + "status": asset.status_found, + })); + } + } + + let mut issues = Map::new(); + if !missing_assets.is_empty() { + issues.insert("missing_assets".into(), Value::Array(missing_assets)); + } + if !attention_assets.is_empty() { + issues.insert("attention_assets".into(), Value::Array(attention_assets)); + } + if !exceptions.is_empty() { + issues.insert("exceptions".into(), Value::Array(exceptions)); + } + if !unexpected_assets.is_empty() { + issues.insert("unexpected_assets".into(), Value::Array(unexpected_assets)); + } + + let status = if issues.contains_key("missing_assets") + || issues.contains_key("attention_assets") + { + "attention" + } else if issues.contains_key("exceptions") || issues.contains_key("unexpected_assets") { + "attention" + } else { + "all-good" + }; + + let mut payload = Map::new(); + payload.insert( + "audit_type".into(), + Value::String(match self.mode { + AuditMode::FullZone => "full-zone".to_string(), + AuditMode::SpotCheck => "spot-check".to_string(), + }), + ); + if let Some(zone) = &self.zone_info { + payload.insert("zone_id".into(), json!(zone.id)); + } + if !self.audit_name.trim().is_empty() { + payload.insert("audit_name".into(), json!(self.audit_name.trim())); + } + payload.insert("started_by".into(), json!(user_id)); + payload.insert( + "started_at".into(), + json!(started_at.format("%Y-%m-%d %H:%M:%S").to_string()), + ); + payload.insert( + "completed_at".into(), + json!(completed_at.format("%Y-%m-%d %H:%M:%S").to_string()), + ); + payload.insert("status".into(), json!(status)); + if let Some(timeout) = self.timeout_minutes { + payload.insert("timeout_minutes".into(), json!(timeout)); + } + if issues.is_empty() { + payload.insert("issues_found".into(), Value::Null); + } else { + payload.insert("issues_found".into(), Value::Object(issues)); + } + payload.insert("assets_expected".into(), json!(required_total as i64)); + payload.insert("assets_found".into(), json!(found_count as i64)); + if !self.notes.trim().is_empty() { + payload.insert("notes".into(), json!(self.notes.trim())); + } + + let audit_insert = api_client.insert("physical_audits", Value::Object(payload))?; + if !audit_insert.success { + return Err(anyhow!( + "Failed to create audit session: {}", + audit_insert + .error + .unwrap_or_else(|| "unknown error".to_string()) + )); + } + let audit_id = audit_insert.data.unwrap_or(0) as i64; + + // Insert audit logs + for asset in &self.expected_assets { + let mut log_payload = Map::new(); + log_payload.insert("physical_audit_id".into(), json!(audit_id)); + log_payload.insert("asset_id".into(), json!(asset.asset_id)); + log_payload.insert("status_found".into(), json!(asset.status_found)); + if let Some(task_id) = asset.audit_task_id { + log_payload.insert("audit_task_id".into(), json!(task_id)); + } + if let Some(responses) = &asset.task_responses { + log_payload.insert("audit_task_responses".into(), responses.clone()); + } + if let Some(exception) = &asset.exception_type { + log_payload.insert("exception_type".into(), json!(exception)); + } + if let Some(details) = &asset.exception_details { + log_payload.insert("exception_details".into(), json!(details)); + } + if let Some(zone) = &self.zone_info { + log_payload.insert("found_in_zone_id".into(), json!(zone.id)); + } + if !asset.notes.trim().is_empty() { + log_payload.insert("notes".into(), json!(asset.notes.trim())); + } + let log_insert = + api_client.insert("physical_audit_logs", Value::Object(log_payload))?; + if !log_insert.success { + return Err(anyhow!( + "Failed to record audit log for asset {}", + asset.asset_tag + )); + } + } + + let completion = AuditCompletion { + audit_id, + status: status.to_string(), + }; + self.completion_snapshot = Some(completion); + self.has_recent_completion = true; + self.reset_core_state(); + Ok(()) + } +} + +#[derive(Debug, Clone)] +struct AuditTaskDefinition { + steps: Vec<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(¤t_id) { + self.steps.get(idx + 1).map(|s| s.step) + } else { + None + } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct AuditTaskStep { + step: i64, + question: String, + #[serde(rename = "type")] + question_type: AuditQuestionType, + #[serde(default)] + options: Vec<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(()) + } +} |
