This commit is contained in:
2023-08-29 21:33:59 +02:00
parent 7f80b83809
commit 3f834cd7d2
11 changed files with 708 additions and 275 deletions

1
rust/assets/luggage.svg Normal file
View File

@@ -0,0 +1 @@
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg"><path d="m40.701 37.6519 2.598 1.5-1 1.7321-2.598-1.5zm-.3651-2.3662a1 1 0 0 0 -.867.5l-2 3.4641a1 1 0 0 0 .366 1.366l4.33 2.5a.9994.9994 0 0 0 1.366-.366l2-3.4641a1 1 0 0 0 -.366-1.366l-4.33-2.5a.9944.9944 0 0 0 -.499-.1342z"/><path d="m30.4974 29.2674a1.0015 1.0015 0 1 1 -.4974.1345.9926.9926 0 0 1 .4974-.1345m0-2a3 3 0 1 0 2.6009 1.5005 2.987 2.987 0 0 0 -2.6009-1.5005z"/><path d="m58.4972 42.2681a1.0008 1.0008 0 1 1 -.4972.1345.9922.9922 0 0 1 .4974-.1345m0-2a3 3 0 1 0 2.6009 1.5005 2.9869 2.9869 0 0 0 -2.6009-1.5005z"/><path d="m33.299 47.6519 1 1.7321-2.598 1.5-1-1.7321zm.3651-2.3662a.9946.9946 0 0 0 -.499.1342l-4.33 2.5a1 1 0 0 0 -.366 1.366l2 3.4641a.9994.9994 0 0 0 1.366.366l4.33-2.5a1 1 0 0 0 .366-1.366l-2-3.4641a1 1 0 0 0 -.867-.5z"/><path d="m15 38.5v3h-2v-3zm1-2h-4a1 1 0 0 0 -1 1v5a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-5a1 1 0 0 0 -1-1z"/><rect fill="#a57939" height="34" rx="3" width="54" x="9" y="22"/><rect fill="#6a462f" height="38" rx="1" width="4" x="19" y="20"/><rect fill="#6a462f" height="38" rx="1" width="4" x="49" y="20"/><rect fill="#e67a94" height="6" rx="1" transform="matrix(.866 .5 -.5 .866 25.194 -15.4891)" width="7" x="38" y="36.2679"/><circle cx="30.5" cy="30.2679" fill="#fcea2b" r="3"/><circle cx="58.4998" cy="43.2686" fill="#61b2e4" r="3"/><rect fill="#f4aa41" height="6" rx="1" transform="matrix(.8660254 -.5 .5 .8660254 -20.2798 22.8507)" width="7" x="29" y="46.2679"/><rect fill="#b1cc33" height="6" rx="1" transform="matrix(-.00000175 1 -1 -.00000175 54 26)" width="7" x="10.5" y="37"/><path d="m33.5 22v-2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v2h3v-3.8a2.2 2.2 0 0 0 -2.2-2.2h-6.6a2.2 2.2 0 0 0 -2.2 2.2v3.8z" fill="#a57939"/><g fill="none" stroke="#000" stroke-width="2"><path d="m33.5 22v-2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v2h3v-3.8a2.2 2.2 0 0 0 -2.2-2.2h-6.6a2.2 2.2 0 0 0 -2.2 2.2v3.8z" stroke-linejoin="round"/><path d="m53 56h7a3 3 0 0 0 3-3v-28a3 3 0 0 0 -3-3h-7" stroke-miterlimit="10"/><path d="m19 22h-7a3 3 0 0 0 -3 3v28a3 3 0 0 0 3 3h7" stroke-miterlimit="10"/><path d="m40.067 40.75-1.732-1 2-3.464 3.031 1.75" stroke-linecap="round" stroke-linejoin="round"/><path d="m31.5 32a2 2 0 1 1 -2-3.4635" stroke-linecap="round" stroke-miterlimit="10"/><path d="m57.5 41.5365a2 2 0 1 1 2 3.4641" stroke-linecap="round" stroke-miterlimit="10"/><path d="m31.067 47.786-1.732 1 2 3.464 3.031-1.75" stroke-linecap="round" stroke-linejoin="round"/><path d="m12 39.5v-2h4v3.5" stroke-linecap="round" stroke-linejoin="round"/><rect height="38" rx="1" stroke-miterlimit="10" width="4" x="19" y="20"/><rect height="38" rx="1" stroke-miterlimit="10" width="4" x="49" y="20"/><path d="m23 22h26" stroke-miterlimit="10"/><path d="m49 56h-26" stroke-miterlimit="10"/></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1,5 +1,7 @@
// generated by `sqlx migrate build-script`
fn main() { fn main() {
// trigger recompilation when a new migration is added // trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations"); println!("cargo:rerun-if-changed=migrations");
}
// recompile when javascript changes, as it's embedded in the binary
println!("cargo:rerun-if-changed=js");
}

View File

@@ -1,3 +1,5 @@
document.body.addEventListener('htmx:responseError', function(evt) { window.onload = function() {
console.log(evt.detail); document.body.addEventListener('htmx:responseError', function(evt) {
}); console.log(evt.detail);
});
};

View File

@@ -0,0 +1,2 @@
-- Add migration script here
ALTER TABLE "trips_items" ADD COLUMN new BOOLEAN NOT NULL;

10
rust/query.sql Normal file
View File

@@ -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

View File

@@ -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 " "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": { "describe": {
"columns": [ "columns": [],
{ "nullable": [],
"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
],
"parameters": { "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": { "18cbb2893df033f5f81f42097fcae7ee036405749a5d93f2ea1d79ba280dfd20": {
"describe": { "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 " "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": { "1f08e9bebf51aab9cabff2a5c79211233a686e9ef9f96ea5c036fbba8f6b06d5": {
"describe": { "describe": {
"columns": [ "columns": [
@@ -435,5 +391,93 @@
} }
}, },
"query": "SELECT * FROM inventory_items AS item\n WHERE item.id = ?" "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 "
} }
} }

View File

@@ -10,13 +10,14 @@ impl Inventory {
pub async fn build(state: ClientState, categories: Vec<Category>) -> Result<Markup, Error> { pub async fn build(state: ClientState, categories: Vec<Category>) -> Result<Markup, Error> {
let doc = html!( let doc = html!(
div id="pkglist-item-manager" { div id="pkglist-item-manager" {
div ."p-8" ."grid" ."grid-cols-4" ."gap-3" { div ."p-8" ."grid" ."grid-cols-4" ."gap-5" {
div ."col-span-2" { div ."col-span-2" ."flex" ."flex-col" ."gap-8" {
h1 ."text-2xl" ."text-center" { "Categories" }
(InventoryCategoryList::build(&state, &categories)) (InventoryCategoryList::build(&state, &categories))
(InventoryNewCategoryForm::build()) (InventoryNewCategoryForm::build())
} }
div ."col-span-2" { div ."col-span-2" ."flex" ."flex-col" ."gap-8" {
h1 ."text-2xl" ."mb-5" ."text-center" { "Items" } h1 ."text-2xl" ."text-center" { "Items" }
@if let Some(active_category_id) = state.active_category_id { @if let Some(active_category_id) = state.active_category_id {
(InventoryItemList::build(&state, categories.iter().find(|category| category.id == 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) })? .ok_or(Error::NotFoundError { description: format!("no category with id {}", active_category_id) })?
@@ -44,94 +45,91 @@ impl InventoryCategoryList {
.unwrap_or(1); .unwrap_or(1);
html!( html!(
div { table
h1 ."text-2xl" ."mb-5" ."text-center" { "Categories" } ."table"
table ."table-auto"
."table" ."border-collapse"
."table-auto" ."border-spacing-0"
."border-collapse" ."border"
."border-spacing-0" ."w-full"
."border" {
."w-full"
{
colgroup { colgroup {
col style="width:50%" {} col style="width:50%" {}
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" { tbody {
th ."border" ."p-2" ."w-3/5" { "Name" } @for category in categories {
th ."border" ."p-2" { "Weight" } @let active = state.active_category_id.map_or(false, |id| category.id == id);
} tr
} ."h-10"
tbody { ."hover:bg-purple-100"
@for category in categories { ."m-3"
@let active = state.active_category_id.map_or(false, |id| category.id == id); ."h-full"
tr ."outline"[active]
."h-10" ."outline-2"[active]
."hover:bg-purple-100" ."outline-indigo-300"[active]
."m-3" ."pointer-events-none"[active]
."h-full" {
."outline"[active]
."outline-2"[active]
."outline-indigo-300"[active]
."pointer-events-none"[active]
{
td td
class=@if state.active_category_id.map_or(false, |id| category.id == id) { class=@if state.active_category_id.map_or(false, |id| category.id == id) {
"border p-0 m-0 font-bold" "border p-0 m-0 font-bold"
} @else { } @else {
"border p-0 m-0" "border p-0 m-0"
} { } {
a a
id="select-category" id="select-category"
href=( href=(
format!( format!(
"/inventory/category/{id}/", "/inventory/category/{id}/",
id=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" { tr ."h-10" ."hover:bg-purple-200" ."bg-gray-300" ."font-bold" {
p ."p-2" ."m-2" { "Sum" } td ."border" ."p-0" ."m-0" {
} p ."p-2" ."m-2" { "Sum" }
td ."border" ."p-0" ."m-0" { }
p ."p-2" ."m-2" { td ."border" ."p-0" ."m-0" {
(categories.iter().map(Category::total_weight).sum::<i64>().to_string()) p ."p-2" ."m-2" {
} (categories.iter().map(Category::total_weight).sum::<i64>().to_string())
} }
} }
} }
@@ -325,7 +323,7 @@ impl InventoryNewItemForm {
action="/inventory/item/" action="/inventory/item/"
target="_self" target="_self"
method="post" 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" { div ."mb-5" ."flex" ."flex-row" ."items-center" {
span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {} span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {}
p ."inline" ."text-xl" { "Add new item" } p ."inline" ."text-xl" { "Add new item" }
@@ -422,7 +420,7 @@ impl InventoryNewCategoryForm {
action="/inventory/category/" action="/inventory/category/"
target="_self" target="_self"
method="post" 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" { div ."mb-5" ."flex" ."flex-row" ."items-center" {
span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {} span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {}
p ."inline" ."text-xl" { "Add new category" } p ."inline" ."text-xl" { "Add new category" }

View File

@@ -27,12 +27,14 @@ impl Root {
script src="https://cdn.tailwindcss.com" {} script src="https://cdn.tailwindcss.com" {}
script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.js" defer {} 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="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"))) } script { (include_str!(concat!(env!("CARGO_MANIFEST_DIR"),"/js/app.js"))) }
} }
body body
hx-boost="true" hx-boost="true"
{ {
header header
."h-full"
."bg-gray-200" ."bg-gray-200"
."p-5" ."p-5"
."flex" ."flex"
@@ -41,18 +43,37 @@ impl Root {
."justify-between" ."justify-between"
."items-center" ."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" } a href="/" { "Packager" }
} }
nav ."grow" ."flex" ."flex-row" ."justify-center" ."gap-x-6" { nav
a href="/inventory/" class={@match active_page { ."grow"
TopLevelPage::Inventory => "text-lg font-bold underline", ."flex"
_ => "text-lg", ."flex-row"
}} { "Inventory" } ."justify-center"
a href="/trips/" class={@match active_page { ."gap-x-10"
TopLevelPage::Trips => "text-lg font-bold underline", ."content-stretch"
_ => "text-lg", {
}} { "Trips" } 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) (body)

View File

@@ -280,6 +280,7 @@ impl TripInfoRow {
attribute_key: TripAttribute, attribute_key: TripAttribute,
edit_attribute: Option<&TripAttribute>, edit_attribute: Option<&TripAttribute>,
input_type: InputType, input_type: InputType,
has_two_columns: bool,
) -> Markup { ) -> Markup {
let edit = edit_attribute.map_or(false, |a| *a == attribute_key); let edit = edit_attribute.map_or(false, |a| *a == attribute_key);
html!( html!(
@@ -357,6 +358,7 @@ impl TripInfoRow {
td ."border" ."p-2" { (name) } td ."border" ."p-2" { (name) }
td ."border" ."p-2" { (value.map_or("".to_string(), |v| v.to_string())) } td ."border" ."p-2" { (value.map_or("".to_string(), |v| v.to_string())) }
td td
colspan=(if has_two_columns {"2"} else {"1"})
."border-none" ."border-none"
."bg-blue-100" ."bg-blue-100"
."hover:bg-blue-200" ."hover:bg-blue-200"
@@ -387,6 +389,8 @@ pub struct TripInfo;
impl TripInfo { impl TripInfo {
pub fn build(state: &ClientState, trip: &models::Trip) -> Markup { 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!( html!(
table table
."table" ."table"
@@ -397,97 +401,216 @@ impl TripInfo {
."w-full" ."w-full"
{ {
tbody { tbody {
(TripInfoRow::build("Location", trip.location.as_ref(), TripAttribute::Location, state.trip_edit_attribute.as_ref(), InputType::Text)) (TripInfoRow::build("Location",
(TripInfoRow::build("Start date", Some(trip.date_start), TripAttribute::DateStart, state.trip_edit_attribute.as_ref(), InputType::Date)) trip.location.as_ref(),
(TripInfoRow::build("End date", Some(trip.date_end), TripAttribute::DateEnd, state.trip_edit_attribute.as_ref(), InputType::Date)) TripAttribute::Location,
(TripInfoRow::build("Temp (min)", trip.temp_min, TripAttribute::TempMin, state.trip_edit_attribute.as_ref(), InputType::Number)) state.trip_edit_attribute.as_ref(),
(TripInfoRow::build("Temp (max)", trip.temp_max, TripAttribute::TempMax, state.trip_edit_attribute.as_ref(), InputType::Number)) 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 { tr .h-full {
td ."border" ."p-2" { "Types" } td ."border" ."p-2" { "State" }
td ."border" ."p-2" { td ."border" ."p-2" { (trip.state) }
ul @let prev_state = trip.state.prev();
."flex" @let next_state = trip.state.next();
."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::<Vec<&TripType>>();
@let inactive_triptypes = types.iter().filter(|t| !t.active).collect::<Vec<&TripType>>();
@if !active_triptypes.is_empty() { @if let Some(ref prev_state) = prev_state {
div td
."flex" colspan=(if next_state.is_none() && has_two_columns { "2" } else { "1" })
."flex-row" ."border-none"
."flex-wrap" ."bg-yellow-100"
."gap-2" ."hover:bg-yellow-200"
."justify-start" ."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 { span
a href=(format!("type/{}/remove", triptype.id)) { ."m-auto"
li ."mdi"
."border" ."mdi-step-backward"
."rounded-2xl" ."text-xl";
."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" @if let Some(ref next_state) = trip.state.next() {
."flex-row" td
."flex-wrap" colspan=(if prev_state.is_none() && has_two_columns { "2" } else { "1" })
."gap-2" ."border-none"
."justify-start" ."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 { span
a href=(format!("type/{}/add", triptype.id)) { ."m-auto"
li ."mdi"
."border" ."mdi-step-forward"
."rounded-2xl" ."text-xl";
."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" {}
}
}
}
} }
} }
} }
} }
} }
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::<Vec<&TripType>>();
@let inactive_triptypes = types.iter().filter(|t| !t.active).collect::<Vec<&TripType>>();
@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 { tr .h-full {
td ."border" ."p-2" { "Carried weight" } 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 { tbody {
@for category in trip.categories() { @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); @let active = state.active_category_id.map_or(false, |id| category.category.id == id);
tr tr
."h-10" ."h-10"
@@ -624,29 +748,66 @@ impl TripCategoryList {
."outline"[active] ."outline"[active]
."outline-2"[active] ."outline-2"[active]
."outline-indigo-300"[active] ."outline-indigo-300"[active]
."pointer-events-none"[active]
{ {
td td
class=@if state.active_category_id.map_or(false, |id| category.category.id == id) {
"border p-0 m-0 font-bold" ."border"
} @else { ."m-0"
"border p-0 m-0"
} { {
a div
id="select-category" ."p-0"
href=( ."flex"
format!( ."flex-row"
"?category={id}", ."items-center"
id=category.category.id ."group"
{
a
id="select-category"
href=(
format!(
"?category={id}",
id=category.category.id
)
) )
) ."inline-block"
."inline-block" ."p-2" ."m-0" ."w-full" ."p-2"
."m-0"
."w-full"
."grow"
."font-bold"[active]
{ {
(category.category.name.clone()) (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 { p {
(category.total_picked_weight().to_string()) (category.total_picked_weight().to_string())
} }
@@ -762,14 +923,31 @@ impl TripItemList {
} }
} }
td ."border" ."p-0" { td ."border" ."p-0" {
a div
."p-2" ."w-full" ."inline-block" ."flex"
href=( ."flex-row"
format!("/inventory/item/{id}/", id=item.item.id) ."items-center"
) { {
a
."p-2" ."w-full" ."inline-block"
href=(
format!("/inventory/item/{id}/", id=item.item.id)
)
{
(item.item.name.clone()) (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;" { td ."border" ."p-2" style="position:relative;" {
p { (item.item.weight.to_string()) } p { (item.item.weight.to_string()) }

View File

@@ -3,7 +3,7 @@ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
headers, headers,
headers::Header, headers::Header,
http::{header::HeaderMap, StatusCode}, http::{header, header::HeaderMap, StatusCode},
response::{Html, Redirect}, response::{Html, Redirect},
routing::{get, post}, routing::{get, post},
Form, Router, Form, Router,
@@ -89,13 +89,23 @@ async fn main() -> Result<(), sqlx::Error> {
client_state: ClientState::new(), 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 // build our application with a route
let app = Router::new() let app = Router::new()
.route("/favicon.svg", get(icon_handler))
.route("/assets/luggage.svg", get(icon_handler))
.route("/", get(root)) .route("/", get(root))
.route("/trips/", get(trips)) .route("/trips/", get(trips))
.route("/trip/", post(trip_create)) .route("/trip/", post(trip_create))
.route("/trip/:id/", get(trip)) .route("/trip/:id/", get(trip))
.route("/trip/:id/comment/submit", post(trip_comment_set)) .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/add", get(trip_type_add))
.route("/trip/:id/type/:id/remove", get(trip_type_remove)) .route("/trip/:id/type/:id/remove", get(trip_type_remove))
.route( .route(
@@ -469,6 +479,7 @@ async fn trip_create(
.date_end .date_end
.format(DATE_FORMAT) .format(DATE_FORMAT)
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let trip_state = TripState::new();
query!( query!(
"INSERT INTO trips "INSERT INTO trips
(id, name, date_start, date_end, state) (id, name, date_start, date_end, state)
@@ -478,7 +489,7 @@ async fn trip_create(
new_trip.name, new_trip.name,
date_start, date_start,
date_end, date_end,
TripState::Planning, trip_state
) )
.execute(&state.database_pool) .execute(&state.database_pool)
.await .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) trip.load_categories(&state.database_pool)
.await .await
.map_err(|e| { .map_err(|e| {
@@ -1013,3 +1033,29 @@ async fn inventory_category_create(
Ok(Redirect::to("/inventory/")) Ok(Redirect::to("/inventory/"))
} }
async fn trip_state_set(
State(state): State<AppState>,
Path((trip_id, new_state)): Path<(Uuid, TripState)>,
) -> Result<Redirect, (StatusCode, Markup)> {
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)))
}
}

View File

@@ -98,8 +98,9 @@ impl convert::From<TimeParseError> for Error {
impl error::Error for Error {} impl error::Error for Error {}
#[derive(sqlx::Type)] #[derive(sqlx::Type, PartialEq, PartialOrd, Deserialize)]
pub enum TripState { pub enum TripState {
Init,
Planning, Planning,
Planned, Planned,
Active, Active,
@@ -107,12 +108,49 @@ pub enum TripState {
Done, Done,
} }
impl TripState {
pub fn new() -> Self {
TripState::Init
}
pub fn next(&self) -> Option<Self> {
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<Self> {
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 { impl fmt::Display for TripState {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!( write!(
f, f,
"{}", "{}",
match self { match self {
Self::Init => "Init",
Self::Planning => "Planning", Self::Planning => "Planning",
Self::Planned => "Planned", Self::Planned => "Planned",
Self::Active => "Active", Self::Active => "Active",
@@ -128,6 +166,7 @@ impl std::convert::TryFrom<&str> for TripState {
fn try_from(value: &str) -> Result<Self, Self::Error> { fn try_from(value: &str) -> Result<Self, Self::Error> {
Ok(match value { Ok(match value {
"Init" => Self::Init,
"Planning" => Self::Planning, "Planning" => Self::Planning,
"Planned" => Self::Planned, "Planned" => Self::Planned,
"Active" => Self::Active, "Active" => Self::Active,
@@ -184,6 +223,7 @@ pub struct TripItem {
pub item: Item, pub item: Item,
pub picked: bool, pub picked: bool,
pub packed: bool, pub packed: bool,
pub new: bool,
} }
pub struct DbTripRow { pub struct DbTripRow {
@@ -326,6 +366,21 @@ impl<'a> Trip {
} }
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::<i64>()
})
.sum::<i64>()
}
pub async fn load_trips_types( pub async fn load_trips_types(
&'a mut self, &'a mut self,
pool: &sqlx::Pool<sqlx::Sqlite>, pool: &sqlx::Pool<sqlx::Sqlite>,
@@ -372,6 +427,79 @@ impl<'a> Trip {
Ok(()) Ok(())
} }
pub async fn sync_trip_items_with_inventory(
&'a mut self,
pool: &sqlx::Pool<sqlx::Sqlite>,
) -> 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<Uuid> = 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<Uuid, Error> { Ok(Uuid::try_parse(&row.item_id)?) })
.try_collect::<Vec<Result<Uuid, Error>>>()
.await?
.into_iter()
.collect::<Result<Vec<Uuid>, 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( pub async fn load_categories(
&'a mut self, &'a mut self,
pool: &sqlx::Pool<sqlx::Sqlite>, pool: &sqlx::Pool<sqlx::Sqlite>,
@@ -392,7 +520,8 @@ impl<'a> Trip {
inner.item_description AS item_description, inner.item_description AS item_description,
inner.item_weight AS item_weight, inner.item_weight AS item_weight,
inner.item_is_picked AS item_is_picked, 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 FROM inventory_items_categories AS category
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
@@ -405,7 +534,8 @@ impl<'a> Trip {
item.description as item_description, item.description as item_description,
item.weight as item_weight, item.weight as item_weight,
trip.pick as item_is_picked, 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 FROM trips_items as trip
INNER JOIN inventory_items as item INNER JOIN inventory_items as item
ON item.id = trip.item_id ON item.id = trip.item_id
@@ -423,8 +553,6 @@ impl<'a> Trip {
category: Category { category: Category {
id: Uuid::try_parse(&row.category_id)?, id: Uuid::try_parse(&row.category_id)?,
name: row.category_name, name: row.category_name,
// TODO align optionality between code and database
// idea: make description nullable
description: row.category_description, description: row.category_description,
items: None, items: None,
@@ -450,6 +578,7 @@ impl<'a> Trip {
}, },
picked: row.item_is_picked.unwrap(), picked: row.item_is_picked.unwrap(),
packed: row.item_is_packed.unwrap(), packed: row.item_is_packed.unwrap(),
new: row.item_is_new.unwrap(),
}; };
if let Some(&mut ref mut c) = categories if let Some(&mut ref mut c) = categories