diff options
Diffstat (limited to 'src/core/components')
| -rw-r--r-- | src/core/components/clone.rs | 69 | ||||
| -rw-r--r-- | src/core/components/filter_builder.rs | 698 | ||||
| -rw-r--r-- | src/core/components/form_builder.rs | 371 | ||||
| -rw-r--r-- | src/core/components/help.rs | 66 | ||||
| -rw-r--r-- | src/core/components/interactions.rs | 225 | ||||
| -rw-r--r-- | src/core/components/mod.rs | 12 | ||||
| -rw-r--r-- | src/core/components/stats.rs | 57 |
7 files changed, 1498 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) +} |
