aboutsummaryrefslogtreecommitdiff
path: root/src/core/components
diff options
context:
space:
mode:
authorUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
committerUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
commit8323fdd73272a2882781aba3c499ba0be3dff2a6 (patch)
treeffbf86473933e69cfaeef30d5c6ea7e5b494856c /src/core/components
committing to insanityHEADmaster
Diffstat (limited to 'src/core/components')
-rw-r--r--src/core/components/clone.rs69
-rw-r--r--src/core/components/filter_builder.rs698
-rw-r--r--src/core/components/form_builder.rs371
-rw-r--r--src/core/components/help.rs66
-rw-r--r--src/core/components/interactions.rs225
-rw-r--r--src/core/components/mod.rs12
-rw-r--r--src/core/components/stats.rs57
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)
+}