Implemented booking overview.

This commit is contained in:
2024-02-14 16:46:31 +01:00
parent ab3e85573d
commit aa45cb48ba
8 changed files with 468 additions and 50 deletions
+6
View File
@@ -7,6 +7,7 @@ use leptos_router::*;
use crate::components::admin_portal::AdminPortal;
use crate::components::header::Header;
use crate::components::user_menu::MenuOpener;
use crate::pages::all_reservations::Bookings;
use crate::pages::login::Login;
use crate::pages::mail_settings::MailSettings;
use crate::pages::public::Public;
@@ -91,6 +92,11 @@ pub fn App() -> impl IntoView {
<MailSettings/>
</AdminPortal>
}/>
<Route path="admin/bookings" view=|| view! {
<AdminPortal>
<Bookings/>
</AdminPortal>
}/>
</Routes>
</main>
</Router>
+40 -9
View File
@@ -1,6 +1,6 @@
#![allow(unused_variables)]
use std::fmt::Display;
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use chrono::{Local, NaiveDate, NaiveTime, Weekday};
use lazy_static::lazy_static;
@@ -310,15 +310,20 @@ fn def_true() -> bool {
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ResProperty {
#[cfg_attr(feature = "ssr", sqlx(default))]
id: i32,
#[validate(length(min = 1,message = "Name cannot be empty"))]
pub name: String,
pub description: String,
#[cfg_attr(feature = "ssr", sqlx(default))]
pub price: Decimal,
#[cfg_attr(feature = "ssr", sqlx(default))]
pub slot: SlotType,
#[serde(default = "def_true")]
#[cfg_attr(feature = "ssr", sqlx(default))]
pub allow_multi: bool,
#[serde(default = "def_true")]
#[cfg_attr(feature = "ssr", sqlx(default))]
pub active: bool
}
@@ -409,10 +414,12 @@ impl CrReservation {
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Customer {
#[cfg_attr(feature = "ssr", sqlx(default))]
id: i32,
pub full_name: String,
pub email: String,
pub phone: String,
#[cfg_attr(feature = "ssr", sqlx(default))]
pub discount: i32
}
@@ -437,30 +444,36 @@ pub enum ReservationState {
Canceled,
}
impl Display for ReservationState {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", match self {
ReservationState::New => {"New"}
ReservationState::Approved => {"Approved"}
ReservationState::Canceled => {"Canceled"}
})
}
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Reservation {
#[cfg_attr(feature = "ssr", sqlx(default))]
id: i32,
pub from: NaiveTime,
pub to: NaiveTime,
#[cfg_attr(feature = "ssr", sqlx(default))]
pub property: i32,
#[cfg_attr(feature = "ssr", sqlx(default))]
pub summary: i32,
}
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ResPropertyView {
pub name: String,
pub description: String
}
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ResWithProperty {
#[cfg_attr(feature = "ssr", sqlx(flatten))]
pub reservation: Reservation,
#[cfg_attr(feature = "ssr", sqlx(flatten))]
pub property: ResPropertyView
pub property: ResProperty
}
pub struct Reservations(Vec<Reservation>);
@@ -538,6 +551,17 @@ pub struct ResSumWithItems {
pub reservations: Vec<ResWithProperty>
}
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ResAllView {
#[cfg_attr(feature = "ssr", sqlx(flatten))]
pub reservation: ResWithProperty,
#[cfg_attr(feature = "ssr", sqlx(flatten))]
pub summary: ReservationSum,
#[cfg_attr(feature = "ssr", sqlx(flatten))]
pub customer: Customer,
}
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(feature = "ssr", sqlx(type_name = "message_type"))]
@@ -576,3 +600,10 @@ impl Message {
self.id
}
}
#[derive(Clone, Debug, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ChartData {
pub count: i64,
pub period: f64
}
+169 -19
View File
@@ -12,9 +12,9 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use uuid::Uuid;
use std::ops::DerefMut;
use std::str::FromStr;
use futures_util::future::join_all;
use std::collections::HashMap;
use log::warn;
use crate::backend::data::{ReservationSum, ReservationState, ResWithProperty, Customer, Message, MessageType};
use crate::backend::data::{ReservationSum, ReservationState, ResWithProperty, Customer, Message, MessageType, ChartData, ResAllView};
use crate::backend::get_pool;
use crate::backend::get_mailing;
use crate::backend::mail::MailMessage;
@@ -23,6 +23,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use sqlx::PgPool;
use crate::backend::user::admin_email;
use crate::backend::user::emails_for_notify;
use rust_decimal::prelude::ToPrimitive;
async fn find_sum_by_uuid(uuid: &Uuid, tx: &mut Transaction<'_, Postgres>) -> Result<ReservationSum, Error> {
let reservation = query_as::<_, ReservationSum>("SELECT * FROM reservation_sum WHERE uuid = $1")
@@ -88,46 +89,64 @@ cfg_if! { if #[cfg(feature = "ssr")] {
async fn reservations_in_range(from: &NaiveDate, to: &NaiveDate, state: Option<ReservationState>) -> Result<Vec<ResSumWithItems>, ServerFnError> {
let pool = get_pool().await?;
let sums = if let Some(s) = state {
query_as::<_, ReservationSum>("SELECT * FROM reservation_sum WHERE date >= $1 AND date <= $2 AND state = $3 ORDER BY date")
let view = if let Some(s) = state {
query_as::<_, ResAllView>(
"SELECT r.from, r.to, p.name, p.description, s.*, c.full_name, c.email, c.phone \
FROM reservation r \
JOIN property p on p.id = r.property \
JOIN reservation_sum s on s.id = r.summary \
JOIN customer c on c.id = s.customer \
WHERE s.date >= $1 AND s.date <= $2 AND s.state = $3 \
ORDER BY s.id, s.date")
.bind(from)
.bind(to)
.bind(s)
.fetch_all(&pool)
.await
} else {
query_as::<_, ReservationSum>("SELECT * FROM reservation_sum WHERE date >= $1 AND date <= $2 ORDER BY date")
query_as::<_, ResAllView>(
"SELECT r.from, r.to, p.name, p.description, s.*, c.full_name, c.email, c.phone \
FROM reservation r \
JOIN property p on p.id = r.property \
JOIN reservation_sum s on s.id = r.summary \
JOIN customer c on c.id = s.customer \
WHERE s.date >= $1 AND s.date <= $2 \
ORDER BY s.id, s.date")
.bind(from)
.bind(to)
.fetch_all(&pool)
.await
};
let sums = if let Err(ref e) = sums {
let view = if let Err(ref e) = view {
if matches!(e, Error::RowNotFound) {
vec![]
} else {
sums?
view?
}
} else {
sums?
view?
};
if sums.is_empty() {
if view.is_empty() {
return Ok(vec![])
}
let res: Result<Vec<ResSumWithItems>, ServerFnError> = join_all(sums.into_iter().map(|s| async {
let reservations = items_for_reservation(s.id(), &pool).await?;
let customer = customer_for_reservation(s.customer, &pool).await?;
Ok(ResSumWithItems {
summary: s,
customer,
reservations
})
})).await.into_iter().collect();
let mut ret = view.into_iter().fold(HashMap::new(), |mut m, v| {
m.entry(v.summary.id())
.and_modify(|i: &mut (ReservationSum, Customer, Vec<ResWithProperty>)| i.2.push(v.reservation.clone()))
.or_insert((v.summary, v.customer, vec![v.reservation]));
m
}).into_iter().map(|i| {
ResSumWithItems {
summary: i.1.0,
customer: i.1.1,
reservations: i.1.2
}
}).collect::<Vec<_>>();
Ok(res?)
ret.sort_by(|a, b| a.summary.date.cmp(&b.summary.date) );
Ok(ret)
}
async fn set_state(uuid: Uuid, state: ReservationState) -> Result<(), ServerFnError> {
@@ -185,6 +204,29 @@ cfg_if! { if #[cfg(feature = "ssr")] {
async fn notify_cancel(uuid: Uuid) -> Result<(), AppError> {
send_notify(uuid, get_message(MessageType::ReservationCanceled).await?).await
}
async fn month_chart_data(year: i32) -> Result<Vec<ChartData>, AppError> {
let pool = get_pool().await?;
let data = query_as::<_, ChartData>(
"SELECT count(id) as count, date_part('month', date) as period \
FROM reservation_sum \
WHERE date_part('year', date) = $1 GROUP BY period ORDER BY period")
.bind(year)
.fetch_all(&pool)
.await?;
Ok(data)
}
async fn year_chart_data() -> Result<Vec<ChartData>, AppError> {
let pool = get_pool().await?;
let data = query_as::<_, ChartData>(
"SELECT count(id) as count, date_part('year', date) as period FROM reservation_sum GROUP BY period ORDER BY period")
.fetch_all(&pool)
.await?;
Ok(data)
}
}}
#[server]
@@ -327,6 +369,25 @@ pub async fn get_next_reservations() -> Result<ApiResponse<Vec<ResSumWithItems>>
Some(ReservationState::Approved)).await?))
}
#[cfg(feature = "ssr")]
fn num_days(month: u32, year: i32) -> i64 {
if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap()
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap()
}.signed_duration_since(NaiveDate::from_ymd_opt(year, month, 1).unwrap())
.num_days()
}
#[server]
pub async fn get_reservations_for_month(month: u32, year: i32) -> Result<ApiResponse<Vec<ResSumWithItems>>, ServerFnError> {
let data = reservations_in_range(&NaiveDate::from_ymd_opt(year, month, 1).unwrap(),
&NaiveDate::from_ymd_opt(year, month, num_days(month, year).to_u32().unwrap_or_default()).unwrap(),
None).await?;
Ok(ApiResponse::Data(data))
}
#[server]
pub async fn approve(uuid: String) -> Result<ApiResponse<()>, ServerFnError> {
use crate::perm_check;
@@ -357,4 +418,93 @@ pub async fn cancel(uuid: String) -> Result<ApiResponse<()>, ServerFnError> {
}
Ok(ApiResponse::Data(()))
}
#[cfg(feature = "ssr")]
fn chart(data: &Vec<ChartData>, title: &str, month: bool) -> String {
use charts_rs::{BarChart, THEME_ANT};
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use std::ops::Add;
use chrono::Month;
let mut chart = BarChart::new_with_theme(
vec![("Bookings", data.iter().map(|d| d.count.to_f32().unwrap_or_default()).collect()).into()],
if month {
data.iter().map(|d| Month::try_from(d.period.to_u8().unwrap_or_default()).unwrap_or(Month::January).name().to_string()).collect()
} else {
data.iter().map(|d| d.period.to_string()).collect()
},
THEME_ANT);
chart.title_text = title.to_string();
chart.legend_show = Some(false);
chart.series_label_font_size = 8.0;
chart.x_axis_font_size = 8.0;
chart.title_font_size = 11.0;
chart.font_family = "Arial".to_string();
chart.y_axis_configs[0].axis_font_size = 8.0;
chart.width = 300.0;
chart.height = 150.0;
"data:image/svg+xml;base64,".to_string().add(&BASE64_STANDARD.encode(&chart.svg().unwrap_or_default()))
}
#[server]
pub async fn month_chart(year: i32) -> Result<ApiResponse<String>, ServerFnError> {
use crate::perm_check;
perm_check!(is_logged_in);
let data = month_chart_data(year).await?;
Ok(ApiResponse::Data(chart(&data, "Month bookings", true)))
}
#[server]
pub async fn year_chart() -> Result<ApiResponse<String>, ServerFnError> {
use crate::perm_check;
perm_check!(is_logged_in);
let data = year_chart_data().await?;
Ok(ApiResponse::Data(chart(&data, "Year bookings", false)))
}
#[server]
pub async fn years() -> Result<Vec<i32>, ServerFnError> {
let pool = get_pool().await?;
let data: Result<Vec<(f64,)>, Error> = query_as("SELECT DISTINCT date_part('year', date) as year FROM reservation_sum ORDER BY year DESC")
.fetch_all(&pool)
.await;
Ok(data?.into_iter().map(|d| d.0.to_i32().unwrap_or_default()).collect())
}
#[server]
pub async fn months(year: i32) -> Result<Vec<u32>, ServerFnError> {
let pool = get_pool().await?;
let data: Result<Vec<(f64,)>, Error> = query_as("SELECT DISTINCT date_part('month', date) as month \
FROM reservation_sum \
WHERE date_part('year', date) = $1 \
ORDER BY month DESC")
.bind(year)
.fetch_all(&pool)
.await;
Ok(data?.into_iter().map(|d| d.0.to_u32().unwrap_or_default()).collect())
}
#[server]
pub async fn reservations_in_month(year: i32, month: u32) -> Result<ApiResponse<Vec<ResSumWithItems>>, ServerFnError> {
use crate::perm_check;
perm_check!(is_logged_in);
let ret = reservations_in_range(&NaiveDate::from_ymd_opt(year, month, 1).ok_or(ServerFnError::new("Cannot parse date"))?,
&NaiveDate::from_ymd_opt(year, month, num_days(month, year).to_u32().unwrap_or_default())
.ok_or(ServerFnError::new("Cannot parse date"))?,
None)
.await?;
Ok(ApiResponse::Data(ret))
}
+2 -2
View File
@@ -69,13 +69,13 @@ pub fn AdminPortal(children: Children) -> impl IntoView {
</a>
</li>
<li class="menu-item">
<a href="/bookings" class="menu-link">
<a href="/admin/bookings" class="menu-link">
<i class="menu-icon tf-icons bx bx-layer"></i>
<div data-i18n="Analytics">"Booking summary"</div>
</a>
</li>
<li class="menu-item">
<a href="/customers" class="menu-link">
<a href="/admin/customers" class="menu-link">
<i class="menu-icon tf-icons bx bx-face"></i>
<div data-i18n="Analytics">"Customers"</div>
</a>
+154
View File
@@ -0,0 +1,154 @@
use chrono::{Datelike, Local};
use leptos::*;
use crate::backend::data::ApiResponse;
use crate::backend::reservation::{month_chart, reservations_in_month, year_chart, years};
use crate::locales::{loc_date, trl};
#[component]
pub fn bookings() -> impl IntoView {
let year_chart = create_blocking_resource(||(), |_| year_chart());
let years = create_blocking_resource(||(), |_| years());
let year = create_rw_signal(Local::now().year());
let month = create_rw_signal(Local::now().month());
let chart = create_blocking_resource(move || year.get(),move |y| month_chart(y));
let reservations = create_blocking_resource(move || (year.get(), month.get()), move |p| reservations_in_month(p.0, p.1));
let all_months: Vec<u32> = vec![1,2,3,4,5,6,7,8,9,10,11,12];
view! {
<h1>{trl("Booking overview")}</h1>
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-5">
<div class="container-fluid">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<div class="nav-link">
"Rok: "
</div>
</li>
<li class="nav-item">
<Transition fallback=move || view! {<div>"Loading"</div>} >
{
years.get().map(|y| match y {
Ok(y) => {
view! {
<select class="form-select" on:change=move |ev| {
let new_value = event_target_value(&ev).parse::<i32>().unwrap_or_default();
year.set(new_value);
}>
<For each=move || y.clone() key=|i| *i let:y>
<option prop:value=move || y prop:selected=move || year.get() == y>{y}</option>
</For>
</select>
}
}
Err(_) => {view! {<select><option></option></select>}}
})
}
</Transition>
</li>
<li class="nav-item">
<div class="nav-link">
"Měsíc: "
</div>
</li>
<li class="nav-item">
<select class="form-select" on:change=move |ev| {
let new_value = event_target_value(&ev).parse::<u32>().unwrap_or_default();
month.set(new_value);
}>
<For each=move || all_months.clone() key=|i| *i let:m>
<option prop:value=move || m prop:selected=move || month.get() == m>{m}</option>
</For>
</select>
</li>
</ul>
</div>
</nav>
<div class="row mb-3">
<div class="col-md">
<div class="card md-3">
<Transition fallback=move || view! {<div>"Loading"</div>}>
{
chart.get().map(move |c| match c {
Ok(c) => { match c {
ApiResponse::Data(c) => {view! {<img style="margin: 1em" src={c.clone()} /> }}
ApiResponse::Error(_) => {view! {<img src=""/> }} }
}
Err(_) => { view! {<img src=""/> } }
})
}
</Transition>
</div>
</div>
<div class="col-md">
<div class="card md-3">
<Transition fallback=move || view! {<div>"Loading"</div>}>
{
year_chart.get().map(move |c| match c {
Ok(c) => { match c {
ApiResponse::Data(c) => {view! {<img style="margin: 1em" src={c.clone()} /> }}
ApiResponse::Error(_) => {view! {<img src=""/> }} }
}
Err(_) => { view! {<img src=""/> } }
})
}
</Transition>
</div>
</div>
</div>
<div class="row mb-3">
<div class="card">
<div class="card-body">
<Transition fallback=move || view! {<div>"Loading"</div>}>
<table class="table card-table">
<thead>
<tr>
<th>{trl("Date")}</th>
<th>{trl("Customer")}</th>
<th>{trl("Price")}</th>
<th>{trl("State")}</th>
<th>{trl("Actions")}</th>
</tr>
</thead>
{
reservations.get().map(|r| match r {
Ok(r) => { match r {
ApiResponse::Data(r) => {
view! {
<tbody class="table-border-bottom-0">
<For each=move || r.clone()
key=|i| i.summary.id()
let:data>
<tr>
<td>{loc_date(data.summary.date)}</td>
<td>{data.customer.full_name}</td>
<td>{data.summary.price.to_string()}</td>
<td>{data.summary.state.to_string()}</td>
<td><button type="button" class="btn p-0 dropdown-toggle hide-arrow">
//on:click=move |_| menu.toggle()>
<i class="bx bx-dots-vertical-rounded"></i>
</button></td>
</tr>
</For>
</tbody>
}
}
ApiResponse::Error(e) => {
view! {<tbody class="table-border-bottom-0">
<tr><td colspan=5>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}
}
}
}
Err(e) => {
view! {<tbody class="table-border-bottom-0">
<tr><td colspan=5>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}
}
})
}
</table>
</Transition>
</div>
</div>
</div>
}
}
+1
View File
@@ -19,4 +19,5 @@ mod today_reservations;
mod new_reservations;
pub mod mail_settings;
mod mail_view;
pub mod all_reservations;