Trip: packing list

This commit is contained in:
itskovacs 2025-08-16 16:20:47 +02:00
parent d8cb828963
commit 3cfd33ce94
10 changed files with 476 additions and 2 deletions

View File

@ -0,0 +1,45 @@
"""Trip Packing list
Revision ID: 1181ac441ce5
Revises: 77027ac49c26
Create Date: 2025-08-16 11:35:34.870999
"""
import sqlalchemy as sa
import sqlmodel.sql.sqltypes
from alembic import op
# revision identifiers, used by Alembic.
revision = "1181ac441ce5"
down_revision = "77027ac49c26"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"trippackinglistitem",
sa.Column("text", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("qt", sa.Integer(), nullable=True),
sa.Column(
"category",
sa.Enum("CLOTHES", "TOILETRIES", "TECH", "DOCUMENTS", "OTHER", name="packinglistcategoryenum"),
nullable=True,
),
sa.Column("packed", sa.Boolean(), nullable=True),
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("trip_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["trip_id"], ["trip.id"], name=op.f("fk_trippackinglistitem_trip_id_trip"), ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["user"], ["user.username"], name=op.f("fk_trippackinglistitem_user_user"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_trippackinglistitem")),
)
def downgrade():
op.drop_table("trippackinglistitem")

View File

@ -39,6 +39,14 @@ class TripItemStatusEnum(str, Enum):
OPTIONAL = "optional" OPTIONAL = "optional"
class PackingListCategoryEnum(str, Enum):
CLOTHES = "clothes"
TOILETRIES = "toiletries"
TECH = "tech"
DOCUMENTS = "documents"
OTHER = "other"
class TripShareURL(BaseModel): class TripShareURL(BaseModel):
url: str url: str
@ -251,6 +259,7 @@ class Trip(TripBase, table=True):
places: list["Place"] = Relationship(back_populates="trips", link_model=TripPlaceLink) places: list["Place"] = Relationship(back_populates="trips", link_model=TripPlaceLink)
days: list["TripDay"] = Relationship(back_populates="trip", cascade_delete=True) days: list["TripDay"] = Relationship(back_populates="trip", cascade_delete=True)
shares: list["TripShare"] = Relationship(back_populates="trip", cascade_delete=True) shares: list["TripShare"] = Relationship(back_populates="trip", cascade_delete=True)
packing_items: list["TripPackingListItem"] = Relationship(back_populates="trip", cascade_delete=True)
class TripCreate(TripBase): class TripCreate(TripBase):
@ -401,3 +410,39 @@ class TripShare(SQLModel, table=True):
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE") trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
trip: Trip | None = Relationship(back_populates="shares") trip: Trip | None = Relationship(back_populates="shares")
class TripPackingListItemBase(SQLModel):
text: str | None = None
qt: int | None = None
category: PackingListCategoryEnum | None = None
packed: bool | None = None
class TripPackingListItem(TripPackingListItemBase, table=True):
id: int | None = Field(default=None, primary_key=True)
user: str = Field(foreign_key="user.username", ondelete="CASCADE")
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
trip: Trip | None = Relationship(back_populates="packing_items")
class TripPackingListItemCreate(TripPackingListItemBase):
packed: bool = False
class TripPackingListItemUpdate(TripPackingListItemBase): ...
class TripPackingListItemRead(TripPackingListItemBase):
id: int
@classmethod
def serialize(cls, obj: "TripPackingListItem") -> "TripPackingListItemRead":
return cls(
id=obj.id,
text=obj.text,
qt=obj.qt,
category=obj.category,
packed=obj.packed,
)

View File

@ -8,8 +8,11 @@ from ..deps import SessionDep, get_current_username
from ..models.models import (Image, Place, Trip, TripCreate, TripDay, from ..models.models import (Image, Place, Trip, TripCreate, TripDay,
TripDayBase, TripDayRead, TripItem, TripDayBase, TripDayRead, TripItem,
TripItemCreate, TripItemRead, TripItemUpdate, TripItemCreate, TripItemRead, TripItemUpdate,
TripPlaceLink, TripRead, TripReadBase, TripShare, TripPackingListItem, TripPackingListItemCreate,
TripShareURL, TripUpdate) TripPackingListItemRead,
TripPackingListItemUpdate, TripPlaceLink,
TripRead, TripReadBase, TripShare, TripShareURL,
TripUpdate)
from ..security import verify_exists_and_owns from ..security import verify_exists_and_owns
from ..utils.utils import (b64img_decode, generate_urlsafe, remove_image, from ..utils.utils import (b64img_decode, generate_urlsafe, remove_image,
save_image_to_file) save_image_to_file)
@ -407,3 +410,88 @@ def delete_shared_trip(
session.delete(db_share) session.delete(db_share)
session.commit() session.commit()
return {} return {}
@router.get("/{trip_id}/packing", response_model=list[TripPackingListItemRead])
def read_packing_list(
session: SessionDep,
trip_id: int,
current_user: Annotated[str, Depends(get_current_username)],
) -> list[TripPackingListItemRead]:
p_items = session.exec(
select(TripPackingListItem)
.where(TripPackingListItem.trip_id == trip_id, TripPackingListItem.user == current_user)
.order_by(TripPackingListItem.id.asc())
).all()
return [TripPackingListItemRead.serialize(i) for i in p_items]
@router.post("/{trip_id}/packing", response_model=TripPackingListItemRead)
def create_packing_item(
session: SessionDep,
trip_id: int,
data: TripPackingListItemCreate,
current_user: Annotated[str, Depends(get_current_username)],
) -> TripPackingListItemRead:
item = TripPackingListItem(
**data.model_dump(),
trip_id=trip_id,
user=current_user,
)
session.add(item)
session.commit()
session.refresh(item)
return TripPackingListItemRead.serialize(item)
@router.put("/{trip_id}/packing/{p_id}", response_model=TripPackingListItemRead)
def update_packing_item(
session: SessionDep,
p_item: TripPackingListItemUpdate,
trip_id: int,
p_id: int,
current_user: Annotated[str, Depends(get_current_username)],
) -> TripPackingListItemRead:
db_item = session.exec(
select(TripPackingListItem).where(
TripPackingListItem.id == p_id,
TripPackingListItem.trip_id == trip_id,
TripPackingListItem.user == current_user,
)
).one_or_none()
if not db_item:
raise HTTPException(status_code=404, detail="Not found")
item_data = p_item.model_dump(exclude_unset=True)
for key, value in item_data.items():
setattr(db_item, key, value)
session.add(db_item)
session.commit()
session.refresh(db_item)
return TripPackingListItemRead.serialize(db_item)
@router.delete("/{trip_id}/packing/{p_id}")
def delete_packing_item(
session: SessionDep,
trip_id: int,
p_id: int,
current_user: Annotated[str, Depends(get_current_username)],
):
item = session.exec(
select(TripPackingListItem).where(
TripPackingListItem.id == p_id,
TripPackingListItem.trip_id == trip_id,
TripPackingListItem.user == current_user,
)
).one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Not found")
session.delete(item)
session.commit()
return {}

View File

@ -21,6 +21,8 @@
<p-button pTooltip="Delete Trip" text (click)="deleteTrip()" icon="pi pi-trash" severity="danger" /> <p-button pTooltip="Delete Trip" text (click)="deleteTrip()" icon="pi pi-trash" severity="danger" />
<p-button pTooltip="Edit Trip" text (click)="editTrip()" icon="pi pi-pencil" /> <p-button pTooltip="Edit Trip" text (click)="editTrip()" icon="pi pi-pencil" />
<div class="border-l border-solid border-gray-700 h-4"></div> <div class="border-l border-solid border-gray-700 h-4"></div>
<p-button pTooltip="Packing list" tooltipPosition="left" text (click)="openPackingList()" icon="pi pi-briefcase"
severity="help" />
</div> </div>
<div class="flex md:hidden"> <div class="flex md:hidden">
@ -551,3 +553,38 @@
</ng-container> </ng-container>
} }
</p-dialog> </p-dialog>
<p-dialog header="Packing list" [draggable]="false" [dismissableMask]="true" [modal]="true"
[(visible)]="packingDialogVisible" styleClass="w-[95%] md:w-[70%] lg:w-[50%]">
<section class="md:max-w-3/4 md:mx-auto max-h-[80%] md:max-h-[600px]">
<div class="flex justify-center">
<p-button (click)="addPackingItem()" icon="pi pi-plus" label="Add item" text />
</div>
<div class="grid gap-2 mt-4 pb-4">
@for (c of dispPackingList | keyvalue; track c.key) {
<div class="mt-4 text-md font-semibold capitalize">{{ c.key }}</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@for (item of c.value; track item.id) {
<div class="relative group flex items-center gap-3 rounded-md p-2 hover:bg-gray-100 dark:hover:bg-white/5">
<label [for]="item.id" [class.line-through]="item.packed"
class="flex items-center gap-2 w-full cursor-pointer">
<p-checkbox (onChange)="onCheckPackingItem($event, item.id)" [binary]="true" [inputId]="item.id.toString()"
[(ngModel)]="item.packed" />
<div class="pr-6 md:pr-0 truncate select-none flex-1">
@if (item.qt) {<span class="text-gray-400 mr-0.5">{{ item.qt }}</span>}
<span>{{ item.text }}</span>
</div>
</label>
<div
class="md:opacity-0 absolute right-0 top-1/2 -translate-y-1/2 md:group-hover:opacity-100 bg-white md:bg-gray-100 rounded">
<p-button size="small" text icon="pi pi-trash" (click)="deletePackingItem(item)" severity="danger" />
</div>
</div>
}
</div>
}
</div>
</section>
</p-dialog>

View File

@ -14,6 +14,7 @@ import {
TripDay, TripDay,
TripItem, TripItem,
TripStatus, TripStatus,
PackingItem,
} from "../../types/trip"; } from "../../types/trip";
import { Place } from "../../types/poi"; import { Place } from "../../types/poi";
import { import {
@ -52,6 +53,8 @@ import { ClipboardModule } from "@angular/cdk/clipboard";
import { TooltipModule } from "primeng/tooltip"; import { TooltipModule } from "primeng/tooltip";
import { MultiSelectModule } from "primeng/multiselect"; import { MultiSelectModule } from "primeng/multiselect";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { CheckboxChangeEvent, CheckboxModule } from "primeng/checkbox";
import { TripCreatePackingModalComponent } from "../../modals/trip-create-packing-modal/trip-create-packing-modal.component";
@Component({ @Component({
selector: "app-trip", selector: "app-trip",
@ -73,6 +76,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
TooltipModule, TooltipModule,
ClipboardModule, ClipboardModule,
MultiSelectModule, MultiSelectModule,
CheckboxModule,
], ],
templateUrl: "./trip.component.html", templateUrl: "./trip.component.html",
styleUrls: ["./trip.component.scss"], styleUrls: ["./trip.component.scss"],
@ -93,8 +97,11 @@ export class TripComponent implements AfterViewInit {
collapsedTripPlaces = false; collapsedTripPlaces = false;
collapsedTripStatuses = false; collapsedTripStatuses = false;
shareDialogVisible = false; shareDialogVisible = false;
packingDialogVisible = false;
isExpanded = false; isExpanded = false;
isFilteringMode = false; isFilteringMode = false;
packingList: PackingItem[] = [];
dispPackingList: Record<string, PackingItem[]> = {};
map?: L.Map; map?: L.Map;
markerClusterGroup?: L.MarkerClusterGroup; markerClusterGroup?: L.MarkerClusterGroup;
@ -107,6 +114,14 @@ export class TripComponent implements AfterViewInit {
{ {
label: "Actions", label: "Actions",
items: [ items: [
{
label: "Packing",
icon: "pi pi-briefcase",
iconClass: "text-purple-500!",
command: () => {
this.openPackingList();
},
},
{ {
label: "Edit", label: "Edit",
icon: "pi pi-pencil", icon: "pi pi-pencil",
@ -1315,4 +1330,117 @@ export class TripComponent implements AfterViewInit {
}, },
}); });
} }
openPackingList() {
if (!this.trip) return;
if (!this.packingList.length)
this.apiService
.getPackingList(this.trip.id)
.pipe(take(1))
.subscribe({
next: (items) => {
this.packingList = [...items];
this.computeDispPackingList();
},
});
this.packingDialogVisible = true;
}
addPackingItem() {
if (!this.trip) return;
const modal: DynamicDialogRef = this.dialogService.open(
TripCreatePackingModalComponent,
{
header: "Create Packing",
modal: true,
appendTo: "body",
closable: true,
dismissableMask: true,
width: "40vw",
breakpoints: {
"1260px": "70vw",
"600px": "90vw",
},
},
);
modal.onClose.pipe(take(1)).subscribe({
next: (item: PackingItem | null) => {
if (!item) return;
this.apiService
.postPackingItem(this.trip!.id, item)
.pipe(take(1))
.subscribe({
next: (item) => {
this.packingList.push(item);
this.computeDispPackingList();
},
});
},
});
}
onCheckPackingItem(e: CheckboxChangeEvent, id: number) {
if (!this.trip) return;
this.apiService
.putPackingItem(this.trip.id, id, { packed: e.checked })
.pipe(take(1))
.subscribe({
next: (item) => {
const i = this.packingList.find((p) => p.id == item.id);
if (i) i.packed = item.packed;
this.computeDispPackingList();
},
});
}
deletePackingItem(item: PackingItem) {
const modal = this.dialogService.open(YesNoModalComponent, {
header: "Confirm deletion",
modal: true,
closable: true,
dismissableMask: true,
breakpoints: {
"640px": "90vw",
},
data: `Delete ${item.text.substring(0, 50)} ?`,
});
modal.onClose.pipe(take(1)).subscribe({
next: (bool) => {
if (!bool) return;
this.apiService
.deletePackingItem(this.trip!.id, item.id)
.pipe(take(1))
.subscribe({
next: () => {
const index = this.packingList.findIndex((p) => p.id == item.id);
if (index > -1) this.packingList.splice(index, 1);
this.computeDispPackingList();
},
});
},
});
}
computeDispPackingList() {
const sorted: PackingItem[] = [...this.packingList].sort((a, b) =>
a.packed !== b.packed
? a.packed
? 1
: -1
: a.text.localeCompare(b.text),
);
this.dispPackingList = sorted.reduce<Record<string, PackingItem[]>>(
(acc, item) => {
(acc[item.category] ??= []).push(item);
return acc;
},
{},
);
}
} }

View File

@ -0,0 +1,23 @@
<div pFocusTrap class="grid items-center gap-4" [formGroup]="packingForm">
<p-floatlabel variant="in">
<p-inputnumber id="qt" formControlName="qt" mode="decimal" [maxFractionDigits]="0" min="0" fluid />
<label for="qt">Qt.</label>
</p-floatlabel>
<p-floatlabel variant="in">
<input class="col-span-2" id="text" formControlName="text" (keypress.enter)="closeDialog()" pInputText fluid />
<label for="text">Text</label>
</p-floatlabel>
<p-floatlabel variant="in">
<p-select [options]="packingCategories" id="category" appendTo="body" formControlName="category"
optionLabel="dispValue" optionValue="value" placeholder="Select Category" fluid />
<label for="category">Category</label>
</p-floatlabel>
<div class="mt-4 text-right">
<p-button (click)="closeDialog()" [disabled]="!packingForm.dirty || !packingForm.valid">{{
packingForm.get("id")?.value
!== -1 ? "Update" : "Create" }}</p-button>
</div>

View File

@ -0,0 +1,66 @@
import { Component } from "@angular/core";
import {
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from "@angular/forms";
import { ButtonModule } from "primeng/button";
import { DynamicDialogConfig, DynamicDialogRef } from "primeng/dynamicdialog";
import { FloatLabelModule } from "primeng/floatlabel";
import { InputTextModule } from "primeng/inputtext";
import { FocusTrapModule } from "primeng/focustrap";
import { SelectModule } from "primeng/select";
import { InputNumberModule } from "primeng/inputnumber";
@Component({
selector: "app-trip-create-packing-modal",
imports: [
FloatLabelModule,
InputTextModule,
ButtonModule,
ReactiveFormsModule,
FocusTrapModule,
SelectModule,
InputNumberModule,
],
standalone: true,
templateUrl: "./trip-create-packing-modal.component.html",
styleUrl: "./trip-create-packing-modal.component.scss",
})
export class TripCreatePackingModalComponent {
packingForm: FormGroup;
readonly packingCategories = [
{ value: "clothes", dispValue: "Clothes" },
{ value: "toiletries", dispValue: "Toiletries" },
{ value: "tech", dispValue: "Tech" },
{ value: "documents", dispValue: "Documents" },
{ value: "other", dispValue: "Other" },
];
constructor(
private ref: DynamicDialogRef,
private fb: FormBuilder,
private config: DynamicDialogConfig,
) {
this.packingForm = this.fb.group({
id: -1,
qt: null,
text: ["", { validators: Validators.required }],
category: ["", { validators: Validators.required }],
});
const patchValue = this.config.data?.packing;
if (patchValue) {
this.packingForm.patchValue(patchValue);
}
}
closeDialog() {
if (!this.packingForm.valid) return;
// Normalize data for API POST
let ret = this.packingForm.value;
this.ref.close(ret);
}
}

View File

@ -5,6 +5,7 @@ import { BehaviorSubject, map, Observable, shareReplay, tap } from "rxjs";
import { Info } from "../types/info"; import { Info } from "../types/info";
import { ImportResponse, Settings } from "../types/settings"; import { ImportResponse, Settings } from "../types/settings";
import { import {
PackingItem,
SharedTripURL, SharedTripURL,
Trip, Trip,
TripBase, TripBase,
@ -232,6 +233,39 @@ export class ApiService {
); );
} }
getPackingList(trip_id: number): Observable<PackingItem[]> {
return this.httpClient.get<PackingItem[]>(
`${this.apiBaseUrl}/trips/${trip_id}/packing`,
);
}
postPackingItem(
trip_id: number,
p_item: PackingItem,
): Observable<PackingItem> {
return this.httpClient.post<PackingItem>(
`${this.apiBaseUrl}/trips/${trip_id}/packing`,
p_item,
);
}
putPackingItem(
trip_id: number,
p_id: number,
p_item: Partial<PackingItem>,
): Observable<PackingItem> {
return this.httpClient.put<PackingItem>(
`${this.apiBaseUrl}/trips/${trip_id}/packing/${p_id}`,
p_item,
);
}
deletePackingItem(trip_id: number, p_id: number): Observable<null> {
return this.httpClient.delete<null>(
`${this.apiBaseUrl}/trips/${trip_id}/packing/${p_id}`,
);
}
checkVersion(): Observable<string> { checkVersion(): Observable<string> {
return this.httpClient.get<string>( return this.httpClient.get<string>(
`${this.apiBaseUrl}/settings/checkversion`, `${this.apiBaseUrl}/settings/checkversion`,

View File

@ -65,3 +65,11 @@ export interface FlattenedTripItem {
export interface SharedTripURL { export interface SharedTripURL {
url: string; url: string;
} }
export interface PackingItem {
id: number;
text: string;
category: string;
qt?: number;
packed?: boolean;
}