aboutsummaryrefslogtreecommitdiff
path: root/src/core/workflows/borrow_flow.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/workflows/borrow_flow.rs')
-rw-r--r--src/core/workflows/borrow_flow.rs1450
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"
+ ))
+ }
+ }
+}