refactoring
This commit is contained in:
@@ -8,37 +8,36 @@ use uuid::{uuid, Uuid};
|
||||
pub struct Inventory;
|
||||
|
||||
impl Inventory {
|
||||
pub fn build(state: ClientState, categories: Vec<Category>) -> Result<Markup, Error> {
|
||||
let doc = html!(
|
||||
pub fn build(
|
||||
active_category: Option<&Category>,
|
||||
categories: &Vec<Category>,
|
||||
edit_item_id: Option<Uuid>,
|
||||
) -> Markup {
|
||||
html!(
|
||||
div id="pkglist-item-manager" {
|
||||
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))
|
||||
(InventoryCategoryList::build(active_category, categories))
|
||||
(InventoryNewCategoryForm::build())
|
||||
}
|
||||
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::NotFound{ description: format!("no category with id {}", active_category_id) })?
|
||||
.items())
|
||||
)
|
||||
@if let Some(active_category) = active_category {
|
||||
(InventoryItemList::build(edit_item_id, active_category.items()))
|
||||
}
|
||||
(InventoryNewItemForm::build(&state, &categories))
|
||||
(InventoryNewItemForm::build(active_category, &categories))
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
Ok(doc)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct InventoryCategoryList;
|
||||
|
||||
impl InventoryCategoryList {
|
||||
pub fn build(state: &ClientState, categories: &Vec<Category>) -> Markup {
|
||||
pub fn build(active_category: Option<&Category>, categories: &Vec<Category>) -> Markup {
|
||||
let biggest_category_weight: i64 = categories
|
||||
.iter()
|
||||
.map(Category::total_weight)
|
||||
@@ -68,10 +67,10 @@ impl InventoryCategoryList {
|
||||
}
|
||||
tbody {
|
||||
@for category in categories {
|
||||
@let active = state.active_category_id.map_or(false, |id| category.id == id);
|
||||
@let active = active_category.map_or(false, |c| category.id == c.id);
|
||||
tr
|
||||
."h-10"
|
||||
."hover:bg-purple-100"
|
||||
."hover:bg-gray-100"
|
||||
."m-3"
|
||||
."h-full"
|
||||
."outline"[active]
|
||||
@@ -81,7 +80,7 @@ impl InventoryCategoryList {
|
||||
{
|
||||
|
||||
td
|
||||
class=@if state.active_category_id.map_or(false, |id| category.id == id) {
|
||||
class=@if active_category.map_or(false, |c| category.id == c.id) {
|
||||
"border p-0 m-0 font-bold"
|
||||
} @else {
|
||||
"border p-0 m-0"
|
||||
@@ -125,7 +124,7 @@ impl InventoryCategoryList {
|
||||
}
|
||||
}
|
||||
}
|
||||
tr ."h-10" ."hover:bg-purple-200" ."bg-gray-300" ."font-bold" {
|
||||
tr ."h-10" ."bg-gray-300" ."font-bold" {
|
||||
td ."border" ."p-0" ."m-0" {
|
||||
p ."p-2" ."m-2" { "Sum" }
|
||||
}
|
||||
@@ -144,18 +143,18 @@ impl InventoryCategoryList {
|
||||
pub struct InventoryItemList;
|
||||
|
||||
impl InventoryItemList {
|
||||
pub fn build(state: &ClientState, items: &Vec<Item>) -> Markup {
|
||||
pub fn build(edit_item_id: Option<Uuid>, items: &Vec<Item>) -> Markup {
|
||||
let biggest_item_weight: i64 = items.iter().map(|item| item.weight).max().unwrap_or(1);
|
||||
html!(
|
||||
div #items {
|
||||
@if items.is_empty() {
|
||||
p ."text-lg" ."text-center" ."py-5" ."text-gray-400" { "[Empty]" }
|
||||
} @else {
|
||||
@if let Some(edit_item) = state.edit_item {
|
||||
@if let Some(edit_item_id) = edit_item_id {
|
||||
form
|
||||
name="edit-item"
|
||||
id="edit-item"
|
||||
action=(format!("/inventory/item/{edit_item}/edit"))
|
||||
action={"/inventory/item/" (edit_item_id) "/edit"}
|
||||
target="_self"
|
||||
method="post"
|
||||
{}
|
||||
@@ -163,7 +162,7 @@ impl InventoryItemList {
|
||||
table
|
||||
."table"
|
||||
."table-auto"
|
||||
.table-fixed
|
||||
."table-fixed"
|
||||
."border-collapse"
|
||||
."border-spacing-0"
|
||||
."border"
|
||||
@@ -179,7 +178,7 @@ impl InventoryItemList {
|
||||
}
|
||||
tbody {
|
||||
@for item in items {
|
||||
@if state.edit_item.map_or(false, |edit_item| edit_item == item.id) {
|
||||
@if edit_item_id.map_or(false, |id| id == item.id) {
|
||||
tr ."h-10" {
|
||||
td ."border" ."bg-blue-300" ."px-2" ."py-0" {
|
||||
div ."h-full" ."w-full" ."flex" {
|
||||
@@ -251,7 +250,7 @@ impl InventoryItemList {
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" {
|
||||
tr ."h-10" {
|
||||
td ."border" ."p-0" {
|
||||
a
|
||||
."p-2" ."w-full" ."inline-block"
|
||||
@@ -321,6 +320,17 @@ pub struct InventoryNewItemFormName;
|
||||
impl InventoryNewItemFormName {
|
||||
pub fn build(value: Option<&str>, error: bool) -> Markup {
|
||||
html!(
|
||||
script {
|
||||
(PreEscaped("
|
||||
function inventory_new_item_check_input() {
|
||||
return document.getElementById('new-item-name').value.length != 0
|
||||
&& is_positive_integer(document.getElementById('new-item-weight').value)
|
||||
}
|
||||
function check_weight() {
|
||||
return document.getElementById('new-item-weight').validity.valid;
|
||||
}
|
||||
"))
|
||||
}
|
||||
div
|
||||
."grid"
|
||||
."grid-cols-[2fr,3fr]"
|
||||
@@ -328,7 +338,7 @@ impl InventoryNewItemFormName {
|
||||
."items-center"
|
||||
hx-post="/inventory/item/name/validate"
|
||||
hx-trigger="input delay:1s, loaded from:document"
|
||||
|
||||
hx-target="this"
|
||||
hx-params="new-item-name"
|
||||
hx-swap="outerHTML"
|
||||
#abc
|
||||
@@ -349,7 +359,7 @@ impl InventoryNewItemFormName {
|
||||
."rounded"
|
||||
."focus:outline-none"
|
||||
."focus:bg-white"
|
||||
."focus:border-purple-500"[!error]
|
||||
."focus:border-gray-500"[!error]
|
||||
value=[value]
|
||||
{}
|
||||
@if error {
|
||||
@@ -369,6 +379,17 @@ pub struct InventoryNewItemFormWeight;
|
||||
impl InventoryNewItemFormWeight {
|
||||
pub fn build() -> Markup {
|
||||
html!(
|
||||
script {
|
||||
(PreEscaped("
|
||||
function inventory_new_item_check_input() {
|
||||
return document.getElementById('new-item-name').value.length != 0
|
||||
&& is_positive_integer(document.getElementById('new-item-weight').value)
|
||||
}
|
||||
function check_weight() {
|
||||
return document.getElementById('new-item-weight').validity.valid;
|
||||
}
|
||||
"))
|
||||
}
|
||||
div
|
||||
."grid"
|
||||
."grid-cols-[2fr,3fr]"
|
||||
@@ -385,7 +406,7 @@ impl InventoryNewItemFormWeight {
|
||||
save_active = inventory_new_item_check_input();
|
||||
weight_error = !check_weight();
|
||||
}"
|
||||
x-bind:class="weight_error && 'border-red-500' || 'border-gray-300 focus:border-purple-500'"
|
||||
x-bind:class="weight_error && 'border-red-500' || 'border-gray-300 focus:border-gray-500'"
|
||||
."block"
|
||||
."w-full"
|
||||
."p-2"
|
||||
@@ -410,7 +431,7 @@ impl InventoryNewItemFormWeight {
|
||||
pub struct InventoryNewItemFormCategory;
|
||||
|
||||
impl InventoryNewItemFormCategory {
|
||||
pub fn build(state: &ClientState, categories: &Vec<Category>) -> Markup {
|
||||
pub fn build(active_category: Option<&Category>, categories: &Vec<Category>) -> Markup {
|
||||
html!(
|
||||
div
|
||||
."grid"
|
||||
@@ -431,11 +452,11 @@ impl InventoryNewItemFormCategory {
|
||||
."rounded"
|
||||
."focus:outline-none"
|
||||
."focus:bg-white"
|
||||
."focus:border-purple-500"
|
||||
."focus:border-gray-500"
|
||||
autocomplete="off" // https://stackoverflow.com/a/10096033
|
||||
{
|
||||
@for category in categories {
|
||||
option value=(category.id) selected[state.active_category_id.map_or(false, |id| id == category.id)] {
|
||||
option value=(category.id) selected[active_category.map_or(false, |c| c.id == category.id)] {
|
||||
(category.name)
|
||||
}
|
||||
}
|
||||
@@ -448,7 +469,7 @@ impl InventoryNewItemFormCategory {
|
||||
pub struct InventoryNewItemForm;
|
||||
|
||||
impl InventoryNewItemForm {
|
||||
pub fn build(state: &ClientState, categories: &Vec<Category>) -> Markup {
|
||||
pub fn build(active_category: Option<&Category>, categories: &Vec<Category>) -> Markup {
|
||||
html!(
|
||||
script {
|
||||
(PreEscaped("
|
||||
@@ -467,6 +488,9 @@ impl InventoryNewItemForm {
|
||||
weight_error: !check_weight(),
|
||||
}"
|
||||
name="new-item"
|
||||
hx-post="/inventory/item/"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="#pkglist-item-manager"
|
||||
id="new-item"
|
||||
action="/inventory/item/"
|
||||
target="_self"
|
||||
@@ -479,7 +503,7 @@ impl InventoryNewItemForm {
|
||||
div ."w-11/12" ."mx-auto" ."flex" ."flex-col" ."gap-8" {
|
||||
(InventoryNewItemFormName::build(None, false))
|
||||
(InventoryNewItemFormWeight::build())
|
||||
(InventoryNewItemFormCategory::build(state, categories))
|
||||
(InventoryNewItemFormCategory::build(active_category, categories))
|
||||
input type="submit" value="Add"
|
||||
x-bind:disabled="!save_active"
|
||||
."enabled:cursor-pointer"
|
||||
@@ -530,7 +554,7 @@ impl InventoryNewCategoryForm {
|
||||
."rounded"
|
||||
."focus:outline-none"
|
||||
."focus:bg-white"
|
||||
."focus:border-purple-500"
|
||||
."focus:border-gray-500"
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -568,19 +592,19 @@ impl InventoryItem {
|
||||
."w-full"
|
||||
{
|
||||
tbody {
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" ."h-full" {
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
|
||||
td ."border" ."p-2" { "Name" }
|
||||
td ."border" ."p-2" { (item.name) }
|
||||
}
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" ."h-full" {
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
|
||||
td ."border" ."p-2" { "Description" }
|
||||
td ."border" ."p-2" { (item.description.clone().unwrap_or("".to_string())) }
|
||||
}
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" ."h-full" {
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
|
||||
td ."border" ."p-2" { "Weight" }
|
||||
td ."border" ."p-2" { (item.weight.to_string()) }
|
||||
}
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" ."h-full" {
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
|
||||
td ."border" ."p-2" { "Category" }
|
||||
td ."border" ."p-2" { (item.category.name) }
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ impl Root {
|
||||
meta name="htmx-config" content=r#"{"useTemplateFragments":true}"# {}
|
||||
}
|
||||
body
|
||||
hx-boost="true"
|
||||
{
|
||||
header
|
||||
#header
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::models;
|
||||
use crate::models::*;
|
||||
use crate::{models, HtmxEvents};
|
||||
|
||||
use maud::{html, Markup, PreEscaped};
|
||||
use uuid::Uuid;
|
||||
@@ -15,7 +15,13 @@ pub use types::*;
|
||||
impl TripManager {
|
||||
pub fn build(trips: Vec<models::Trip>) -> Markup {
|
||||
html!(
|
||||
div ."p-8" {
|
||||
div
|
||||
."p-8"
|
||||
."flex"
|
||||
."flex-col"
|
||||
."gap-8"
|
||||
{
|
||||
h1 ."text-2xl" {"Trips"}
|
||||
(TripTable::build(trips))
|
||||
(NewTrip::build())
|
||||
}
|
||||
@@ -55,7 +61,6 @@ pub struct TripTable;
|
||||
impl TripTable {
|
||||
pub fn build(trips: Vec<models::Trip>) -> Markup {
|
||||
html!(
|
||||
h1 ."text-2xl" ."mb-5" {"Trips"}
|
||||
table
|
||||
."table"
|
||||
."table-auto"
|
||||
@@ -75,7 +80,7 @@ impl TripTable {
|
||||
}
|
||||
tbody {
|
||||
@for trip in trips {
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-purple-100" ."h-full" {
|
||||
tr ."h-10" ."even:bg-gray-100" ."hover:bg-gray-100" ."h-full" {
|
||||
(TripTableRow::build(trip.id, &trip.name))
|
||||
(TripTableRow::build(trip.id, trip.date_start.to_string()))
|
||||
(TripTableRow::build(trip.id, trip.date_end.to_string()))
|
||||
@@ -118,7 +123,7 @@ impl NewTrip {
|
||||
action="/trips/"
|
||||
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" ."trips-center" {
|
||||
span ."mdi" ."mdi-playlist-plus" ."text-2xl" ."mr-4" {}
|
||||
@@ -210,83 +215,110 @@ impl NewTrip {
|
||||
pub struct Trip;
|
||||
|
||||
impl Trip {
|
||||
pub fn build(state: &ClientState, trip: &models::Trip) -> Result<Markup, Error> {
|
||||
Ok(html!(
|
||||
pub fn build(
|
||||
trip: &models::Trip,
|
||||
trip_edit_attribute: Option<TripAttribute>,
|
||||
active_category: Option<&TripCategory>,
|
||||
) -> Markup {
|
||||
html!(
|
||||
div ."p-8" ."flex" ."flex-col" ."gap-8" {
|
||||
div ."flex" ."flex-row" ."items-center" ."gap-x-3" {
|
||||
@if state.trip_edit_attribute.as_ref().map_or(false, |a| *a == TripAttribute::Name) {
|
||||
form
|
||||
id="edit-trip"
|
||||
action=(format!("edit/{}/submit", to_variant_name(&TripAttribute::Name).unwrap()))
|
||||
target="_self"
|
||||
method="post"
|
||||
div
|
||||
."flex"
|
||||
."flex-row"
|
||||
."items-stretch"
|
||||
."gap-x-5"
|
||||
{
|
||||
a
|
||||
href="/trips/"
|
||||
."text-sm"
|
||||
."text-gray-500"
|
||||
."flex"
|
||||
{
|
||||
div
|
||||
."m-auto"
|
||||
{
|
||||
div
|
||||
."flex"
|
||||
."flex-row"
|
||||
."items-center"
|
||||
."gap-x-3"
|
||||
."items-stretch"
|
||||
span
|
||||
."mdi"
|
||||
."mdi-arrow-left"
|
||||
{}
|
||||
"back"
|
||||
}
|
||||
}
|
||||
div ."flex" ."flex-row" ."items-center" ."gap-x-3" {
|
||||
@if trip_edit_attribute.as_ref().map_or(false, |a| *a == TripAttribute::Name) {
|
||||
form
|
||||
id="edit-trip"
|
||||
action=(format!("edit/{}/submit", to_variant_name(&TripAttribute::Name).unwrap()))
|
||||
target="_self"
|
||||
method="post"
|
||||
{
|
||||
input
|
||||
."bg-blue-200"
|
||||
."w-full"
|
||||
."text-2xl"
|
||||
."font-semibold"
|
||||
type=(<InputType as Into<&'static str>>::into(InputType::Text))
|
||||
name="new-value"
|
||||
form="edit-trip"
|
||||
value=(trip.name)
|
||||
{}
|
||||
a
|
||||
href="."
|
||||
."bg-red-200"
|
||||
."hover:bg-red-300"
|
||||
."w-8"
|
||||
div
|
||||
."flex"
|
||||
."flex-row"
|
||||
."items-center"
|
||||
."gap-x-3"
|
||||
."items-stretch"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."mdi-cancel"
|
||||
."text-xl"
|
||||
."m-auto"
|
||||
{}
|
||||
}
|
||||
button
|
||||
type="submit"
|
||||
form="edit-trip"
|
||||
."bg-green-200"
|
||||
."hover:bg-green-300"
|
||||
."w-8"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."mdi-content-save"
|
||||
."text-xl"
|
||||
input
|
||||
."bg-blue-200"
|
||||
."w-full"
|
||||
."text-2xl"
|
||||
."font-semibold"
|
||||
type=(<InputType as Into<&'static str>>::into(InputType::Text))
|
||||
name="new-value"
|
||||
form="edit-trip"
|
||||
value=(trip.name)
|
||||
{}
|
||||
a
|
||||
href="."
|
||||
."bg-red-200"
|
||||
."hover:bg-red-300"
|
||||
."w-8"
|
||||
."flex"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."mdi-cancel"
|
||||
."text-xl"
|
||||
."m-auto"
|
||||
{}
|
||||
}
|
||||
button
|
||||
type="submit"
|
||||
form="edit-trip"
|
||||
."bg-green-200"
|
||||
."hover:bg-green-300"
|
||||
."w-8"
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."mdi-content-save"
|
||||
."text-xl"
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
h1 ."text-2xl" ."font-semibold"{ (trip.name) }
|
||||
span {
|
||||
a href=(format!("?edit={}", to_variant_name(&TripAttribute::Name).unwrap()))
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."mdi-pencil"
|
||||
."text-xl"
|
||||
."opacity-50"
|
||||
{}
|
||||
} @else {
|
||||
h1 ."text-2xl" { (trip.name) }
|
||||
span {
|
||||
a href=(format!("?edit={}", to_variant_name(&TripAttribute::Name).unwrap()))
|
||||
{
|
||||
span
|
||||
."mdi"
|
||||
."mdi-pencil"
|
||||
."text-xl"
|
||||
."opacity-50"
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(TripInfo::build(state, trip))
|
||||
(TripInfo::build(trip_edit_attribute, trip))
|
||||
(TripComment::build(trip))
|
||||
(TripItems::build(state, trip)?)
|
||||
(TripItems::build(active_category, trip))
|
||||
}
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +331,6 @@ 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!(
|
||||
@@ -327,58 +358,66 @@ impl TripInfoRow {
|
||||
}
|
||||
}
|
||||
td
|
||||
."border-none"
|
||||
."bg-red-100"
|
||||
."hover:bg-red-200"
|
||||
."border"
|
||||
."border-solid"
|
||||
."border-gray-300"
|
||||
."p-0"
|
||||
."h-full"
|
||||
."w-8"
|
||||
{
|
||||
a
|
||||
."aspect-square"
|
||||
div
|
||||
."flex"
|
||||
."w-full"
|
||||
."flex-row"
|
||||
."items-stretch"
|
||||
."h-full"
|
||||
."p-0"
|
||||
href="." // strips query parameters
|
||||
{
|
||||
span
|
||||
."m-auto"
|
||||
."mdi"
|
||||
."mdi-cancel"
|
||||
."text-xl"
|
||||
{}
|
||||
}
|
||||
}
|
||||
td
|
||||
."border-none"
|
||||
."bg-green-100"
|
||||
."hover:bg-green-200"
|
||||
."p-0"
|
||||
."h-full"
|
||||
."w-8"
|
||||
{
|
||||
button
|
||||
."aspect-square"
|
||||
."flex"
|
||||
."w-full"
|
||||
."h-full"
|
||||
type="submit"
|
||||
form="edit-trip"
|
||||
{
|
||||
span
|
||||
."m-auto"
|
||||
."mdi"
|
||||
."mdi-content-save"
|
||||
."text-xl"
|
||||
{}
|
||||
div
|
||||
."bg-red-100"
|
||||
."hover:bg-red-200"
|
||||
."w-8"
|
||||
."h-full"
|
||||
{
|
||||
a
|
||||
."flex"
|
||||
."w-full"
|
||||
."h-full"
|
||||
."p-0"
|
||||
href="." // strips query parameters
|
||||
{
|
||||
span
|
||||
."m-auto"
|
||||
."mdi"
|
||||
."mdi-cancel"
|
||||
."text-xl"
|
||||
{}
|
||||
}
|
||||
}
|
||||
div
|
||||
."bg-green-100"
|
||||
."hover:bg-green-200"
|
||||
."w-8"
|
||||
."h-full"
|
||||
{
|
||||
button
|
||||
."flex"
|
||||
."w-full"
|
||||
."h-full"
|
||||
type="submit"
|
||||
form="edit-trip"
|
||||
{
|
||||
span
|
||||
."m-auto"
|
||||
."mdi"
|
||||
."mdi-content-save"
|
||||
."text-xl"
|
||||
{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
td ."border" ."p-2" { (name) }
|
||||
td ."border" ."p-2" { (value.map_or(String::new(), |v| v.to_string())) }
|
||||
td
|
||||
colspan=(if has_two_columns {"2"} else {"1"})
|
||||
."border-none"
|
||||
."bg-blue-100"
|
||||
."hover:bg-blue-200"
|
||||
@@ -406,79 +445,64 @@ impl TripInfoRow {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TripInfo;
|
||||
pub struct TripInfoTotalWeightRow;
|
||||
|
||||
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());
|
||||
impl TripInfoTotalWeightRow {
|
||||
pub fn build(trip_id: Uuid, value: i64) -> Markup {
|
||||
html!(
|
||||
table
|
||||
."table"
|
||||
."table-auto"
|
||||
."border-collapse"
|
||||
."border-spacing-0"
|
||||
."border"
|
||||
."w-full"
|
||||
span
|
||||
hx-trigger={
|
||||
(HtmxEvents::TripItemEdited.to_str()) " from:body"
|
||||
}
|
||||
hx-get={"/trips/" (trip_id) "/total_weight"}
|
||||
{
|
||||
tbody {
|
||||
(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" { "State" }
|
||||
td ."border" {
|
||||
span .flex .flex-row .items-center .justify-start ."gap-2" {
|
||||
span ."mdi" .(trip_state_icon(&trip.state)) ."text-2xl" ."pl-2" {}
|
||||
span ."pr-2" ."py-2" { (trip.state) }
|
||||
}
|
||||
}
|
||||
@let prev_state = trip.state.prev();
|
||||
@let next_state = trip.state.next();
|
||||
(value)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TripInfoStateRow;
|
||||
|
||||
impl TripInfoStateRow {
|
||||
pub fn build(trip_state: &models::TripState) -> Markup {
|
||||
let prev_state = trip_state.prev();
|
||||
let next_state = trip_state.next();
|
||||
html!(
|
||||
tr .h-full {
|
||||
td ."border" ."p-2" { "State" }
|
||||
td ."border" {
|
||||
span .flex .flex-row .items-center .justify-start ."gap-2" {
|
||||
span ."mdi" .(trip_state_icon(&trip_state)) ."text-2xl" ."pl-2" {}
|
||||
span ."pr-2" ."py-2" { (trip_state) }
|
||||
}
|
||||
}
|
||||
|
||||
td
|
||||
."border-none"
|
||||
."p-0"
|
||||
."w-8"
|
||||
."h-full"
|
||||
{
|
||||
div
|
||||
."h-full"
|
||||
."flex"
|
||||
."flex-row"
|
||||
."items-stretch"
|
||||
."justify-stretch"
|
||||
{
|
||||
@if let Some(ref prev_state) = prev_state {
|
||||
td
|
||||
colspan=(if next_state.is_none() && has_two_columns { "2" } else { "1" })
|
||||
."border-none"
|
||||
div
|
||||
."w-8"
|
||||
."grow"
|
||||
."h-full"
|
||||
."bg-yellow-100"
|
||||
."hover:bg-yellow-200"
|
||||
."p-0"
|
||||
."w-8"
|
||||
."h-full"
|
||||
{
|
||||
form
|
||||
hx-post={"./state/" (prev_state)}
|
||||
hx-target="closest tr"
|
||||
hx-swap="outerHTML"
|
||||
action={"./state/" (prev_state)}
|
||||
method="post"
|
||||
."flex"
|
||||
@@ -501,17 +525,18 @@ impl TripInfo {
|
||||
}
|
||||
}
|
||||
}
|
||||
@if let Some(ref next_state) = trip.state.next() {
|
||||
td
|
||||
colspan=(if prev_state.is_none() && has_two_columns { "2" } else { "1" })
|
||||
."border-none"
|
||||
@if let Some(ref next_state) = next_state {
|
||||
div
|
||||
."w-8"
|
||||
."grow"
|
||||
."h-full"
|
||||
."bg-green-100"
|
||||
."hover:bg-green-200"
|
||||
."p-0"
|
||||
."w-8"
|
||||
."h-full"
|
||||
{
|
||||
form
|
||||
hx-post={"./state/" (next_state)}
|
||||
hx-target="closest tr"
|
||||
hx-swap="outerHTML"
|
||||
action={"./state/" (next_state)}
|
||||
method="post"
|
||||
."flex"
|
||||
@@ -535,9 +560,63 @@ impl TripInfo {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TripInfo;
|
||||
|
||||
impl TripInfo {
|
||||
pub fn build(trip_edit_attribute: Option<TripAttribute>, trip: &models::Trip) -> Markup {
|
||||
html!(
|
||||
table
|
||||
."table"
|
||||
."table-auto"
|
||||
."border-collapse"
|
||||
."border-spacing-0"
|
||||
."border"
|
||||
."w-full"
|
||||
{
|
||||
tbody {
|
||||
(TripInfoRow::build("Location",
|
||||
trip.location.as_ref(),
|
||||
&TripAttribute::Location,
|
||||
trip_edit_attribute.as_ref(),
|
||||
InputType::Text,
|
||||
))
|
||||
(TripInfoRow::build("Start date",
|
||||
Some(trip.date_start),
|
||||
&TripAttribute::DateStart,
|
||||
trip_edit_attribute.as_ref(),
|
||||
InputType::Date,
|
||||
))
|
||||
(TripInfoRow::build("End date",
|
||||
Some(trip.date_end),
|
||||
&TripAttribute::DateEnd,
|
||||
trip_edit_attribute.as_ref(),
|
||||
InputType::Date,
|
||||
))
|
||||
(TripInfoRow::build("Temp (min)",
|
||||
trip.temp_min,
|
||||
&TripAttribute::TempMin,
|
||||
trip_edit_attribute.as_ref(),
|
||||
InputType::Number,
|
||||
))
|
||||
(TripInfoRow::build("Temp (max)",
|
||||
trip.temp_max,
|
||||
&TripAttribute::TempMax,
|
||||
trip_edit_attribute.as_ref(),
|
||||
InputType::Number,
|
||||
))
|
||||
(TripInfoStateRow::build(&trip.state))
|
||||
tr .h-full {
|
||||
td ."border" ."p-2" { "Types" }
|
||||
td ."border" {
|
||||
td
|
||||
colspan="2"
|
||||
."border"
|
||||
{
|
||||
div
|
||||
."flex"
|
||||
."flex-row"
|
||||
@@ -636,9 +715,12 @@ impl TripInfo {
|
||||
}
|
||||
tr .h-full {
|
||||
td ."border" ."p-2" { "Carried weight" }
|
||||
td ."border" ."p-2"
|
||||
td
|
||||
colspan="2"
|
||||
."border"
|
||||
."p-2"
|
||||
{
|
||||
(trip.total_picked_weight())
|
||||
(TripInfoTotalWeightRow::build(trip.id, trip.total_picked_weight()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -704,38 +786,24 @@ impl TripComment {
|
||||
pub struct TripItems;
|
||||
|
||||
impl TripItems {
|
||||
pub fn build(state: &ClientState, trip: &models::Trip) -> Result<Markup, Error> {
|
||||
Ok(html!(
|
||||
div ."grid" ."grid-cols-4" ."gap-3" {
|
||||
pub fn build(active_category: Option<&TripCategory>, trip: &models::Trip) -> Markup {
|
||||
html!(
|
||||
div #trip-items ."grid" ."grid-cols-4" ."gap-3" {
|
||||
div ."col-span-2" {
|
||||
(TripCategoryList::build(state, trip))
|
||||
(TripCategoryList::build(active_category, trip))
|
||||
}
|
||||
div ."col-span-2" {
|
||||
h1 ."text-2xl" ."mb-5" ."text-center" { "Items" }
|
||||
@if let Some(active_category_id) = state.active_category_id {
|
||||
@if let Some(active_category) = active_category {
|
||||
(TripItemList::build(
|
||||
state,
|
||||
trip,
|
||||
trip
|
||||
.categories()
|
||||
.iter()
|
||||
.find(|category|
|
||||
category.category.id == active_category_id
|
||||
)
|
||||
.ok_or(
|
||||
Error::NotFound{
|
||||
description: format!("no category with id {active_category_id}")
|
||||
}
|
||||
)?
|
||||
.items
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
trip.id,
|
||||
active_category.items.as_ref().unwrap()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,6 +811,7 @@ pub struct TripCategoryListRow;
|
||||
|
||||
impl TripCategoryListRow {
|
||||
pub fn build(
|
||||
trip_id: Uuid,
|
||||
category: &TripCategory,
|
||||
active: bool,
|
||||
biggest_category_weight: i64,
|
||||
@@ -754,19 +823,17 @@ impl TripCategoryListRow {
|
||||
id={"category-" (category.category.id)}
|
||||
hx-swap-oob=[htmx_swap.then_some("outerHTML")]
|
||||
."h-10"
|
||||
."hover:bg-purple-100"
|
||||
."hover:bg-gray-100"
|
||||
."m-3"
|
||||
."h-full"
|
||||
."outline"[active]
|
||||
."outline-2"[active]
|
||||
."outline-indigo-300"[active]
|
||||
{
|
||||
|
||||
td
|
||||
|
||||
."border"
|
||||
."m-0"
|
||||
|
||||
{
|
||||
div
|
||||
."p-0"
|
||||
@@ -783,6 +850,13 @@ impl TripCategoryListRow {
|
||||
id=category.category.id
|
||||
)
|
||||
)
|
||||
hx-post={
|
||||
"/trips/" (trip_id)
|
||||
"/categories/" (category.category.id)
|
||||
"/select"
|
||||
}
|
||||
hx-target="#trip-items"
|
||||
hx-swap="outerHTML"
|
||||
."inline-block"
|
||||
."p-2"
|
||||
."m-0"
|
||||
@@ -833,7 +907,8 @@ impl TripCategoryListRow {
|
||||
* 100.0
|
||||
)
|
||||
)
|
||||
) {}
|
||||
)
|
||||
{}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -843,7 +918,7 @@ impl TripCategoryListRow {
|
||||
pub struct TripCategoryList;
|
||||
|
||||
impl TripCategoryList {
|
||||
pub fn build(state: &ClientState, trip: &models::Trip) -> Markup {
|
||||
pub fn build(active_category: Option<&TripCategory>, trip: &models::Trip) -> Markup {
|
||||
let categories = trip.categories();
|
||||
|
||||
let biggest_category_weight: i64 = categories
|
||||
@@ -874,10 +949,10 @@ impl TripCategoryList {
|
||||
}
|
||||
tbody {
|
||||
@for category in trip.categories() {
|
||||
@let active = state.active_category_id.map_or(false, |id| category.category.id == id);
|
||||
(TripCategoryListRow::build(category, active, biggest_category_weight,false))
|
||||
@let active = active_category.map_or(false, |c| category.category.id == c.category.id);
|
||||
(TripCategoryListRow::build(trip.id, category, active, biggest_category_weight, false))
|
||||
}
|
||||
tr ."h-10" ."hover:bg-purple-200" ."bg-gray-300" ."font-bold" {
|
||||
tr ."h-10" ."bg-gray-300" ."font-bold" {
|
||||
td ."border" ."p-0" ."m-0" {
|
||||
p ."p-2" ."m-2" { "Sum" }
|
||||
}
|
||||
@@ -896,22 +971,13 @@ impl TripCategoryList {
|
||||
pub struct TripItemList;
|
||||
|
||||
impl TripItemList {
|
||||
pub fn build(state: &ClientState, trip: &models::Trip, items: &Vec<TripItem>) -> Markup {
|
||||
pub fn build(trip_id: Uuid, items: &Vec<TripItem>) -> Markup {
|
||||
let biggest_item_weight: i64 = items.iter().map(|item| item.item.weight).max().unwrap_or(1);
|
||||
|
||||
html!(
|
||||
@if items.is_empty() {
|
||||
p ."text-lg" ."text-center" ."py-5" ."text-gray-400" { "[Empty]" }
|
||||
} @else {
|
||||
@if let Some(edit_item) = state.edit_item {
|
||||
form
|
||||
name="edit-item"
|
||||
id="edit-item"
|
||||
action=(format!("/inventory/item/{edit_item}/edit"))
|
||||
target="_self"
|
||||
method="post"
|
||||
{}
|
||||
}
|
||||
table
|
||||
."table"
|
||||
."table-auto"
|
||||
@@ -931,7 +997,7 @@ impl TripItemList {
|
||||
}
|
||||
tbody {
|
||||
@for item in items {
|
||||
(TripItemListRow::build(trip.id, item, biggest_item_weight))
|
||||
(TripItemListRow::build(trip_id, item, biggest_item_weight))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
439
rust/src/main.rs
439
rust/src/main.rs
@@ -3,8 +3,12 @@ use axum::{
|
||||
extract::{Path, Query, State},
|
||||
headers,
|
||||
headers::Header,
|
||||
http::{header, header::HeaderMap, StatusCode},
|
||||
response::{Html, Redirect},
|
||||
http::{
|
||||
header,
|
||||
header::{HeaderMap, HeaderName, HeaderValue},
|
||||
StatusCode,
|
||||
},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::{get, post},
|
||||
Form, Router,
|
||||
};
|
||||
@@ -50,7 +54,9 @@ use clap::Parser;
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
#[arg(long)]
|
||||
port: Option<u16>,
|
||||
database_url: String,
|
||||
#[arg(long, default_value_t = 3000)]
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -78,19 +84,60 @@ impl Default for ClientState {
|
||||
}
|
||||
}
|
||||
|
||||
enum HtmxEvents {
|
||||
TripItemEdited,
|
||||
}
|
||||
|
||||
impl Into<HeaderValue> for HtmxEvents {
|
||||
fn into(self) -> HeaderValue {
|
||||
HeaderValue::from_static(self.to_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl HtmxEvents {
|
||||
fn to_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::TripItemEdited => "TripItemEdited",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum HtmxResponseHeaders {
|
||||
Trigger,
|
||||
}
|
||||
|
||||
impl Into<HeaderName> for HtmxResponseHeaders {
|
||||
fn into(self) -> HeaderName {
|
||||
match self {
|
||||
Self::Trigger => HeaderName::from_static("hx-trigger"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum HtmxRequestHeaders {
|
||||
HtmxRequest,
|
||||
}
|
||||
|
||||
impl Into<HeaderName> for HtmxRequestHeaders {
|
||||
fn into(self) -> HeaderName {
|
||||
match self {
|
||||
Self::HtmxRequest => HeaderName::from_static("hx-request"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), sqlx::Error> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
let database_pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(
|
||||
SqliteConnectOptions::from_str(
|
||||
&std::env::var("DATABASE_URL").expect("env DATABASE_URL not found"),
|
||||
)?
|
||||
.pragma("foreign_keys", "1"),
|
||||
SqliteConnectOptions::from_str(&args.database_url)?.pragma("foreign_keys", "1"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -118,12 +165,14 @@ async fn main() -> Result<(), sqlx::Error> {
|
||||
"/trips/",
|
||||
Router::new()
|
||||
.route("/", get(trips))
|
||||
.route("/trips/types/", get(trips_types).post(trip_type_create))
|
||||
.route("/types/", get(trips_types).post(trip_type_create))
|
||||
.route("/types/:id/edit/name/submit", post(trips_types_edit_name))
|
||||
.route("/", post(trip_create))
|
||||
.route("/:id/", get(trip))
|
||||
.route("/:id/comment/submit", post(trip_comment_set))
|
||||
.route("/:id/categories/:id/select", post(trip_category_select))
|
||||
.route("/:id/state/:id", post(trip_state_set))
|
||||
.route("/:id/total_weight", get(trip_total_weight_htmx))
|
||||
.route("/:id/type/:id/add", get(trip_type_add))
|
||||
.route("/:id/type/:id/remove", get(trip_type_remove))
|
||||
.route("/:id/edit/:attribute/submit", post(trip_edit_attribute))
|
||||
@@ -163,9 +212,7 @@ async fn main() -> Result<(), sqlx::Error> {
|
||||
.fallback(|| async { (StatusCode::NOT_FOUND, "not found") })
|
||||
.with_state(state);
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], args.port.unwrap_or(3000)));
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], args.port));
|
||||
tracing::debug!("listening on {}", addr);
|
||||
axum::Server::bind(&addr)
|
||||
.serve(app.into_make_service())
|
||||
@@ -193,7 +240,43 @@ async fn inventory_active(
|
||||
Query(inventory_query): Query<InventoryQuery>,
|
||||
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
|
||||
state.client_state.edit_item = inventory_query.edit_item;
|
||||
inventory(state, Some(id)).await
|
||||
state.client_state.active_category_id = Some(id);
|
||||
|
||||
let inventory = models::Inventory::load(&state.database_pool)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorPage::build(&error.to_string()),
|
||||
)
|
||||
})?;
|
||||
|
||||
let active_category: Option<&Category> = state
|
||||
.client_state
|
||||
.active_category_id
|
||||
.map(|id| {
|
||||
inventory
|
||||
.categories
|
||||
.iter()
|
||||
.find(|category| category.id == id)
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
ErrorPage::build(&format!("a category with id {id} does not exist")),
|
||||
))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Root::build(
|
||||
&components::Inventory::build(
|
||||
active_category,
|
||||
&inventory.categories,
|
||||
state.client_state.edit_item,
|
||||
),
|
||||
&TopLevelPage::Inventory,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
async fn inventory_inactive(
|
||||
@@ -201,71 +284,53 @@ async fn inventory_inactive(
|
||||
Query(inventory_query): Query<InventoryQuery>,
|
||||
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
|
||||
state.client_state.edit_item = inventory_query.edit_item;
|
||||
inventory(state, None).await
|
||||
}
|
||||
state.client_state.active_category_id = None;
|
||||
|
||||
async fn inventory(
|
||||
mut state: AppState,
|
||||
active_id: Option<Uuid>,
|
||||
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
|
||||
state.client_state.active_category_id = active_id;
|
||||
|
||||
let mut categories = query_as!(
|
||||
DbCategoryRow,
|
||||
"SELECT id,name,description FROM inventory_items_categories"
|
||||
)
|
||||
.fetch(&state.database_pool)
|
||||
.map_ok(|row: DbCategoryRow| row.try_into())
|
||||
.try_collect::<Vec<Result<Category, models::Error>>>()
|
||||
.await
|
||||
// we have two error handling lines here. these are distinct errors
|
||||
// this one is the SQL error that may arise during the query
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorPage::build(&e.to_string()),
|
||||
)
|
||||
})?
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<Category>, models::Error>>()
|
||||
// and this one is the model mapping error that may arise e.g. during
|
||||
// reading of the rows
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorPage::build(&e.to_string()),
|
||||
)
|
||||
})?;
|
||||
|
||||
for category in &mut categories {
|
||||
category
|
||||
.populate_items(&state.database_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorPage::build(&e.to_string()),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let inventory = models::Inventory::load(&state.database_pool)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorPage::build(&error.to_string()),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Root::build(
|
||||
&Inventory::build(state.client_state, categories).map_err(|e| match e {
|
||||
Error::NotFound { description } => {
|
||||
(StatusCode::NOT_FOUND, ErrorPage::build(&description))
|
||||
}
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorPage::build(&e.to_string()),
|
||||
),
|
||||
})?,
|
||||
&components::Inventory::build(
|
||||
None,
|
||||
&inventory.categories,
|
||||
state.client_state.edit_item,
|
||||
),
|
||||
&TopLevelPage::Inventory,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
// async fn inventory(
|
||||
// mut state: AppState,
|
||||
// active_id: Option<Uuid>,
|
||||
// ) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
|
||||
// state.client_state.active_category_id = active_id;
|
||||
|
||||
// Ok((
|
||||
// StatusCode::OK,
|
||||
// Root::build(
|
||||
// &components::Inventory::build(state.client_state, categories).map_err(|e| match e {
|
||||
// Error::NotFound { description } => {
|
||||
// (StatusCode::NOT_FOUND, ErrorPage::build(&description))
|
||||
// }
|
||||
// _ => (
|
||||
// StatusCode::INTERNAL_SERVER_ERROR,
|
||||
// ErrorPage::build(&e.to_string()),
|
||||
// ),
|
||||
// })?,
|
||||
// &TopLevelPage::Inventory,
|
||||
// ),
|
||||
// ))
|
||||
// }
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NewItem {
|
||||
#[serde(rename = "new-item-name")]
|
||||
@@ -325,12 +390,13 @@ async fn inventory_item_validate_name(
|
||||
|
||||
async fn inventory_item_create(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(new_item): Form<NewItem>,
|
||||
) -> Result<Redirect, (StatusCode, String)> {
|
||||
) -> Result<impl IntoResponse, (StatusCode, Markup)> {
|
||||
if new_item.name.is_empty() {
|
||||
return Err((
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"name cannot be empty".to_string(),
|
||||
ErrorPage::build("name cannot be empty"),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -360,42 +426,90 @@ async fn inventory_item_create(
|
||||
// SQLITE_CONSTRAINT_FOREIGNKEY
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("category {id} not found", id = new_item.category_id),
|
||||
ErrorPage::build(&format!(
|
||||
"category {id} not found",
|
||||
id = new_item.category_id
|
||||
)),
|
||||
)
|
||||
}
|
||||
"2067" => {
|
||||
// SQLITE_CONSTRAINT_UNIQUE
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!(
|
||||
ErrorPage::build(&format!(
|
||||
"item with name \"{name}\" already exists in category {id}",
|
||||
name = new_item.name,
|
||||
id = new_item.category_id
|
||||
),
|
||||
)),
|
||||
)
|
||||
}
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("got error with unknown code: {}", sqlite_error.to_string()),
|
||||
ErrorPage::build(&format!(
|
||||
"got error with unknown code: {}",
|
||||
sqlite_error.to_string()
|
||||
)),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("got error without code: {}", sqlite_error.to_string()),
|
||||
ErrorPage::build(&format!(
|
||||
"got error without code: {}",
|
||||
sqlite_error.to_string()
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("got unknown error: {}", e.to_string()),
|
||||
ErrorPage::build(&format!("got unknown error: {}", e.to_string())),
|
||||
),
|
||||
})?;
|
||||
|
||||
Ok(Redirect::to(&format!(
|
||||
"/inventory/category/{id}/",
|
||||
id = new_item.category_id
|
||||
)))
|
||||
if is_htmx(&headers) {
|
||||
let inventory = models::Inventory::load(&state.database_pool)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorPage::build(&error.to_string()),
|
||||
)
|
||||
})?;
|
||||
|
||||
// it's impossible to NOT find the item here, as we literally just added
|
||||
// it. but good error handling never hurts
|
||||
let active_category: Option<&Category> = state
|
||||
.client_state
|
||||
.active_category_id
|
||||
.map(|id| {
|
||||
inventory
|
||||
.categories
|
||||
.iter()
|
||||
.find(|category| category.id == id)
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
ErrorPage::build(&format!("a category with id {id} was inserted but does not exist, this is a bug")),
|
||||
))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
components::Inventory::build(
|
||||
active_category,
|
||||
&inventory.categories,
|
||||
state.client_state.edit_item,
|
||||
),
|
||||
)
|
||||
.into_response())
|
||||
} else {
|
||||
Ok(Redirect::to(&format!(
|
||||
"/inventory/category/{id}/",
|
||||
id = new_item.category_id
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
async fn inventory_item_delete(
|
||||
@@ -778,18 +892,28 @@ async fn trip(
|
||||
)
|
||||
})?;
|
||||
|
||||
let active_category: Option<&TripCategory> = state
|
||||
.client_state
|
||||
.active_category_id
|
||||
.map(|id| {
|
||||
trip.categories()
|
||||
.iter()
|
||||
.find(|category| category.category.id == id)
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
ErrorPage::build(&format!("an active category with id {id} does not exist")),
|
||||
))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Root::build(
|
||||
&components::Trip::build(&state.client_state, &trip).map_err(|e| match e {
|
||||
Error::NotFound { description } => {
|
||||
(StatusCode::NOT_FOUND, ErrorPage::build(&description))
|
||||
}
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorPage::build(&e.to_string()),
|
||||
),
|
||||
})?,
|
||||
&components::Trip::build(
|
||||
&trip,
|
||||
state.client_state.trip_edit_attribute,
|
||||
active_category,
|
||||
),
|
||||
&TopLevelPage::Trips,
|
||||
),
|
||||
))
|
||||
@@ -1049,7 +1173,9 @@ async fn trip_row(
|
||||
)
|
||||
})?;
|
||||
|
||||
let category_row = components::trip::TripCategoryListRow::build(&category, true, 0, true);
|
||||
// TODO biggest_category_weight?
|
||||
let category_row =
|
||||
components::trip::TripCategoryListRow::build(trip_id, &category, true, 0, true);
|
||||
|
||||
Ok(html!((item_row)(category_row)))
|
||||
}
|
||||
@@ -1083,9 +1209,18 @@ async fn trip_item_set_pick(
|
||||
async fn trip_item_set_pick_htmx(
|
||||
State(state): State<AppState>,
|
||||
Path((trip_id, item_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
|
||||
) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> {
|
||||
trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pick, true).await?;
|
||||
Ok((StatusCode::OK, trip_row(&state, trip_id, item_id).await?))
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert::<HeaderName>(
|
||||
HtmxResponseHeaders::Trigger.into(),
|
||||
HtmxEvents::TripItemEdited.into(),
|
||||
);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
trip_row(&state, trip_id, item_id).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn trip_item_set_unpick(
|
||||
@@ -1117,9 +1252,18 @@ async fn trip_item_set_unpick(
|
||||
async fn trip_item_set_unpick_htmx(
|
||||
State(state): State<AppState>,
|
||||
Path((trip_id, item_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
|
||||
) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> {
|
||||
trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pick, false).await?;
|
||||
Ok((StatusCode::OK, trip_row(&state, trip_id, item_id).await?))
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert::<HeaderName>(
|
||||
HtmxResponseHeaders::Trigger.into(),
|
||||
HtmxEvents::TripItemEdited.into(),
|
||||
);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
trip_row(&state, trip_id, item_id).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn trip_item_set_pack(
|
||||
@@ -1151,9 +1295,18 @@ async fn trip_item_set_pack(
|
||||
async fn trip_item_set_pack_htmx(
|
||||
State(state): State<AppState>,
|
||||
Path((trip_id, item_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
|
||||
) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> {
|
||||
trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pack, true).await?;
|
||||
Ok((StatusCode::OK, trip_row(&state, trip_id, item_id).await?))
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert::<HeaderName>(
|
||||
HtmxResponseHeaders::Trigger.into(),
|
||||
HtmxEvents::TripItemEdited.into(),
|
||||
);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
trip_row(&state, trip_id, item_id).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn trip_item_set_unpack(
|
||||
@@ -1185,9 +1338,40 @@ async fn trip_item_set_unpack(
|
||||
async fn trip_item_set_unpack_htmx(
|
||||
State(state): State<AppState>,
|
||||
Path((trip_id, item_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
|
||||
) -> Result<(StatusCode, HeaderMap, Markup), (StatusCode, Markup)> {
|
||||
trip_item_set_state(&state, trip_id, item_id, TripItemStateKey::Pack, false).await?;
|
||||
Ok((StatusCode::OK, trip_row(&state, trip_id, item_id).await?))
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert::<HeaderName>(
|
||||
HtmxResponseHeaders::Trigger.into(),
|
||||
HtmxEvents::TripItemEdited.into(),
|
||||
);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
headers,
|
||||
trip_row(&state, trip_id, item_id).await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn trip_total_weight_htmx(
|
||||
State(state): State<AppState>,
|
||||
Path(trip_id): Path<Uuid>,
|
||||
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
|
||||
let total_weight = models::Trip::find_total_picked_weight(&state.database_pool, trip_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
ErrorPage::build(&error.to_string()),
|
||||
)
|
||||
})?
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
ErrorPage::build(&format!("trip with id {trip_id} not found")),
|
||||
))?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
components::trip::TripInfoTotalWeightRow::build(trip_id, total_weight),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -1263,8 +1447,9 @@ async fn inventory_category_create(
|
||||
|
||||
async fn trip_state_set(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path((trip_id, new_state)): Path<(Uuid, TripState)>,
|
||||
) -> Result<Redirect, (StatusCode, Markup)> {
|
||||
) -> Result<impl IntoResponse, (StatusCode, Markup)> {
|
||||
let trip_id = trip_id.to_string();
|
||||
let result = query!(
|
||||
"UPDATE trips
|
||||
@@ -1283,10 +1468,25 @@ async fn trip_state_set(
|
||||
ErrorPage::build(&format!("trip with id {id} not found", id = trip_id)),
|
||||
))
|
||||
} else {
|
||||
Ok(Redirect::to(&format!("/trips/{id}/", id = trip_id)))
|
||||
if is_htmx(&headers) {
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
components::trip::TripInfoStateRow::build(&new_state),
|
||||
)
|
||||
.into_response())
|
||||
} else {
|
||||
Ok(Redirect::to(&format!("/trips/{id}/", id = trip_id)).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_htmx(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get::<HeaderName>(HtmxRequestHeaders::HtmxRequest.into())
|
||||
.map(|value| value == "true")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TripTypeQuery {
|
||||
edit: Option<Uuid>,
|
||||
@@ -1500,3 +1700,44 @@ async fn inventory_item(
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
async fn trip_category_select(
|
||||
State(state): State<AppState>,
|
||||
Path((trip_id, category_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<(StatusCode, Markup), (StatusCode, Markup)> {
|
||||
let mut trip = models::Trip::find(&state.database_pool, trip_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorPage::build(&e.to_string()),
|
||||
)
|
||||
})?
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
ErrorPage::build(&format!("trip with id {trip_id} not found")),
|
||||
))?;
|
||||
|
||||
trip.load_categories(&state.database_pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorPage::build(&e.to_string()),
|
||||
)
|
||||
})?;
|
||||
|
||||
let active_category = trip
|
||||
.categories()
|
||||
.iter()
|
||||
.find(|c| c.category.id == category_id)
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
ErrorPage::build(&format!("category with id {category_id} not found")),
|
||||
))?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
components::trip::TripItems::build(Some(&active_category), &trip),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ pub enum Error {
|
||||
Sql { description: String },
|
||||
Uuid { description: String },
|
||||
Enum { description: String },
|
||||
NotFound { description: String },
|
||||
Int { description: String },
|
||||
TimeParse { description: String },
|
||||
}
|
||||
@@ -39,9 +38,6 @@ impl fmt::Display for Error {
|
||||
Self::Uuid { description } => {
|
||||
write!(f, "UUID error: {description}")
|
||||
}
|
||||
Self::NotFound { description } => {
|
||||
write!(f, "Not found: {description}")
|
||||
}
|
||||
Self::Int { description } => {
|
||||
write!(f, "Integer error: {description}")
|
||||
}
|
||||
@@ -132,14 +128,6 @@ impl TripState {
|
||||
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 {
|
||||
@@ -467,7 +455,79 @@ pub enum TripAttribute {
|
||||
TempMax,
|
||||
}
|
||||
|
||||
pub struct DbTripWeightRow {
|
||||
pub total_weight: Option<i32>,
|
||||
}
|
||||
|
||||
impl<'a> Trip {
|
||||
pub async fn find(
|
||||
pool: &sqlx::Pool<sqlx::Sqlite>,
|
||||
trip_id: Uuid,
|
||||
) -> Result<Option<Self>, Error> {
|
||||
let trip_id_param = trip_id.to_string();
|
||||
let trip = sqlx::query_as!(
|
||||
DbTripRow,
|
||||
"SELECT
|
||||
id,
|
||||
name,
|
||||
CAST (date_start AS TEXT) date_start,
|
||||
CAST (date_end AS TEXT) date_end,
|
||||
state,
|
||||
location,
|
||||
temp_min,
|
||||
temp_max,
|
||||
comment
|
||||
FROM trips
|
||||
WHERE id = ?",
|
||||
trip_id_param
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.map_ok(|row| row.try_into())
|
||||
.await;
|
||||
|
||||
match trip {
|
||||
Err(e) => match e {
|
||||
sqlx::Error::RowNotFound => Ok(None),
|
||||
_ => Err(e.into()),
|
||||
},
|
||||
Ok(v) => Ok(Some(v?)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn find_total_picked_weight(
|
||||
pool: &sqlx::Pool<sqlx::Sqlite>,
|
||||
trip_id: Uuid,
|
||||
) -> Result<Option<i64>, Error> {
|
||||
let trip_id_param = trip_id.to_string();
|
||||
let weight = sqlx::query_as!(
|
||||
DbTripWeightRow,
|
||||
"
|
||||
SELECT
|
||||
CAST(IFNULL(SUM(i_item.weight), 0) AS INTEGER) AS total_weight
|
||||
FROM trips AS trip
|
||||
INNER JOIN trips_items AS t_item
|
||||
ON t_item.trip_id = trip.id
|
||||
INNER JOIN inventory_items AS i_item
|
||||
ON t_item.item_id = i_item.id
|
||||
WHERE
|
||||
trip.id = ?
|
||||
AND t_item.pick = true
|
||||
",
|
||||
trip_id_param
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.map_ok(|row| row.total_weight.map(|weight| weight as i64))
|
||||
.await;
|
||||
|
||||
match weight {
|
||||
Err(e) => match e {
|
||||
sqlx::Error::RowNotFound => Ok(None),
|
||||
_ => Err(e.into()),
|
||||
},
|
||||
Ok(v) => Ok(v),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn types(&'a self) -> &Vec<TripType> {
|
||||
self.types
|
||||
.as_ref()
|
||||
@@ -479,9 +539,7 @@ impl<'a> Trip {
|
||||
.as_ref()
|
||||
.expect("you need to call load_trips_types()")
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Trip {
|
||||
pub fn total_picked_weight(&self) -> i64 {
|
||||
self.categories()
|
||||
.iter()
|
||||
@@ -1067,3 +1125,38 @@ impl TryFrom<DbInventoryItemRow> for InventoryItem {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Inventory {
|
||||
pub categories: Vec<Category>,
|
||||
}
|
||||
|
||||
impl Inventory {
|
||||
pub async fn load(pool: &sqlx::Pool<sqlx::Sqlite>) -> Result<Self, Error> {
|
||||
let mut categories = sqlx::query_as!(
|
||||
DbCategoryRow,
|
||||
"SELECT id,name,description FROM inventory_items_categories"
|
||||
)
|
||||
.fetch(pool)
|
||||
.map_ok(|row: DbCategoryRow| row.try_into())
|
||||
.try_collect::<Vec<Result<Category, Error>>>()
|
||||
.await
|
||||
// we have two error handling lines here. these are distinct errors
|
||||
// this one is the SQL error that may arise during the query
|
||||
.map_err(|e| Error::Sql {
|
||||
description: e.to_string(),
|
||||
})?
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<Category>, Error>>()
|
||||
// and this one is the model mapping error that may arise e.g. during
|
||||
// reading of the rows
|
||||
.map_err(|e| Error::Sql {
|
||||
description: e.to_string(),
|
||||
})?;
|
||||
|
||||
for category in &mut categories {
|
||||
category.populate_items(pool).await?;
|
||||
}
|
||||
|
||||
Ok(Self { categories })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user