diff --git a/backend/trip/alembic/versions/1181ac441ce5_trip_packing_list.py b/backend/trip/alembic/versions/1181ac441ce5_trip_packing_list.py
new file mode 100644
index 0000000..027259b
--- /dev/null
+++ b/backend/trip/alembic/versions/1181ac441ce5_trip_packing_list.py
@@ -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")
diff --git a/backend/trip/models/models.py b/backend/trip/models/models.py
index e53022c..5000e7f 100644
--- a/backend/trip/models/models.py
+++ b/backend/trip/models/models.py
@@ -39,6 +39,14 @@ class TripItemStatusEnum(str, Enum):
OPTIONAL = "optional"
+class PackingListCategoryEnum(str, Enum):
+ CLOTHES = "clothes"
+ TOILETRIES = "toiletries"
+ TECH = "tech"
+ DOCUMENTS = "documents"
+ OTHER = "other"
+
+
class TripShareURL(BaseModel):
url: str
@@ -251,6 +259,7 @@ class Trip(TripBase, table=True):
places: list["Place"] = Relationship(back_populates="trips", link_model=TripPlaceLink)
days: list["TripDay"] = 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):
@@ -401,3 +410,39 @@ class TripShare(SQLModel, table=True):
trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE")
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,
+ )
diff --git a/backend/trip/routers/trips.py b/backend/trip/routers/trips.py
index d612c38..ea13d2a 100644
--- a/backend/trip/routers/trips.py
+++ b/backend/trip/routers/trips.py
@@ -8,8 +8,11 @@ from ..deps import SessionDep, get_current_username
from ..models.models import (Image, Place, Trip, TripCreate, TripDay,
TripDayBase, TripDayRead, TripItem,
TripItemCreate, TripItemRead, TripItemUpdate,
- TripPlaceLink, TripRead, TripReadBase, TripShare,
- TripShareURL, TripUpdate)
+ TripPackingListItem, TripPackingListItemCreate,
+ TripPackingListItemRead,
+ TripPackingListItemUpdate, TripPlaceLink,
+ TripRead, TripReadBase, TripShare, TripShareURL,
+ TripUpdate)
from ..security import verify_exists_and_owns
from ..utils.utils import (b64img_decode, generate_urlsafe, remove_image,
save_image_to_file)
@@ -407,3 +410,88 @@ def delete_shared_trip(
session.delete(db_share)
session.commit()
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 {}
diff --git a/src/src/app/components/trip/trip.component.html b/src/src/app/components/trip/trip.component.html
index 070dfc3..0c7bd11 100644
--- a/src/src/app/components/trip/trip.component.html
+++ b/src/src/app/components/trip/trip.component.html
@@ -21,6 +21,8 @@
+
@@ -551,3 +553,38 @@
}
+
+
+
+
+
+
+ @for (c of dispPackingList | keyvalue; track c.key) {
+
{{ c.key }}
+
+
+ @for (item of c.value; track item.id) {
+
+
+
+
+ }
+
+ }
+
+
+
\ No newline at end of file
diff --git a/src/src/app/components/trip/trip.component.ts b/src/src/app/components/trip/trip.component.ts
index 63de307..f144546 100644
--- a/src/src/app/components/trip/trip.component.ts
+++ b/src/src/app/components/trip/trip.component.ts
@@ -14,6 +14,7 @@ import {
TripDay,
TripItem,
TripStatus,
+ PackingItem,
} from "../../types/trip";
import { Place } from "../../types/poi";
import {
@@ -52,6 +53,8 @@ import { ClipboardModule } from "@angular/cdk/clipboard";
import { TooltipModule } from "primeng/tooltip";
import { MultiSelectModule } from "primeng/multiselect";
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({
selector: "app-trip",
@@ -73,6 +76,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
TooltipModule,
ClipboardModule,
MultiSelectModule,
+ CheckboxModule,
],
templateUrl: "./trip.component.html",
styleUrls: ["./trip.component.scss"],
@@ -93,8 +97,11 @@ export class TripComponent implements AfterViewInit {
collapsedTripPlaces = false;
collapsedTripStatuses = false;
shareDialogVisible = false;
+ packingDialogVisible = false;
isExpanded = false;
isFilteringMode = false;
+ packingList: PackingItem[] = [];
+ dispPackingList: Record
= {};
map?: L.Map;
markerClusterGroup?: L.MarkerClusterGroup;
@@ -107,6 +114,14 @@ export class TripComponent implements AfterViewInit {
{
label: "Actions",
items: [
+ {
+ label: "Packing",
+ icon: "pi pi-briefcase",
+ iconClass: "text-purple-500!",
+ command: () => {
+ this.openPackingList();
+ },
+ },
{
label: "Edit",
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>(
+ (acc, item) => {
+ (acc[item.category] ??= []).push(item);
+ return acc;
+ },
+ {},
+ );
+ }
}
diff --git a/src/src/app/modals/trip-create-packing-modal/trip-create-packing-modal.component.html b/src/src/app/modals/trip-create-packing-modal/trip-create-packing-modal.component.html
new file mode 100644
index 0000000..c3db525
--- /dev/null
+++ b/src/src/app/modals/trip-create-packing-modal/trip-create-packing-modal.component.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{
+ packingForm.get("id")?.value
+ !== -1 ? "Update" : "Create" }}
+
\ No newline at end of file
diff --git a/src/src/app/modals/trip-create-packing-modal/trip-create-packing-modal.component.scss b/src/src/app/modals/trip-create-packing-modal/trip-create-packing-modal.component.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/src/app/modals/trip-create-packing-modal/trip-create-packing-modal.component.ts b/src/src/app/modals/trip-create-packing-modal/trip-create-packing-modal.component.ts
new file mode 100644
index 0000000..a580e80
--- /dev/null
+++ b/src/src/app/modals/trip-create-packing-modal/trip-create-packing-modal.component.ts
@@ -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);
+ }
+}
diff --git a/src/src/app/services/api.service.ts b/src/src/app/services/api.service.ts
index 2a965a7..7c41071 100644
--- a/src/src/app/services/api.service.ts
+++ b/src/src/app/services/api.service.ts
@@ -5,6 +5,7 @@ import { BehaviorSubject, map, Observable, shareReplay, tap } from "rxjs";
import { Info } from "../types/info";
import { ImportResponse, Settings } from "../types/settings";
import {
+ PackingItem,
SharedTripURL,
Trip,
TripBase,
@@ -232,6 +233,39 @@ export class ApiService {
);
}
+ getPackingList(trip_id: number): Observable
{
+ return this.httpClient.get(
+ `${this.apiBaseUrl}/trips/${trip_id}/packing`,
+ );
+ }
+
+ postPackingItem(
+ trip_id: number,
+ p_item: PackingItem,
+ ): Observable {
+ return this.httpClient.post(
+ `${this.apiBaseUrl}/trips/${trip_id}/packing`,
+ p_item,
+ );
+ }
+
+ putPackingItem(
+ trip_id: number,
+ p_id: number,
+ p_item: Partial,
+ ): Observable {
+ return this.httpClient.put(
+ `${this.apiBaseUrl}/trips/${trip_id}/packing/${p_id}`,
+ p_item,
+ );
+ }
+
+ deletePackingItem(trip_id: number, p_id: number): Observable {
+ return this.httpClient.delete(
+ `${this.apiBaseUrl}/trips/${trip_id}/packing/${p_id}`,
+ );
+ }
+
checkVersion(): Observable {
return this.httpClient.get(
`${this.apiBaseUrl}/settings/checkversion`,
diff --git a/src/src/app/types/trip.ts b/src/src/app/types/trip.ts
index 539ce77..826f931 100644
--- a/src/src/app/types/trip.ts
+++ b/src/src/app/types/trip.ts
@@ -65,3 +65,11 @@ export interface FlattenedTripItem {
export interface SharedTripURL {
url: string;
}
+
+export interface PackingItem {
+ id: number;
+ text: string;
+ category: string;
+ qt?: number;
+ packed?: boolean;
+}