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