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