From adc0d4ea0999393b9597ff74f15459ac392baf45 Mon Sep 17 00:00:00 2001 From: Lucas Vas Date: Mon, 8 Dec 2025 16:13:52 -0500 Subject: [PATCH] feat: implement full loaner return form The only thing missing from this component would be sorting entries by their most recent date. I'm not super invested in sorting by this right now. --- src/pages/loaner_page.rs | 174 ++++++++++++++++++++++++++++++++++----- 1 file changed, 153 insertions(+), 21 deletions(-) diff --git a/src/pages/loaner_page.rs b/src/pages/loaner_page.rs index 416e09a..9e224e0 100644 --- a/src/pages/loaner_page.rs +++ b/src/pages/loaner_page.rs @@ -1,21 +1,22 @@ #[cfg(feature = "ssr")] -use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata}; +use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue}; #[cfg(feature = "ssr")] use serde_dynamo::{from_item, to_item}; -use chrono::{DateTime, Utc}; -use leptos::either::EitherOf3; +use leptos::either::{Either, EitherOf3}; use leptos::prelude::*; use serde::{Deserialize, Serialize}; use leptos::logging::log; use leptos_router::params::Params; -use leptos_router::hooks::{use_navigate, use_params, use_query}; +use leptos_router::hooks::{use_navigate, use_params}; use leptos::Params; use traits::{AsReactive, ReactiveCapture}; use crate::common::{ExpandableInfo, ValueType}; use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select}; use std::collections::HashMap; +use chrono::{DateTime, FixedOffset, TimeZone, Utc}; +use leptos::html::Dialog; #[derive(Params, PartialEq)] struct LoanerParams { @@ -30,6 +31,8 @@ struct LoanerReturnOutput { first_name: String, /// The last name of the student that is borrowing. last_name: String, + /// The date that the student borrowed the loaner. + date: String, } #[server] @@ -43,16 +46,18 @@ async fn get_loaners_return_list() -> Result, ServerFnEr match dbclient .scan() .table_name("loaner-info") - .projection_expression("subject, first_name, last_name") + .projection_expression("subject, first_name, last_name, date_taken") .send() .await { Ok(output) => { if let Some(items) = output.items { Ok(items.into_iter().map(|item| { let expandable: ExpandableInfo = from_item(item).unwrap(); + // This set of function calls is ridiculous, we need to make a better way to do this. LoanerReturnOutput { first_name: expandable.data.get("first_name").unwrap().as_string().unwrap_or_default().unwrap_or_default(), last_name: expandable.data.get("last_name").unwrap().as_string().unwrap_or_default().unwrap_or_default(), + date: expandable.data.get("date_taken").unwrap().as_string().unwrap_or_default().unwrap_or_default(), subject: expandable.subject } }).collect()) @@ -68,11 +73,37 @@ async fn get_loaners_return_list() -> Result, ServerFnEr } } +#[server] +async fn check_in_loaner(student: LoanerReturnOutput) -> Result<(), ServerFnError> { + let dbclient = Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); + + match dbclient + .delete_item() + .table_name("loaner-info") + .key("subject", AttributeValue::S(student.subject)) + .send() + .await + { + Ok(_) => Ok(()), + Err(err) => { + log!("{:?}", err); + let msg = err.message().unwrap_or("Unknown error"); + Err(ServerFnError::new(msg)) + } + } +} + #[server] async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> { let mut input = input; let dbclient = Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); - + + let current_time = chrono::Utc::now().format("%H:%M, %m/%d/%Y").to_string(); + log!("Creating entry with timestamp {}", current_time); + + // Add the timestamp to the data map as a string and then push it to the database. + input.data.insert("date_taken".to_string(), ValueType::String(Some(current_time.clone()))); + loop { let item: serde_dynamo::Item = to_item(&input)?; match dbclient @@ -88,7 +119,7 @@ async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> log!("{:?}", err); match err.code() { Some("ConditionalCheckFailedException") => { - // Retry database write until we have a good subject ID. + // Retry database writes until we have a good subject ID. log!("Failed conditional check, forcing new subject ID."); input.subject = uuid::Uuid::new_v4().to_string(); continue @@ -105,7 +136,7 @@ async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> /// # Loaner Page /// -/// This page is going to temporarily piggy-back on the general scholarship app, at least until +/// This page is going to temporarily piggyback on the general scholarship app, at least until /// we spin off the components used here into a new standalone crate. /// /// The loaner app handles management of Chromebook loaners to students. It was originally hosted @@ -187,11 +218,15 @@ pub fn LoanerBorrowForm() -> impl IntoView { let create_entry_action = ServerAction::::new(); let navigate = use_navigate(); - let temp = ExpandableInfo::new("test"); + let temp = ExpandableInfo::new(uuid::Uuid::new_v4().to_string()); let temp_reactive = temp.as_reactive(); let elements_disabled = RwSignal::new(false); view! { +
+

"Borrower Information"

+

"Please fill out the form below."

+
impl IntoView { #[component] pub fn LoanerReturnForm() -> impl IntoView { + // Register server actions + let check_in_action = ServerAction::::new(); + // Register server resources + let return_list_refresh = RwSignal::new(0); let return_list_resource: Resource> = Resource::new( - move || (), + move || return_list_refresh.get(), async |_| get_loaners_return_list().await.unwrap_or_else(|e| { log!("There was an error getting the loaner list: {}", e); vec![] }) ); - + + let dialog_ref = NodeRef::::new(); + let chosen_student = RwSignal::new(None::); + let dialog_disabled = RwSignal::new(false); + + // Create an effect to run after check-in - refreshes the list with another scan request. + // This could be beneficial if we ever decided to make multiple loaner kiosks (think SAT days) + Effect::new(move || { + if let Some(Ok(_)) = check_in_action.value().get() { + // Check the local loaner list and remove the indicated student from it. + return_list_refresh.update(|n| *n += 1); + if let Some(dialog) = dialog_ref.get() { + dialog.close(); + } + } + }); + + // We can't use a Memo in our custom components for the disabled signal. + Effect::new(move || { + if check_in_action.pending().get() { + dialog_disabled.set(true); + } else { + dialog_disabled.set(false); + } + }); + + // Handles checking in a student. + let dialog_check_in = move |_| { + chosen_student.with(|opt_student| { + if let Some(student) = opt_student.as_ref() { + check_in_action.dispatch(CheckInLoaner { student: student.clone() }); + } + }) + }; + + // Handles closing the dialog menu. + let close_dialog = move |_| { + if let Some(dialog) = dialog_ref.get() { + dialog.close(); + } + }; + view! { + +

"Confirmation"

+

+ {move || chosen_student.with(|opt_student| { + if let Some(student) = opt_student.as_ref() { + format!("Are you sure want to check in {} {}?", student.first_name, student.last_name) + } else { + "There is no student selected.".to_string() + } + })} +

+ "Close" + "Check In" +
+
+

"Student List"

+

"Please select your name from the list."

+
-
- {move || return_list_resource.get() - .map(move |loaner_list| { - loaner_list.into_iter().map(move |student| { - view! { - {student.first_name}" "{student.last_name} - } - }).collect_view() - }) - } +
+ {move || { + match return_list_resource.get() { + Some(loaner_list) => { + let list_view = loaner_list.iter().map(|student| { + let on_click = { + let student_click = student.clone(); + move |_| { + log!("Running on_click using student {:?}", student_click); + if let Some(dialog) = dialog_ref.get() { + chosen_student.set(Some(student_click.clone())); + dialog.show_modal().expect("Couldn't show modal dialog."); + } + } + }; + + let naive_time = chrono::NaiveDateTime::parse_from_str(&student.date, "%H:%M, %m/%d/%Y") + .unwrap(); + let time_zone = FixedOffset::west_opt(5 * 3600).unwrap(); + let student_date = time_zone.from_utc_datetime(&naive_time) + .format("%l:%M %p, %m/%d/%Y") + .to_string(); + + log!("Found current date {:?}", student_date); + + view! { +
+
+ {student.first_name.clone()}" "{student.last_name.clone()} +
+
+ {student_date.clone()} +
+
+ } + }).collect_view(); + + Either::Left(list_view) + }, + None => Either::Right("No loaners found.".into_view()) + } + }}
}