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; +}