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/core/workflows/borrow_flow.rs | |
Diffstat (limited to 'src/core/workflows/borrow_flow.rs')
| -rw-r--r-- | src/core/workflows/borrow_flow.rs | 1450 |
1 files changed, 1450 insertions, 0 deletions
diff --git a/src/core/workflows/borrow_flow.rs b/src/core/workflows/borrow_flow.rs new file mode 100644 index 0000000..08c287f --- /dev/null +++ b/src/core/workflows/borrow_flow.rs @@ -0,0 +1,1450 @@ +use anyhow::Result; +use chrono::{Duration, Local}; +use eframe::egui; +use egui_phosphor::variants::regular as icons; +use serde_json::Value; + +use crate::api::ApiClient; +use crate::models::QueryRequest; + +#[derive(Debug, Clone, PartialEq)] +pub enum BorrowStep { + SelectAsset, + SelectBorrower, + SelectDuration, + Confirm, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum BorrowerSelection { + None, + Existing(Value), // Existing borrower data + NewRegistration { + // New borrower being registered + name: String, + department: String, // "class" in the UI + borrower_type: String, // "role" in the UI + phone: String, + email: String, + }, +} + +pub struct BorrowFlow { + // State + pub is_open: bool, + pub current_step: BorrowStep, + + // Step 1: Asset Selection + pub scan_input: String, + pub available_assets: Vec<Value>, + pub selected_asset: Option<Value>, + pub asset_search: String, + pub asset_loading: bool, + + // Step 2: Borrower Selection + pub borrower_selection: BorrowerSelection, + pub registered_borrowers: Vec<Value>, + pub banned_borrowers: Vec<Value>, + pub borrower_search: String, + pub borrower_loading: bool, + + // New borrower registration fields + pub new_borrower_name: String, + pub new_borrower_class: String, + pub new_borrower_role: String, + pub new_borrower_phone: String, + pub new_borrower_email: String, + + // Step 3: Duration Selection + pub selected_duration_days: Option<u32>, + pub custom_due_date: String, + + // Step 4: Confirmation + pub lending_notes: String, + + // Confirmation for lending risky items (Faulty/Attention) + pub confirm_risky_asset: bool, + + // Error handling + pub error_message: Option<String>, + pub success_message: Option<String>, + pub just_completed_successfully: bool, +} + +impl Default for BorrowFlow { + fn default() -> Self { + Self { + is_open: false, + current_step: BorrowStep::SelectAsset, + + scan_input: String::new(), + available_assets: Vec::new(), + selected_asset: None, + asset_search: String::new(), + asset_loading: false, + + borrower_selection: BorrowerSelection::None, + registered_borrowers: Vec::new(), + banned_borrowers: Vec::new(), + borrower_search: String::new(), + borrower_loading: false, + + new_borrower_name: String::new(), + new_borrower_class: String::new(), + new_borrower_role: String::from("Student"), + new_borrower_phone: String::new(), + new_borrower_email: String::new(), + + selected_duration_days: None, + custom_due_date: String::new(), + + lending_notes: String::new(), + confirm_risky_asset: false, + + error_message: None, + success_message: None, + just_completed_successfully: false, + } + } +} + +impl BorrowFlow { + pub fn new() -> Self { + Self::default() + } + + pub fn open(&mut self, api_client: &ApiClient) { + self.is_open = true; + self.current_step = BorrowStep::SelectAsset; + self.reset_fields(); + self.just_completed_successfully = false; + self.load_available_assets(api_client); + } + + pub fn close(&mut self) { + self.is_open = false; + self.reset_fields(); + } + + pub fn take_recent_success(&mut self) -> bool { + if self.just_completed_successfully { + self.just_completed_successfully = false; + true + } else { + false + } + } + + fn reset_fields(&mut self) { + self.scan_input.clear(); + self.available_assets.clear(); + self.selected_asset = None; + self.asset_search.clear(); + + self.borrower_selection = BorrowerSelection::None; + self.registered_borrowers.clear(); + self.banned_borrowers.clear(); + self.borrower_search.clear(); + + self.new_borrower_name.clear(); + self.new_borrower_class.clear(); + self.new_borrower_role = String::from("Student"); + self.new_borrower_phone.clear(); + self.new_borrower_email.clear(); + + self.selected_duration_days = None; + self.custom_due_date.clear(); + self.lending_notes.clear(); + self.confirm_risky_asset = false; + + self.error_message = None; + self.success_message = None; + } + + pub fn show(&mut self, ctx: &egui::Context, api_client: &ApiClient) -> bool { + let was_open = self.is_open; + let mut keep_open = self.is_open; + + egui::Window::new("Borrow an Item") + .id(egui::Id::new("borrow_flow_main_window")) + .default_size(egui::vec2(1100.0, 800.0)) + .resizable(true) + .collapsible(false) + .open(&mut keep_open) + .show(ctx, |ui| { + // Progress indicator + self.show_progress_bar(ui); + + ui.separator(); + + // Show error/success messages + if let Some(err) = &self.error_message { + ui.colored_label(egui::Color32::RED, err); + ui.separator(); + } + if let Some(msg) = &self.success_message { + ui.colored_label(egui::Color32::GREEN, msg); + ui.separator(); + } + + // Main content area + egui::ScrollArea::vertical() + .id_salt("borrow_flow_main_scroll") + .show(ui, |ui| match self.current_step { + BorrowStep::SelectAsset => self.show_asset_selection(ui, api_client), + BorrowStep::SelectBorrower => self.show_borrower_selection(ui, api_client), + BorrowStep::SelectDuration => self.show_duration_selection(ui), + BorrowStep::Confirm => self.show_confirmation(ui), + }); + + ui.separator(); + + // Navigation buttons + self.show_navigation_buttons(ui, api_client); + }); + if !self.is_open { + keep_open = false; + } + + self.is_open = keep_open; + + if !keep_open && was_open { + self.close(); + } + + keep_open + } + + fn show_progress_bar(&self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + let step_index = match self.current_step { + BorrowStep::SelectAsset => 0, + BorrowStep::SelectBorrower => 1, + BorrowStep::SelectDuration => 2, + BorrowStep::Confirm => 3, + }; + + let steps = [ + (icons::PACKAGE, "Asset"), + (icons::USER, "Borrower"), + (icons::CLOCK, "Duration"), + (icons::CHECK_CIRCLE, "Confirm"), + ]; + for (i, (icon, step_name)) in steps.iter().enumerate() { + let color = if i == step_index { + egui::Color32::from_rgb(100, 149, 237) + } else if i < step_index { + egui::Color32::from_rgb(60, 179, 113) + } else { + egui::Color32::GRAY + }; + + ui.colored_label(color, format!("{} {}", icon, step_name)); + if i < steps.len() - 1 { + ui.add_space(5.0); + ui.label(icons::CARET_RIGHT); + ui.add_space(5.0); + } + } + }); + } + + fn show_asset_selection(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.heading("What do you want to borrow?"); + ui.add_space(10.0); + + // Scan field + ui.horizontal(|ui| { + ui.label("Scan or Enter Asset Tag/ID:"); + let response = ui.add( + egui::TextEdit::singleline(&mut self.scan_input) + .id(egui::Id::new("borrow_flow_scan_input")) + .hint_text("Scan barcode or type asset tag...") + .desired_width(300.0), + ); + + if response.changed() && !self.scan_input.is_empty() { + self.try_scan_asset(api_client); + } + + if ui.button("Clear").clicked() { + self.scan_input.clear(); + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Search bar + ui.horizontal(|ui| { + ui.label("Search:"); + if ui + .add( + egui::TextEdit::singleline(&mut self.asset_search) + .id(egui::Id::new("borrow_flow_asset_search")), + ) + .changed() + { + // Filter is applied in the table rendering + } + if ui.button("Refresh").clicked() { + self.load_available_assets(api_client); + } + }); + + ui.add_space(5.0); + + // Assets table + ui.label(egui::RichText::new("All Lendable Items").strong()); + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), 300.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + self.render_assets_table(ui); + }, + ); + } + + fn show_borrower_selection(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.heading("Who will borrow it?"); + ui.add_space(10.0); + + // New borrower registration section + egui::CollapsingHeader::new(egui::RichText::new("Register New Borrower").strong()) + .id_salt("borrow_flow_new_borrower_header") + .default_open(false) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Name:"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_name) + .id(egui::Id::new("borrow_flow_new_borrower_name")), + ); + }); + + ui.horizontal(|ui| { + ui.label("Class:"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_class) + .id(egui::Id::new("borrow_flow_new_borrower_class")), + ); + }); + + ui.horizontal(|ui| { + ui.label("Role:"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_role) + .id(egui::Id::new("borrow_flow_new_borrower_role")) + .hint_text("e.g. Student, Faculty, Staff, External"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Phone (optional):"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_phone) + .id(egui::Id::new("borrow_flow_new_borrower_phone")), + ); + }); + + ui.horizontal(|ui| { + ui.label("Email (optional):"); + ui.add( + egui::TextEdit::singleline(&mut self.new_borrower_email) + .id(egui::Id::new("borrow_flow_new_borrower_email")), + ); + }); + + ui.add_space(5.0); + + if ui.button("Use This New Borrower").clicked() { + if self.new_borrower_name.trim().is_empty() { + self.error_message = Some("Name is required".to_string()); + } else { + self.borrower_selection = BorrowerSelection::NewRegistration { + name: self.new_borrower_name.clone(), + department: self.new_borrower_class.clone(), + borrower_type: self.new_borrower_role.clone(), + phone: self.new_borrower_phone.clone(), + email: self.new_borrower_email.clone(), + }; + self.error_message = None; + } + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Banned borrowers warning section + if !self.banned_borrowers.is_empty() { + ui.colored_label( + egui::Color32::RED, + egui::RichText::new("WARNING: DO NOT LEND TO THESE BORROWERS!").strong(), + ); + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), 150.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + self.render_banned_borrowers_table(ui); + }, + ); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + } + + // Registered borrowers section + ui.label(egui::RichText::new("Select Registered Borrower").strong()); + + ui.horizontal(|ui| { + ui.label("Search:"); + if ui + .add( + egui::TextEdit::singleline(&mut self.borrower_search) + .id(egui::Id::new("borrow_flow_borrower_search")), + ) + .changed() + { + // Filter is applied in table rendering + } + if ui.button("Refresh").clicked() { + self.load_borrowers(api_client); + } + }); + + ui.add_space(5.0); + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), 300.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + self.render_borrowers_table(ui); + }, + ); + } + + fn show_duration_selection(&mut self, ui: &mut egui::Ui) { + ui.heading("How long does the borrower need it?"); + ui.add_space(10.0); + + ui.label(egui::RichText::new("Common Timeframes:").strong()); + ui.add_space(5.0); + + // Common duration buttons in a grid + egui::Grid::new("duration_grid") + .num_columns(4) + .spacing(egui::vec2(8.0, 8.0)) + .show(ui, |ui| { + for (days, label) in [(1, "1 Day"), (2, "2 Days"), (3, "3 Days"), (4, "4 Days")] { + let selected = self.selected_duration_days == Some(days); + if ui.selectable_label(selected, label).clicked() { + self.selected_duration_days = Some(days); + self.custom_due_date.clear(); + } + } + ui.end_row(); + + for (days, label) in [(5, "5 Days"), (6, "6 Days"), (7, "1 Week"), (14, "2 Weeks")] + { + let selected = self.selected_duration_days == Some(days); + if ui.selectable_label(selected, label).clicked() { + self.selected_duration_days = Some(days); + self.custom_due_date.clear(); + } + } + ui.end_row(); + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Special option: Deploy (indefinite) - separate from time options + ui.horizontal(|ui| { + ui.label("Special:"); + let selected = self.selected_duration_days == Some(0); + let deploy_label = format!("{} Deploy (Indefinite)", icons::ROCKET_LAUNCH); + if ui.selectable_label(selected, deploy_label).clicked() { + self.selected_duration_days = Some(0); + self.custom_due_date.clear(); + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.label("Or specify a custom date (YYYY-MM-DD):"); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut self.custom_due_date) + .id(egui::Id::new("borrow_flow_custom_due_date")), + ); + if ui.button("Clear").clicked() { + self.custom_due_date.clear(); + self.selected_duration_days = None; + } + }); + + if !self.custom_due_date.is_empty() { + self.selected_duration_days = None; + } + } + + fn show_confirmation(&mut self, ui: &mut egui::Ui) { + ui.heading("Overview"); + ui.add_space(10.0); + + // Summary box + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.label(egui::RichText::new("You will authorize lending:").strong()); + ui.add_space(5.0); + + // Asset info + if let Some(asset) = &self.selected_asset { + let tag = asset + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let name = asset.get("name").and_then(|v| v.as_str()).unwrap_or("N/A"); + ui.label(format!("Asset: {} - {}", tag, name)); + } + + // Borrower info + match &self.borrower_selection { + BorrowerSelection::Existing(borrower) => { + let name = borrower + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"); + let class = borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + ui.label(format!("Borrower: {} ({})", name, class)); + } + BorrowerSelection::NewRegistration { + name, + department, + borrower_type, + .. + } => { + ui.label(format!( + "New Borrower: {} ({}) - {}", + name, department, borrower_type + )); + } + BorrowerSelection::None => { + ui.colored_label(egui::Color32::RED, "WARNING: No borrower selected!"); + } + } + + // Duration info + if let Some(days) = self.selected_duration_days { + if days == 0 { + ui.label(format!( + "{} Deployed (Indefinite - No due date)", + icons::ROCKET_LAUNCH + )); + } else { + let due_date = Local::now() + Duration::days(days as i64); + ui.label(format!( + "Duration: {} days (Due: {})", + days, + due_date.format("%Y-%m-%d") + )); + } + } else if !self.custom_due_date.is_empty() { + ui.label(format!("Due Date: {}", self.custom_due_date)); + } else { + ui.colored_label(egui::Color32::RED, "WARNING: No duration selected!"); + } + }); + + ui.add_space(15.0); + + // Risk warning for Faulty/Attention assets + if let Some(asset) = &self.selected_asset { + let status = asset.get("status").and_then(|v| v.as_str()).unwrap_or(""); + if status == "Faulty" || status == "Attention" { + let (color, label) = if status == "Faulty" { + ( + egui::Color32::from_rgb(244, 67, 54), + "This item is marked as Faulty and may be unsafe or unusable.", + ) + } else { + ( + egui::Color32::from_rgb(255, 193, 7), + "This item has Attention status and may have minor defects.", + ) + }; + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.colored_label(color, label); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.checkbox( + &mut self.confirm_risky_asset, + "I acknowledge the issues and still wish to lend this item", + ); + }); + }); + ui.add_space(10.0); + } + } + + // Optional notes + ui.label("Optional Lending Notes:"); + ui.add( + egui::TextEdit::multiline(&mut self.lending_notes) + .id(egui::Id::new("borrow_flow_lending_notes")) + .desired_width(f32::INFINITY) + .desired_rows(4), + ); + } + + fn show_navigation_buttons(&mut self, ui: &mut egui::Ui, api_client: &ApiClient) { + ui.horizontal(|ui| { + // Back button + if self.current_step != BorrowStep::SelectAsset { + if ui.button(format!("{} Back", icons::ARROW_LEFT)).clicked() { + self.go_back(); + } + } + + ui.add_space(10.0); + + // Cancel button + if ui.button(format!("{} Cancel", icons::X)).clicked() { + self.close(); + } + + // Spacer + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Next/Approve button + match self.current_step { + BorrowStep::SelectAsset => { + let enabled = self.selected_asset.is_some(); + if ui + .add_enabled( + enabled, + egui::Button::new(format!("{} Next", icons::ARROW_RIGHT)), + ) + .clicked() + { + self.go_to_borrower_selection(api_client); + } + } + BorrowStep::SelectBorrower => { + let enabled = self.borrower_selection != BorrowerSelection::None; + if ui + .add_enabled( + enabled, + egui::Button::new(format!("{} Next", icons::ARROW_RIGHT)), + ) + .clicked() + { + self.current_step = BorrowStep::SelectDuration; + self.error_message = None; + } + } + BorrowStep::SelectDuration => { + let enabled = self.selected_duration_days.is_some() + || !self.custom_due_date.is_empty(); + if ui + .add_enabled( + enabled, + egui::Button::new(format!("{} Next", icons::ARROW_RIGHT)), + ) + .clicked() + { + self.current_step = BorrowStep::Confirm; + self.error_message = None; + } + } + BorrowStep::Confirm => { + // If asset is risky (Faulty/Attention), require explicit acknowledgment before enabling submit + let mut risky_requires_ack = false; + if let Some(asset) = &self.selected_asset { + let status = asset.get("status").and_then(|v| v.as_str()).unwrap_or(""); + if status == "Faulty" || status == "Attention" { + risky_requires_ack = true; + } + } + + let can_submit = !risky_requires_ack || self.confirm_risky_asset; + if ui + .add_enabled( + can_submit, + egui::Button::new(format!( + "{} Approve & Submit", + icons::ARROW_LEFT + )), + ) + .clicked() + { + self.submit_lending(api_client); + } + } + } + }); + }); + } + + fn go_back(&mut self) { + self.error_message = None; + self.current_step = match self.current_step { + BorrowStep::SelectAsset => BorrowStep::SelectAsset, + BorrowStep::SelectBorrower => BorrowStep::SelectAsset, + BorrowStep::SelectDuration => BorrowStep::SelectBorrower, + BorrowStep::Confirm => BorrowStep::SelectDuration, + }; + } + + fn go_to_borrower_selection(&mut self, api_client: &ApiClient) { + self.current_step = BorrowStep::SelectBorrower; + self.load_borrowers(api_client); + self.error_message = None; + } + + // Data loading methods + fn load_available_assets(&mut self, api_client: &ApiClient) { + self.asset_loading = true; + self.error_message = None; + + let request = QueryRequest { + action: "select".to_string(), + table: "assets".to_string(), + columns: Some(vec![ + "assets.id".to_string(), + "assets.asset_tag".to_string(), + "assets.name".to_string(), + "assets.category_id".to_string(), + "assets.lending_status".to_string(), + "categories.category_name".to_string(), + ]), + r#where: Some(serde_json::json!({ + "assets.lendable": true, + "assets.lending_status": "Available" + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: Some(vec![crate::models::Join { + table: "categories".to_string(), + on: "assets.category_id = categories.id".to_string(), + join_type: "LEFT".to_string(), + }]), + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + self.available_assets = arr.clone(); + } + } + } else { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to load assets".to_string()), + ); + } + } + Err(e) => { + self.error_message = Some(format!("Error loading assets: {}", e)); + } + } + + self.asset_loading = false; + } + + fn try_scan_asset(&mut self, api_client: &ApiClient) { + let scan_value = self.scan_input.trim(); + if scan_value.is_empty() { + return; + } + + // Try to find by asset_tag or id + let request = QueryRequest { + action: "select".to_string(), + table: "assets".to_string(), + columns: Some(vec![ + "assets.id".to_string(), + "assets.asset_tag".to_string(), + "assets.name".to_string(), + "assets.category_id".to_string(), + "assets.lending_status".to_string(), + "assets.lendable".to_string(), + "categories.category_name".to_string(), + ]), + r#where: Some(serde_json::json!({ + "assets.asset_tag": scan_value + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: Some(vec![crate::models::Join { + table: "categories".to_string(), + on: "assets.category_id = categories.id".to_string(), + join_type: "LEFT".to_string(), + }]), + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + if let Some(asset) = arr.first() { + // Verify it's lendable and available + let lendable = asset + .get("lendable") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let status = asset + .get("lending_status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if lendable && status == "Available" { + self.selected_asset = Some(asset.clone()); + self.error_message = None; + } else { + self.error_message = Some(format!( + "Asset '{}' is not available for lending", + scan_value + )); + } + } else { + self.error_message = + Some(format!("Asset '{}' not found", scan_value)); + } + } + } + } else { + self.error_message = + Some(response.error.unwrap_or_else(|| "Scan failed".to_string())); + } + } + Err(e) => { + self.error_message = Some(format!("Scan error: {}", e)); + } + } + } + + fn load_borrowers(&mut self, api_client: &ApiClient) { + self.borrower_loading = true; + self.error_message = None; + + // Load registered (non-banned) borrowers + let request = QueryRequest { + action: "select".to_string(), + table: "borrowers".to_string(), + columns: Some(vec![ + "id".to_string(), + "name".to_string(), + "email".to_string(), + "phone_number".to_string(), + "role".to_string(), + "class_name".to_string(), + "banned".to_string(), + ]), + r#where: Some(serde_json::json!({ + "banned": false + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + self.registered_borrowers = arr.clone(); + } + } + } + } + Err(e) => { + self.error_message = Some(format!("Error loading borrowers: {}", e)); + } + } + + // Load banned borrowers + let banned_request = QueryRequest { + action: "select".to_string(), + table: "borrowers".to_string(), + columns: Some(vec![ + "id".to_string(), + "name".to_string(), + "class_name".to_string(), + "unban_fine".to_string(), + ]), + r#where: Some(serde_json::json!({ + "banned": true + })), + data: None, + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&banned_request) { + Ok(response) => { + if response.success { + if let Some(data) = &response.data { + if let Some(arr) = data.as_array() { + self.banned_borrowers = arr.clone(); + } + } + } + } + Err(_) => { + // Don't overwrite error message if we already have one + } + } + + self.borrower_loading = false; + } + + // Table rendering methods + fn render_assets_table(&mut self, ui: &mut egui::Ui) { + use egui_extras::{Column, TableBuilder}; + + // Filter assets based on search + let filtered_assets: Vec<&Value> = self + .available_assets + .iter() + .filter(|asset| { + if self.asset_search.is_empty() { + return true; + } + let search_lower = self.asset_search.to_lowercase(); + let tag = asset + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let name = asset.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let category = asset + .get("category_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + tag.to_lowercase().contains(&search_lower) + || name.to_lowercase().contains(&search_lower) + || category.to_lowercase().contains(&search_lower) + }) + .collect(); + + TableBuilder::new(ui) + .id_salt("borrow_flow_assets_table") + .striped(true) + .resizable(true) + .sense(egui::Sense::click()) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto().resizable(true)) + .column(Column::remainder().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .min_scrolled_height(0.0) + .max_scroll_height(300.0) + .header(22.0, |mut header| { + header.col(|ui| { + ui.strong("Asset Tag"); + }); + header.col(|ui| { + ui.strong("Name"); + }); + header.col(|ui| { + ui.strong("Category"); + }); + header.col(|ui| { + ui.strong("Action"); + }); + }) + .body(|mut body| { + for asset in filtered_assets { + body.row(20.0, |mut row| { + let asset_id = asset.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + let is_selected = self + .selected_asset + .as_ref() + .and_then(|s| s.get("id").and_then(|v| v.as_i64())) + .map(|id| id == asset_id) + .unwrap_or(false); + + row.col(|ui| { + ui.label( + asset + .get("asset_tag") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label(asset.get("name").and_then(|v| v.as_str()).unwrap_or("N/A")); + }); + row.col(|ui| { + ui.label( + asset + .get("category_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + if is_selected { + ui.colored_label(egui::Color32::GREEN, "Selected"); + } else { + let button_id = format!("select_asset_{}", asset_id); + if ui.button("Select").on_hover_text(&button_id).clicked() { + self.selected_asset = Some((*asset).clone()); + } + } + }); + }); + } + }); + } + + fn render_borrowers_table(&mut self, ui: &mut egui::Ui) { + use egui_extras::{Column, TableBuilder}; + + // Filter borrowers based on search + let filtered_borrowers: Vec<&Value> = self + .registered_borrowers + .iter() + .filter(|borrower| { + if self.borrower_search.is_empty() { + return true; + } + let search_lower = self.borrower_search.to_lowercase(); + let name = borrower.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let class = borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let role = borrower.get("role").and_then(|v| v.as_str()).unwrap_or(""); + + name.to_lowercase().contains(&search_lower) + || class.to_lowercase().contains(&search_lower) + || role.to_lowercase().contains(&search_lower) + }) + .collect(); + + TableBuilder::new(ui) + .id_salt("borrow_flow_borrowers_table") + .striped(true) + .resizable(true) + .sense(egui::Sense::click()) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::remainder().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .min_scrolled_height(0.0) + .max_scroll_height(300.0) + .header(22.0, |mut header| { + header.col(|ui| { + ui.strong("Name"); + }); + header.col(|ui| { + ui.strong("Class"); + }); + header.col(|ui| { + ui.strong("Role"); + }); + header.col(|ui| { + ui.strong("Email"); + }); + header.col(|ui| { + ui.strong("Phone"); + }); + header.col(|ui| { + ui.strong("Action"); + }); + }) + .body(|mut body| { + for borrower in filtered_borrowers { + body.row(20.0, |mut row| { + let borrower_id = borrower.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + let is_selected = match &self.borrower_selection { + BorrowerSelection::Existing(b) => b + .get("id") + .and_then(|v| v.as_i64()) + .map(|id| id == borrower_id) + .unwrap_or(false), + _ => false, + }; + + row.col(|ui| { + ui.label( + borrower + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("role") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("email") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("phone_number") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + if is_selected { + ui.colored_label(egui::Color32::GREEN, "Selected"); + } else { + let button_id = format!("select_borrower_{}", borrower_id); + if ui.button("Select").on_hover_text(&button_id).clicked() { + self.borrower_selection = + BorrowerSelection::Existing((*borrower).clone()); + } + } + }); + }); + } + }); + } + + fn render_banned_borrowers_table(&self, ui: &mut egui::Ui) { + use egui_extras::{Column, TableBuilder}; + + TableBuilder::new(ui) + .id_salt("borrow_flow_banned_borrowers_table") + .striped(true) + .resizable(true) + .sense(egui::Sense::click()) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto().resizable(true)) + .column(Column::auto().resizable(true)) + .column(Column::remainder().resizable(true)) + .min_scrolled_height(0.0) + .max_scroll_height(150.0) + .header(22.0, |mut header| { + header.col(|ui| { + ui.strong("Name"); + }); + header.col(|ui| { + ui.strong("Class/Dept"); + }); + header.col(|ui| { + ui.strong("Unban Fine"); + }); + }) + .body(|mut body| { + for borrower in &self.banned_borrowers { + body.row(20.0, |mut row| { + row.col(|ui| { + ui.colored_label( + egui::Color32::RED, + borrower + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("class_name") + .and_then(|v| v.as_str()) + .unwrap_or("N/A"), + ); + }); + row.col(|ui| { + ui.label( + borrower + .get("unban_fine") + .and_then(|v| v.as_f64()) + .map(|f| format!("${:.2}", f)) + .unwrap_or("N/A".to_string()), + ); + }); + }); + } + }); + } + + // Submission method + fn submit_lending(&mut self, api_client: &ApiClient) { + self.error_message = None; + self.success_message = None; + + // Validate all required data + let asset = match &self.selected_asset { + Some(a) => a, + None => { + self.error_message = Some("No asset selected".to_string()); + return; + } + }; + + let asset_id = match asset.get("id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => { + self.error_message = Some("Invalid asset ID".to_string()); + return; + } + }; + + // Calculate due date (0 days = deployment/indefinite, no due date) + let due_date_str = if let Some(days) = self.selected_duration_days { + if days == 0 { + // Deployment mode: no due date + String::new() + } else { + let due = Local::now() + Duration::days(days as i64); + due.format("%Y-%m-%d").to_string() + } + } else if !self.custom_due_date.is_empty() { + self.custom_due_date.clone() + } else { + self.error_message = Some("No duration selected".to_string()); + return; + }; + + // Handle borrower (either create new or use existing) + let borrower_id = match &self.borrower_selection { + BorrowerSelection::Existing(borrower) => { + match borrower.get("id").and_then(|v| v.as_i64()) { + Some(id) => id, + None => { + self.error_message = Some("Invalid borrower ID".to_string()); + return; + } + } + } + BorrowerSelection::NewRegistration { + name, + department, + borrower_type, + phone, + email, + } => { + // First register the new borrower + match self.register_new_borrower( + api_client, + name, + department, + borrower_type, + phone, + email, + ) { + Ok(id) => id, + Err(e) => { + self.error_message = Some(format!("Failed to register borrower: {}", e)); + return; + } + } + } + BorrowerSelection::None => { + self.error_message = Some("No borrower selected".to_string()); + return; + } + }; + + // Create lending history record + let checkout_date = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + let mut lending_data = serde_json::json!({ + "asset_id": asset_id, + "borrower_id": borrower_id, + "checkout_date": checkout_date + }); + + // Only set due_date if not deployment mode + if !due_date_str.is_empty() { + lending_data["due_date"] = serde_json::Value::String(due_date_str.clone()); + } + + if !self.lending_notes.is_empty() { + lending_data["notes"] = serde_json::Value::String(self.lending_notes.clone()); + } + + let lending_request = QueryRequest { + action: "insert".to_string(), + table: "lending_history".to_string(), + columns: None, + r#where: None, + data: Some(lending_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&lending_request) { + Ok(response) => { + if !response.success { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to create lending record".to_string()), + ); + return; + } + } + Err(e) => { + self.error_message = Some(format!("Error creating lending record: {}", e)); + return; + } + } + + // Update asset status to "Borrowed" or "Deployed" based on duration + let lending_status = if self.selected_duration_days == Some(0) { + "Deployed" + } else { + "Borrowed" + }; + + let mut asset_update_data = serde_json::json!({ + "lending_status": lending_status, + "current_borrower_id": borrower_id + }); + if !due_date_str.is_empty() { + asset_update_data["due_date"] = serde_json::Value::String(due_date_str.clone()); + } + + let asset_update = QueryRequest { + action: "update".to_string(), + table: "assets".to_string(), + columns: None, + r#where: Some(serde_json::json!({ + "id": asset_id + })), + data: Some(asset_update_data), + filter: None, + order_by: None, + limit: None, + offset: None, + joins: None, + }; + + match api_client.query(&asset_update) { + Ok(response) => { + if response.success { + self.just_completed_successfully = true; + self.success_message = Some("Item successfully lent!".to_string()); + // Auto-close after a brief success message + // In a real app, you might want to add a delay here + self.close(); + } else { + self.error_message = Some( + response + .error + .unwrap_or_else(|| "Failed to update asset status".to_string()), + ); + } + } + Err(e) => { + self.error_message = Some(format!("Error updating asset: {}", e)); + } + } + } + + fn register_new_borrower( + &self, + api_client: &ApiClient, + name: &str, + department: &str, + borrower_type: &str, + phone: &str, + email: &str, + ) -> Result<i64> { + let mut borrower_data = serde_json::json!({ + "name": name, + "role": borrower_type, + "class_name": department, + }); + + if !phone.is_empty() { + borrower_data["phone_number"] = serde_json::Value::String(phone.to_string()); + } + if !email.is_empty() { + borrower_data["email"] = serde_json::Value::String(email.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()))); + } + + // Get the newly created borrower ID from response + if let Some(data) = &response.data { + if let Some(id) = data.get("id").and_then(|v| v.as_i64()) { + Ok(id) + } else if let Some(id) = data.get("inserted_id").and_then(|v| v.as_i64()) { + Ok(id) + } else { + Err(anyhow::anyhow!( + "Failed to get new borrower ID from response" + )) + } + } else { + Err(anyhow::anyhow!( + "No data returned from borrower registration" + )) + } + } +} |
