From e69db7f40bd7c2313f98e0dab5032daa10106274 Mon Sep 17 00:00:00 2001
From: Josef Rokos <pepa@bukova.info>
Date: Mon, 5 Feb 2024 12:00:38 +0100
Subject: [PATCH] Implemented admin overview.

---
 src/backend/data.rs             |  23 +++++
 src/backend/reservation.rs      | 119 ++++++++++++++++++++++++-
 src/locales/catalogues.rs       |  22 +++++
 src/locales/mod.rs              |  14 ++-
 src/pages/home_page.rs          |  69 +++------------
 src/pages/mod.rs                |   2 +
 src/pages/new_reservations.rs   | 150 ++++++++++++++++++++++++++++++++
 src/pages/opening_hours.rs      |  14 +--
 src/pages/today_reservations.rs |  63 ++++++++++++++
 9 files changed, 402 insertions(+), 74 deletions(-)
 create mode 100644 src/pages/new_reservations.rs
 create mode 100644 src/pages/today_reservations.rs

diff --git a/src/backend/data.rs b/src/backend/data.rs
index 44e270b..2404db4 100644
--- a/src/backend/data.rs
+++ b/src/backend/data.rs
@@ -447,6 +447,22 @@ pub struct Reservation {
     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 struct Reservations(Vec<Reservation>);
 
 // Transform slots to reservations
@@ -515,6 +531,13 @@ impl ReservationSum {
     }
 }
 
+#[derive(Clone, Serialize, Deserialize, Debug, Default)]
+pub struct ResSumWithItems {
+    pub summary: ReservationSum,
+    pub customer: Customer,
+    pub reservations: Vec<ResWithProperty>
+}
+
 /*
 pub enum MessageType {
     NewReservation,
diff --git a/src/backend/reservation.rs b/src/backend/reservation.rs
index c344989..1e624f2 100644
--- a/src/backend/reservation.rs
+++ b/src/backend/reservation.rs
@@ -1,18 +1,19 @@
 use leptos::*;
 use validator::Validate;
-use crate::backend::data::{ApiResponse, CrReservation, Reservation, PublicFormData};
+use crate::backend::data::{ApiResponse, CrReservation, Reservation, PublicFormData, ResSumWithItems};
 use crate::components::data_form::ForValidation;
 use cfg_if::cfg_if;
 use chrono::{NaiveDate, NaiveTime};
 
 cfg_if! { if #[cfg(feature = "ssr")] {
-    use sqlx::{Postgres, Transaction};
+    use sqlx::{Postgres, Transaction, query};
     use sqlx::query_as;
     use sqlx::Error;
     use uuid::Uuid;
     use std::ops::DerefMut;
     use std::str::FromStr;
-    use crate::backend::data::ReservationSum;
+    use futures_util::future::join_all;
+    use crate::backend::data::{ReservationSum, ReservationState, ResWithProperty, Customer};
     use crate::backend::get_pool;
 
     async fn find_sum_by_uuid(uuid: &Uuid, tx: &mut Transaction<'_, Postgres>) -> Result<ReservationSum, Error> {
@@ -41,6 +42,70 @@ cfg_if! { if #[cfg(feature = "ssr")] {
             Ok(reservations?)
         }
     }
+
+    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")
+                .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")
+                .bind(from)
+                .bind(to)
+                .fetch_all(&pool)
+                .await
+        };
+
+        let sums = if let Err(ref e) = sums {
+            if matches!(e, Error::RowNotFound) {
+                vec![]
+            } else {
+                sums?
+            }
+        } else {
+            sums?
+        };
+
+        if sums.is_empty() {
+            return Ok(vec![])
+        }
+
+        let res:  Result<Vec<ResSumWithItems>, Error> = join_all(sums.into_iter().map(|s| async {
+            let reservations = query_as::<_, ResWithProperty>(
+                    "SELECT r.id, r.from, r.to, r.property, r.summary, p.name, p,description \
+                        FROM reservation as r  \
+                        JOIN property as p ON r.property = p.id WHERE r.summary = $1")
+                .bind(s.id())
+                .fetch_all(&pool)
+                .await?;
+            let customer = query_as::<_, Customer>("SELECT * FROM customer WHERE id = $1")
+                .bind(s.customer)
+                .fetch_one(&pool)
+                .await?;
+            Ok(ResSumWithItems {
+                summary: s,
+                customer,
+                reservations
+            })
+        })).await.into_iter().collect();
+
+        Ok(res?)
+    }
+
+    async fn set_state(uuid: Uuid, state: ReservationState) -> Result<(), ServerFnError> {
+        let pool = get_pool().await?;
+        query("UPDATE reservation_sum SET state = $1 WHERE uuid = $2")
+            .bind(state)
+            .bind(uuid)
+            .execute(&pool)
+            .await?;
+
+        Ok(())
+    }
 }}
 
 #[server]
@@ -148,4 +213,52 @@ impl ForValidation for CreateReservation {
     fn entity(&self) -> &dyn Validate {
         &self.reservation
     }
+}
+
+#[server]
+pub async fn get_new_reservations() -> Result<ApiResponse<Vec<ResSumWithItems>>, ServerFnError> {
+    use crate::perm_check;
+    use chrono::{Days, Local};
+    use crate::backend::data::ReservationState;
+
+    perm_check!(is_logged_in);
+
+    Ok(ApiResponse::Data(reservations_in_range(&Local::now().date_naive(),
+                                               &Local::now().checked_add_days(Days::new(7)).unwrap().date_naive(),
+                                               Some(ReservationState::New)).await?))
+}
+
+#[server]
+pub async fn get_next_reservations() -> Result<ApiResponse<Vec<ResSumWithItems>>, ServerFnError> {
+    use crate::perm_check;
+    use chrono::{Days, Local};
+    use crate::backend::data::ReservationState;
+
+    perm_check!(is_logged_in);
+
+    Ok(ApiResponse::Data(reservations_in_range(&Local::now().date_naive(),
+                                               &Local::now().checked_add_days(Days::new(7)).unwrap().date_naive(),
+                                               Some(ReservationState::Approved)).await?))
+}
+
+#[server]
+pub async fn approve(uuid: String) -> Result<ApiResponse<()>, ServerFnError> {
+    use crate::perm_check;
+    use crate::backend::data::ReservationState;
+
+    perm_check!(is_logged_in);
+    set_state(Uuid::parse_str(&uuid)?, ReservationState::Approved).await?;
+
+    Ok(ApiResponse::Data(()))
+}
+
+#[server]
+pub async fn cancel(uuid: String)  -> Result<ApiResponse<()>, ServerFnError> {
+    use crate::perm_check;
+    use crate::backend::data::ReservationState;
+
+    perm_check!(is_logged_in);
+    set_state(Uuid::parse_str(&uuid)?, ReservationState::Canceled).await?;
+
+    Ok(ApiResponse::Data(()))
 }
\ No newline at end of file
diff --git a/src/locales/catalogues.rs b/src/locales/catalogues.rs
index 0cf9999..e51d0b0 100644
--- a/src/locales/catalogues.rs
+++ b/src/locales/catalogues.rs
@@ -56,6 +56,28 @@ lazy_static! {
                 ("Date", "Datum"),
                 ("Note", "Poznámka"),
                 ("Enter note", "Zadejte poznámku"),
+                ("New bookings", "Nové rezervace"),
+                ("Next approved bookings", "Potvrzené rezervace"),
+                ("Approve", "Potvrdit"),
+                ("Cancel booking", "Zrušit rezervaci"),
+                ("Approve booking", "Potvrdit rezervaci"),
+                ("Reservation saved", "Rezervace byla uložena"),
+                ("Cancel", "Zrušit"),
+                ("Approve booking on ", "Potvrdit rezervaci na "),
+                (" for ", " pro "),
+                ("Cancel booking on ", "Zrušit rezervaci na "),
+                ("Customer: ", "Zákazník: "),
+                ("Price: ", "Cena: "),
+                ("Note: ", "Poznámka: "),
+                ("Overview", "Přehled"),
+                ("Can't create reservation", "Rezervaci nelze vytvořit"),
+                ("Enter your full name", "Zadejte své jméno"),
+                ("Enter valid email address", "Zadejte platný e-mail"),
+                ("Select at last one time slot", "Zvolte čas rezervace"),
+                ("Enter your phone number", "Zadejte telefonní číslo"),
+                ("Your reservation has been successfully saved.", "Vaše rezervace byla úspěšně uložena."),
+                ("We look forward to seeing you on", "Těšíme se na vaši návštěvu"),
+                ("Create booking", "Vytvořit rezervaci"),
             ])),
             ("sk", HashMap::from( [
                 ("Dashboard", "Prehlad"),
diff --git a/src/locales/mod.rs b/src/locales/mod.rs
index dc72bb6..df41e81 100644
--- a/src/locales/mod.rs
+++ b/src/locales/mod.rs
@@ -1,4 +1,4 @@
-use chrono::NaiveDate;
+use chrono::{NaiveDate, Weekday};
 use leptos::use_context;
 use crate::locales::catalogues::get_dictionary;
 
@@ -51,4 +51,16 @@ pub fn loc_date(date: NaiveDate) -> impl Fn() -> String {
     }
 
     move || { dt.clone() }
+}
+
+pub fn show_day(day: &Weekday) -> impl Fn() -> String {
+    match day {
+        Weekday::Mon => { trl("Monday") }
+        Weekday::Tue => { trl("Tuesday") }
+        Weekday::Wed => { trl("Wednesday") }
+        Weekday::Thu => { trl("Thursday") }
+        Weekday::Fri => { trl("Friday") }
+        Weekday::Sat => { trl("Saturday") }
+        Weekday::Sun => { trl("Sunday") }
+    }
 }
\ No newline at end of file
diff --git a/src/pages/home_page.rs b/src/pages/home_page.rs
index 74a0157..d1305b8 100644
--- a/src/pages/home_page.rs
+++ b/src/pages/home_page.rs
@@ -1,69 +1,24 @@
 use leptos::*;
-use crate::components::modal_box::{DialogOpener, ModalDialog, ModalBody, ModalFooter};
+use crate::components::modal_box::DialogOpener;
 use crate::locales::trl;
+use crate::pages::new_reservations::NewReservations;
+use crate::pages::today_reservations::NextReservations;
 
 /// Renders the home page of your application.
 #[component]
 pub fn HomePage() -> impl IntoView {
-    // Creates a reactive value to update the button
-    let (count, set_count) = create_signal(0);
-    let on_click = move |_| set_count.update(|count| *count += 1);
-
-    let dialog = DialogOpener::new();
-
-    //let (dialog, set_dialog) = create_signal(false);
-    //let on_dialog = move |_| dialog.set_visible.update(|dialog| {*dialog = true});
-
-    //let pok = use_context::<Request>();
-    //log!("{:?}", pok);
-
+    let app_opener = DialogOpener::new();
+    let cancel_opener = DialogOpener::new();
 
     view! {
-        <ModalDialog opener={dialog} title="Titulek">
-        <ModalBody>
-        <div class="row">
-              <div class="col mb-3">
-                <label for="nameWithTitle" class="form-label">"Name"</label>
-                <input
-                  type="text"
-                  id="nameWithTitle"
-                  class="form-control"
-                  placeholder="Enter Name"
-                />
-              </div>
+        <h1>{trl("Overview")}</h1>
+        <div class="row mb-5">
+            <div class="col-md">
+                <NewReservations app_opener=app_opener cancel_opener=cancel_opener/>
             </div>
-            <div class="row g-2">
-              <div class="col mb-0">
-                <label for="emailWithTitle" class="form-label">"Email"</label>
-                <input
-                  type="text"
-                  id="emailWithTitle"
-                  class="form-control"
-                  placeholder="xxxx@xxx.xx"
-                />
-              </div>
-              <div class="col mb-0">
-                <label for="dobWithTitle" class="form-label">"DOB"</label>
-                <input
-                  type="text"
-                  id="dobWithTitle"
-                  class="form-control"
-                  placeholder="DD / MM / YY"
-                />
-              </div>
+            <div class="col-md">
+                <NextReservations app_opener=app_opener cancel_opener=cancel_opener/>
             </div>
-        </ModalBody>
-        <ModalFooter>
-            <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" on:click=move |_| dialog.hide()>
-                "Close"
-            </button>
-            <button type="button" class="btn btn-primary">"Save changes"</button>
-        </ModalFooter>
-        </ModalDialog>
-
-        <h1>"Welcome to Leptos!"</h1>
-        <button on:click=on_click>"Click Me: " {count}</button>
-        <button on:click=move |_| dialog.show()>"Dialog"</button>
-        <p>{trl("testik!")}</p>
+        </div>
     }
 }
\ No newline at end of file
diff --git a/src/pages/mod.rs b/src/pages/mod.rs
index 6111658..9dbae3b 100644
--- a/src/pages/mod.rs
+++ b/src/pages/mod.rs
@@ -15,4 +15,6 @@ mod properties;
 mod property_edit;
 mod property_delete;
 mod res_dialogs;
+mod today_reservations;
+mod new_reservations;
 
diff --git a/src/pages/new_reservations.rs b/src/pages/new_reservations.rs
new file mode 100644
index 0000000..14a1404
--- /dev/null
+++ b/src/pages/new_reservations.rs
@@ -0,0 +1,150 @@
+use leptos::*;
+use crate::backend::data::{ApiResponse, ResSumWithItems};
+use crate::backend::reservation::{Approve, Cancel, get_new_reservations};
+use crate::components::data_form::QuestionDialog;
+use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog};
+use crate::components::user_menu::MenuOpener;
+use crate::locales::{loc_date, show_day, trl};
+use crate::pages::public::Public;
+use chrono::Datelike;
+
+#[component]
+fn approve_dialog(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView {
+    let approve = create_server_action::<Approve>();
+
+    view! {
+        <QuestionDialog opener=opener action=approve title="Approve booking">
+            <input type="hidden" prop:value=move || reservation.get().summary.uuid.to_string() name="uuid"/>
+            <div>
+                {trl("Approve booking on ")}
+                {move || loc_date(reservation.get().summary.date)}
+                {trl(" for ")}
+                {move || reservation.get().customer.full_name}"?"
+            </div>
+        </QuestionDialog>
+    }
+}
+
+#[component]
+fn cancel_dialog(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView {
+    let cancel = create_server_action::<Cancel>();
+
+    view! {
+        <QuestionDialog opener=opener action=cancel title="Cancel booking">
+            <input type="hidden" prop:value=move || reservation.get().summary.uuid.to_string() name="uuid"/>
+            <div>
+                {trl("Cancel booking on ")}
+                {move || loc_date(reservation.get().summary.date)}
+                {trl(" for ")}
+                {move || reservation.get().customer.full_name}"?"
+            </div>
+        </QuestionDialog>
+    }
+}
+
+#[component]
+fn create_dialog(opener: DialogOpener) -> impl IntoView {
+    view! {
+        <ModalDialog opener=opener title="Create booking">
+            <ModalBody>
+                <Public/>
+            </ModalBody>
+        </ModalDialog>
+    }
+}
+
+#[component]
+pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -> impl IntoView {
+    let create = DialogOpener::new();
+    let res = create_blocking_resource(move || app_opener.visible() || cancel_opener.visible() || create.visible(),
+                                       move |_| get_new_reservations());
+    let reservation = create_rw_signal(ResSumWithItems::default());
+
+    view! {
+        <ApproveDialog opener=app_opener reservation=reservation.read_only() />
+        <CancelDialog opener=cancel_opener reservation=reservation.read_only() />
+        <CreateDialog opener=create />
+        <div class="card mb-3">
+        <div class="card-body">
+            <h5 class="card-title"><i class="bx bx-basket"></i>" "{trl("New bookings")}</h5>
+            <Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
+        {move || {
+            res.get().map(|r| match r {
+                Err(e) => {
+                    view! {<div>{trl("Something went wrong")}<br/>{e.to_string()}</div>}}
+                Ok(r) => { match r {
+                    ApiResponse::Data(r) => {
+                        view! {
+                            <div>
+                            <For each=move || r.clone()
+                                key=|res| res.summary.id()
+                                let:data>
+                            {move || {
+                                let menu = MenuOpener::new();
+                                let data = data.clone();
+                                let app_data = data.clone();
+                                let cancel_data = data.clone();
+                                view! {
+                                    <b>{show_day(&data.summary.date.weekday())}" - "{loc_date(data.summary.date)}</b><br/>
+                                    <For each=move || data.reservations.clone()
+                                        key=|item| item.reservation.id()
+                                        let:item>
+                                        {item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/>
+                                    </For>
+                                    {trl("Customer: ")}{data.customer.full_name}", "<a href={format!("mailto:{}", data.customer.email)}>{data.customer.email}</a>", "{data.customer.phone}<br/>
+                                    {move || {
+                                        let note = data.summary.note.clone();
+                                        let show = note.is_some() && !note.clone().unwrap().is_empty();
+                                        view! {
+                                            <Show when=move || show>
+                                                {trl("Note: ")}{note.clone()}<br/>
+                                            </Show>
+                                        }
+                                    }}
+                                    {trl("Price: ")}{data.summary.price.to_string()}<br/>
+                                    <table width="100%">
+                                    <tr>
+                                        <td width="100%"></td>
+                                        <td>
+                                            <div class="dropdown">
+                                                <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>
+                                                <div class={move || if menu.visible() {"dropdown-menu show"} else {"dropdown-menu"} }
+                                                style="position: absolute; insert: 0px 0px auto; margin: 0px; transform: translate3d(-160px, 0px, 0px);"
+                                                on:mouseleave=move |_| menu.toggle()>
+                                                    <a class="dropdown-item" href="javascript:void(0);" on:click=move |_| {
+                                                            reservation.set(app_data.clone());
+                                                            app_opener.show();
+                                                        }>
+                                                        <i class="bx bx-edit-alt me-1"></i> {trl("Approve")}</a>
+                                                    <a class="dropdown-item text-danger" href="javascript:void(0);" on:click=move |_| {
+                                                            reservation.set(cancel_data.clone());
+                                                            cancel_opener.show();
+                                                        }>
+                                                        <i class="bx bx-trash me-1"></i> {trl("Cancel")}</a>
+                                                </div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                    </table>
+                                    <hr/>
+                                }
+                            }}
+                            </For>
+                            </div>
+                        }
+                    }
+                    ApiResponse::Error(e) => {view! {<div>{trl(&e)}</div>}}}
+                }
+            })
+        }}
+            </Transition>
+            <a href="#" class="card-link" on:click=move |_| create.show()>
+                <i class="bx bx-plus-circle fs-4 lh-0"></i>
+            </a>
+        </div>
+        </div>
+    }
+}
\ No newline at end of file
diff --git a/src/pages/opening_hours.rs b/src/pages/opening_hours.rs
index d3f23ae..247bf23 100644
--- a/src/pages/opening_hours.rs
+++ b/src/pages/opening_hours.rs
@@ -3,7 +3,7 @@ use leptos::*;
 use crate::backend::data::{DayHours, WeekHours};
 use crate::backend::opening_hours::get_hours;
 use crate::components::modal_box::DialogOpener;
-use crate::locales::trl;
+use crate::locales::{show_day, trl};
 use crate::pages::hours_edit::EditHours;
 
 fn show_time(tm: &str) -> impl Fn() -> String {
@@ -14,18 +14,6 @@ fn show_time(tm: &str) -> impl Fn() -> String {
     }
 }
 
-fn show_day(day: &Weekday) -> impl Fn() -> String {
-    match day {
-        Weekday::Mon => { trl("Monday") }
-        Weekday::Tue => { trl("Tuesday") }
-        Weekday::Wed => { trl("Wednesday") }
-        Weekday::Thu => { trl("Thursday") }
-        Weekday::Fri => { trl("Friday") }
-        Weekday::Sat => { trl("Saturday") }
-        Weekday::Sun => { trl("Sunday") }
-    }
-}
-
 #[component]
 pub fn OpeningHours() -> impl IntoView {
     let editor = DialogOpener::new();
diff --git a/src/pages/today_reservations.rs b/src/pages/today_reservations.rs
new file mode 100644
index 0000000..3f810b6
--- /dev/null
+++ b/src/pages/today_reservations.rs
@@ -0,0 +1,63 @@
+use leptos::*;
+use crate::backend::data::ApiResponse;
+use crate::backend::reservation::get_next_reservations;
+use crate::components::modal_box::DialogOpener;
+use crate::locales::{loc_date, show_day, trl};
+use chrono::Datelike;
+
+#[component]
+pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -> impl IntoView {
+    let res = create_blocking_resource(  move || app_opener.visible() || cancel_opener.visible(), move |_| get_next_reservations());
+
+    view! {
+    <div class="card mb-3">
+        <div class="card-body">
+            <h5 class="card-title"><i class="bx bx-basket"></i>" "{trl("Next approved bookings")}</h5>
+            <Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
+        {move || {
+            res.get().map(|r| match r {
+                Err(e) => {
+                    view! {<div>{trl("Something went wrong")}<br/>{e.to_string()}</div>}}
+                Ok(r) => { match r {
+                    ApiResponse::Data(r) => {
+                        view! {
+                            <div>
+                            <For each=move || r.clone()
+                                key=|res| res.summary.id()
+                                let:data>
+                            {move || {
+                                let data = data.clone();
+                                view! {
+                                    <b>{show_day(&data.summary.date.weekday())}" - "{loc_date(data.summary.date)}</b><br/>
+                                    <For each=move || data.reservations.clone()
+                                        key=|item| item.reservation.id()
+                                        let:item>
+                                        {item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/>
+                                    </For>
+                                    {trl("Customer: ")}{data.customer.full_name}", "<a href={format!("mailto:{}", data.customer.email)}>{data.customer.email}</a>", "{data.customer.phone}<br/>
+                                    {move || {
+                                        let note = data.summary.note.clone();
+                                        let show = note.is_some() && !note.clone().unwrap().is_empty();
+                                        view! {
+                                            <Show when=move || show>
+                                                {trl("Note: ")}{note.clone()}<br/>
+                                            </Show>
+                                        }
+                                    }}
+                                    {trl("Price: ")}{data.summary.price.to_string()}<br/>
+                                    <hr/>
+                                }
+                            }}
+                            </For>
+                            </div>
+                        }
+                    }
+                    ApiResponse::Error(e) => {view! {<div>{trl(&e)}</div>}}}
+                }
+            })
+        }}
+            </Transition>
+        </div>
+        </div>
+    }
+}
\ No newline at end of file