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, borrowers: Vec, is_loading: bool, last_error: Option, // 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, // Edit borrower dialog (using FormBuilder) borrower_editor: FormBuilder, // Ban/Unban borrower dialog show_ban_dialog: bool, show_unban_dialog: bool, ban_borrower_data: Option, ban_fine_amount: String, ban_reason: String, // Return item confirm dialog show_return_confirm_dialog: bool, return_loan_data: Option, // Delete borrower confirm dialog show_delete_borrower_dialog: bool, delete_borrower_data: Option, // Search and filtering loans_search: String, borrowers_search: String, // Navigation pub switch_to_inventory_with_borrower: Option, // 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 = 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 = 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 = None; struct LoanEventHandler<'a> { return_action: &'a mut Option, } impl<'a> crate::core::table_renderer::TableEventHandler 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 = 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 = None; let mut ban_borrower: Option = None; let mut unban_borrower: Option = None; let mut delete_borrower: Option = None; let mut show_items_for_borrower: Option = None; // Create event handler for context menu struct BorrowerEventHandler<'a> { edit_action: &'a mut Option, ban_action: &'a mut Option, unban_action: &'a mut Option, delete_action: &'a mut Option, show_items_action: &'a mut Option, } impl<'a> crate::core::TableEventHandler 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, ) -> 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::().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::() { 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(()) } }