This commit is contained in:
JMARyA 2025-06-07 21:27:12 +02:00
parent ca24591d9d
commit 995a8b3476
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
16 changed files with 789 additions and 246 deletions

72
Cargo.lock generated
View file

@ -26,6 +26,21 @@ dependencies = [
"memchr", "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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.98" version = "1.0.98"
@ -377,12 +392,14 @@ version = "0.1.0"
dependencies = [ dependencies = [
"bardecoder", "bardecoder",
"base64", "base64",
"chrono",
"dioxus", "dioxus",
"dioxus-material-icons", "dioxus-material-icons",
"dioxus-sdk", "dioxus-sdk",
"gloo-timers", "gloo-timers",
"image", "image",
"log", "log",
"qrc",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@ -425,6 +442,20 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 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]] [[package]]
name = "ciborium" name = "ciborium"
version = "0.2.2" version = "0.2.2"
@ -2417,6 +2448,30 @@ dependencies = [
"tracing", "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]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.0.0" version = "2.0.0"
@ -3728,6 +3783,23 @@ dependencies = [
"bytemuck", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.40"

View file

@ -22,6 +22,8 @@ dioxus-sdk = { version = "0.6.0", features = ["storage"] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
reqwest = { version = "0.12.15", features = ["json"] } reqwest = { version = "0.12.15", features = ["json"] }
dioxus-material-icons = "3.0.0" dioxus-material-icons = "3.0.0"
chrono = "0.4.41"
qrc = "0.0.5"
[features] [features]
default = ["web"] default = ["web"]

View file

@ -21,7 +21,4 @@ fn main() {
if !status.success() { if !status.success() {
panic!("Tailwind build failed with exit code: {}", status); panic!("Tailwind build failed with exit code: {}", status);
} }
println!("cargo:rerun-if-changed={input}");
println!("cargo:rerun-if-changed=tailwind.config.js");
} }

View file

@ -1,10 +1,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use dioxus::signals::Readable; use dioxus::signals::{Readable, Writable};
use dioxus_sdk::storage::use_persistent; use dioxus_sdk::storage::use_persistent;
use serde_json::json; use serde_json::json;
use crate::setup::Credentials; use crate::setup::Credentials;
use crate::try_recover_api;
use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::Client; use reqwest::Client;
@ -12,11 +13,11 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub fn get_item(item: &str) -> Option<Item> { pub fn get_item(item: &str) -> Option<Item> {
crate::API if let Some(api) = crate::API.read().as_ref() {
.read() api.get_item(item.to_string())
.as_ref() } else {
.unwrap() try_recover_api().get_item(item.to_string())
.get_item(item.to_string()) }
} }
pub async fn api_get_auth<T>(path: String) -> Result<T, reqwest::Error> pub async fn api_get_auth<T>(path: String) -> Result<T, reqwest::Error>
@ -88,8 +89,18 @@ impl API {
let mut items: HashMap<String, Vec<Item>> = let mut items: HashMap<String, Vec<Item>> =
api_get_auth("/items".to_string()).await.unwrap(); api_get_auth("/items".to_string()).await.unwrap();
let items = items.remove("items").unwrap(); let items = items.remove("items").unwrap();
let locations = api_get_auth("/locations".to_string()).await.unwrap(); let locations: HashMap<String, Location> =
let flow_info = api_get_auth("/flows".to_string()).await.unwrap(); api_get_auth("/locations".to_string()).await.unwrap();
let flow_info: HashMap<String, FlowInfo> =
api_get_auth("/flows".to_string()).await.unwrap();
let mut p_items = use_persistent("api_items", || Vec::<Item>::new());
p_items.set(items.clone());
let mut p_locations =
use_persistent("api_locations", || HashMap::<String, Location>::new());
p_locations.set(locations.clone());
let mut p_flowinfo = use_persistent("api_flow_info", || HashMap::<String, FlowInfo>::new());
p_flowinfo.set(flow_info.clone());
Self { Self {
instance, 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::<Item>::new())
.read()
.clone(),
locations: use_persistent("api_locations", || HashMap::<String, Location>::new())
.read()
.clone(),
flow_info: use_persistent("api_flow_info", || HashMap::<String, FlowInfo>::new())
.read()
.clone(),
}
}
pub async fn get_items(&self) -> &[Item] { pub async fn get_items(&self) -> &[Item] {
&self.items &self.items
} }
@ -358,7 +387,7 @@ impl API {
} }
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct FlowInfo { pub struct FlowInfo {
pub id: String, pub id: String,
pub name: String, pub name: String,
@ -376,6 +405,13 @@ pub struct Item {
pub variants: HashMap<String, ItemVariant>, pub variants: HashMap<String, ItemVariant>,
} }
impl Item {
pub fn get_variant(&self, variant: &str) -> Option<ItemVariant> {
let var = self.variants.iter().find(|x| *x.0 == variant)?;
Some(var.1.clone())
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct ItemVariant { pub struct ItemVariant {
pub item: String, pub item: String,

View file

@ -24,16 +24,18 @@ enum Route {
FlowPage {}, FlowPage {},
#[route("/locations")] #[route("/locations")]
LocationsPage {}, LocationsPage {},
#[route("/location/:location")]
LocationPage { location: String },
#[route("/item/:id")] #[route("/item/:id")]
ItemDetailPage { id: String }, ItemDetailPage { id: String },
#[route("/transaction/:id")] #[route("/transaction/:id")]
TransactionPage { id: String }, TransactionPage { id: String },
#[end_layout]
#[route("/item/:item/:variant/consume/:id")] #[route("/item/:item/:variant/consume/:id")]
ConsumePage { id: String, item: String, variant: String }, ConsumePage { id: String, item: String, variant: String },
#[route("/item/:item/supply?:param")] #[route("/item/:item/supply?:param")]
SupplyPage { item: String, param: SupplyPageParam } SupplyPage { item: String, param: SupplyPageParam }
// #[route("/blog/:id")] // #[route("/blog/:id")]
@ -54,16 +56,26 @@ fn main() {
dioxus::launch(App); 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] #[component]
fn App() -> Element { fn App() -> Element {
let creds = use_persistent("creds", || Credentials::default()); let creds = use_persistent("creds", || Credentials::default());
spawn(async move { spawn(async move {
let res = use_persistent("creds", || Credentials::default()); fetch_api().await;
if !res.read().empty() {
let api = api::API::new(res.read().instance_url.clone()).await;
*crate::API.write() = Some(api);
}
}); });
rsx! { rsx! {
@ -84,9 +96,21 @@ fn App() -> Element {
fn ItemTile(item: Item) -> Element { fn ItemTile(item: Item) -> Element {
rsx! { rsx! {
Link { Link {
to: Route::ItemDetailPage { id: item.uuid }, to: Route::ItemDetailPage { id: item.uuid },
{item.name.as_str()} 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] #[component]
fn Navbar() -> Element { fn Navbar() -> Element {
rsx! { rsx! {
div { div {
id: "navbar", id: "navbar",
class: "flex flex-wrap gap-2 p-2 shadow-md rounded-sm",
style: "background-color:hsl(223, 18.90%, 13.30%);",
Link { Link {
to: Route::Home {}, to: Route::Home {},
div { 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",
class: "flex flex-row gap-2 p-2", MaterialIcon { name: "home", size: 24 },
MaterialIcon { name: "home", size: 24 }, "Home"
"Home"
}
} }
Link { Link {
to: Route::ItemPage { }, to: Route::ItemPage { },
div { 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",
class: "flex flex-row gap-2 p-2", MaterialIcon { name: "category", size: 24 },
MaterialIcon { name: "category", size: 24 }, "Items"
"Items"
}
} }
Link { Link {
to: Route::FlowPage { }, to: Route::FlowPage { },
div { 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",
class: "flex flex-row gap-2 p-2", MaterialIcon { name: "assignment", size: 24 },
MaterialIcon { name: "assignment", size: 24 }, "Flows"
"Flows"
}
} }
Link { Link {
to: Route::LocationsPage { }, to: Route::LocationsPage { },
div { 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",
class: "flex flex-row gap-2 p-2", MaterialIcon { name: "map", size: 24 },
MaterialIcon { name: "map", size: 24 }, "Locations"
"Locations"
}
} }
} }
Outlet::<Route> {} div {
class: "py-4",
Outlet::<Route> {}
}
} }
} }

90
src/page/components.rs Normal file
View file

@ -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<MouseEvent>) -> 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<Utc> = DateTime::from_utc(naive, Utc);
let datetime_local: DateTime<Local> = 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)
}

View file

@ -1,6 +1,9 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::api; use crate::{
api,
page::{BasicButton, LabeledInput, TransientHeader},
};
#[component] #[component]
pub fn ConsumePage(id: String, item: String, variant: String) -> Element { 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); let mut price = use_signal(|| 0.00);
rsx! { rsx! {
div {
class: "w-full py-4 flex flex-col gap-6",
p { "Item: {item}"}, TransientHeader { title: format!("Consume {item} - {variant}") }
p { "Variant: {variant}"}
// TODO : destination
LabeledInput {
label: "Destination",
PredefinedSelector { PredefinedSelector {
name: "Destination", name: "Destination",
value: dest, value: dest,
custom: true,
predefined: destinations predefined: destinations
} }
}
label { // Price Field
"Price: ", LabeledInput {
label: "Price",
input { 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", r#type: "number",
value: "{price}", 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 |_| { onclick: move |_| {
let id2 = id.clone(); let id2 = id.clone();
spawn(async move { spawn(async move {
api::API::consume_item(id2, dest(), price()).await; api::API::consume_item(id2, dest(), price()).await;
navigator().go_back(); navigator().go_back();
}); });
}, },
"Consume"
} }
} }
}
} }
#[component] #[component]
pub fn PredefinedSelector( pub fn PredefinedSelector(
name: String, name: String,
value: Signal<String>, value: Signal<String>,
custom: bool,
predefined: Resource<Vec<String>>, predefined: Resource<Vec<String>>,
) -> Element { ) -> Element {
let mut screen_visible = use_signal(|| false); let mut screen_visible = use_signal(|| false);
@ -60,32 +76,46 @@ pub fn PredefinedSelector(
div { div {
class: "p-2", class: "p-2",
button { 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), onclick: move |_| screen_visible.set(true),
"{name}: {value}" "{name}: {value}"
} }
} }
if *screen_visible.read() { if *screen_visible.read() {
div { div {
class: "fixed inset-0 z-40 flex flex-col p-4 space-y-4 text-white", class: "\
style: "background-color: #0f1116;", 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 { 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", r#type: "text",
value: "{input_value}", value: "{input_value}",
oninput: move |evt| input_value.set(evt.value().clone()), oninput: move |evt| input_value.set(evt.value().clone()),
} }
}
div { div {
class: "flex flex-wrap gap-2", class: "flex flex-wrap gap-3",
match &*predefined.read() { match &*predefined.read() {
Some(list) => rsx ! { {list.iter().map(|item| rsx! { Some(list) => rsx! {
{list.iter().map(|item| rsx! {
button { 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: { onclick: {
let item = item.clone(); let item = item.clone();
move |_| { move |_| {
@ -96,13 +126,18 @@ pub fn PredefinedSelector(
}, },
"{item}" "{item}"
} }
})}}, })}
None => rsx!(p { "Loading..." }), },
} None => rsx!(p { "Loading..." }),
} }
}
div {
class: "mt-auto flex gap-4",
button { 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 |_| { onclick: move |_| {
value.set(input_value.read().clone()); value.set(input_value.read().clone());
screen_visible.set(false); screen_visible.set(false);
@ -111,11 +146,14 @@ pub fn PredefinedSelector(
} }
button { 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), onclick: move |_| screen_visible.set(false),
"Cancel" "Cancel"
} }
} }
} }
}
} }
} }

View file

@ -1,6 +1,7 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus_material_icons::MaterialIcon;
use crate::TransactionCard; use crate::{page::HeaderTitle, TransactionCard};
/// Home Page /// Home Page
#[component] #[component]
@ -10,42 +11,119 @@ pub fn Home() -> Element {
let global = use_resource(move || async move { crate::api::API::get_global_item_stat().await }); let global = use_resource(move || async move { crate::api::API::get_global_item_stat().await });
rsx! { rsx! {
div {
HeaderTitle { title: "Home" }
div { div {
h1 { "Home" }, id: "column",
div { class: "space-y-6",
id: "column",
match &*global.read_unchecked() { match &*global.read_unchecked() {
Some(resp) => rsx! { 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 { div {
id: "card", class: "flex items-center gap-3",
p { {format!("Items: {}", resp.item_count)} } MaterialIcon {
p { {format!("Inventory: {}", resp.total_transactions)} } name: "inventory_2",
p { {format!("Price: {}", resp.total_price)} } size: 24
} },
}, p {
None => rsx! { class: "text-md font-medium",
div { "Loading dogs..." } {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 let Some(min) = &*min.read_unchecked() {
if !min.is_empty() { if !min.is_empty() {
h1 { "Items under Minimum" }, h2 {
for item in min { class: "text-2xl font-semibold mt-8 mb-4",
p { {format!("{} under minimum. Needs {} more.", item.item_variant, item.need)} } "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 let Some(expired) = &*expired.read_unchecked() {
if !expired.is_empty() { if !expired.is_empty() {
h1 { "Expired Items" }, h2 {
for item in expired { class: "text-2xl font-semibold mt-8 mb-4",
TransactionCard { t: item.clone() } "Expired Items"
},
div {
class: "space-y-4",
for item in expired {
TransactionCard { t: item.clone() }
}
} }
}
} }
}
} }
} }
} }

View file

@ -1,4 +1,5 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus_material_icons::MaterialIcon;
use crate::{page::supply::SupplyPageParam, Route, TransactionCard}; use crate::{page::supply::SupplyPageParam, Route, TransactionCard};
@ -13,14 +14,14 @@ pub fn ItemDetailPage(id: String) -> Element {
rsx! { rsx! {
div { div {
class: "flex flex-col h-screen", class: "p-4 flex flex-col h-screen",
header { header {
class: "p-4 text-white text-lg font-bold", class: "text-white text-lg font-bold",
{item.name.as_str()} {item.name.as_str()}
} }
div { div {
class: "p-6 flex flex-col space-y-4", class: "flex flex-col space-y-4",
div { div {
class: "flex items-start space-x-4", class: "flex items-start space-x-4",
if let Some(image) = &item.image { 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 { div {
class: "grid grid-cols-2 gap-4", class: "grid grid-cols-2 gap-4",
{item.variants.iter().map(|(key, variant)| { {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<MouseEvent>) -> 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}
}
}
}

View file

@ -1,6 +1,9 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use crate::ItemTile; use crate::{
page::{item_detail::EventButton, HeaderTitle},
ItemTile,
};
#[component] #[component]
pub fn ItemPage() -> Element { pub fn ItemPage() -> Element {
@ -13,31 +16,32 @@ pub fn ItemPage() -> Element {
}); });
rsx! { rsx! {
h1 { "Items" }, HeaderTitle { title: "Items" }
// barcode scan button
match &*items.read_unchecked() { div {
Some(items) => { class: "pb-4",
rsx! { 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 { div {
class: "flex flex-col", class: "py-3 px-4 hover:bg-gray-100 dark:hover:bg-gray-800 transition cursor-pointer",
for item in items { ItemTile { item: item.clone() }
ItemTile { item: item.clone() }
}
} }
} }
}, }
None => { rsx! { p { "Loading" }}} },
None => rsx! {
p { "Loading" }
} }
button {
onclick: move |_| {
// TODO : scan transaction qr
},
"Scan QR Transaction"
}
} }
}
} }

View file

@ -1,6 +1,8 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
use crate::{page::HeaderTitle, Route};
#[component] #[component]
pub fn LocationsPage() -> Element { pub fn LocationsPage() -> Element {
let api = crate::API.read(); let api = crate::API.read();
@ -9,7 +11,7 @@ pub fn LocationsPage() -> Element {
let roots: Vec<String> = get_root_locations(&*locations.read()); let roots: Vec<String> = get_root_locations(&*locations.read());
rsx! { rsx! {
h1 { "Locations" }, HeaderTitle { title: "Locations" },
for l in &roots { for l in &roots {
LocationTreeTile { location: l.clone(), locations } LocationTreeTile { location: l.clone(), locations }
@ -53,19 +55,39 @@ pub fn LocationTreeTile(
) -> Element { ) -> Element {
let mut open = use_signal(|| false); let mut open = use_signal(|| false);
// TODO
let children = get_children_of_loc(location.clone(), &*locations.read()); let children = get_children_of_loc(location.clone(), &*locations.read());
rsx! { let is_open = *open.read();
p { {location} } let toggle_icon = if is_open { "" } else { "" };
button {
onclick: move |_| { open.set(true); },
"Expand"
}
if *open.read() { rsx! {
for loc in &children { div {
LocationTreeTile { location: loc.clone(), locations } 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 }
}
}
} }
} }
} }

View file

@ -1,3 +1,4 @@
pub mod components;
pub mod consume; pub mod consume;
pub mod home; pub mod home;
pub mod item_detail; pub mod item_detail;
@ -6,10 +7,11 @@ pub mod locations;
pub mod supply; pub mod supply;
pub mod transaction; pub mod transaction;
pub use components::*;
pub use consume::ConsumePage; pub use consume::ConsumePage;
pub use home::Home; pub use home::Home;
pub use item_detail::ItemDetailPage; pub use item_detail::ItemDetailPage;
pub use items::ItemPage; pub use items::ItemPage;
pub use locations::LocationsPage; pub use locations::*;
pub use supply::SupplyPage; pub use supply::SupplyPage;
pub use transaction::TransactionPage; pub use transaction::TransactionPage;

View file

@ -1,9 +1,14 @@
use std::str::FromStr; use std::str::FromStr;
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus_material_icons::MaterialIcon;
use serde::{Deserialize, Serialize}; 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] #[component]
pub fn SupplyPage(item: String, param: SupplyPageParam) -> Element { 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()); let mut state = use_signal(|| String::new());
rsx! { 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 // Variant Selection
PredefinedSelector { LabeledInput {
name: "Variant", label: "Variant",
value: variant_id, PredefinedSelector {
predefined: variants_str name: "Variant",
} value: variant_id,
custom: false,
predefined: variants_str
}
}
// Origin Field with Dropdown and Text Input // Origin Selection
PredefinedSelector { LabeledInput {
name: "Origin", label: "Origin",
value: origin, PredefinedSelector {
predefined: origins name: "Origin",
} value: origin,
custom: true,
predefined: origins
}
}
// Price Field // Price Field
label { LabeledInput {
"Price: ", label: "Price",
input { 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", r#type: "number",
value: "{price}", value: "{price}",
disabled: param.force_price.is_some(), disabled: param.force_price.is_some(),
@ -81,34 +96,49 @@ pub fn SupplyPage(item: String, param: SupplyPageParam) -> Element {
} }
} }
} }
}, }
// Location Dropdown
// Note // Note Field
label { LabeledInput {
"Note: ", label: "Note",
input { 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", r#type: "text",
value: "{note}", value: "{note}",
oninput: move |e| note.set(e.value()) oninput: move |e| note.set(e.value())
} }
}, }
// Submit Button BasicButton {
onclick: move |_| {
button { spawn(async move {
onclick: move |_| { let variant = variants.iter().find(|x| x.name == variant_id()).unwrap().variant.clone();
spawn(async move { let s = format!("{} {} {} {} {} {}", item(), variant, price(), origin(), location(), note());
let variant = variants.iter().find(|x| x.name == variant_id()).unwrap().variant.clone(); state.set(s);
let s = format!("{} {} {} {} {} {}", item(), variant, price(), origin(), location(), note()); let tid = crate::api::API::supply_item(item(), variant, price(), Some(origin()), Some(location()), Some(note())).await;
state.set(s); navigator().replace(Route::TransactionPage { id: tid });
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"
}, }
"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 }
}
} }
} }

View file

@ -1,7 +1,14 @@
use dioxus::prelude::*; use dioxus::prelude::*;
use dioxus_material_icons::MaterialIcon; 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] #[component]
pub fn TransactionPage(id: String) -> Element { 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 crate::api::API::get_transaction(id.read().to_string()).await
}); });
rsx! { let qr = QRCode::from_string(id.read().clone()).to_png(512);
match transaction.value()() { let qr_code_src = image_buffer_to_data_url(&qr).unwrap();
Some(transaction) => rsx! {
button { rsx! {
onclick: move |_| { match transaction.value()() {
navigator().push(Route::ConsumePage { id: transaction.uuid.clone(), item: transaction.item.clone(), variant: transaction.variant.clone() }); Some(transaction) => rsx! {
}, div {
"Consume" 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 { // CONSUME INFO CARD or Consume Button
id: "column", 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 { h3 { "Consumed" }
id: "row",
// 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 { div {
id: "col-1", class: "space-y-3",
p { {get_item(&transaction.item).unwrap().name.as_str()} } IconLabel {
p { {get_item(&transaction.item).unwrap().variants.get(&transaction.variant).as_ref().unwrap().name.clone()} } icon: "attach_money".to_string(),
p { {transaction.timestamp.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 None => rsx! {
p {
class: "text-center text-gray-500 dark:text-gray-400 p-6",
// consume info "Loading transaction..."
}
} }
},
None => rsx! { p { "Loading..." }}
}
} }
}
} }
#[component] #[component]
pub fn IconLabel(icon: String, label: String, value: String) -> Element { pub fn IconLabel(icon: String, label: String, value: String) -> Element {
rsx! { rsx! {
div { class: "flex items-center space-x-3", div {
MaterialIcon { 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",
name: icon,
size: 12,
},
div { class: "flex flex-col", MaterialIcon {
span { class: "text-sm font-medium text-gray-700", "{label}" } name: icon,
span { class: "text-lg font-semibold text-gray-900", "{value}" } 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}"
}
} }
} }
} }

View file

@ -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<Rgba<u8>, Vec<u8>>, img: &ImageBuffer<Rgba<u8>, Vec<u8>>,
) -> Result<String, Box<dyn std::error::Error>> { ) -> Result<String, Box<dyn std::error::Error>> {
// Create an in-memory buffer // Create an in-memory buffer

View file

@ -58,40 +58,66 @@ pub fn SetupPage() -> Element {
rsx! { rsx! {
div { 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 { form {
class: "w-full max-w-md bg-stone-900 rounded-lg shadow-lg p-6 space-y-6",
onsubmit: on_submit, onsubmit: on_submit,
div { div {
label { "Instance URL:" } class: "flex flex-col",
label {
class: "mb-2 font-semibold text-gray-700 dark:text-gray-300",
"Instance URL:"
}
input { 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}", value: "{form_data.read().instance_url}",
oninput: move |evt| { oninput: move |evt| {
let mut form_data = form_data.write(); let mut form_data = form_data.write();
form_data.instance_url = evt.value(); form_data.instance_url = evt.value();
} }
}, }
{form_errors.read().instance_url.as_ref().map(|error| rsx! { {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 { div {
label { "Token:" } class: "flex flex-col",
label {
class: "mb-2 font-semibold text-gray-700 dark:text-gray-300",
"Token:"
}
input { 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", r#type: "password",
value: "{form_data.read().token}", value: "{form_data.read().token}",
oninput: move |evt| { oninput: move |evt| {
let mut form_data = form_data.write(); let mut form_data = form_data.write();
form_data.token = evt.value(); form_data.token = evt.value();
}, }
} }
{form_errors.read().token.as_ref().map(|error| rsx! { {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() { if *submitted.read() {
div { 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!" } h3 { "Setup Complete!" }
} }
} }