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/ui/suppliers.rs | |
Diffstat (limited to 'src/ui/suppliers.rs')
| -rw-r--r-- | src/ui/suppliers.rs | 802 |
1 files changed, 802 insertions, 0 deletions
diff --git a/src/ui/suppliers.rs b/src/ui/suppliers.rs new file mode 100644 index 0000000..ce7679f --- /dev/null +++ b/src/ui/suppliers.rs @@ -0,0 +1,802 @@ +use eframe::egui; +use std::collections::HashSet; + +use crate::api::ApiClient; +use crate::core::components::form_builder::FormBuilder; +use crate::core::components::interactions::ConfirmDialog; +use crate::core::counters::count_entities; +use crate::core::tables::get_suppliers; +use crate::core::{EditorField, FieldType}; +use crate::ui::ribbon::RibbonUI; + +#[derive(Clone)] +struct ColumnConfig { + name: String, + field: String, + visible: bool, +} + +pub struct SuppliersView { + rows: Vec<serde_json::Value>, + display_rows: Vec<serde_json::Value>, + is_loading: bool, + last_error: Option<String>, + init_loaded: bool, + // Columns & selector + columns: Vec<ColumnConfig>, + show_column_panel: bool, + // Selection & interactions + selected_row: Option<usize>, + last_click_time: Option<std::time::Instant>, + last_click_row: Option<usize>, + selected_rows: HashSet<usize>, + selection_anchor: Option<usize>, + // Dialogs + delete_dialog: ConfirmDialog, + edit_dialog: FormBuilder, + add_dialog: FormBuilder, + // Track ids for operations + edit_current_id: Option<i64>, + delete_current_id: Option<i64>, +} + +impl SuppliersView { + pub fn new() -> Self { + let columns = vec![ + ColumnConfig { + name: "Name".into(), + field: "name".into(), + visible: true, + }, + ColumnConfig { + name: "Contact".into(), + field: "contact".into(), + visible: true, + }, + ColumnConfig { + name: "Email".into(), + field: "email".into(), + visible: true, + }, + ColumnConfig { + name: "Phone".into(), + field: "phone".into(), + visible: true, + }, + ColumnConfig { + name: "Website".into(), + field: "website".into(), + visible: true, + }, + ColumnConfig { + name: "Items".into(), + field: "items_count".into(), + visible: true, + }, + // Hidden by default + ColumnConfig { + name: "Notes".into(), + field: "notes".into(), + visible: false, + }, + ColumnConfig { + name: "Created".into(), + field: "created_at".into(), + visible: false, + }, + ColumnConfig { + name: "ID".into(), + field: "id".into(), + visible: false, + }, + ]; + Self { + rows: vec![], + display_rows: vec![], + is_loading: false, + last_error: None, + init_loaded: false, + columns, + show_column_panel: false, + selected_row: None, + last_click_time: None, + last_click_row: None, + selected_rows: HashSet::new(), + selection_anchor: None, + delete_dialog: ConfirmDialog::new( + "Delete Supplier", + "Are you sure you want to delete this supplier?", + ), + edit_dialog: FormBuilder::new( + "Edit Supplier", + vec![ + EditorField { + name: "name".into(), + label: "Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "contact".into(), + label: "Contact".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "email".into(), + label: "Email".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "phone".into(), + label: "Phone".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "website".into(), + label: "Website".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "notes".into(), + label: "Notes".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ], + ), + add_dialog: FormBuilder::new( + "Add Supplier", + vec![ + EditorField { + name: "name".into(), + label: "Name".into(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "contact".into(), + label: "Contact".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "email".into(), + label: "Email".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "phone".into(), + label: "Phone".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "website".into(), + label: "Website".into(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "notes".into(), + label: "Notes".into(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + ], + ), + edit_current_id: None, + delete_current_id: None, + } + } + + fn load(&mut self, api: &ApiClient) { + if self.is_loading { + return; + } + self.is_loading = true; + self.last_error = None; + match get_suppliers(api, Some(200)) { + Ok(mut list) => { + // Compute items_count per supplier + for row in &mut list { + if let Some(id) = row.get("id").and_then(|v| v.as_i64()) { + let where_clause = serde_json::json!({"supplier_id": id}); + let count = count_entities(api, "assets", Some(where_clause)).unwrap_or(0); + row.as_object_mut().map(|o| { + o.insert("items_count".into(), serde_json::json!(count)); + }); + } + } + self.rows = list; + self.display_rows = self.rows.clone(); + } + Err(e) => self.last_error = Some(e.to_string()), + } + self.is_loading = false; + self.init_loaded = true; + } + + pub fn show( + &mut self, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon: Option<&mut RibbonUI>, + ) -> Vec<String> { + let mut flags_to_clear = Vec::new(); + + // Apply simple search from ribbon (by name/code/contact/email/phone/website) + if let Some(ribbon) = ribbon.as_ref() { + let term = ribbon + .search_texts + .get("supplier_search") + .cloned() + .unwrap_or_default(); + let tl = term.to_lowercase(); + if tl.is_empty() { + self.display_rows = self.rows.clone(); + } else { + self.display_rows = self + .rows + .iter() + .filter(|row| { + let fields = ["name", "contact", "email", "phone", "website"]; + fields.iter().any(|f| { + row.get(*f) + .and_then(|v| v.as_str()) + .map(|s| s.to_lowercase().contains(&tl)) + .unwrap_or(false) + }) + }) + .cloned() + .collect(); + } + } + + if let Some(ribbon) = ribbon.as_ref() { + // Handle ribbon actions before rendering + if ribbon + .checkboxes + .get("suppliers_action_new") + .copied() + .unwrap_or(false) + { + self.add_dialog.open_new(None); + flags_to_clear.push("suppliers_action_new".to_string()); + } + + if ribbon + .checkboxes + .get("suppliers_action_edit") + .copied() + .unwrap_or(false) + { + if let Some(selected) = self.first_selected_supplier() { + self.open_editor_with(&selected); + } else { + log::warn!("Ribbon edit triggered but no supplier selected"); + } + flags_to_clear.push("suppliers_action_edit".to_string()); + } + + if ribbon + .checkboxes + .get("suppliers_action_delete") + .copied() + .unwrap_or(false) + { + if let Some((name, id)) = self.first_selected_supplier_id_name() { + self.delete_current_id = Some(id); + self.delete_dialog.open(name, id.to_string()); + } else { + log::warn!("Ribbon delete triggered but no supplier selected"); + } + flags_to_clear.push("suppliers_action_delete".to_string()); + } + } + + ui.horizontal(|ui| { + ui.heading("Suppliers"); + if self.is_loading { + ui.spinner(); + ui.label("Loading..."); + } + if let Some(err) = &self.last_error { + ui.colored_label(egui::Color32::RED, err); + if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + } else if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + + ui.separator(); + let button_text = if self.show_column_panel { + "Hide Column Selector" + } else { + "Show Column Selector" + }; + if ui.button(button_text).clicked() { + self.show_column_panel = !self.show_column_panel; + } + }); + ui.separator(); + + if !self.init_loaded { + if let Some(api) = api_client { + self.load(api); + } + } + + // Column selector window + if self.show_column_panel { + let ctx = ui.ctx(); + let screen_rect = ctx.input(|i| { + i.viewport().inner_rect.unwrap_or(egui::Rect::from_min_max( + egui::pos2(0.0, 0.0), + egui::pos2(1920.0, 1080.0), + )) + }); + let max_w = (screen_rect.width() - 20.0).max(220.0); + let max_h = (screen_rect.height() - 100.0).max(200.0); + + egui::Window::new("Column Selector") + .collapsible(false) + .resizable(true) + .default_width(260.0) + .default_height(360.0) + .anchor(egui::Align2::RIGHT_TOP, [-10.0, 90.0]) + .open(&mut self.show_column_panel) + .min_size(egui::vec2(220.0, 200.0)) + .max_size(egui::vec2(max_w, max_h)) + .frame(egui::Frame { + fill: egui::Color32::from_rgb(30, 30, 30), + stroke: egui::Stroke::new(1.0, egui::Color32::from_gray(60)), + inner_margin: egui::Margin::from(10.0), + outer_margin: egui::Margin::ZERO, + corner_radius: 6.0.into(), + shadow: egui::epaint::Shadow::NONE, + }) + .show(ctx, |ui| { + ui.heading("Columns"); + ui.separator(); + ui.add_space(8.0); + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + for column in &mut self.columns { + ui.checkbox(&mut column.visible, &column.name); + } + }); + ui.add_space(8.0); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Show All").clicked() { + for col in &mut self.columns { + col.visible = true; + } + } + if ui.button("Hide All").clicked() { + for col in &mut self.columns { + col.visible = false; + } + } + }); + }); + } + + // compute visible columns and render table + let visible_columns: Vec<ColumnConfig> = + self.columns.iter().filter(|c| c.visible).cloned().collect(); + self.render_table(ui, &visible_columns); + + // Process selection and dialog events + let ctx = ui.ctx(); + // selection handled inline via checkbox/row clicks + if let Some(row_idx) = + ctx.data_mut(|d| d.remove_temp::<usize>(egui::Id::new("sup_double_click_idx"))) + { + self.selected_row = Some(row_idx); + self.last_click_row = None; + self.last_click_time = None; + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::<serde_json::Value>(egui::Id::new("sup_double_click_edit")) + }) { + self.open_editor_with(&item); + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::<serde_json::Value>(egui::Id::new("sup_context_menu_edit")) + }) { + self.open_editor_with(&item); + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::<serde_json::Value>(egui::Id::new("sup_context_menu_clone")) + }) { + // Prepare cloned payload: clear id, suffix name, keep other fields + let cloned = crate::core::components::prepare_cloned_value( + &item, + &["id"], + Some("name"), + Some(""), + ); + if let Some(obj) = cloned.as_object() { + self.add_dialog.open_new(Some(obj)); + } + ctx.request_repaint(); + } + if let Some(item) = ctx.data_mut(|d| { + d.remove_temp::<serde_json::Value>(egui::Id::new("sup_context_menu_delete")) + }) { + let name = item + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let id = item.get("id").and_then(|v| v.as_i64()).unwrap_or(-1); + self.delete_current_id = Some(id); + self.delete_dialog.open(name, id.to_string()); + ctx.request_repaint(); + } + + // Handle delete confirmation dialog + if let Some(confirmed) = self.delete_dialog.show_dialog(ctx) { + if confirmed { + if let (Some(api), Some(id)) = (api_client, self.delete_current_id) { + let where_clause = serde_json::json!({"id": id}); + match api.delete("suppliers", where_clause) { + Ok(resp) if resp.success => { + self.load(api); + } + Ok(resp) => { + self.last_error = Some(format!("Delete failed: {:?}", resp.error)); + } + Err(e) => { + self.last_error = Some(format!("Delete error: {}", e)); + } + } + } + } + } + + // Handle edit dialog save + if let Some(result) = self.edit_dialog.show_editor(ctx) { + if let Some(updated) = result { + if let (Some(api), Some(id)) = (api_client, self.edit_current_id) { + let mut updated = updated; + strip_editor_metadata(&mut updated); + let values = serde_json::Value::Object(updated); + let where_clause = serde_json::json!({"id": id}); + match api.update("suppliers", values, where_clause) { + Ok(resp) if resp.success => { + self.load(api); + } + Ok(resp) => { + self.last_error = Some(format!("Update failed: {:?}", resp.error)); + } + Err(e) => { + self.last_error = Some(format!("Update error: {}", e)); + } + } + } + } + } + + // Handle add dialog (clone -> insert) + self.handle_add_dialog(ui.ctx(), api_client); + + flags_to_clear + } + + fn render_table(&mut self, ui: &mut egui::Ui, visible_columns: &Vec<ColumnConfig>) { + use egui_extras::{Column, TableBuilder}; + let mut table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)); + // Add selection checkbox column first + table = table.column(Column::initial(28.0)); + for _ in 0..visible_columns.len() { + table = table.column(Column::remainder()); + } + table + .header(22.0, |mut header| { + // Select-all checkbox + header.col(|ui| { + let all_selected = self + .display_rows + .iter() + .enumerate() + .all(|(i, _)| self.selected_rows.contains(&i)); + let mut chk = all_selected; + if ui + .checkbox(&mut chk, "") + .on_hover_text("Select All") + .clicked() + { + if chk { + self.selected_rows = (0..self.display_rows.len()).collect(); + } else { + self.selected_rows.clear(); + } + } + }); + for col in visible_columns { + header.col(|ui| { + ui.strong(&col.name); + }); + } + }) + .body(|mut body| { + for (idx, r) in self.display_rows.iter().enumerate() { + let r_clone = r.clone(); + let is_selected = self.selected_rows.contains(&idx); + body.row(20.0, |mut row| { + if is_selected { + row.set_selected(true); + } + // Checkbox column + row.col(|ui| { + let mut checked = self.selected_rows.contains(&idx); + let resp = ui.checkbox(&mut checked, ""); + if resp.changed() { + let mods = ui.input(|i| i.modifiers); + if mods.shift { + let anchor = self.selection_anchor.unwrap_or(idx); + let (a, b) = if anchor <= idx { + (anchor, idx) + } else { + (idx, anchor) + }; + for i in a..=b { + if checked { + self.selected_rows.insert(i); + } else { + self.selected_rows.remove(&i); + } + } + } else if mods.command || mods.ctrl { + if checked { + self.selected_rows.insert(idx); + } else { + self.selected_rows.remove(&idx); + } + self.selection_anchor = Some(idx); + } else { + self.selected_rows.clear(); + if checked { + self.selected_rows.insert(idx); + self.selection_anchor = Some(idx); + } + } + } + }); + let mut combined_cell_response: Option<egui::Response> = None; + for col in visible_columns { + row.col(|ui| { + let resp = render_supplier_cell(ui, &r_clone, &col.field); + combined_cell_response = + Some(match combined_cell_response.take() { + Some(prev) => prev.union(resp), + None => resp, + }); + }); + } + let mut row_resp = row.response(); + if let Some(cell_r) = combined_cell_response { + row_resp = row_resp.union(cell_r); + } + if row_resp.clicked() { + let now = std::time::Instant::now(); + let is_double_click = if let (Some(last_time), Some(last_row)) = + (self.last_click_time, self.last_click_row) + { + last_row == idx && now.duration_since(last_time).as_millis() < 500 + } else { + false + }; + if is_double_click { + row_resp.ctx.data_mut(|d| { + d.insert_temp(egui::Id::new("sup_double_click_idx"), idx); + d.insert_temp( + egui::Id::new("sup_double_click_edit"), + r_clone.clone(), + ); + }); + } else { + // Multi-select on row click + let mods = row_resp.ctx.input(|i| i.modifiers); + if mods.shift { + let anchor = self.selection_anchor.unwrap_or(idx); + let (a, b) = if anchor <= idx { + (anchor, idx) + } else { + (idx, anchor) + }; + for i in a..=b { + self.selected_rows.insert(i); + } + } else if mods.command || mods.ctrl { + if self.selected_rows.contains(&idx) { + self.selected_rows.remove(&idx); + } else { + self.selected_rows.insert(idx); + } + self.selection_anchor = Some(idx); + } else { + self.selected_rows.clear(); + self.selected_rows.insert(idx); + self.selection_anchor = Some(idx); + } + self.last_click_time = Some(now); + self.last_click_row = Some(idx); + } + } + row_resp.context_menu(|ui| { + if ui + .button(format!("{} Edit", egui_phosphor::regular::PENCIL)) + .clicked() + { + ui.ctx().data_mut(|d| { + d.insert_temp( + egui::Id::new("sup_context_menu_edit"), + r_clone.clone(), + ) + }); + ui.close(); + } + if ui + .button(format!("{} Clone", egui_phosphor::regular::COPY)) + .clicked() + { + ui.ctx().data_mut(|d| { + d.insert_temp( + egui::Id::new("sup_context_menu_clone"), + r_clone.clone(), + ) + }); + ui.close(); + } + ui.separator(); + if ui + .button(format!("{} Delete", egui_phosphor::regular::TRASH)) + .clicked() + { + ui.ctx().data_mut(|d| { + d.insert_temp( + egui::Id::new("sup_context_menu_delete"), + r_clone.clone(), + ) + }); + ui.close(); + } + }); + }); + } + }); + } + + fn open_editor_with(&mut self, item: &serde_json::Value) { + self.edit_current_id = item.get("id").and_then(|v| v.as_i64()); + self.edit_dialog.open(item); + } + + fn first_selected_supplier(&self) -> Option<serde_json::Value> { + self.first_selected_index() + .and_then(|idx| self.display_rows.get(idx).cloned()) + } + + fn first_selected_supplier_id_name(&self) -> Option<(String, i64)> { + self.first_selected_index().and_then(|idx| { + self.display_rows.get(idx).and_then(|row| { + let name = row + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let id = row.get("id").and_then(|v| v.as_i64())?; + Some((name, id)) + }) + }) + } + + fn first_selected_index(&self) -> Option<usize> { + if self.selected_rows.is_empty() { + None + } else { + self.selected_rows.iter().copied().min() + } + } +} + +fn label_trunc(ui: &mut egui::Ui, text: &str, max: usize) -> egui::Response { + if text.len() > max { + let short = format!("{}…", &text[..max]); + ui.label(short).on_hover_ui(|ui| { + ui.label(text); + }) + } else { + ui.label(text) + } +} + +fn render_supplier_cell(ui: &mut egui::Ui, row: &serde_json::Value, field: &str) -> egui::Response { + let t = |k: &str| row.get(k).and_then(|v| v.as_str()).unwrap_or(""); + match field { + "id" => ui.label( + row.get("id") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string(), + ), + "name" => ui.label(t("name")), + "contact" => ui.label(t("contact")), + "email" => ui.label(t("email")), + "phone" => ui.label(t("phone")), + "website" => ui.label(t("website")), + "notes" => label_trunc(ui, t("notes"), 60), + "created_at" => ui.label(t("created_at")), + "items_count" => ui.label( + row.get("items_count") + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string(), + ), + other => ui.label(format!("{}", other)), + } +} + +// Handle add dialog save and insert +impl SuppliersView { + fn handle_add_dialog(&mut self, ctx: &egui::Context, api_client: Option<&ApiClient>) { + if let Some(Some(mut new_data)) = self.add_dialog.show_editor(ctx) { + if let Some(api) = api_client { + strip_editor_metadata(&mut new_data); + match api.insert("suppliers", serde_json::Value::Object(new_data)) { + Ok(resp) if resp.success => { + self.load(api); + } + Ok(resp) => { + self.last_error = Some(format!("Insert failed: {:?}", resp.error)); + } + Err(e) => { + self.last_error = Some(format!("Insert error: {}", e)); + } + } + } + } + } +} + +fn strip_editor_metadata(map: &mut serde_json::Map<String, serde_json::Value>) { + let meta_keys: Vec<String> = map + .keys() + .filter(|k| k.starts_with("__editor_")) + .cloned() + .collect(); + for key in meta_keys { + map.remove(&key); + } +} |
