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/borrowing.rs | |
Diffstat (limited to 'src/ui/borrowing.rs')
| -rw-r--r-- | src/ui/borrowing.rs | 1618 |
1 files changed, 1618 insertions, 0 deletions
diff --git a/src/ui/borrowing.rs b/src/ui/borrowing.rs new file mode 100644 index 0000000..8b1fc93 --- /dev/null +++ b/src/ui/borrowing.rs @@ -0,0 +1,1618 @@ +use eframe::egui; + +use crate::api::ApiClient; +use crate::core::components::form_builder::FormBuilder; +use crate::core::tables::{get_all_loans, get_borrowers_summary}; +use crate::core::workflows::borrow_flow::BorrowFlow; +use crate::core::workflows::return_flow::ReturnFlow; +use crate::core::{ColumnConfig, TableRenderer}; +use crate::core::{EditorField, FieldType}; + +pub struct BorrowingView { + // data + loans: Vec<serde_json::Value>, + borrowers: Vec<serde_json::Value>, + is_loading: bool, + last_error: Option<String>, + + // UI + init_loaded: bool, + show_loans_column_selector: bool, + show_borrowers_column_selector: bool, + + // Table renderers + loans_table: TableRenderer, + borrowers_table: TableRenderer, + + // Workflows + borrow_flow: BorrowFlow, + return_flow: ReturnFlow, + + // Register borrower dialog + show_register_dialog: bool, + new_borrower_name: String, + new_borrower_email: String, + new_borrower_phone: String, + new_borrower_class: String, + new_borrower_role: String, + register_error: Option<String>, + + // Edit borrower dialog (using FormBuilder) + borrower_editor: FormBuilder, + + // Ban/Unban borrower dialog + show_ban_dialog: bool, + show_unban_dialog: bool, + ban_borrower_data: Option<serde_json::Value>, + ban_fine_amount: String, + ban_reason: String, + + // Return item confirm dialog + show_return_confirm_dialog: bool, + return_loan_data: Option<serde_json::Value>, + + // Delete borrower confirm dialog + show_delete_borrower_dialog: bool, + delete_borrower_data: Option<serde_json::Value>, + + // Search and filtering + loans_search: String, + borrowers_search: String, + + // Navigation + pub switch_to_inventory_with_borrower: Option<i64>, // borrower_id to filter by +} + +impl BorrowingView { + pub fn new() -> Self { + // Define columns for loans table - ALL columns from the query + let loans_columns = vec![ + ColumnConfig::new("Loan ID", "id").with_width(60.0).hidden(), + ColumnConfig::new("Asset ID", "asset_id") + .with_width(70.0) + .hidden(), + ColumnConfig::new("Borrower ID", "borrower_id") + .with_width(80.0) + .hidden(), + ColumnConfig::new("Tag", "asset_tag").with_width(80.0), + ColumnConfig::new("Name", "name").with_width(200.0), + ColumnConfig::new("Borrower", "borrower_name").with_width(120.0), + ColumnConfig::new("Class", "class_name").with_width(80.0), + ColumnConfig::new("Status", "lending_status").with_width(80.0), + ColumnConfig::new("Checked Out", "checkout_date").with_width(100.0), + ColumnConfig::new("Due", "due_date").with_width(90.0), + ColumnConfig::new("Returned", "return_date").with_width(100.0), + ColumnConfig::new("Notes", "notes") + .with_width(150.0) + .hidden(), + ]; + + // Define columns for borrowers table - with all backend fields + let borrowers_columns = vec![ + ColumnConfig::new("ID", "borrower_id").with_width(60.0), + ColumnConfig::new("Name", "borrower_name").with_width(150.0), + ColumnConfig::new("Email", "email") + .with_width(150.0) + .hidden(), + ColumnConfig::new("Phone", "phone_number") + .with_width(120.0) + .hidden(), + ColumnConfig::new("Class", "class_name").with_width(80.0), + ColumnConfig::new("Role", "role").with_width(80.0).hidden(), + ColumnConfig::new("Active", "active_loans").with_width(60.0), + ColumnConfig::new("Overdue", "overdue_loans").with_width(60.0), + ColumnConfig::new("Banned", "banned").with_width(60.0), + ]; + + Self { + loans: vec![], + borrowers: vec![], + is_loading: false, + last_error: None, + init_loaded: false, + show_loans_column_selector: false, + show_borrowers_column_selector: false, + loans_table: TableRenderer::new() + .with_columns(loans_columns) + .with_default_sort("checkout_date", false), // Sort by checkout date DESC (most recent first) + borrowers_table: TableRenderer::new().with_columns(borrowers_columns), + borrow_flow: BorrowFlow::new(), + return_flow: ReturnFlow::new(), + show_register_dialog: false, + new_borrower_name: String::new(), + new_borrower_email: String::new(), + new_borrower_phone: String::new(), + new_borrower_class: String::new(), + new_borrower_role: String::new(), + register_error: None, + borrower_editor: { + let fields = vec![ + EditorField { + name: "borrower_id".to_string(), + label: "ID".to_string(), + field_type: FieldType::Text, + required: false, + read_only: true, + }, + EditorField { + name: "name".to_string(), + label: "Name".to_string(), + field_type: FieldType::Text, + required: true, + read_only: false, + }, + EditorField { + name: "email".to_string(), + label: "Email".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "phone_number".to_string(), + label: "Phone".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "class_name".to_string(), + label: "Class/Department".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "role".to_string(), + label: "Role/Type".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + EditorField { + name: "notes".to_string(), + label: "Notes".to_string(), + field_type: FieldType::MultilineText, + required: false, + read_only: false, + }, + EditorField { + name: "banned".to_string(), + label: "Banned".to_string(), + field_type: FieldType::Checkbox, + required: false, + read_only: false, + }, + EditorField { + name: "unban_fine".to_string(), + label: "Unban Fine".to_string(), + field_type: FieldType::Text, + required: false, + read_only: false, + }, + ]; + FormBuilder::new("Edit Borrower", fields) + }, + show_ban_dialog: false, + show_unban_dialog: false, + ban_borrower_data: None, + ban_fine_amount: String::new(), + ban_reason: String::new(), + show_return_confirm_dialog: false, + return_loan_data: None, + show_delete_borrower_dialog: false, + delete_borrower_data: None, + loans_search: String::new(), + borrowers_search: String::new(), + switch_to_inventory_with_borrower: None, + } + } + + pub fn get_filter_columns() -> Vec<(String, String)> { + vec![ + ("Any".to_string(), "Any".to_string()), + ("Asset Tag".to_string(), "assets.asset_tag".to_string()), + ("Asset Name".to_string(), "assets.name".to_string()), + ("Borrower Name".to_string(), "borrowers.name".to_string()), + ("Class".to_string(), "borrowers.class_name".to_string()), + ("Status".to_string(), "assets.lending_status".to_string()), + ( + "Checkout Date".to_string(), + "lending_history.checkout_date".to_string(), + ), + ( + "Due Date".to_string(), + "lending_history.due_date".to_string(), + ), + ( + "Return Date".to_string(), + "lending_history.return_date".to_string(), + ), + ] + } + + fn load(&mut self, api: &ApiClient) { + if self.is_loading { + return; + } + self.is_loading = true; + self.last_error = None; + match get_all_loans(api, None) { + Ok(list) => { + self.loans = list; + } + Err(e) => { + self.last_error = Some(e.to_string()); + } + } + if self.last_error.is_none() { + match get_borrowers_summary(api) { + Ok(list) => { + self.borrowers = list; + } + Err(e) => { + self.last_error = Some(e.to_string()); + } + } + } + self.is_loading = false; + self.init_loaded = true; + } + + pub fn show( + &mut self, + ctx: &egui::Context, + ui: &mut egui::Ui, + api_client: Option<&ApiClient>, + ribbon: &mut crate::ui::ribbon::RibbonUI, + ) { + ui.horizontal(|ui| { + ui.heading("Borrowing"); + if self.is_loading { + ui.spinner(); + ui.label("Loading..."); + } + if let Some(err) = &self.last_error { + ui.colored_label(egui::Color32::RED, err); + if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + } else if ui.button("Refresh").clicked() { + if let Some(api) = api_client { + self.load(api); + } + } + }); + ui.separator(); + + // Check for filter changes + if ribbon + .checkboxes + .get("borrowing_filter_changed") + .copied() + .unwrap_or(false) + { + ribbon + .checkboxes + .insert("borrowing_filter_changed".to_string(), false); + // For now just note that filters changed - we'll apply them client-side in render + // In the future we could reload with server-side filtering + } + + // Check for ribbon actions + if let Some(api) = api_client { + if ribbon + .checkboxes + .get("borrowing_action_checkout") + .copied() + .unwrap_or(false) + { + self.borrow_flow.open(api); + } + + if ribbon + .checkboxes + .get("borrowing_action_return") + .copied() + .unwrap_or(false) + { + self.return_flow.open(api); + } + + if ribbon + .checkboxes + .get("borrowing_action_register") + .copied() + .unwrap_or(false) + { + self.show_register_dialog = true; + self.register_error = None; + } + + if ribbon + .checkboxes + .get("borrowing_action_refresh") + .copied() + .unwrap_or(false) + { + self.load(api); + } + } + + if !self.init_loaded { + if let Some(api) = api_client { + self.load(api); + } + } + + // Show borrow flow if open + if let Some(api) = api_client { + self.borrow_flow.show(ctx, api); + if self.borrow_flow.take_recent_success() { + self.load(api); + } + } + + // Show return flow if open + if let Some(api) = api_client { + self.return_flow.show(ctx, api); + if self.return_flow.take_recent_success() { + self.load(api); + } + } + + // Show register dialog if open + if self.show_register_dialog { + if let Some(api) = api_client { + self.show_register_borrower_dialog(ctx, api); + } + } + + // Show borrower editor if open + if let Some(api) = api_client { + if let Some(result) = self.borrower_editor.show_editor(ctx) { + if let Some(data) = result { + // Editor returned data - save it + if let Err(e) = self.save_borrower_changes(api, &data) { + log::error!("Failed to save borrower changes: {}", e); + } else { + self.load(api); + } + } + // else: user cancelled + } + } + + // Show ban dialog if open + if self.show_ban_dialog { + if let Some(api) = api_client { + self.show_ban_dialog(ctx, api); + } + } + + // Show unban dialog if open + if self.show_unban_dialog { + if let Some(api) = api_client { + self.show_unban_dialog(ctx, api); + } + } + + // Show return confirm dialog if open + if self.show_return_confirm_dialog { + if let Some(api) = api_client { + self.show_return_confirm_dialog(ctx, api); + } + } + + // Show delete borrower confirm dialog if open + if self.show_delete_borrower_dialog { + if let Some(api) = api_client { + self.show_delete_borrower_dialog(ctx, api); + } + } + + // Wrap entire content in ScrollArea + egui::ScrollArea::vertical().show(ui, |ui| { + // Section 1: Lending history + egui::CollapsingHeader::new("Lending History") + .default_open(true) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.heading("Loans"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Columns").clicked() { + self.show_loans_column_selector = !self.show_loans_column_selector; + } + }); + }); + + // Search and filter controls + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(&mut self.loans_search); + + ui.separator(); + + // Status filters from ribbon + ui.label("Show:"); + ui.checkbox( + ribbon + .checkboxes + .entry("borrowing_show_normal".to_string()) + .or_insert(true), + "Normal", + ); + ui.checkbox( + ribbon + .checkboxes + .entry("borrowing_show_overdue".to_string()) + .or_insert(true), + "Overdue", + ); + ui.checkbox( + ribbon + .checkboxes + .entry("borrowing_show_stolen".to_string()) + .or_insert(true), + "Stolen", + ); + ui.checkbox( + ribbon + .checkboxes + .entry("borrowing_show_returned".to_string()) + .or_insert(false), + "Returned", + ); + }); + + ui.separator(); + self.render_active_loans(ui, ribbon); + }); + + ui.add_space(10.0); + + // Section 2: Borrowers summary + egui::CollapsingHeader::new("Borrowers") + .default_open(true) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.heading("Borrowers"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Columns").clicked() { + self.show_borrowers_column_selector = + !self.show_borrowers_column_selector; + } + }); + }); + + // Search control + ui.horizontal(|ui| { + ui.label("Search:"); + ui.text_edit_singleline(&mut self.borrowers_search); + }); + + ui.separator(); + self.render_borrowers_table(ui); + }); + }); // End ScrollArea + + // Show column selector windows + if self.show_loans_column_selector { + egui::Window::new("Loans Columns") + .open(&mut self.show_loans_column_selector) + .resizable(true) + .default_width(250.0) + .show(ctx, |ui| { + self.loans_table.show_column_selector(ui, "loans"); + }); + } + + if self.show_borrowers_column_selector { + egui::Window::new("Borrowers Columns") + .open(&mut self.show_borrowers_column_selector) + .resizable(true) + .default_width(250.0) + .show(ctx, |ui| { + self.borrowers_table.show_column_selector(ui, "borrowers"); + }); + } + } + + fn render_active_loans(&mut self, ui: &mut egui::Ui, ribbon: &crate::ui::ribbon::RibbonUI) { + // Get checkbox states + let show_returned = ribbon + .checkboxes + .get("borrowing_show_returned") + .copied() + .unwrap_or(false); + let show_normal = ribbon + .checkboxes + .get("borrowing_show_normal") + .copied() + .unwrap_or(true); + let show_overdue = ribbon + .checkboxes + .get("borrowing_show_overdue") + .copied() + .unwrap_or(true); + let show_stolen = ribbon + .checkboxes + .get("borrowing_show_stolen") + .copied() + .unwrap_or(true); + + // Apply filters + let filtered_loans: Vec<serde_json::Value> = self + .loans + .iter() + .filter(|loan| { + // First apply search filter + if !self.loans_search.is_empty() { + let search_lower = self.loans_search.to_lowercase(); + let asset_tag = loan.get("asset_tag").and_then(|v| v.as_str()).unwrap_or(""); + let asset_name = loan.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let borrower_name = loan + .get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let class_name = loan + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if !(asset_tag.to_lowercase().contains(&search_lower) + || asset_name.to_lowercase().contains(&search_lower) + || borrower_name.to_lowercase().contains(&search_lower) + || class_name.to_lowercase().contains(&search_lower)) + { + return false; + } + } + + // Apply filter builder filters + if !Self::matches_filter_builder(loan, &ribbon.filter_builder) { + return false; + } + + // Check if this loan has been returned + let has_return_date = loan + .get("return_date") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .is_some(); + + // If returned, check the show_returned checkbox + if has_return_date { + return show_returned; + } + + // For active loans, check the lending_status from assets table + let lending_status = loan + .get("lending_status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Check if stolen + if lending_status == "Stolen" || lending_status == "Illegally Handed Out" { + return show_stolen; + } + + // Check if overdue + if let Some(due_date_str) = loan.get("due_date").and_then(|v| v.as_str()) { + let now = chrono::Local::now().format("%Y-%m-%d").to_string(); + if due_date_str < now.as_str() { + return show_overdue; + } + } + + // Otherwise it's a normal active loan (not overdue, not stolen) + show_normal + }) + .cloned() + .collect(); + + // Derive a display status per loan to avoid confusion: + // If a loan has a return_date, always show "Returned" regardless of the current asset status. + // Otherwise, use the existing lending_status value (Overdue, etc. handled by DB). + let mut display_loans: Vec<serde_json::Value> = Vec::with_capacity(filtered_loans.len()); + for loan in &filtered_loans { + let mut row = loan.clone(); + let has_return = row + .get("return_date") + .and_then(|v| v.as_str()) + .map(|s| !s.is_empty()) + .unwrap_or(false); + + if has_return { + row["lending_status"] = serde_json::Value::String("Returned".to_string()); + } + display_loans.push(row); + } + + let prepared_data = self.loans_table.prepare_json_data(&display_loans); + + // Handle loan table events (return item) + let mut return_loan: Option<serde_json::Value> = None; + + struct LoanEventHandler<'a> { + return_action: &'a mut Option<serde_json::Value>, + } + + impl<'a> crate::core::table_renderer::TableEventHandler<serde_json::Value> + for LoanEventHandler<'a> + { + fn on_double_click(&mut self, _item: &serde_json::Value, _row_index: usize) { + // Not used for loans + } + + fn on_context_menu( + &mut self, + ui: &mut egui::Ui, + item: &serde_json::Value, + _row_index: usize, + ) { + // Only show "Return Item" if the loan is active (no return_date) + let has_return_date = item.get("return_date").and_then(|v| v.as_str()).is_some(); + + if !has_return_date { + if ui + .button(format!( + "{} Return Item", + egui_phosphor::regular::ARROW_RIGHT + )) + .clicked() + { + *self.return_action = Some(item.clone()); + ui.close(); + } + } + } + + fn on_selection_changed(&mut self, _selected_indices: &[usize]) { + // Not used for now + } + } + + let mut handler = LoanEventHandler { + return_action: &mut return_loan, + }; + + self.loans_table + .render_json_table(ui, &prepared_data, Some(&mut handler)); + + // Store return action for processing after all rendering + if let Some(loan) = return_loan { + self.return_loan_data = Some(loan); + self.show_return_confirm_dialog = true; + } + } + + /// Client-side filter matching for filter builder conditions + fn matches_filter_builder( + loan: &serde_json::Value, + filter_builder: &crate::core::components::filter_builder::FilterBuilder, + ) -> bool { + use crate::core::components::filter_builder::FilterOperator; + + // If no valid conditions, don't filter + if !filter_builder.filter_group.is_valid() { + return true; + } + + // Check each condition + for condition in &filter_builder.filter_group.conditions { + if !condition.is_valid() { + continue; + } + + // Map the filter column to the actual JSON field name + let field_name = match condition.column.as_str() { + "assets.asset_tag" => "asset_tag", + "assets.name" => "name", + "borrowers.name" => "borrower_name", + "borrowers.class_name" => "class_name", + "assets.lending_status" => "lending_status", + "lending_history.checkout_date" => "checkout_date", + "lending_history.due_date" => "due_date", + "lending_history.return_date" => "return_date", + _ => { + // Fallback: strip table prefix if present + if condition.column.contains('.') { + condition + .column + .split('.') + .last() + .unwrap_or(&condition.column) + } else { + &condition.column + } + } + }; + + let field_value = loan.get(field_name).and_then(|v| v.as_str()).unwrap_or(""); + + // Apply the operator + let matches = match &condition.operator { + FilterOperator::Is => field_value == condition.value, + FilterOperator::IsNot => field_value != condition.value, + FilterOperator::Contains => field_value + .to_lowercase() + .contains(&condition.value.to_lowercase()), + FilterOperator::DoesntContain => !field_value + .to_lowercase() + .contains(&condition.value.to_lowercase()), + FilterOperator::IsNull => field_value.is_empty(), + FilterOperator::IsNotNull => !field_value.is_empty(), + }; + + if !matches { + return false; // For now, treat as AND logic + } + } + + true + } + + fn render_borrowers_table(&mut self, ui: &mut egui::Ui) { + // Apply search filter if set + let filtered_borrowers: Vec<serde_json::Value> = if self.borrowers_search.is_empty() { + self.borrowers.clone() + } else { + let search_lower = self.borrowers_search.to_lowercase(); + self.borrowers + .iter() + .filter(|borrower| { + let name = borrower + .get("borrower_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let class = borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + name.to_lowercase().contains(&search_lower) + || class.to_lowercase().contains(&search_lower) + }) + .cloned() + .collect() + }; + + let prepared_data = self.borrowers_table.prepare_json_data(&filtered_borrowers); + + // Store actions to perform after rendering (to avoid borrow checker issues) + let mut edit_borrower: Option<serde_json::Value> = None; + let mut ban_borrower: Option<serde_json::Value> = None; + let mut unban_borrower: Option<serde_json::Value> = None; + let mut delete_borrower: Option<serde_json::Value> = None; + let mut show_items_for_borrower: Option<i64> = None; + + // Create event handler for context menu + struct BorrowerEventHandler<'a> { + edit_action: &'a mut Option<serde_json::Value>, + ban_action: &'a mut Option<serde_json::Value>, + unban_action: &'a mut Option<serde_json::Value>, + delete_action: &'a mut Option<serde_json::Value>, + show_items_action: &'a mut Option<i64>, + } + + impl<'a> crate::core::TableEventHandler<serde_json::Value> for BorrowerEventHandler<'a> { + fn on_double_click(&mut self, item: &serde_json::Value, _row_index: usize) { + // Open edit dialog on double-click + *self.edit_action = Some(item.clone()); + } + + fn on_context_menu( + &mut self, + ui: &mut egui::Ui, + item: &serde_json::Value, + _row_index: usize, + ) { + let is_banned = item + .get("banned") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let borrower_id = item.get("borrower_id").and_then(|v| v.as_i64()); + + if ui + .button(format!("{} Edit Borrower", egui_phosphor::regular::PENCIL)) + .clicked() + { + *self.edit_action = Some(item.clone()); + ui.close(); + } + + if let Some(id) = borrower_id { + if ui + .button(format!( + "{} Show Items Borrowed to this User", + egui_phosphor::regular::PACKAGE + )) + .clicked() + { + *self.show_items_action = Some(id); + ui.close(); + } + } + + ui.separator(); + + if is_banned { + if ui + .button(format!( + "{} Unban Borrower", + egui_phosphor::regular::CHECK_CIRCLE + )) + .clicked() + { + *self.unban_action = Some(item.clone()); + ui.close(); + } + } else { + if ui + .button(format!("{} Ban Borrower", egui_phosphor::regular::PROHIBIT)) + .clicked() + { + *self.ban_action = Some(item.clone()); + ui.close(); + } + } + + ui.separator(); + + if ui + .button(format!("{} Delete Borrower", egui_phosphor::regular::TRASH)) + .clicked() + { + *self.delete_action = Some(item.clone()); + ui.close(); + } + } + + fn on_selection_changed(&mut self, _selected_indices: &[usize]) { + // Not used for now + } + } + + let mut handler = BorrowerEventHandler { + edit_action: &mut edit_borrower, + ban_action: &mut ban_borrower, + unban_action: &mut unban_borrower, + delete_action: &mut delete_borrower, + show_items_action: &mut show_items_for_borrower, + }; + + self.borrowers_table + .render_json_table(ui, &prepared_data, Some(&mut handler)); + + // Process actions after rendering + if let Some(borrower) = edit_borrower { + self.open_edit_borrower_dialog(borrower); + } + if let Some(borrower) = ban_borrower { + self.open_ban_dialog(borrower); + } + if let Some(borrower) = unban_borrower { + self.open_unban_dialog(borrower); + } + if let Some(borrower) = delete_borrower { + self.delete_borrower_data = Some(borrower); + self.show_delete_borrower_dialog = true; + } + if let Some(borrower_id) = show_items_for_borrower { + // Set the flag to switch to inventory with this borrower filter + self.switch_to_inventory_with_borrower = Some(borrower_id); + } + } + + fn show_register_borrower_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) { + egui::Window::new("Register New Borrower") + .collapsible(false) + .resizable(false) + .default_width(400.0) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + + if let Some(err) = &self.register_error { + ui.colored_label(egui::Color32::RED, err); + ui.separator(); + } + + ui.horizontal(|ui| { + ui.label("Name:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_name) + .hint_text("Full name"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Email:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_email) + .hint_text("email@example.com"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Phone:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_phone) + .hint_text("Phone number"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Class:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_class) + .hint_text("Class or department"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Role:"); + ui.add_sized( + [250.0, 20.0], + egui::TextEdit::singleline(&mut self.new_borrower_role) + .hint_text("Student, Staff, etc."), + ); + }); + + ui.add_space(10.0); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Register").clicked() { + if self.new_borrower_name.trim().is_empty() { + self.register_error = Some("Name is required".to_string()); + } else { + match self.register_borrower(api_client) { + Ok(_) => { + // Success - close dialog and reload data + self.show_register_dialog = false; + self.clear_register_form(); + self.load(api_client); + } + Err(e) => { + self.register_error = Some(e.to_string()); + } + } + } + } + + if ui.button("Cancel").clicked() { + self.show_register_dialog = false; + self.clear_register_form(); + } + }); + }); + }); + } + + fn register_borrower(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let mut borrower_data = serde_json::json!({ + "name": self.new_borrower_name.trim(), + "banned": false, + }); + + if !self.new_borrower_email.is_empty() { + borrower_data["email"] = + serde_json::Value::String(self.new_borrower_email.trim().to_string()); + } + + if !self.new_borrower_phone.is_empty() { + borrower_data["phone_number"] = + serde_json::Value::String(self.new_borrower_phone.trim().to_string()); + } + + if !self.new_borrower_class.is_empty() { + borrower_data["class_name"] = + serde_json::Value::String(self.new_borrower_class.trim().to_string()); + } + + if !self.new_borrower_role.is_empty() { + borrower_data["role"] = + serde_json::Value::String(self.new_borrower_role.trim().to_string()); + } + + let request = QueryRequest { + action: "insert".to_string(), + table: "borrowers".to_string(), + columns: None, + r#where: None, + data: Some(borrower_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to register borrower".to_string()))); + } + + Ok(()) + } + + fn clear_register_form(&mut self) { + self.new_borrower_name.clear(); + self.new_borrower_email.clear(); + self.new_borrower_phone.clear(); + self.new_borrower_class.clear(); + self.new_borrower_role.clear(); + self.register_error = None; + } + + // Edit borrower dialog methods + fn open_edit_borrower_dialog(&mut self, borrower: serde_json::Value) { + // The summary doesn't have all fields, so we'll populate what we have + // and the editor will show empty fields for missing data + let mut editor_data = serde_json::Map::new(); + + // Map the summary fields to editor fields + if let Some(id) = borrower.get("borrower_id") { + editor_data.insert("borrower_id".to_string(), id.clone()); + editor_data.insert("id".to_string(), id.clone()); // Also set 'id' for WHERE clause + } + if let Some(name) = borrower.get("borrower_name") { + editor_data.insert("name".to_string(), name.clone()); + } + if let Some(email) = borrower.get("email") { + if !email.is_null() && email.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("email".to_string(), email.clone()); + } + } + if let Some(phone) = borrower.get("phone_number") { + if !phone.is_null() && phone.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("phone_number".to_string(), phone.clone()); + } + } + if let Some(class) = borrower.get("class_name") { + if !class.is_null() && class.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("class_name".to_string(), class.clone()); + } + } + if let Some(role) = borrower.get("role") { + if !role.is_null() && role.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("role".to_string(), role.clone()); + } + } + if let Some(notes) = borrower.get("notes") { + if !notes.is_null() && notes.as_str().map(|s| !s.is_empty()).unwrap_or(false) { + editor_data.insert("notes".to_string(), notes.clone()); + } + } + if let Some(banned) = borrower.get("banned") { + editor_data.insert("banned".to_string(), banned.clone()); + } + if let Some(unban_fine) = borrower.get("unban_fine") { + if !unban_fine.is_null() { + editor_data.insert("unban_fine".to_string(), unban_fine.clone()); + } + } + + // Open the editor with the borrower data + let value = serde_json::Value::Object(editor_data); + self.borrower_editor.open(&value); + } + + fn save_borrower_changes( + &self, + api_client: &ApiClient, + diff: &serde_json::Map<String, serde_json::Value>, + ) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + // Extract borrower ID from the diff (editor includes it as __editor_item_id) + let borrower_id = diff + .get("__editor_item_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::<i64>().ok()) + .or_else(|| diff.get("borrower_id").and_then(|v| v.as_i64())) + .or_else(|| diff.get("id").and_then(|v| v.as_i64())) + .ok_or_else(|| anyhow::anyhow!("Invalid borrower ID"))?; + + // Build update data from the diff (exclude editor metadata) + let mut update_data = serde_json::Map::new(); + for (key, value) in diff.iter() { + if !key.starts_with("__editor_") && key != "borrower_id" && key != "id" { + update_data.insert(key.clone(), value.clone()); + } + } + + if update_data.is_empty() { + return Ok(()); // Nothing to update + } + + let request = QueryRequest { + action: "update".to_string(), + table: "borrowers".to_string(), + data: Some(serde_json::Value::Object(update_data)), + r#where: Some(serde_json::json!({"id": borrower_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to update borrower".to_string()))); + } + + Ok(()) + } + + // Ban/Unban dialog methods + fn open_ban_dialog(&mut self, borrower: serde_json::Value) { + self.ban_borrower_data = Some(borrower); + self.show_ban_dialog = true; + self.ban_fine_amount.clear(); + self.ban_reason.clear(); + } + + fn open_unban_dialog(&mut self, borrower: serde_json::Value) { + self.ban_borrower_data = Some(borrower); + self.show_unban_dialog = true; + } + + fn show_ban_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) { + let mut keep_open = true; + let mut confirmed = false; + let mut cancelled = false; + + let borrower_name = self + .ban_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + egui::Window::new("Ban Borrower") + .collapsible(false) + .resizable(false) + .default_width(400.0) + .open(&mut keep_open) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + + ui.label( + egui::RichText::new(format!( + "⚠ Are you sure you want to ban '{}'?", + borrower_name + )) + .color(egui::Color32::from_rgb(255, 152, 0)) + .strong(), + ); + + ui.add_space(10.0); + + ui.horizontal(|ui| { + ui.label("Fine Amount ($):"); + ui.text_edit_singleline(&mut self.ban_fine_amount); + }); + ui.label( + egui::RichText::new("(Optional: leave empty for no fine)") + .small() + .color(ui.visuals().weak_text_color()), + ); + + ui.add_space(5.0); + + ui.label("Reason:"); + ui.text_edit_multiline(&mut self.ban_reason); + ui.label( + egui::RichText::new("(Optional: reason for banning)") + .small() + .color(ui.visuals().weak_text_color()), + ); + + ui.add_space(10.0); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Confirm Ban").clicked() { + confirmed = true; + } + + if ui.button("Cancel").clicked() { + cancelled = true; + } + }); + }); + }); + + if confirmed { + match self.ban_borrower(api_client) { + Ok(_) => { + self.show_ban_dialog = false; + self.ban_borrower_data = None; + self.load(api_client); + } + Err(e) => { + log::error!("Failed to ban borrower: {}", e); + } + } + } + + if cancelled || !keep_open { + self.show_ban_dialog = false; + self.ban_borrower_data = None; + } + } + + fn show_unban_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) { + let mut keep_open = true; + let mut confirmed = false; + let mut cancelled = false; + + let borrower_name = self + .ban_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + egui::Window::new("Unban Borrower") + .collapsible(false) + .resizable(false) + .default_width(400.0) + .open(&mut keep_open) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + + ui.label( + egui::RichText::new(format!( + "Are you sure you want to unban '{}'?", + borrower_name + )) + .color(egui::Color32::from_rgb(76, 175, 80)) + .strong(), + ); + + ui.add_space(10.0); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Confirm Unban").clicked() { + confirmed = true; + } + + if ui.button("Cancel").clicked() { + cancelled = true; + } + }); + }); + }); + + if confirmed { + match self.unban_borrower(api_client) { + Ok(_) => { + self.show_unban_dialog = false; + self.ban_borrower_data = None; + self.load(api_client); + } + Err(e) => { + log::error!("Failed to unban borrower: {}", e); + } + } + } + + if cancelled || !keep_open { + self.show_unban_dialog = false; + self.ban_borrower_data = None; + } + } + + fn show_return_confirm_dialog(&mut self, _ctx: &egui::Context, api_client: &ApiClient) { + // Replace the basic confirm dialog with the full Return Flow, pre-selecting the loan + if let Some(loan) = self.return_loan_data.clone() { + // Open the full-featured return flow and jump to confirmation + self.return_flow.open(api_client); + self.return_flow.selected_loan = Some(loan); + self.return_flow.current_step = + crate::core::workflows::return_flow::ReturnStep::Confirm; + } + // Close the legacy confirm dialog path + self.show_return_confirm_dialog = false; + self.return_loan_data = None; + } + + fn show_delete_borrower_dialog(&mut self, ctx: &egui::Context, api_client: &ApiClient) { + let mut keep_open = true; + let mut confirmed = false; + let mut cancelled = false; + + let borrower_name = self + .delete_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_name")) + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + egui::Window::new("Delete Borrower") + .collapsible(false) + .resizable(false) + .default_width(400.0) + .open(&mut keep_open) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + + ui.label( + egui::RichText::new(format!( + "Are you sure you want to delete '{}'?", + borrower_name + )) + .color(egui::Color32::RED) + .strong(), + ); + + ui.add_space(10.0); + + ui.label( + egui::RichText::new("This action cannot be undone!") + .color(egui::Color32::RED) + .small(), + ); + + ui.add_space(10.0); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Confirm Delete").clicked() { + confirmed = true; + } + + if ui.button("Cancel").clicked() { + cancelled = true; + } + }); + }); + }); + + if confirmed { + match self.delete_borrower(api_client) { + Ok(_) => { + self.show_delete_borrower_dialog = false; + self.delete_borrower_data = None; + self.load(api_client); + } + Err(e) => { + log::error!("Failed to delete borrower: {}", e); + self.last_error = Some(format!("Delete failed: {}", e)); + } + } + } + + if cancelled || !keep_open { + self.show_delete_borrower_dialog = false; + self.delete_borrower_data = None; + } + } + + fn ban_borrower(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let borrower_id = self + .ban_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid borrower ID"))?; + + let mut update_data = serde_json::json!({ + "banned": true, + }); + + // Add unban fine amount if provided + if !self.ban_fine_amount.trim().is_empty() { + if let Ok(fine) = self.ban_fine_amount.trim().parse::<f64>() { + update_data["unban_fine"] = serde_json::Value::Number( + serde_json::Number::from_f64(fine).unwrap_or(serde_json::Number::from(0)), + ); + } + } + + // Add reason to notes if provided + if !self.ban_reason.trim().is_empty() { + update_data["notes"] = serde_json::Value::String(self.ban_reason.trim().to_string()); + } + + let request = QueryRequest { + action: "update".to_string(), + table: "borrowers".to_string(), + data: Some(update_data), + r#where: Some(serde_json::json!({"id": borrower_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to ban borrower".to_string()))); + } + + Ok(()) + } + + fn unban_borrower(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let borrower_id = self + .ban_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid borrower ID"))?; + + let update_data = serde_json::json!({ + "banned": false, + "unban_fine": 0.0, + }); + + let request = QueryRequest { + action: "update".to_string(), + table: "borrowers".to_string(), + data: Some(update_data), + r#where: Some(serde_json::json!({"id": borrower_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to unban borrower".to_string()))); + } + + Ok(()) + } + + #[allow(dead_code)] + fn process_return(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let loan_id = self + .return_loan_data + .as_ref() + .and_then(|l| l.get("id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid loan ID"))?; + + let asset_id = self + .return_loan_data + .as_ref() + .and_then(|l| l.get("asset_id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid asset ID"))?; + + let return_date = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + // Update lending_history to set return_date + let update_data = serde_json::json!({ + "return_date": return_date + }); + + let request = QueryRequest { + action: "update".to_string(), + table: "lending_history".to_string(), + data: Some(update_data), + r#where: Some(serde_json::json!({"id": loan_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to update loan record".to_string()))); + } + + // Update asset status to "Available" + let asset_update = serde_json::json!({ + "lending_status": "Available" + }); + + let asset_request = QueryRequest { + action: "update".to_string(), + table: "assets".to_string(), + data: Some(asset_update), + r#where: Some(serde_json::json!({"id": asset_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let asset_response = api_client.query(&asset_request)?; + + if !asset_response.success { + return Err(anyhow::anyhow!(asset_response + .error + .unwrap_or_else(|| "Failed to update asset status".to_string()))); + } + + Ok(()) + } + + fn delete_borrower(&self, api_client: &ApiClient) -> anyhow::Result<()> { + use crate::models::QueryRequest; + + let borrower_id = self + .delete_borrower_data + .as_ref() + .and_then(|b| b.get("borrower_id")) + .and_then(|v| v.as_i64()) + .ok_or_else(|| anyhow::anyhow!("Invalid borrower ID"))?; + + let request = QueryRequest { + action: "delete".to_string(), + table: "borrowers".to_string(), + data: None, + r#where: Some(serde_json::json!({"id": borrower_id})), + columns: None, + joins: None, + order_by: None, + limit: None, + offset: None, + filter: None, + }; + + let response = api_client.query(&request)?; + + if !response.success { + return Err(anyhow::anyhow!(response + .error + .unwrap_or_else(|| "Failed to delete borrower".to_string()))); + } + + Ok(()) + } +} |
