From 995a8b34768c3bc9cdb9ac574109450e59c20c77 Mon Sep 17 00:00:00 2001 From: JMARyA Date: Sat, 7 Jun 2025 21:27:12 +0200 Subject: [PATCH] update --- Cargo.lock | 72 +++++++++++++++ Cargo.toml | 2 + build.rs | 3 - src/api.rs | 54 +++++++++-- src/main.rs | 81 ++++++++++------- src/page/components.rs | 90 +++++++++++++++++++ src/page/consume.rs | 96 ++++++++++++++------ src/page/home.rs | 132 +++++++++++++++++++++------ src/page/item_detail.rs | 64 +++++++++---- src/page/items.rs | 48 +++++----- src/page/locations.rs | 44 ++++++--- src/page/mod.rs | 4 +- src/page/supply.rs | 106 ++++++++++++++-------- src/page/transaction.rs | 195 ++++++++++++++++++++++++++++++---------- src/qrscan.rs | 2 +- src/setup.rs | 42 +++++++-- 16 files changed, 789 insertions(+), 246 deletions(-) create mode 100644 src/page/components.rs diff --git a/Cargo.lock b/Cargo.lock index 5d990f6..e6ff005 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.98" @@ -377,12 +392,14 @@ version = "0.1.0" dependencies = [ "bardecoder", "base64", + "chrono", "dioxus", "dioxus-material-icons", "dioxus-sdk", "gloo-timers", "image", "log", + "qrc", "reqwest", "serde", "serde_json", @@ -425,6 +442,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -2417,6 +2448,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -3728,6 +3783,23 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "qrc" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f64ee6dc98fa28fec2e13ebcbe910530ef3b82a733853d48a7c98ffc0c534e" +dependencies = [ + "flate2", + "image", + "qrcode", +] + +[[package]] +name = "qrcode" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "166f136dfdb199f98186f3649cf7a0536534a61417a1a30221b492b4fb60ce3f" + [[package]] name = "quote" version = "1.0.40" diff --git a/Cargo.toml b/Cargo.toml index f2d4b5e..67e8b3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ dioxus-sdk = { version = "0.6.0", features = ["storage"] } serde = { version = "1.0.219", features = ["derive"] } reqwest = { version = "0.12.15", features = ["json"] } dioxus-material-icons = "3.0.0" +chrono = "0.4.41" +qrc = "0.0.5" [features] default = ["web"] diff --git a/build.rs b/build.rs index 2394064..aa67d41 100644 --- a/build.rs +++ b/build.rs @@ -21,7 +21,4 @@ fn main() { if !status.success() { panic!("Tailwind build failed with exit code: {}", status); } - - println!("cargo:rerun-if-changed={input}"); - println!("cargo:rerun-if-changed=tailwind.config.js"); } diff --git a/src/api.rs b/src/api.rs index a394212..6b7ba1a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; -use dioxus::signals::Readable; +use dioxus::signals::{Readable, Writable}; use dioxus_sdk::storage::use_persistent; use serde_json::json; use crate::setup::Credentials; +use crate::try_recover_api; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::Client; @@ -12,11 +13,11 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; pub fn get_item(item: &str) -> Option { - crate::API - .read() - .as_ref() - .unwrap() - .get_item(item.to_string()) + if let Some(api) = crate::API.read().as_ref() { + api.get_item(item.to_string()) + } else { + try_recover_api().get_item(item.to_string()) + } } pub async fn api_get_auth(path: String) -> Result @@ -88,8 +89,18 @@ impl API { let mut items: HashMap> = api_get_auth("/items".to_string()).await.unwrap(); let items = items.remove("items").unwrap(); - let locations = api_get_auth("/locations".to_string()).await.unwrap(); - let flow_info = api_get_auth("/flows".to_string()).await.unwrap(); + let locations: HashMap = + api_get_auth("/locations".to_string()).await.unwrap(); + let flow_info: HashMap = + api_get_auth("/flows".to_string()).await.unwrap(); + + let mut p_items = use_persistent("api_items", || Vec::::new()); + p_items.set(items.clone()); + let mut p_locations = + use_persistent("api_locations", || HashMap::::new()); + p_locations.set(locations.clone()); + let mut p_flowinfo = use_persistent("api_flow_info", || HashMap::::new()); + p_flowinfo.set(flow_info.clone()); Self { instance, @@ -99,6 +110,24 @@ impl API { } } + pub fn try_recover() -> Self { + Self { + instance: use_persistent("creds", || Credentials::default()) + .read() + .instance_url + .clone(), + items: use_persistent("api_items", || Vec::::new()) + .read() + .clone(), + locations: use_persistent("api_locations", || HashMap::::new()) + .read() + .clone(), + flow_info: use_persistent("api_flow_info", || HashMap::::new()) + .read() + .clone(), + } + } + pub async fn get_items(&self) -> &[Item] { &self.items } @@ -358,7 +387,7 @@ impl API { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct FlowInfo { pub id: String, pub name: String, @@ -376,6 +405,13 @@ pub struct Item { pub variants: HashMap, } +impl Item { + pub fn get_variant(&self, variant: &str) -> Option { + let var = self.variants.iter().find(|x| *x.0 == variant)?; + Some(var.1.clone()) + } +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct ItemVariant { pub item: String, diff --git a/src/main.rs b/src/main.rs index fdefd02..a17d485 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,16 +24,18 @@ enum Route { FlowPage {}, #[route("/locations")] LocationsPage {}, + #[route("/location/:location")] + LocationPage { location: String }, #[route("/item/:id")] ItemDetailPage { id: String }, #[route("/transaction/:id")] TransactionPage { id: String }, + #[end_layout] #[route("/item/:item/:variant/consume/:id")] ConsumePage { id: String, item: String, variant: String }, - #[route("/item/:item/supply?:param")] SupplyPage { item: String, param: SupplyPageParam } // #[route("/blog/:id")] @@ -54,16 +56,26 @@ fn main() { dioxus::launch(App); } +pub fn try_recover_api() -> crate::api::API { + api::API::try_recover() +} + +pub async fn fetch_api() { + let res = use_persistent("creds", || Credentials::default()) + .read() + .clone(); + if !res.empty() { + let api = api::API::new(res.instance_url.clone()).await; + *crate::API.write() = Some(api); + } +} + #[component] fn App() -> Element { let creds = use_persistent("creds", || Credentials::default()); spawn(async move { - let res = use_persistent("creds", || Credentials::default()); - if !res.read().empty() { - let api = api::API::new(res.read().instance_url.clone()).await; - *crate::API.write() = Some(api); - } + fetch_api().await; }); rsx! { @@ -84,9 +96,21 @@ fn App() -> Element { fn ItemTile(item: Item) -> Element { rsx! { Link { - to: Route::ItemDetailPage { id: item.uuid }, - {item.name.as_str()} + to: Route::ItemDetailPage { id: item.uuid }, + class: "flex gap-2", + if let Some(img) = item.image { + img { src: crate::API.read().as_ref().unwrap().get_url_instance(img.to_string()), width: "48" } + } else { + MaterialIcon { + name: "view_in_ar", + size: 48 } + } + p { + class: "text-lg my-auto", + {item.name.as_str()} + } + } } } @@ -137,47 +161,42 @@ fn TransactionCard(t: Transaction) -> Element { } } } - -/// Shared navbar component. #[component] fn Navbar() -> Element { rsx! { div { id: "navbar", + class: "flex flex-wrap gap-2 p-2 shadow-md rounded-sm", + style: "background-color:hsl(223, 18.90%, 13.30%);", Link { to: Route::Home {}, - div { - class: "flex flex-row gap-2 p-2", - MaterialIcon { name: "home", size: 24 }, - "Home" - } + class: "flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer text-gray-900 dark:text-gray-100", + MaterialIcon { name: "home", size: 24 }, + "Home" } Link { to: Route::ItemPage { }, - div { - class: "flex flex-row gap-2 p-2", - MaterialIcon { name: "category", size: 24 }, - "Items" - } + class: "flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer text-gray-900 dark:text-gray-100", + MaterialIcon { name: "category", size: 24 }, + "Items" } Link { to: Route::FlowPage { }, - div { - class: "flex flex-row gap-2 p-2", - MaterialIcon { name: "assignment", size: 24 }, - "Flows" - } + class: "flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer text-gray-900 dark:text-gray-100", + MaterialIcon { name: "assignment", size: 24 }, + "Flows" } Link { to: Route::LocationsPage { }, - div { - class: "flex flex-row gap-2 p-2", - MaterialIcon { name: "map", size: 24 }, - "Locations" - } + class: "flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer text-gray-900 dark:text-gray-100", + MaterialIcon { name: "map", size: 24 }, + "Locations" } } - Outlet:: {} + div { + class: "py-4", + Outlet:: {} + } } } diff --git a/src/page/components.rs b/src/page/components.rs new file mode 100644 index 0000000..8a8098c --- /dev/null +++ b/src/page/components.rs @@ -0,0 +1,90 @@ +use chrono::{DateTime, Local, NaiveDateTime, Utc}; +use dioxus::prelude::*; + +use crate::page::supply::CloseXButton; + +#[component] +pub fn HeaderTitle(title: String) -> Element { + rsx! { + h1 { + class: "text-3xl font-bold mb-6", + {title} + }, + } +} + +#[component] +pub fn TransientHeader(title: String) -> Element { + rsx! { + h1 { + class: "flex text-2xl font-semibold border-b border-neutral-200 dark:border-neutral-700 pb-4", + CloseXButton { } + {title} + } + } +} + +#[component] +pub fn BasicButton(title: String, onclick: EventHandler) -> Element { + rsx! { + div { + class: "mt-4", + button { + class: "\ + inline-flex items-center gap-2 px-4 py-2 rounded-md \ + bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 \ + text-white text-sm font-medium shadow-sm hover:shadow-md transition-all duration-200 cursor-pointer", + onclick: onclick, + {title} + } + } + } +} + +#[component] +pub fn LabeledInput(label: String, children: Element) -> Element { + rsx! { + div { + class: "flex flex-col gap-1", + label { class: "text-sm font-medium", {label} } + {children} + } + } +} + +pub fn to_human_date(unix_ts: i64) -> String { + let naive = NaiveDateTime::from_timestamp(unix_ts, 0); + let datetime_utc: DateTime = DateTime::from_utc(naive, Utc); + let datetime_local: DateTime = DateTime::from(datetime_utc); + + let now = Local::now(); + let duration = now.signed_duration_since(datetime_local); + + // Format the date part + let date_str = datetime_local.format("%Y-%m-%d %H:%M").to_string(); + + // Format the delta part + let ago_str = if duration.num_seconds() < 60 { + "just now".to_string() + } else if duration.num_minutes() < 60 { + format!( + "{} minute{} ago", + duration.num_minutes(), + if duration.num_minutes() != 1 { "s" } else { "" } + ) + } else if duration.num_hours() < 24 { + format!( + "{} hour{} ago", + duration.num_hours(), + if duration.num_hours() != 1 { "s" } else { "" } + ) + } else { + format!( + "{} day{} ago", + duration.num_days(), + if duration.num_days() != 1 { "s" } else { "" } + ) + }; + + format!("{} ({})", date_str, ago_str) +} diff --git a/src/page/consume.rs b/src/page/consume.rs index 2c079f5..5b41543 100644 --- a/src/page/consume.rs +++ b/src/page/consume.rs @@ -1,6 +1,9 @@ use dioxus::prelude::*; -use crate::api; +use crate::{ + api, + page::{BasicButton, LabeledInput, TransientHeader}, +}; #[component] pub fn ConsumePage(id: String, item: String, variant: String) -> Element { @@ -14,43 +17,56 @@ pub fn ConsumePage(id: String, item: String, variant: String) -> Element { let mut price = use_signal(|| 0.00); rsx! { + div { + class: "w-full py-4 flex flex-col gap-6", - p { "Item: {item}"}, - p { "Variant: {variant}"} + TransientHeader { title: format!("Consume {item} - {variant}") } - // TODO : destination + + LabeledInput { + label: "Destination", PredefinedSelector { name: "Destination", value: dest, + custom: true, predefined: destinations - } + } + } - label { - "Price: ", + // Price Field + LabeledInput { + label: "Price", input { + class: "rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400", r#type: "number", value: "{price}", - oninput: move |e| price.set(e.value().parse().unwrap()) + oninput: move |e| { + price.set(e.value().parse().unwrap()) + } } - }, + } + + + BasicButton { + title: "Consume", - button { onclick: move |_| { let id2 = id.clone(); spawn(async move { api::API::consume_item(id2, dest(), price()).await; navigator().go_back(); }); - }, - "Consume" + }, } } + } } #[component] pub fn PredefinedSelector( name: String, value: Signal, + custom: bool, predefined: Resource>, ) -> Element { let mut screen_visible = use_signal(|| false); @@ -60,32 +76,46 @@ pub fn PredefinedSelector( div { class: "p-2", button { - class: "cursor-pointer underline text-blue-600", + class: "\ + cursor-pointer rounded-lg border border-neutral-300 dark:border-neutral-700 \ + px-3 py-1 text-md font-medium text-neutral-700 dark:text-neutral-300 \ + hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors duration-200", onclick: move |_| screen_visible.set(true), "{name}: {value}" } } - if *screen_visible.read() { - div { - class: "fixed inset-0 z-40 flex flex-col p-4 space-y-4 text-white", - style: "background-color: #0f1116;", + if *screen_visible.read() { + div { + class: "\ + fixed inset-0 z-40 flex flex-col bg-black bg-opacity-80 p-6 space-y-6 text-white \ + backdrop-blur-sm", - h2 { class: "text-xl font-bold", "Select a value for {name}" } + h2 { + class: "text-2xl font-semibold", + "Select a value for {name}" + } + if custom { input { - class: "border p-2 text-lg", + class: "\ + rounded-md border border-neutral-600 bg-neutral-900 px-4 py-2 text-lg \ + text-white focus:outline-none focus:ring-2 focus:ring-blue-500 transition", r#type: "text", value: "{input_value}", oninput: move |evt| input_value.set(evt.value().clone()), } + } - div { - class: "flex flex-wrap gap-2", - match &*predefined.read() { - Some(list) => rsx ! { {list.iter().map(|item| rsx! { + div { + class: "flex flex-wrap gap-3", + match &*predefined.read() { + Some(list) => rsx! { + {list.iter().map(|item| rsx! { button { - class: "bg-gray-200 px-3 py-1 rounded hover:bg-gray-300", + class: "\ + rounded-md bg-neutral-700 px-4 py-2 text-sm hover:bg-neutral-600 \ + transition-colors cursor-pointer select-none", onclick: { let item = item.clone(); move |_| { @@ -96,13 +126,18 @@ pub fn PredefinedSelector( }, "{item}" } - })}}, - None => rsx!(p { "Loading..." }), - } + })} + }, + None => rsx!(p { "Loading..." }), } + } + div { + class: "mt-auto flex gap-4", button { - class: "mt-auto bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600", + class: "\ + flex-1 bg-blue-600 hover:bg-blue-700 rounded-md px-5 py-2 text-white font-semibold \ + transition-colors cursor-pointer", onclick: move |_| { value.set(input_value.read().clone()); screen_visible.set(false); @@ -111,11 +146,14 @@ pub fn PredefinedSelector( } button { - class: "text-gray-500 underline", + class: "\ + flex-1 text-center underline text-neutral-400 hover:text-neutral-200 \ + transition-colors cursor-pointer", onclick: move |_| screen_visible.set(false), "Cancel" } } } + } } } diff --git a/src/page/home.rs b/src/page/home.rs index 8eae90e..3367b52 100644 --- a/src/page/home.rs +++ b/src/page/home.rs @@ -1,6 +1,7 @@ use dioxus::prelude::*; +use dioxus_material_icons::MaterialIcon; -use crate::TransactionCard; +use crate::{page::HeaderTitle, TransactionCard}; /// Home Page #[component] @@ -10,42 +11,119 @@ pub fn Home() -> Element { let global = use_resource(move || async move { crate::api::API::get_global_item_stat().await }); rsx! { + div { + HeaderTitle { title: "Home" } + div { - h1 { "Home" }, - div { - id: "column", + id: "column", + class: "space-y-6", - match &*global.read_unchecked() { - Some(resp) => rsx! { + match &*global.read_unchecked() { + Some(resp) => rsx! { + div { + id: "card", + class: "\ + w-full flex items-center justify-between bg-white dark:bg-neutral-800 \ + shadow-md rounded-xl p-5", + + // Items div { - id: "card", - p { {format!("Items: {}", resp.item_count)} } - p { {format!("Inventory: {}", resp.total_transactions)} } - p { {format!("Price: {}", resp.total_price)} } - } - }, - None => rsx! { - div { "Loading dogs..." } - }, + class: "flex items-center gap-3", + MaterialIcon { + name: "inventory_2", + size: 24 + }, + p { + class: "text-md font-medium", + {format!("Items: {}", resp.item_count)} + } + }, + + // Inventory + div { + class: "flex items-center gap-3", + MaterialIcon { + name: "storage", + size: 24 + }, + p { + class: "text-md font-medium", + {format!("Inventory: {}", resp.total_transactions)} + } + }, + + // Price + div { + class: "flex items-center gap-3", + MaterialIcon { + name: "attach_money", + size: 24 + }, + p { + class: "text-md font-medium", + {format!("Price: ${:.2}", resp.total_price)} + } + } + } }, + None => rsx! { + div { + class: "text-center text-gray-500 dark:text-gray-400", + "Loading dogs..." + } + }, + }, - if let Some(min) = &*min.read_unchecked() { - if !min.is_empty() { - h1 { "Items under Minimum" }, - for item in min { - p { {format!("{} under minimum. Needs {} more.", item.item_variant, item.need)} } - } + if let Some(min) = &*min.read_unchecked() { + if !min.is_empty() { + h2 { + class: "text-2xl font-semibold mt-8 mb-4", + "Items under Minimum" + }, + div { + class: "space-y-3", + for item in min { + div { + class: "\ + w-full flex items-center justify-between bg-white dark:bg-gray-800 \ + shadow rounded-lg px-4 py-3", + + div { + class: "flex items-center gap-2", + MaterialIcon { + name: "warning", + size: 20 + }, + p { + class: "font-medium", + {format!("{} under minimum", item.item_variant)} + } + }, + + p { + class: "text-sm text-gray-600 dark:text-gray-400", + {format!("Needs {} more.", item.need)} + } + } + } } } + } - if let Some(expired) = &*expired.read_unchecked() { - if !expired.is_empty() { - h1 { "Expired Items" }, - for item in expired { - TransactionCard { t: item.clone() } + if let Some(expired) = &*expired.read_unchecked() { + if !expired.is_empty() { + h2 { + class: "text-2xl font-semibold mt-8 mb-4", + "Expired Items" + }, + div { + class: "space-y-4", + for item in expired { + TransactionCard { t: item.clone() } + } } - } } + } } } } diff --git a/src/page/item_detail.rs b/src/page/item_detail.rs index b906106..9d0809a 100644 --- a/src/page/item_detail.rs +++ b/src/page/item_detail.rs @@ -1,4 +1,5 @@ use dioxus::prelude::*; +use dioxus_material_icons::MaterialIcon; use crate::{page::supply::SupplyPageParam, Route, TransactionCard}; @@ -13,14 +14,14 @@ pub fn ItemDetailPage(id: String) -> Element { rsx! { div { - class: "flex flex-col h-screen", + class: "p-4 flex flex-col h-screen", header { - class: "p-4 text-white text-lg font-bold", + class: "text-white text-lg font-bold", {item.name.as_str()} } div { - class: "p-6 flex flex-col space-y-4", + class: "flex flex-col space-y-4", div { class: "flex items-start space-x-4", if let Some(image) = &item.image { @@ -38,6 +39,23 @@ pub fn ItemDetailPage(id: String) -> Element { } } + EventButton { + icon: "pallet", + title: "Supply", + onclick: move |_| { + navigator().push( + Route::SupplyPage { + item: item.uuid.clone(), + param: SupplyPageParam { + only_variants: None, + force_price: None, + force_origin: None, + }, + } + ); + } + } + div { class: "grid grid-cols-2 gap-4", {item.variants.iter().map(|(key, variant)| { @@ -72,22 +90,30 @@ pub fn ItemDetailPage(id: String) -> Element { } } - button { - class: "fixed bottom-4 right-4 p-4 bg-green-500 text-white rounded-full shadow-lg", - onclick: move |_| { - navigator().push( - Route::SupplyPage { - item: item.uuid.clone(), - param: SupplyPageParam { - only_variants: None, - force_price: None, - force_origin: None - } - } - ); - }, - "+" - } + } } } + +#[component] +pub fn EventButton(icon: String, title: String, onclick: EventHandler) -> Element { + rsx! { + button { + class: "\ + flex items-center gap-2 px-3 py-1.5 rounded-md \ + text-sm font-medium \ + border border-neutral-300 dark:border-neutral-700 \ + text-neutral-800 dark:text-neutral-200 \ + bg-white dark:bg-neutral-900 \ + hover:bg-neutral-100 dark:hover:bg-neutral-800 \ + hover:shadow-sm \ + transition-all duration-200 cursor-pointer w-fit", + onclick: onclick, + MaterialIcon { + name: icon, + size: 20 + }, + {title} + } + } +} diff --git a/src/page/items.rs b/src/page/items.rs index f898c63..735e438 100644 --- a/src/page/items.rs +++ b/src/page/items.rs @@ -1,6 +1,9 @@ use dioxus::prelude::*; -use crate::ItemTile; +use crate::{ + page::{item_detail::EventButton, HeaderTitle}, + ItemTile, +}; #[component] pub fn ItemPage() -> Element { @@ -13,31 +16,32 @@ pub fn ItemPage() -> Element { }); rsx! { - h1 { "Items" }, - // barcode scan button + HeaderTitle { title: "Items" } - match &*items.read_unchecked() { - Some(items) => { - rsx! { + div { + class: "pb-4", + EventButton { icon: "qr_code_2", title: "Scan Transaction QR", onclick: move |_| {} } + } + + // barcode scan button + + match &*items.read_unchecked() { + Some(items) => rsx! { + div { + class: "flex flex-col divide-y divide-gray-200 dark:divide-gray-700", + for item in items { div { - class: "flex flex-col", - for item in items { - ItemTile { item: item.clone() } - } + class: "py-3 px-4 hover:bg-gray-100 dark:hover:bg-gray-800 transition cursor-pointer", + ItemTile { item: item.clone() } } } - }, - None => { rsx! { p { "Loading" }}} + } + }, + None => rsx! { + p { "Loading" } } - - - button { - onclick: move |_| { - // TODO : scan transaction qr - }, - "Scan QR Transaction" - } - - } + + + } } diff --git a/src/page/locations.rs b/src/page/locations.rs index 66a7678..59bb77e 100644 --- a/src/page/locations.rs +++ b/src/page/locations.rs @@ -1,6 +1,8 @@ use dioxus::prelude::*; use std::collections::HashMap; +use crate::{page::HeaderTitle, Route}; + #[component] pub fn LocationsPage() -> Element { let api = crate::API.read(); @@ -9,7 +11,7 @@ pub fn LocationsPage() -> Element { let roots: Vec = get_root_locations(&*locations.read()); rsx! { - h1 { "Locations" }, + HeaderTitle { title: "Locations" }, for l in &roots { LocationTreeTile { location: l.clone(), locations } @@ -53,19 +55,39 @@ pub fn LocationTreeTile( ) -> Element { let mut open = use_signal(|| false); - // TODO let children = get_children_of_loc(location.clone(), &*locations.read()); - rsx! { - p { {location} } - button { - onclick: move |_| { open.set(true); }, - "Expand" - } + let is_open = *open.read(); + let toggle_icon = if is_open { "▼" } else { "▶" }; - if *open.read() { - for loc in &children { - LocationTreeTile { location: loc.clone(), locations } + rsx! { + div { + class: "w-full", + div { + class: "flex items-center justify-start p-2 gap-2 rounded cursor-pointer transition", + onclick: move |_| { + open.set(!is_open); + }, + if children.len() != 0 { + span { + class: "text-sm", + "{toggle_icon}" + } + } + Link { + class: "text-sm font-medium", + to: Route::LocationPage { location: location.clone() }, + "{location}" + } + } + + if is_open { + div { + class: "ml-4 mt-1 space-y-1", + for loc in &children { + LocationTreeTile { location: loc.clone(), locations } + } + } } } } diff --git a/src/page/mod.rs b/src/page/mod.rs index e5b0c80..fd34ce9 100644 --- a/src/page/mod.rs +++ b/src/page/mod.rs @@ -1,3 +1,4 @@ +pub mod components; pub mod consume; pub mod home; pub mod item_detail; @@ -6,10 +7,11 @@ pub mod locations; pub mod supply; pub mod transaction; +pub use components::*; pub use consume::ConsumePage; pub use home::Home; pub use item_detail::ItemDetailPage; pub use items::ItemPage; -pub use locations::LocationsPage; +pub use locations::*; pub use supply::SupplyPage; pub use transaction::TransactionPage; diff --git a/src/page/supply.rs b/src/page/supply.rs index da50910..6a290d3 100644 --- a/src/page/supply.rs +++ b/src/page/supply.rs @@ -1,9 +1,14 @@ use std::str::FromStr; use dioxus::prelude::*; +use dioxus_material_icons::MaterialIcon; use serde::{Deserialize, Serialize}; -use crate::{api, page::consume::PredefinedSelector, Route}; +use crate::{ + api::{self, get_item}, + page::{consume::PredefinedSelector, BasicButton, LabeledInput, TransientHeader}, + Route, +}; #[component] pub fn SupplyPage(item: String, param: SupplyPageParam) -> Element { @@ -48,28 +53,38 @@ pub fn SupplyPage(item: String, param: SupplyPageParam) -> Element { let mut state = use_signal(|| String::new()); rsx! { - h1 { "Add new Item" }, + div { + class: "w-full py-4 flex flex-col gap-6", - p { {state} }, + TransientHeader { title: format!("Add new {}", get_item(&*item.read()).unwrap().name) } - // Variant Selection - PredefinedSelector { - name: "Variant", - value: variant_id, - predefined: variants_str - } + // Variant Selection + LabeledInput { + label: "Variant", + PredefinedSelector { + name: "Variant", + value: variant_id, + custom: false, + predefined: variants_str + } + } - // Origin Field with Dropdown and Text Input - PredefinedSelector { - name: "Origin", - value: origin, - predefined: origins - } + // Origin Selection + LabeledInput { + label: "Origin", + PredefinedSelector { + name: "Origin", + value: origin, + custom: true, + predefined: origins + } + } - // Price Field - label { - "Price: ", + // Price Field + LabeledInput { + label: "Price", input { + class: "rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400", r#type: "number", value: "{price}", disabled: param.force_price.is_some(), @@ -81,34 +96,49 @@ pub fn SupplyPage(item: String, param: SupplyPageParam) -> Element { } } } - }, - // Location Dropdown + } - // Note - label { - "Note: ", + // Note Field + LabeledInput { + label: "Note", input { + class: "rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400", r#type: "text", value: "{note}", oninput: move |e| note.set(e.value()) } - }, + } - // Submit Button - - button { - onclick: move |_| { - spawn(async move { - let variant = variants.iter().find(|x| x.name == variant_id()).unwrap().variant.clone(); - let s = format!("{} {} {} {} {} {}", item(), variant, price(), origin(), location(), note()); - state.set(s); - let tid = crate::api::API::supply_item(item(), variant, price(), Some(origin()), Some(location()), Some(note())).await; - navigator().replace(Route::TransactionPage { id: tid }); - }); - }, - "Create" - } + BasicButton { + onclick: move |_| { + spawn(async move { + let variant = variants.iter().find(|x| x.name == variant_id()).unwrap().variant.clone(); + let s = format!("{} {} {} {} {} {}", item(), variant, price(), origin(), location(), note()); + state.set(s); + let tid = crate::api::API::supply_item(item(), variant, price(), Some(origin()), Some(location()), Some(note())).await; + navigator().replace(Route::TransactionPage { id: tid }); + }); + }, + title: "Create" + } + } + } +} +#[component] +pub fn CloseXButton() -> Element { + rsx! { + button { + class: "\ + relative mx-4 flex items-center justify-center w-8 h-8 \ + rounded-full border border-neutral-300 dark:border-neutral-700 \ + text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-700 \ + transition-colors duration-200 cursor-pointer", + onclick: move |_| { + navigator().go_back(); + }, + MaterialIcon { name: "arrow_back", size: 24 } + } } } diff --git a/src/page/transaction.rs b/src/page/transaction.rs index f70e464..6281d2c 100644 --- a/src/page/transaction.rs +++ b/src/page/transaction.rs @@ -1,7 +1,14 @@ use dioxus::prelude::*; use dioxus_material_icons::MaterialIcon; +use qrc::QRCode; -use crate::{api::get_item, Route}; +use crate::{ + api::get_item, + fetch_api, + page::{item_detail::EventButton, to_human_date}, + qrscan::image_buffer_to_data_url, + Route, +}; #[component] pub fn TransactionPage(id: String) -> Element { @@ -10,70 +17,164 @@ pub fn TransactionPage(id: String) -> Element { crate::api::API::get_transaction(id.read().to_string()).await }); - rsx! { - match transaction.value()() { - Some(transaction) => rsx! { + let qr = QRCode::from_string(id.read().clone()).to_png(512); + let qr_code_src = image_buffer_to_data_url(&qr).unwrap(); - button { - onclick: move |_| { - navigator().push(Route::ConsumePage { id: transaction.uuid.clone(), item: transaction.item.clone(), variant: transaction.variant.clone() }); - }, - "Consume" + rsx! { + match transaction.value()() { + Some(transaction) => rsx! { + div { + class: "max-w-4xl mx-auto p-4 space-y-6", + + // HEADER CARD + div { + class: "flex flex-row items-center space-x-6 p-4 rounded-lg bg-white dark:bg-gray-800 shadow-md", + + img { + class: "w-24 h-24 rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-md", + src: qr_code_src, + alt: "Transaction QR code" + } + + div { + class: "flex flex-col space-y-1", + + p { class: "text-xl font-semibold text-gray-900 dark:text-gray-100", "{get_item(&transaction.item).unwrap().name}" } + p { class: "text-md text-gray-600 dark:text-gray-300", "{get_item(&transaction.item).unwrap().get_variant(&transaction.variant).unwrap().name}" } + p { class: "text-sm text-gray-500 dark:text-gray-400", {to_human_date(transaction.timestamp)} } + if transaction.expired && transaction.consumed.is_none() { + span { + class: "inline-block bg-red-100 text-red-700 text-xs font-semibold px-2 py-1 rounded-full uppercase tracking-wide", + "Expired" + } + } + } }, - div { - id: "column", + // CONSUME INFO CARD or Consume Button + if let Some(consumed) = transaction.consumed.as_ref() { + div { + class: "p-4 rounded-lg bg-blue-50 dark:bg-blue-900 shadow-md flex flex-col space-y-3", - div { - id: "row", + h3 { "Consumed" } - // TODO : transaction qr code + IconLabel { + icon: "location_on".to_string(), + label: "Destination".to_string(), + value: consumed.destination.clone() + } + IconLabel { + icon: "attach_money".to_string(), + label: "Price".to_string(), + value: format!("{:.2}", consumed.price) + } + IconLabel { + icon: "schedule".to_string(), + label: "Consumed At".to_string(), + value: to_human_date(consumed.timestamp) + } + } + } else { + EventButton { + icon: "check_circle".to_string(), + title: "Consume".to_string(), + onclick: move |_| { + navigator().push(Route::ConsumePage { + id: transaction.uuid.clone(), + item: transaction.item.clone(), + variant: transaction.variant.clone(), + }); + } + } + }, + // OTHER INFO LIST div { - id: "col-1", + class: "space-y-3", - p { {get_item(&transaction.item).unwrap().name.as_str()} } - p { {get_item(&transaction.item).unwrap().variants.get(&transaction.variant).as_ref().unwrap().name.clone()} } - p { {transaction.timestamp.to_string()} } + IconLabel { + icon: "attach_money".to_string(), + label: "Price".to_string(), + value: format!("{:.2}", transaction.price) + } + + if let Some(origin) = transaction.origin.as_ref() { + IconLabel { + icon: "home".to_string(), + label: "Origin".to_string(), + value: origin.clone() + } + } + + if let Some(location) = transaction.location.as_ref() { + IconLabel { + icon: "place".to_string(), + label: "Location".to_string(), + value: location.name.clone() + } + } + + if let Some(note) = transaction.note.as_ref() { + if !note.is_empty() { + IconLabel { + icon: "note".to_string(), + label: "Note".to_string(), + value: note.clone() + } + } + } + + if let Some(quanta) = transaction.quanta { + IconLabel { + icon: "inventory_2".to_string(), + label: "Quantity".to_string(), + value: quanta.to_string() + } + } + + if let Some(properties) = transaction.properties.as_ref() { + IconLabel { + icon: "info".to_string(), + label: "Properties".to_string(), + value: serde_json::to_string_pretty(properties).unwrap_or_else(|_| "{}".to_string()) + } + } } - }, - - // TODO : expired notify - - // icon texts - - // price - p { {transaction.price.to_string()} }, - // origin - if let Some(origin) = &transaction.origin { - IconLabel { icon: "home".to_string(), label: "Origin".to_owned(), value: origin.clone() } } - // location - // note - - - // consume info + }, + None => rsx! { + p { + class: "text-center text-gray-500 dark:text-gray-400 p-6", + "Loading transaction..." + } } - }, - None => rsx! { p { "Loading..." }} - } - - } + } } #[component] pub fn IconLabel(icon: String, label: String, value: String) -> Element { rsx! { - div { class: "flex items-center space-x-3", - MaterialIcon { - name: icon, - size: 12, - }, + div { + class: "flex items-center space-x-4 p-2 rounded-md bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200 shadow-sm", - div { class: "flex flex-col", - span { class: "text-sm font-medium text-gray-700", "{label}" } - span { class: "text-lg font-semibold text-gray-900", "{value}" } + MaterialIcon { + name: icon, + size: 24, + }, + + div { + class: "flex flex-col", + + span { + class: "text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400", + "{label}" + }, + + span { + class: "text-base font-bold text-gray-900 dark:text-gray-100", + "{value}" + } } } } diff --git a/src/qrscan.rs b/src/qrscan.rs index 0633319..8ae32bd 100644 --- a/src/qrscan.rs +++ b/src/qrscan.rs @@ -134,7 +134,7 @@ fn stop_camera_stream(media_stream: &web_sys::MediaStream) { } } -fn image_buffer_to_data_url( +pub fn image_buffer_to_data_url( img: &ImageBuffer, Vec>, ) -> Result> { // Create an in-memory buffer diff --git a/src/setup.rs b/src/setup.rs index 1b0cf51..9105e40 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -58,40 +58,66 @@ pub fn SetupPage() -> Element { rsx! { div { - h1 { "Setup Page" } + class: "min-h-screen flex flex-col justify-center items-center px-4", + + h1 { + class: "text-3xl font-bold mb-8 text-neutral-100", + "Setup Page" + } + form { + class: "w-full max-w-md bg-stone-900 rounded-lg shadow-lg p-6 space-y-6", onsubmit: on_submit, + div { - label { "Instance URL:" } + class: "flex flex-col", + label { + class: "mb-2 font-semibold text-gray-700 dark:text-gray-300", + "Instance URL:" + } input { + class: "border border-gray-300 dark:border-gray-700 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100", value: "{form_data.read().instance_url}", oninput: move |evt| { let mut form_data = form_data.write(); form_data.instance_url = evt.value(); } - }, + } {form_errors.read().instance_url.as_ref().map(|error| rsx! { - p { class: "error", "{error}" } + p { class: "text-red-600 mt-1 text-sm", "{error}" } })} } + div { - label { "Token:" } + class: "flex flex-col", + label { + class: "mb-2 font-semibold text-gray-700 dark:text-gray-300", + "Token:" + } input { + class: "border border-gray-300 dark:border-gray-700 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100", r#type: "password", value: "{form_data.read().token}", oninput: move |evt| { let mut form_data = form_data.write(); form_data.token = evt.value(); - }, + } } {form_errors.read().token.as_ref().map(|error| rsx! { - p { class: "error", "{error}" } + p { class: "text-red-600 mt-1 text-sm", "{error}" } })} } - button { type: "submit", "Complete Setup" } + + button { + type: "submit", + class: "cursor-pointer w-full bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 text-white font-semibold py-2 rounded-md transition", + "Complete Setup" + } } + if *submitted.read() { div { + class: "mt-6 bg-green-100 dark:bg-green-800 text-green-800 dark:text-green-200 rounded-md p-4 max-w-md w-full text-center font-semibold", h3 { "Setup Complete!" } } }