From 3f834cd7d24c6950b6d244d8888f6664c3edace5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=B6rber?= Date: Tue, 29 Aug 2023 21:33:59 +0200 Subject: [PATCH] more --- rust/assets/luggage.svg | 1 + rust/build.rs | 6 +- rust/js/app.js | 8 +- rust/migrations/20230520131340_1.sql | 2 + rust/query.sql | 10 + rust/sqlx-data.json | 178 ++++++++----- rust/src/components/inventory.rs | 172 ++++++------ rust/src/components/mod.rs | 41 ++- rust/src/components/trip.rs | 376 ++++++++++++++++++++------- rust/src/main.rs | 50 +++- rust/src/models.rs | 139 +++++++++- 11 files changed, 708 insertions(+), 275 deletions(-) create mode 100644 rust/assets/luggage.svg create mode 100644 rust/migrations/20230520131340_1.sql create mode 100644 rust/query.sql diff --git a/rust/assets/luggage.svg b/rust/assets/luggage.svg new file mode 100644 index 0000000..d38f558 --- /dev/null +++ b/rust/assets/luggage.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rust/build.rs b/rust/build.rs index 7609593..064a531 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -1,5 +1,7 @@ -// generated by `sqlx migrate build-script` fn main() { // trigger recompilation when a new migration is added println!("cargo:rerun-if-changed=migrations"); -} \ No newline at end of file + + // recompile when javascript changes, as it's embedded in the binary + println!("cargo:rerun-if-changed=js"); +} diff --git a/rust/js/app.js b/rust/js/app.js index b0f02ec..2435022 100644 --- a/rust/js/app.js +++ b/rust/js/app.js @@ -1,3 +1,5 @@ -document.body.addEventListener('htmx:responseError', function(evt) { - console.log(evt.detail); -}); +window.onload = function() { + document.body.addEventListener('htmx:responseError', function(evt) { + console.log(evt.detail); + }); +}; diff --git a/rust/migrations/20230520131340_1.sql b/rust/migrations/20230520131340_1.sql new file mode 100644 index 0000000..31af799 --- /dev/null +++ b/rust/migrations/20230520131340_1.sql @@ -0,0 +1,2 @@ +-- Add migration script here +ALTER TABLE "trips_items" ADD COLUMN new BOOLEAN NOT NULL; diff --git a/rust/query.sql b/rust/query.sql new file mode 100644 index 0000000..9e730c8 --- /dev/null +++ b/rust/query.sql @@ -0,0 +1,10 @@ +SELECT + i_item.id AS item_id +FROM inventory_items AS i_item + LEFT JOIN ( + SELECT t_item.item_id as item_id + FROM trips_items AS t_item + WHERE t_item.trip_id = '2be5c6b9-9a46-4c90-b17a-87b2ede66163' + ) AS t_item + ON t_item.item_id = i_item.id +WHERE t_item.item_id IS NULL diff --git a/rust/sqlx-data.json b/rust/sqlx-data.json index a45787b..e1e966a 100644 --- a/rust/sqlx-data.json +++ b/rust/sqlx-data.json @@ -18,77 +18,15 @@ }, "query": "UPDATE inventory_items AS item\n SET\n name = ?,\n weight = ?\n WHERE item.id = ?\n RETURNING inventory_items.category_id AS id\n " }, - "10886f1ddebc2a11bd2f2cbd41bd5220cde17405e1210c792dda29ca100c01cb": { + "1320943d04e921a8e5f409737e466838b4ecf7e73ad0ade59ccd7664459a9c80": { "describe": { - "columns": [ - { - "name": "category_id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "category_name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "category_description", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "trip_id", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "item_id", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "item_name", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "item_description", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "item_weight", - "ordinal": 7, - "type_info": "Int64" - }, - { - "name": "item_is_picked", - "ordinal": 8, - "type_info": "Bool" - }, - { - "name": "item_is_packed", - "ordinal": 9, - "type_info": "Bool" - } - ], - "nullable": [ - false, - false, - true, - true, - true, - true, - true, - true, - true, - true - ], + "columns": [], + "nullable": [], "parameters": { - "Right": 1 + "Right": 5 } }, - "query": "\n SELECT\n category.id as category_id,\n category.name as category_name,\n category.description AS category_description,\n inner.trip_id AS trip_id,\n inner.item_id AS item_id,\n inner.item_name AS item_name,\n inner.item_description AS item_description,\n inner.item_weight AS item_weight,\n inner.item_is_picked AS item_is_picked,\n inner.item_is_packed AS item_is_packed\n FROM inventory_items_categories AS category\n LEFT JOIN (\n SELECT\n trip.trip_id AS trip_id,\n category.id as category_id,\n category.name as category_name,\n category.description as category_description,\n item.id as item_id,\n item.name as item_name,\n item.description as item_description,\n item.weight as item_weight,\n trip.pick as item_is_picked,\n trip.pack as item_is_packed\n FROM trips_items as trip\n INNER JOIN inventory_items as item\n ON item.id = trip.item_id\n INNER JOIN inventory_items_categories as category\n ON category.id = item.category_id\n WHERE trip.trip_id = ?\n ) AS inner\n ON inner.category_id = category.id\n " + "query": "\n INSERT INTO trips_items\n (\n item_id,\n trip_id,\n pick,\n pack,\n new\n )\n VALUES (?, ?, ?, ?, ?)\n " }, "18cbb2893df033f5f81f42097fcae7ee036405749a5d93f2ea1d79ba280dfd20": { "describe": { @@ -100,6 +38,24 @@ }, "query": "DELETE FROM trips_to_trips_types AS ttt\n WHERE ttt.trip_id = ?\n AND ttt.trip_type_id = ?\n " }, + "1994305e1521fe1f5f927ad28e21c9cab8a25598b19e1c9038dae9092fe18f1f": { + "describe": { + "columns": [ + { + "name": "item_id", + "ordinal": 0, + "type_info": "Text" + } + ], + "nullable": [ + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "\n SELECT\n i_item.id AS item_id\n FROM inventory_items AS i_item\n LEFT JOIN (\n SELECT t_item.item_id as item_id\n FROM trips_items AS t_item\n WHERE t_item.trip_id = ?\n ) AS t_item\n ON t_item.item_id = i_item.id\n WHERE t_item.item_id IS NULL\n " + }, "1f08e9bebf51aab9cabff2a5c79211233a686e9ef9f96ea5c036fbba8f6b06d5": { "describe": { "columns": [ @@ -435,5 +391,93 @@ } }, "query": "SELECT * FROM inventory_items AS item\n WHERE item.id = ?" + }, + "f2038d75ff5ff10d4baeb30b9dc4cc1c991da1facdb1f05e16f271372eee0c7a": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 2 + } + }, + "query": "UPDATE trips\n SET state = ?\n WHERE id = ?" + }, + "f24056f8d6e2d483185d71b036ae8a0a1943b8718e8255d826df76ac77ad6326": { + "describe": { + "columns": [ + { + "name": "category_id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "category_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "category_description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "trip_id", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "item_id", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "item_name", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "item_description", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "item_weight", + "ordinal": 7, + "type_info": "Int64" + }, + { + "name": "item_is_picked", + "ordinal": 8, + "type_info": "Bool" + }, + { + "name": "item_is_packed", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "item_is_new", + "ordinal": 10, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true + ], + "parameters": { + "Right": 1 + } + }, + "query": "\n SELECT\n category.id as category_id,\n category.name as category_name,\n category.description AS category_description,\n inner.trip_id AS trip_id,\n inner.item_id AS item_id,\n inner.item_name AS item_name,\n inner.item_description AS item_description,\n inner.item_weight AS item_weight,\n inner.item_is_picked AS item_is_picked,\n inner.item_is_packed AS item_is_packed,\n inner.item_is_new AS item_is_new\n FROM inventory_items_categories AS category\n LEFT JOIN (\n SELECT\n trip.trip_id AS trip_id,\n category.id as category_id,\n category.name as category_name,\n category.description as category_description,\n item.id as item_id,\n item.name as item_name,\n item.description as item_description,\n item.weight as item_weight,\n trip.pick as item_is_picked,\n trip.pack as item_is_packed,\n trip.new as item_is_new\n FROM trips_items as trip\n INNER JOIN inventory_items as item\n ON item.id = trip.item_id\n INNER JOIN inventory_items_categories as category\n ON category.id = item.category_id\n WHERE trip.trip_id = ?\n ) AS inner\n ON inner.category_id = category.id\n " } } \ No newline at end of file diff --git a/rust/src/components/inventory.rs b/rust/src/components/inventory.rs index dac1141..27f73ad 100644 --- a/rust/src/components/inventory.rs +++ b/rust/src/components/inventory.rs @@ -10,13 +10,14 @@ impl Inventory { pub async fn build(state: ClientState, categories: Vec) -> Result { let doc = html!( div id="pkglist-item-manager" { - div ."p-8" ."grid" ."grid-cols-4" ."gap-3" { - div ."col-span-2" { + div ."p-8" ."grid" ."grid-cols-4" ."gap-5" { + div ."col-span-2" ."flex" ."flex-col" ."gap-8" { + h1 ."text-2xl" ."text-center" { "Categories" } (InventoryCategoryList::build(&state, &categories)) (InventoryNewCategoryForm::build()) } - div ."col-span-2" { - h1 ."text-2xl" ."mb-5" ."text-center" { "Items" } + div ."col-span-2" ."flex" ."flex-col" ."gap-8" { + h1 ."text-2xl" ."text-center" { "Items" } @if let Some(active_category_id) = state.active_category_id { (InventoryItemList::build(&state, categories.iter().find(|category| category.id == active_category_id) .ok_or(Error::NotFoundError { description: format!("no category with id {}", active_category_id) })? @@ -44,94 +45,91 @@ impl InventoryCategoryList { .unwrap_or(1); html!( - div { - h1 ."text-2xl" ."mb-5" ."text-center" { "Categories" } - table - ."table" - ."table-auto" - ."border-collapse" - ."border-spacing-0" - ."border" - ."w-full" - { + table + ."table" + ."table-auto" + ."border-collapse" + ."border-spacing-0" + ."border" + ."w-full" + { - colgroup { - col style="width:50%" {} - col style="width:50%" {} + colgroup { + col style="width:50%" {} + col style="width:50%" {} + } + thead ."bg-gray-200" { + tr ."h-10" { + th ."border" ."p-2" ."w-3/5" { "Name" } + th ."border" ."p-2" { "Weight" } } - thead ."bg-gray-200" { - tr ."h-10" { - th ."border" ."p-2" ."w-3/5" { "Name" } - th ."border" ."p-2" { "Weight" } - } - } - tbody { - @for category in categories { - @let active = state.active_category_id.map_or(false, |id| category.id == id); - tr - ."h-10" - ."hover:bg-purple-100" - ."m-3" - ."h-full" - ."outline"[active] - ."outline-2"[active] - ."outline-indigo-300"[active] - ."pointer-events-none"[active] - { + } + tbody { + @for category in categories { + @let active = state.active_category_id.map_or(false, |id| category.id == id); + tr + ."h-10" + ."hover:bg-purple-100" + ."m-3" + ."h-full" + ."outline"[active] + ."outline-2"[active] + ."outline-indigo-300"[active] + ."pointer-events-none"[active] + { - td - class=@if state.active_category_id.map_or(false, |id| category.id == id) { - "border p-0 m-0 font-bold" - } @else { - "border p-0 m-0" - } { - a - id="select-category" - href=( - format!( - "/inventory/category/{id}/", - id=category.id + td + class=@if state.active_category_id.map_or(false, |id| category.id == id) { + "border p-0 m-0 font-bold" + } @else { + "border p-0 m-0" + } { + a + id="select-category" + href=( + format!( + "/inventory/category/{id}/", + id=category.id + ) + ) + // hx-post=( + // format!( + // "/inventory/category/{id}/items", + // id=category.id + // ) + // ) + // hx-swap="outerHTML" + // hx-target="#items" + ."inline-block" ."p-2" ."m-0" ."w-full" + { + (category.name.clone()) + } + } + td ."border" ."p-2" ."m-0" style="position:relative;" { + p { + (category.total_weight().to_string()) + } + div ."bg-blue-600" ."h-1.5" + style=( + format!( + "width: {width}%;position:absolute;left:0;bottom:0;right:0;", + width=( + (category.total_weight() as f64) + / (biggest_category_weight as f64) + * 100.0 ) ) - // hx-post=( - // format!( - // "/inventory/category/{id}/items", - // id=category.id - // ) - // ) - // hx-swap="outerHTML" - // hx-target="#items" - ."inline-block" ."p-2" ."m-0" ."w-full" - { - (category.name.clone()) - } - } - td ."border" ."p-2" ."m-0" style="position:relative;" { - p { - (category.total_weight().to_string()) - } - div ."bg-blue-600" ."h-1.5" - style=( - format!( - "width: {width}%;position:absolute;left:0;bottom:0;right:0;", - width=( - (category.total_weight() as f64) - / (biggest_category_weight as f64) - * 100.0 - ) - ) - ) {} - } + ) {} } } - tr ."h-10" ."hover:bg-purple-200" ."bg-gray-300" ."font-bold" { - td ."border" ."p-0" ."m-0" { - p ."p-2" ."m-2" { "Sum" } - } - td ."border" ."p-0" ."m-0" { - p ."p-2" ."m-2" { - (categories.iter().map(Category::total_weight).sum::().to_string()) - } + } + tr ."h-10" ."hover:bg-purple-200" ."bg-gray-300" ."font-bold" { + td ."border" ."p-0" ."m-0" { + p ."p-2" ."m-2" { "Sum" } + } + td ."border" ."p-0" ."m-0" { + p ."p-2" ."m-2" { + (categories.iter().map(Category::total_weight).sum::().to_string()) } } } @@ -325,7 +323,7 @@ impl InventoryNewItemForm { action="/inventory/item/" target="_self" method="post" - ."mt-8" ."p-5" ."border-2" ."border-gray-200" { + ."p-5" ."border-2" ."border-gray-200" { div ."mb-5" ."flex" ."flex-row" ."items-center" { span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {} p ."inline" ."text-xl" { "Add new item" } @@ -422,7 +420,7 @@ impl InventoryNewCategoryForm { action="/inventory/category/" target="_self" method="post" - ."mt-8" ."p-5" ."border-2" ."border-gray-200" { + ."p-5" ."border-2" ."border-gray-200" { div ."mb-5" ."flex" ."flex-row" ."items-center" { span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {} p ."inline" ."text-xl" { "Add new category" } diff --git a/rust/src/components/mod.rs b/rust/src/components/mod.rs index a405af4..15897d9 100644 --- a/rust/src/components/mod.rs +++ b/rust/src/components/mod.rs @@ -27,12 +27,14 @@ impl Root { script src="https://cdn.tailwindcss.com" {} script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.js" defer {} link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css"; + link rel="shortcut icon" type="image/svg+xml" href="/favicon.svg"; script { (include_str!(concat!(env!("CARGO_MANIFEST_DIR"),"/js/app.js"))) } } body hx-boost="true" { header + ."h-full" ."bg-gray-200" ."p-5" ."flex" @@ -41,18 +43,37 @@ impl Root { ."justify-between" ."items-center" { - span ."text-xl" ."font-semibold" { + span + ."text-xl" + ."font-semibold" + ."flex" + ."flex-row" + ."items-center" + ."gap-3" + { + img ."h-12" src="/assets/luggage.svg"; a href="/" { "Packager" } } - nav ."grow" ."flex" ."flex-row" ."justify-center" ."gap-x-6" { - a href="/inventory/" class={@match active_page { - TopLevelPage::Inventory => "text-lg font-bold underline", - _ => "text-lg", - }} { "Inventory" } - a href="/trips/" class={@match active_page { - TopLevelPage::Trips => "text-lg font-bold underline", - _ => "text-lg", - }} { "Trips" } + nav + ."grow" + ."flex" + ."flex-row" + ."justify-center" + ."gap-x-10" + ."content-stretch" + { + a href="/inventory/" + ."h-full" + ."text-lg" + ."font-bold"[matches!(active_page, TopLevelPage::Inventory)] + ."underline"[matches!(active_page, TopLevelPage::Inventory)] + { "Inventory" } + a href="/trips/" + ."h-full" + ."text-lg" + ."font-bold"[matches!(active_page, TopLevelPage::Trips)] + ."underline"[matches!(active_page, TopLevelPage::Trips)] + { "Trips" } } } (body) diff --git a/rust/src/components/trip.rs b/rust/src/components/trip.rs index a4f16f7..825a211 100644 --- a/rust/src/components/trip.rs +++ b/rust/src/components/trip.rs @@ -280,6 +280,7 @@ impl TripInfoRow { attribute_key: TripAttribute, edit_attribute: Option<&TripAttribute>, input_type: InputType, + has_two_columns: bool, ) -> Markup { let edit = edit_attribute.map_or(false, |a| *a == attribute_key); html!( @@ -357,6 +358,7 @@ impl TripInfoRow { td ."border" ."p-2" { (name) } td ."border" ."p-2" { (value.map_or("".to_string(), |v| v.to_string())) } td + colspan=(if has_two_columns {"2"} else {"1"}) ."border-none" ."bg-blue-100" ."hover:bg-blue-200" @@ -387,6 +389,8 @@ pub struct TripInfo; impl TripInfo { pub fn build(state: &ClientState, trip: &models::Trip) -> Markup { + let has_two_columns = + state.trip_edit_attribute.is_some() || !(trip.state.is_first() || trip.state.is_last()); html!( table ."table" @@ -397,97 +401,216 @@ impl TripInfo { ."w-full" { tbody { - (TripInfoRow::build("Location", trip.location.as_ref(), TripAttribute::Location, state.trip_edit_attribute.as_ref(), InputType::Text)) - (TripInfoRow::build("Start date", Some(trip.date_start), TripAttribute::DateStart, state.trip_edit_attribute.as_ref(), InputType::Date)) - (TripInfoRow::build("End date", Some(trip.date_end), TripAttribute::DateEnd, state.trip_edit_attribute.as_ref(), InputType::Date)) - (TripInfoRow::build("Temp (min)", trip.temp_min, TripAttribute::TempMin, state.trip_edit_attribute.as_ref(), InputType::Number)) - (TripInfoRow::build("Temp (max)", trip.temp_max, TripAttribute::TempMax, state.trip_edit_attribute.as_ref(), InputType::Number)) + (TripInfoRow::build("Location", + trip.location.as_ref(), + TripAttribute::Location, + state.trip_edit_attribute.as_ref(), + InputType::Text, + has_two_columns + )) + (TripInfoRow::build("Start date", + Some(trip.date_start), + TripAttribute::DateStart, + state.trip_edit_attribute.as_ref(), + InputType::Date, + has_two_columns + )) + (TripInfoRow::build("End date", + Some(trip.date_end), + TripAttribute::DateEnd, + state.trip_edit_attribute.as_ref(), + InputType::Date, + has_two_columns + )) + (TripInfoRow::build("Temp (min)", + trip.temp_min, + TripAttribute::TempMin, + state.trip_edit_attribute.as_ref(), + InputType::Number, + has_two_columns + )) + (TripInfoRow::build("Temp (max)", + trip.temp_max, + TripAttribute::TempMax, + state.trip_edit_attribute.as_ref(), + InputType::Number, + has_two_columns + )) tr .h-full { - td ."border" ."p-2" { "Types" } - td ."border" ."p-2" { - ul - ."flex" - ."flex-row" - ."flex-wrap" - ."gap-2" - ."justify-between" - // as we have a gap between the elements, we have - // to completely skip an element when there are no - // active or inactive items, otherwise we get the gap - // between the empty (invisible) item, throwing off - // the margins - { - @let types = trip.types(); - @let active_triptypes = types.iter().filter(|t| t.active).collect::>(); - @let inactive_triptypes = types.iter().filter(|t| !t.active).collect::>(); + td ."border" ."p-2" { "State" } + td ."border" ."p-2" { (trip.state) } + @let prev_state = trip.state.prev(); + @let next_state = trip.state.next(); - @if !active_triptypes.is_empty() { - div - ."flex" - ."flex-row" - ."flex-wrap" - ."gap-2" - ."justify-start" + @if let Some(ref prev_state) = prev_state { + td + colspan=(if next_state.is_none() && has_two_columns { "2" } else { "1" }) + ."border-none" + ."bg-yellow-100" + ."hover:bg-yellow-200" + ."p-0" + ."w-8" + ."h-full" + { + form + action={"./state/" (prev_state)} + method="post" + ."flex" + ."w-full" + ."h-full" + + { + button + type="submit" + ."w-full" + ."h-full" { - @for triptype in active_triptypes { - a href=(format!("type/{}/remove", triptype.id)) { - li - ."border" - ."rounded-2xl" - ."py-0.5" - ."px-2" - ."bg-green-100" - ."cursor-pointer" - ."flex" - ."flex-column" - ."items-center" - ."hover:bg-red-200" - ."gap-1" - { - span { (triptype.name) } - span ."mdi" ."mdi-delete" ."text-sm" {} - } - } - } + span + ."m-auto" + ."mdi" + ."mdi-step-backward" + ."text-xl"; } } - @if !inactive_triptypes.is_empty() { - div - ."flex" - ."flex-row" - ."flex-wrap" - ."gap-2" - ."justify-start" + } + } + @if let Some(ref next_state) = trip.state.next() { + td + colspan=(if prev_state.is_none() && has_two_columns { "2" } else { "1" }) + ."border-none" + ."bg-green-100" + ."hover:bg-green-200" + ."p-0" + ."w-8" + ."h-full" + { + form + action={"./state/" (next_state)} + method="post" + ."flex" + ."w-full" + ."h-full" + + { + button + type="submit" + ."w-full" + ."h-full" { - @for triptype in inactive_triptypes { - a href=(format!("type/{}/add", triptype.id)) { - li - ."border" - ."rounded-2xl" - ."py-0.5" - ."px-2" - ."bg-gray-100" - ."cursor-pointer" - ."flex" - ."flex-column" - ."items-center" - ."hover:bg-green-200" - ."gap-1" - ."opacity-60" - { - span { (triptype.name) } - span ."mdi" ."mdi-plus" ."text-sm" {} - } - } - } + span + ."m-auto" + ."mdi" + ."mdi-step-forward" + ."text-xl"; } } } } } + tr .h-full { + td ."border" ."p-2" { "Types" } + td ."border" { + div + ."flex" + ."flex-row" + ."items-center" + ."justify-between" + { + ul + ."flex" + ."flex-row" + ."flex-wrap" + ."gap-2" + ."justify-between" + ."p-2" + // as we have a gap between the elements, we have + // to completely skip an element when there are no + // active or inactive items, otherwise we get the gap + // between the empty (invisible) item, throwing off + // the margins + { + @let types = trip.types(); + @let active_triptypes = types.iter().filter(|t| t.active).collect::>(); + @let inactive_triptypes = types.iter().filter(|t| !t.active).collect::>(); + + @if !active_triptypes.is_empty() { + div + ."flex" + ."flex-row" + ."flex-wrap" + ."gap-2" + ."justify-start" + { + @for triptype in active_triptypes { + a href=(format!("type/{}/remove", triptype.id)) { + li + ."border" + ."rounded-2xl" + ."py-0.5" + ."px-2" + ."bg-green-100" + ."cursor-pointer" + ."flex" + ."flex-column" + ."items-center" + ."hover:bg-red-200" + ."gap-1" + { + span { (triptype.name) } + span ."mdi" ."mdi-delete" ."text-sm" {} + } + } + } + } + } + @if !inactive_triptypes.is_empty() { + div + ."flex" + ."flex-row" + ."flex-wrap" + ."gap-2" + ."justify-start" + { + @for triptype in inactive_triptypes { + a href=(format!("type/{}/add", triptype.id)) { + li + ."border" + ."rounded-2xl" + ."py-0.5" + ."px-2" + ."bg-gray-100" + ."cursor-pointer" + ."flex" + ."flex-column" + ."items-center" + ."hover:bg-green-200" + ."gap-1" + ."opacity-60" + { + span { (triptype.name) } + span ."mdi" ."mdi-plus" ."text-sm" {} + } + } + } + } + } + } + a href="/trips/types/" + ."text-sm" + ."text-gray-500" + ."mr-2" + { + "Manage" br; "types" + } + } + } + } tr .h-full { td ."border" ."p-2" { "Carried weight" } - td ."border" ."p-2" { "TODO" } + td ."border" ."p-2" + { + (trip.total_picked_weight()) + } } } } @@ -615,6 +738,7 @@ impl TripCategoryList { } tbody { @for category in trip.categories() { + @let has_new_items = category.items.as_ref().unwrap().iter().any(|item| item.new); @let active = state.active_category_id.map_or(false, |id| category.category.id == id); tr ."h-10" @@ -624,29 +748,66 @@ impl TripCategoryList { ."outline"[active] ."outline-2"[active] ."outline-indigo-300"[active] - ."pointer-events-none"[active] { td - class=@if state.active_category_id.map_or(false, |id| category.category.id == id) { - "border p-0 m-0 font-bold" - } @else { - "border p-0 m-0" - } { - a - id="select-category" - href=( - format!( - "?category={id}", - id=category.category.id + + ."border" + ."m-0" + + { + div + ."p-0" + ."flex" + ."flex-row" + ."items-center" + ."group" + { + a + id="select-category" + href=( + format!( + "?category={id}", + id=category.category.id + ) ) - ) - ."inline-block" ."p-2" ."m-0" ."w-full" + ."inline-block" + ."p-2" + ."m-0" + ."w-full" + ."grow" + ."font-bold"[active] { (category.category.name.clone()) } + @if has_new_items { + div + ."mr-2" + ."flex" + ."flex-row" + ."items-center" + { + p + ."hidden" + ."group-hover:inline" + ."text-sm" + ."text-gray-500" + ."grow" + { + "new items" + } + span + ."mdi" + ."mdi-exclamation-thick" + ."text-xl" + ."text-yellow-400" + ."grow-0" + ; + } + } + } } - td ."border" ."p-2" ."m-0" style="position:relative;" { + td ."border" ."m-0" ."p-2" style="position:relative;" { p { (category.total_picked_weight().to_string()) } @@ -762,14 +923,31 @@ impl TripItemList { } } td ."border" ."p-0" { - a - ."p-2" ."w-full" ."inline-block" - href=( - format!("/inventory/item/{id}/", id=item.item.id) - ) { - + div + ."flex" + ."flex-row" + ."items-center" + { + a + ."p-2" ."w-full" ."inline-block" + href=( + format!("/inventory/item/{id}/", id=item.item.id) + ) + { (item.item.name.clone()) } + @if item.new { + div ."mr-2" { + span + ."mdi" + ."mdi-exclamation-thick" + ."text-xl" + ."text-yellow-400" + ."grow-0" + ; + } + } + } } td ."border" ."p-2" style="position:relative;" { p { (item.item.weight.to_string()) } diff --git a/rust/src/main.rs b/rust/src/main.rs index 7c24ac5..2326f23 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Path, Query, State}, headers, headers::Header, - http::{header::HeaderMap, StatusCode}, + http::{header, header::HeaderMap, StatusCode}, response::{Html, Redirect}, routing::{get, post}, Form, Router, @@ -89,13 +89,23 @@ async fn main() -> Result<(), sqlx::Error> { client_state: ClientState::new(), }; + let icon_handler = || async { + ( + [(header::CONTENT_TYPE, "image/svg+xml")], + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/luggage.svg")), + ) + }; + // build our application with a route let app = Router::new() + .route("/favicon.svg", get(icon_handler)) + .route("/assets/luggage.svg", get(icon_handler)) .route("/", get(root)) .route("/trips/", get(trips)) .route("/trip/", post(trip_create)) .route("/trip/:id/", get(trip)) .route("/trip/:id/comment/submit", post(trip_comment_set)) + .route("/trip/:id/state/:id", post(trip_state_set)) .route("/trip/:id/type/:id/add", get(trip_type_add)) .route("/trip/:id/type/:id/remove", get(trip_type_remove)) .route( @@ -469,6 +479,7 @@ async fn trip_create( .date_end .format(DATE_FORMAT) .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + let trip_state = TripState::new(); query!( "INSERT INTO trips (id, name, date_start, date_end, state) @@ -478,7 +489,7 @@ async fn trip_create( new_trip.name, date_start, date_end, - TripState::Planning, + trip_state ) .execute(&state.database_pool) .await @@ -628,6 +639,15 @@ async fn trip( ) })?; + trip.sync_trip_items_with_inventory(&state.database_pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorPage::build(&e.to_string()), + ) + })?; + trip.load_categories(&state.database_pool) .await .map_err(|e| { @@ -1013,3 +1033,29 @@ async fn inventory_category_create( Ok(Redirect::to("/inventory/")) } + +async fn trip_state_set( + State(state): State, + Path((trip_id, new_state)): Path<(Uuid, TripState)>, +) -> Result { + let trip_id = trip_id.to_string(); + let result = query!( + "UPDATE trips + SET state = ? + WHERE id = ?", + new_state, + trip_id, + ) + .execute(&state.database_pool) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, ErrorPage::build(&e.to_string())))?; + + if result.rows_affected() == 0 { + Err(( + StatusCode::NOT_FOUND, + ErrorPage::build(&format!("trip with id {id} not found", id = trip_id)), + )) + } else { + Ok(Redirect::to(&format!("/trip/{id}/", id = trip_id))) + } +} diff --git a/rust/src/models.rs b/rust/src/models.rs index c123eb9..8126c7e 100644 --- a/rust/src/models.rs +++ b/rust/src/models.rs @@ -98,8 +98,9 @@ impl convert::From for Error { impl error::Error for Error {} -#[derive(sqlx::Type)] +#[derive(sqlx::Type, PartialEq, PartialOrd, Deserialize)] pub enum TripState { + Init, Planning, Planned, Active, @@ -107,12 +108,49 @@ pub enum TripState { Done, } +impl TripState { + pub fn new() -> Self { + TripState::Init + } + + pub fn next(&self) -> Option { + match self { + Self::Init => Some(Self::Planning), + Self::Planning => Some(Self::Planned), + Self::Planned => Some(Self::Active), + Self::Active => Some(Self::Review), + Self::Review => Some(Self::Done), + Self::Done => None, + } + } + + pub fn prev(&self) -> Option { + match self { + Self::Init => None, + Self::Planning => Some(Self::Init), + Self::Planned => Some(Self::Planning), + Self::Active => Some(Self::Planned), + Self::Review => Some(Self::Active), + Self::Done => Some(Self::Review), + } + } + + pub fn is_first(&self) -> bool { + self == &TripState::new() + } + + pub fn is_last(&self) -> bool { + self == &TripState::Done + } +} + impl fmt::Display for TripState { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "{}", match self { + Self::Init => "Init", Self::Planning => "Planning", Self::Planned => "Planned", Self::Active => "Active", @@ -128,6 +166,7 @@ impl std::convert::TryFrom<&str> for TripState { fn try_from(value: &str) -> Result { Ok(match value { + "Init" => Self::Init, "Planning" => Self::Planning, "Planned" => Self::Planned, "Active" => Self::Active, @@ -184,6 +223,7 @@ pub struct TripItem { pub item: Item, pub picked: bool, pub packed: bool, + pub new: bool, } pub struct DbTripRow { @@ -326,6 +366,21 @@ impl<'a> Trip { } impl<'a> Trip { + pub fn total_picked_weight(&self) -> i64 { + self.categories() + .iter() + .map(|category| -> i64 { + category + .items + .as_ref() + .unwrap() + .iter() + .filter_map(|item| Some(item.item.weight).filter(|_| item.picked)) + .sum::() + }) + .sum::() + } + pub async fn load_trips_types( &'a mut self, pool: &sqlx::Pool, @@ -372,6 +427,79 @@ impl<'a> Trip { Ok(()) } + pub async fn sync_trip_items_with_inventory( + &'a mut self, + pool: &sqlx::Pool, + ) -> Result<(), Error> { + // we need to get all items that are part of the inventory but not + // part of the trip items + // + // then, we know which items we need to sync. there are different + // states for them: + // + // * if the trip is new (it's state is INITIAL), we can just forward + // as-is + // * if the trip is new, we have to make these new items prominently + // visible so the user knows that there might be new items to + // consider + let trip_id = self.id.to_string(); + let unsynced_items: Vec = sqlx::query!( + " + SELECT + i_item.id AS item_id + FROM inventory_items AS i_item + LEFT JOIN ( + SELECT t_item.item_id as item_id + FROM trips_items AS t_item + WHERE t_item.trip_id = ? + ) AS t_item + ON t_item.item_id = i_item.id + WHERE t_item.item_id IS NULL + ", + trip_id + ) + .fetch(pool) + .map_ok(|row| -> Result { Ok(Uuid::try_parse(&row.item_id)?) }) + .try_collect::>>() + .await? + .into_iter() + .collect::, Error>>()?; + + // looks like there is currently no nice way to do multiple inserts + // with sqlx. whatever, this won't matter + + // only mark as new when the trip is already underway + let mark_as_new = self.state != TripState::new(); + + for unsynced_item in &unsynced_items { + let item_id = unsynced_item.to_string(); + sqlx::query!( + " + INSERT INTO trips_items + ( + item_id, + trip_id, + pick, + pack, + new + ) + VALUES (?, ?, ?, ?, ?) + ", + item_id, + trip_id, + false, + false, + mark_as_new, + ) + .execute(pool) + .await?; + } + + tracing::info!("unsynced items: {:?}", &unsynced_items); + + Ok(()) + } + pub async fn load_categories( &'a mut self, pool: &sqlx::Pool, @@ -392,7 +520,8 @@ impl<'a> Trip { inner.item_description AS item_description, inner.item_weight AS item_weight, inner.item_is_picked AS item_is_picked, - inner.item_is_packed AS item_is_packed + inner.item_is_packed AS item_is_packed, + inner.item_is_new AS item_is_new FROM inventory_items_categories AS category LEFT JOIN ( SELECT @@ -405,7 +534,8 @@ impl<'a> Trip { item.description as item_description, item.weight as item_weight, trip.pick as item_is_picked, - trip.pack as item_is_packed + trip.pack as item_is_packed, + trip.new as item_is_new FROM trips_items as trip INNER JOIN inventory_items as item ON item.id = trip.item_id @@ -423,8 +553,6 @@ impl<'a> Trip { category: Category { id: Uuid::try_parse(&row.category_id)?, name: row.category_name, - // TODO align optionality between code and database - // idea: make description nullable description: row.category_description, items: None, @@ -450,6 +578,7 @@ impl<'a> Trip { }, picked: row.item_is_picked.unwrap(), packed: row.item_is_packed.unwrap(), + new: row.item_is_new.unwrap(), }; if let Some(&mut ref mut c) = categories