diff --git a/migrations/01_init_db.sql b/migrations/01_init_db.sql index a6e9d5d..0c67c13 100644 --- a/migrations/01_init_db.sql +++ b/migrations/01_init_db.sql @@ -13,15 +13,20 @@ CREATE TABLE "user" ( password VARCHAR NOT NULL, full_name VARCHAR, email VARCHAR, - admin bool, - get_emails bool + admin bool NOT NULL default false, + get_emails bool NOT NULL default false ); +CREATE TYPE slot_type AS ENUM ('Quarter', 'Half', 'Hour', 'Day'); + CREATE TABLE property ( id SERIAL PRIMARY KEY, name VARCHAR NOT NULL, description VARCHAR, - price NUMERIC(9, 2) NOT NULL + price NUMERIC(9, 2) NOT NULL, + slot slot_type NOT NULL default 'Hour', + allow_multi BOOLEAN NOT NULL default true, + active BOOLEAN NOT NULL default true ); CREATE TYPE message_type AS ENUM ('NewReservation', 'NewReservationCust', 'ReservationApp', 'ReservationCanceled'); diff --git a/src/backend/company.rs b/src/backend/company.rs index 5d7de09..29bd42d 100644 --- a/src/backend/company.rs +++ b/src/backend/company.rs @@ -1,8 +1,29 @@ +use cfg_if::cfg_if; use leptos::*; use validator::Validate; use crate::backend::data::{ApiResponse, Company}; use crate::components::data_form::ForValidation; +cfg_if! { if #[cfg(feature = "ssr")] { + use sqlx::{query_as, PgPool, query}; + use crate::error::AppError; + use log::info; + + pub async fn check_company(pool: &PgPool) -> Result<(), AppError> { + let count: (i64,) = query_as("SELECT COUNT(id) FROM company") + .fetch_one(pool) + .await?; + + if count.0 == 0 { + info!("Creating initial company"); + query("INSERT INTO company(name, street, house_number, zip_code, city) VALUES('Company name', '', '', '', '')") + .execute(pool).await?; + } + + Ok(()) + } +}} + #[server(GetCompany, "/api", "Url", "get_company")] pub async fn get_company() -> Result, ServerFnError> { use crate::backend::AppData; diff --git a/src/backend/data.rs b/src/backend/data.rs index 4acc5ae..49bf721 100644 --- a/src/backend/data.rs +++ b/src/backend/data.rs @@ -2,9 +2,11 @@ //use rust_decimal::Decimal; #![allow(unused_variables)] +use std::fmt::Display; use chrono::{NaiveTime, Weekday}; use lazy_static::lazy_static; use regex::Regex; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; //use uuid::Uuid; use validator::{Validate, ValidationError}; @@ -162,19 +164,21 @@ impl DayHours { } } -impl ToString for DayHours { - fn to_string(&self) -> String { +impl Display for DayHours { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.0.is_empty() { - return "".to_string() + return write!(f, "{}", "".to_string()) } - self.0.iter().map(|h| { + let str = self.0.iter().map(|h| { let discount = if let Some(d) = h.discount() { format!(" ({})", d).to_string() } else { "".to_string() }; - format!("{} - {}{}", h.from().format("%H:%M"), h.to().format("%H:%M"), discount).to_string()}) + format!("{} - {}{}", h.from().format("%H:%M"), h.to().format("%H:%M"), discount).to_string() + }) .collect::>() - .join(", ") + .join(", "); + write!(f, "{}", str) } } @@ -289,13 +293,43 @@ impl WeekHours { } } -/*pub struct Property { - id: u16, - name: String, - description: String, - price: Decimal +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "slot_type"))] +pub enum SlotType { + Quarter, + Half, + #[default] + Hour, + Day +} + +fn def_true() -> bool { + true +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Validate, Default)] +#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] +pub struct ResProperty { + id: i32, + #[validate(length(min = 1,message = "Name cannot be empty"))] + pub name: String, + pub description: String, + pub price: Decimal, + pub slot: SlotType, + #[serde(default = "def_true")] + pub allow_multi: bool, + #[serde(default = "def_true")] + pub active: bool +} + +impl ResProperty { + pub fn id(&self) -> i32 { + self.id + } } +/* pub enum MessageType { NewReservation, NewReservationCust, diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 7b456ee..8922e8e 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -4,6 +4,7 @@ pub mod company; pub mod user; pub mod auth_middleware; pub mod opening_hours; +pub mod property; #[macro_export] macro_rules! perm_check { diff --git a/src/backend/property.rs b/src/backend/property.rs new file mode 100644 index 0000000..0304685 --- /dev/null +++ b/src/backend/property.rs @@ -0,0 +1,82 @@ +use leptos::*; +use validator::Validate; +use crate::backend::data::{ApiResponse, ResProperty}; +use crate::components::data_form::ForValidation; + +#[server] +pub async fn get_properties() -> Result>, ServerFnError> { + use crate::backend::get_pool; + + let pool = get_pool().await?; + let props = sqlx::query_as::<_, ResProperty>("SELECT * FROM property").fetch_all(&pool).await?; + + Ok(ApiResponse::Data(props)) +} + +#[server] +pub async fn create_property(property: ResProperty) ->Result, ServerFnError> { + use crate::backend::get_pool; + use crate::perm_check; + + perm_check!(is_admin); + + let pool = get_pool().await?; + sqlx::query("INSERT INTO property(name, description, price, slot) VALUES($1, $2, $3, $4)") + .bind(&property.name) + .bind(&property.description) + .bind(&property.price) + .bind(&property.slot) + .execute(&pool) + .await?; + + Ok(ApiResponse::Data(())) +} + +#[server] +pub async fn edit_property(property: ResProperty) -> Result, ServerFnError> { + use crate::backend::get_pool; + use crate::perm_check; + + perm_check!(is_admin); + + let pool = get_pool().await?; + sqlx::query("UPDATE property SET name = $1, description = $2, price = $3, active = $4, slot = $5 WHERE id = $6") + .bind(&property.name) + .bind(&property.description) + .bind(&property.price) + .bind(property.active) + .bind(&property.slot) + .bind(property.id()) + .execute(&pool) + .await?; + + Ok(ApiResponse::Data(())) +} + +#[server] +pub async fn delete_property(id: i32) -> Result, ServerFnError> { + use crate::backend::get_pool; + use crate::perm_check; + + perm_check!(is_admin); + + let pool = get_pool().await?; + sqlx::query("DELETE FROM property WHERE id = $1") + .bind(id) + .execute(&pool) + .await?; + + Ok(ApiResponse::Data(())) +} + +impl ForValidation for CreateProperty { + fn entity(&self) -> &dyn Validate { + &self.property + } +} + +impl ForValidation for EditProperty { + fn entity(&self) -> &dyn Validate { + &self.property + } +} \ No newline at end of file diff --git a/src/backend/user.rs b/src/backend/user.rs index 773e542..2b6dc44 100644 --- a/src/backend/user.rs +++ b/src/backend/user.rs @@ -9,6 +9,7 @@ cfg_if! { if #[cfg(feature = "ssr")] { use actix_session::*; use leptos_actix::{extract, redirect}; use log::{info, warn}; + use crate::error::AppError; pub async fn has_admin_user(pool: &PgPool) -> Result { let count: (i64,) = query_as(r#"SELECT COUNT(id) FROM "user" WHERE admin = $1"#) @@ -19,7 +20,7 @@ cfg_if! { if #[cfg(feature = "ssr")] { Ok(count.0 > 0) } - pub async fn create_admin(pool: &PgPool) -> Result<(), Error> { + pub async fn create_admin(pool: &PgPool) -> Result<(), AppError> { if !has_admin_user(pool).await? { let pwd = pwhash::bcrypt::hash("admin"); query(r#"INSERT INTO "user"(login, password, full_name, admin) VALUES($1, $2, $3, $4)"#) @@ -44,6 +45,11 @@ cfg_if! { if #[cfg(feature = "ssr")] { extract(|session: Session| async move { session.get::("user").unwrap_or(None) }).await.unwrap_or(None) + /*let mut usr = User::default(); + usr.full_name = Some("PokAdm".to_string()); + usr.admin = true; + + Some(usr)*/ } pub async fn is_logged_in() -> bool { diff --git a/src/error.rs b/src/error.rs index 0c30e9c..470393d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,15 +1,21 @@ use std::error::Error; use std::fmt::{Debug, Display, Formatter}; +use leptos::ServerFnError; #[derive(Debug, Eq, PartialEq)] pub enum AppError { - HourParseError + HourParseError, + ServerError(String), + FatalError(String) } impl AppError { fn as_string(&self) -> String { - //match self { AppError::HourParseError => {"Hour parse error"} } - "Hours parse error".to_string() + match self { + AppError::HourParseError => {"Hour parse error".to_string()}, + AppError::ServerError(e) => {format!("Server error: {}", e)}, + AppError::FatalError(e) => {format!("Fatal error: {}", e)} + } } } @@ -19,4 +25,17 @@ impl Display for AppError { } } -impl Error for AppError {} \ No newline at end of file +impl Error for AppError {} + +impl From for AppError { + fn from(value: ServerFnError) -> Self { + AppError::ServerError(value.to_string()) + } +} + +#[cfg(feature = "ssr")] +impl From for AppError { + fn from(value: sqlx::Error) -> Self { + AppError::FatalError(value.to_string()) + } +} \ No newline at end of file diff --git a/src/locales/catalogues.rs b/src/locales/catalogues.rs index 93260b9..a570697 100644 --- a/src/locales/catalogues.rs +++ b/src/locales/catalogues.rs @@ -12,6 +12,8 @@ lazy_static! { ("Search...", "Najít..."), ("Close", "Zavřít"), ("Save changes", "Uložit změny"), + ("Yes", "Ano"), + ("No", "Ne"), ("Company info", "Organizace"), ("Name cannot be empty", "Jméno nesmí být prázdné"), ("Invalid old password", "Neplatné staré heslo"), @@ -25,6 +27,23 @@ lazy_static! { ("Saturday", "Sobota"), ("Sunday", "Neděle"), ("Opening hours", "Otvírací hodiny"), + ("Username", "Uživatel"), + ("Password", "Heslo"), + ("Sign in", "Přihlásit"), + ("Create user", "Vytvořit uživatele"), + ("Delete user", "Smazat uživatele"), + ("Users", "Uživatelé"), + ("Full name", "Celé jméno"), + ("Actions", "Akce"), + ("Edit", "Upravit"), + ("Change password", "Změnit heslo"), + ("Delete", "Smazat"), + ("Properties", "Předměty"), + ("Name", "Jméno"), + ("Description", "Popis"), + ("Price", "Cena"), + ("Edit hours", "Upravit hodiny"), + ("Hours", "Hodiny"), ])), ("sk", HashMap::from( [ ("Dashboard", "Prehlad"), diff --git a/src/main.rs b/src/main.rs index 3207e14..7414bb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,7 @@ +use log::error; +use rezervator::backend::company::check_company; +use rezervator::backend::user::create_admin; + #[cfg(feature = "ssr")] #[actix_web::main] async fn main() -> std::io::Result<()> { @@ -34,7 +38,7 @@ async fn main() -> std::io::Result<()> { let cfg_path = matches.opt_str("c").unwrap_or("config.toml".to_string()); - env_logger::Builder::from_env(Env::default().default_filter_or("info")).init(); + env_logger::Builder::from_env(Env::default().default_filter_or("debug")).init(); info!("Starting server"); let conf = get_configuration(None).await.unwrap(); @@ -49,6 +53,13 @@ async fn main() -> std::io::Result<()> { migrate!().run(&pool).await.expect("could not run SQLx migrations"); + if let Err(e) = create_admin(&pool).await { + error!("Error while checking admin user: {:?}", e); + } + if let Err(e) = check_company(&pool).await { + error!("Error while checking company: {:?}", e); + } + HttpServer::new(move || { let leptos_options = &conf.leptos_options; let site_root = &leptos_options.site_root; diff --git a/src/pages/mod.rs b/src/pages/mod.rs index 8f26323..d631262 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -11,4 +11,7 @@ mod user_edit; mod user_delete; mod opening_hours; mod hours_edit; +mod properties; +mod property_edit; +mod property_delete; diff --git a/src/pages/properties.rs b/src/pages/properties.rs new file mode 100644 index 0000000..6540667 --- /dev/null +++ b/src/pages/properties.rs @@ -0,0 +1,111 @@ +use leptos::*; +use leptos_use::use_media_query; +use crate::backend::data::{ApiResponse, ResProperty}; +use crate::backend::property::get_properties; +use crate::components::modal_box::DialogOpener; +use crate::components::user_menu::MenuOpener; +use crate::locales::trl; +use crate::pages::property_delete::PropertyDelete; +use crate::pages::property_edit::PropertyEdit; + +#[component] +pub fn properties() -> impl IntoView { + let is_wide = use_media_query("(min-width: 500px)"); + let properties = create_rw_signal::>(vec![]); + let prop = create_rw_signal(ResProperty::default()); + let empty_prop = create_rw_signal(ResProperty::default()); + let create_form = DialogOpener::new(); + let edit_form = DialogOpener::new(); + let delete_dlg = DialogOpener::new(); + let props = create_blocking_resource(move || create_form.visible() || edit_form.visible() || delete_dlg.visible(), move |_| get_properties()); + + view! { + + + +
+
+
" "{trl("Properties")}
+ {trl("Loading...")}

}> + + + + + {move || if is_wide.get() {view! {}} + else {view! {}} } + + + + + {move || { + props.get().map(|u| match u { + Err(e) => { + view! { + }} + Ok(u) => { + match u { + ApiResponse::Data(p) => { + properties.set(p.clone()); + view! { + + {move || { + let menu = MenuOpener::new(); + let data = data.clone(); + let prop_for_edit = data.clone(); + let prop_for_delet = data.clone(); + view! { + + + {move || if is_wide.get() {view! {}} + else {view! {}} } + + + } + }} + + + } + }, + ApiResponse::Error(s) => { + view! { + } + } + } + } + }) + } + } +
{trl("Name")}{trl("Description")}{trl("Price")}{trl("Actions")}
{trl("Something went wrong")}
{e.to_string()}
{data.name.clone()}{data.description.clone()}{data.price.to_string()} + +
{trl(&s)}
+
+ + + +
+
+ } +} \ No newline at end of file diff --git a/src/pages/property_delete.rs b/src/pages/property_delete.rs new file mode 100644 index 0000000..0ffafac --- /dev/null +++ b/src/pages/property_delete.rs @@ -0,0 +1,17 @@ +use leptos::*; +use crate::backend::data::ResProperty; +use crate::backend::property::DeleteProperty; +use crate::components::data_form::QuestionDialog; +use crate::components::modal_box::DialogOpener; + +#[component] +pub fn property_delete(property: ReadSignal, opener: DialogOpener) -> impl IntoView { + let del_property = create_server_action::(); + + view! { + + +
"Are you sure you want to delete property "{move || property.get().name}"?"
+
+ } +} \ No newline at end of file diff --git a/src/pages/property_edit.rs b/src/pages/property_edit.rs new file mode 100644 index 0000000..193fce6 --- /dev/null +++ b/src/pages/property_edit.rs @@ -0,0 +1,105 @@ +use leptos::*; +use crate::backend::data::{ResProperty, SlotType}; +use crate::backend::property::{CreateProperty, EditProperty}; +use crate::components::data_form::DataForm; +use crate::components::modal_box::DialogOpener; + +#[component] +fn form_inner(property: ReadSignal) -> impl IntoView { + let active_str = create_rw_signal(if property.get().active + { "true".to_string() } else { "false".to_string() }); + view! { + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ {move || { + if property.get().id() != 0 { + view! { +
+
+ + + +
+
+ } + } else { + view! {
} + } + } + }} +} + +#[component] +pub fn property_edit(property: ReadSignal, edit: bool, opener: DialogOpener) -> impl IntoView { + let action_create = create_server_action::(); + let action_edit = create_server_action::(); + + view! { + {move || + if edit { + view! { + + }} + else { + view! { + + }} + } + } +} \ No newline at end of file diff --git a/src/pages/settings.rs b/src/pages/settings.rs index aaf515e..e72479b 100644 --- a/src/pages/settings.rs +++ b/src/pages/settings.rs @@ -3,6 +3,7 @@ use crate::locales::trl; use crate::pages::company_info::CompanyInfo; use crate::pages::opening_hours::OpeningHours; use crate::pages::users::Users; +use crate::pages::properties::Properties; #[component] pub fn Settings() -> impl IntoView { @@ -21,7 +22,7 @@ pub fn Settings() -> impl IntoView {
- +
}