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.
This commit is contained in:
alphafetus committed 2025-12-08 16:13:52 -05:00
1 parent 24fe9802b9
commit adc0d4ea09
1 file changed
+151 -19
+151 -19
View File
@@ -1,21 +1,22 @@
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata}; use aws_sdk_dynamodb::{Client, error::ProvideErrorMetadata, types::AttributeValue};
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use serde_dynamo::{from_item, to_item}; use serde_dynamo::{from_item, to_item};
use chrono::{DateTime, Utc}; use leptos::either::{Either, EitherOf3};
use leptos::either::EitherOf3;
use leptos::prelude::*; use leptos::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use leptos::logging::log; use leptos::logging::log;
use leptos_router::params::Params; 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 leptos::Params;
use traits::{AsReactive, ReactiveCapture}; use traits::{AsReactive, ReactiveCapture};
use crate::common::{ExpandableInfo, ValueType}; use crate::common::{ExpandableInfo, ValueType};
use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select}; use crate::components::{ActionButton, Banner, DashboardButton, OutlinedTextField, Panel, Row, Select};
use std::collections::HashMap; use std::collections::HashMap;
use chrono::{DateTime, FixedOffset, TimeZone, Utc};
use leptos::html::Dialog;
#[derive(Params, PartialEq)] #[derive(Params, PartialEq)]
struct LoanerParams { struct LoanerParams {
@@ -30,6 +31,8 @@ struct LoanerReturnOutput {
first_name: String, first_name: String,
/// The last name of the student that is borrowing. /// The last name of the student that is borrowing.
last_name: String, last_name: String,
/// The date that the student borrowed the loaner.
date: String,
} }
#[server] #[server]
@@ -43,16 +46,18 @@ async fn get_loaners_return_list() -> Result<Vec<LoanerReturnOutput>, ServerFnEr
match dbclient match dbclient
.scan() .scan()
.table_name("loaner-info") .table_name("loaner-info")
.projection_expression("subject, first_name, last_name") .projection_expression("subject, first_name, last_name, date_taken")
.send() .send()
.await { .await {
Ok(output) => { Ok(output) => {
if let Some(items) = output.items { if let Some(items) = output.items {
Ok(items.into_iter().map(|item| { Ok(items.into_iter().map(|item| {
let expandable: ExpandableInfo = from_item(item).unwrap(); 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 { LoanerReturnOutput {
first_name: expandable.data.get("first_name").unwrap().as_string().unwrap_or_default().unwrap_or_default(), 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(), 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 subject: expandable.subject
} }
}).collect()) }).collect())
@@ -68,11 +73,37 @@ async fn get_loaners_return_list() -> Result<Vec<LoanerReturnOutput>, 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] #[server]
async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> { async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError> {
let mut input = input; let mut input = input;
let dbclient = Client::new(&aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await); 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 { loop {
let item: serde_dynamo::Item = to_item(&input)?; let item: serde_dynamo::Item = to_item(&input)?;
match dbclient match dbclient
@@ -88,7 +119,7 @@ async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError>
log!("{:?}", err); log!("{:?}", err);
match err.code() { match err.code() {
Some("ConditionalCheckFailedException") => { 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."); log!("Failed conditional check, forcing new subject ID.");
input.subject = uuid::Uuid::new_v4().to_string(); input.subject = uuid::Uuid::new_v4().to_string();
continue continue
@@ -105,7 +136,7 @@ async fn create_borrow_entry(input: ExpandableInfo) -> Result<(), ServerFnError>
/// # Loaner Page /// # 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. /// 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 /// 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::<CreateBorrowEntry>::new(); let create_entry_action = ServerAction::<CreateBorrowEntry>::new();
let navigate = use_navigate(); 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 temp_reactive = temp.as_reactive();
let elements_disabled = RwSignal::new(false); let elements_disabled = RwSignal::new(false);
view! { view! {
<div class="flex flex-col justify-center gap-2 mb-1 py-3 px-2">
<h2 class="text-2xl font-bold">"Borrower Information"</h2>
<p class="text-lg">"Please fill out the form below."</p>
</div>
<Row> <Row>
<OutlinedTextField <OutlinedTextField
label="First Name:" label="First Name:"
@@ -263,27 +298,124 @@ pub fn LoanerBorrowForm() -> impl IntoView {
#[component] #[component]
pub fn LoanerReturnForm() -> impl IntoView { pub fn LoanerReturnForm() -> impl IntoView {
// Register server actions
let check_in_action = ServerAction::<CheckInLoaner>::new();
// Register server resources // Register server resources
let return_list_refresh = RwSignal::new(0);
let return_list_resource: Resource<Vec<LoanerReturnOutput>> = Resource::new( let return_list_resource: Resource<Vec<LoanerReturnOutput>> = Resource::new(
move || (), move || return_list_refresh.get(),
async |_| get_loaners_return_list().await.unwrap_or_else(|e| { async |_| get_loaners_return_list().await.unwrap_or_else(|e| {
log!("There was an error getting the loaner list: {}", e); log!("There was an error getting the loaner list: {}", e);
vec![] vec![]
}) })
); );
let dialog_ref = NodeRef::<Dialog>::new();
let chosen_student = RwSignal::new(None::<LoanerReturnOutput>);
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! { view! {
<dialog
class="p-3 m-auto rounded-md shadow-lg/33 backdrop:backdrop-blur-xs backdrop:transition-backdrop-filter"
node_ref=dialog_ref>
<h2 class="text-2xl font-bold">"Confirmation"</h2>
<h4 class="text-xl mb-3">
{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()
}
})}
</h4>
<ActionButton on:click=close_dialog disabled=dialog_disabled>"Close"</ActionButton>
<ActionButton on:click=dialog_check_in disabled=dialog_disabled>"Check In"</ActionButton>
</dialog>
<div class="flex flex-col justify-center gap-2 mb-3 py-3 px-2">
<h2 class="text-2xl font-bold">"Student List"</h2>
<p class="text-lg">"Please select your name from the list."</p>
</div>
<Suspense fallback=move || "Loading...".into_view()> <Suspense fallback=move || "Loading...".into_view()>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2 m-2">
{move || return_list_resource.get() {move || {
.map(move |loaner_list| { match return_list_resource.get() {
loaner_list.into_iter().map(move |student| { Some(loaner_list) => {
view! { let list_view = loaner_list.iter().map(|student| {
<span>{student.first_name}" "{student.last_name}</span> let on_click = {
} let student_click = student.clone();
}).collect_view() 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! {
<div class="flex flex-row p-3 rounded-md cursor-pointer transition-shadow shadow-sm hover:shadow-lg/33" on:click=on_click>
<div class="flex-1">
{student.first_name.clone()}" "{student.last_name.clone()}
</div>
<div class="flex-none">
{student_date.clone()}
</div>
</div>
}
}).collect_view();
Either::Left(list_view)
},
None => Either::Right("No loaners found.".into_view())
}
}}
</div> </div>
</Suspense> </Suspense>
} }