diff --git a/backend/trip/alembic/versions/60a9bb641d8a_trip_checklist.py b/backend/trip/alembic/versions/60a9bb641d8a_trip_checklist.py new file mode 100644 index 0000000..900454f --- /dev/null +++ b/backend/trip/alembic/versions/60a9bb641d8a_trip_checklist.py @@ -0,0 +1,39 @@ +"""Trip Checklist + +Revision ID: 60a9bb641d8a +Revises: 1181ac441ce5 +Create Date: 2025-08-17 21:12:41.336514 + +""" + +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op + +# revision identifiers, used by Alembic. +revision = "60a9bb641d8a" +down_revision = "1181ac441ce5" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "tripchecklistitem", + sa.Column("text", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("checked", 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_tripchecklistitem_trip_id_trip"), ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["user"], ["user.username"], name=op.f("fk_tripchecklistitem_user_user"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_tripchecklistitem")), + ) + + +def downgrade(): + op.drop_table("tripchecklistitem") diff --git a/backend/trip/models/models.py b/backend/trip/models/models.py index 5000e7f..3ebdbb5 100644 --- a/backend/trip/models/models.py +++ b/backend/trip/models/models.py @@ -260,6 +260,7 @@ class Trip(TripBase, table=True): 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) + checklist_items: list["TripChecklistItem"] = Relationship(back_populates="trip", cascade_delete=True) class TripCreate(TripBase): @@ -297,7 +298,6 @@ class TripRead(TripBase): image_id: int | None days: list["TripDayRead"] places: list["PlaceRead"] - shared: bool @classmethod def serialize(cls, obj: Trip) -> "TripRead": @@ -309,7 +309,6 @@ class TripRead(TripBase): image_id=obj.image_id, days=[TripDayRead.serialize(day) for day in obj.days], places=[PlaceRead.serialize(place) for place in obj.places], - shared=bool(obj.shares), ) @@ -446,3 +445,34 @@ class TripPackingListItemRead(TripPackingListItemBase): category=obj.category, packed=obj.packed, ) + + +class TripChecklistItemBase(SQLModel): + text: str | None = None + checked: bool | None = None + + +class TripChecklistItem(TripChecklistItemBase, table=True): + id: int | None = Field(default=None, primary_key=True) + + trip_id: int = Field(foreign_key="trip.id", ondelete="CASCADE") + trip: Trip | None = Relationship(back_populates="checklist_items") + + +class TripChecklistItemCreate(TripChecklistItemBase): + checked: bool = False + + +class TripChecklistItemUpdate(TripChecklistItemBase): ... + + +class TripChecklistItemRead(TripChecklistItemBase): + id: int + + @classmethod + def serialize(cls, obj: "TripChecklistItem") -> "TripChecklistItemRead": + return cls( + id=obj.id, + text=obj.text, + checked=obj.checked, + ) diff --git a/backend/trip/routers/trips.py b/backend/trip/routers/trips.py index ea13d2a..2194cfe 100644 --- a/backend/trip/routers/trips.py +++ b/backend/trip/routers/trips.py @@ -5,14 +5,15 @@ from sqlmodel import select from ..config import settings from ..deps import SessionDep, get_current_username -from ..models.models import (Image, Place, Trip, TripCreate, TripDay, +from ..models.models import (Image, Place, Trip, TripChecklistItem, + TripChecklistItemCreate, TripChecklistItemRead, + TripChecklistItemUpdate, TripCreate, TripDay, TripDayBase, TripDayRead, TripItem, TripItemCreate, TripItemRead, TripItemUpdate, TripPackingListItem, TripPackingListItemCreate, - TripPackingListItemRead, - TripPackingListItemUpdate, TripPlaceLink, - TripRead, TripReadBase, TripShare, TripShareURL, - TripUpdate) + TripPackingListItemRead, TripPackingListItemUpdate, + 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) @@ -495,3 +496,91 @@ def delete_packing_item( session.delete(item) session.commit() return {} + + +@router.get("/{trip_id}/checklist", response_model=list[TripChecklistItemRead]) +def read_checklist( + session: SessionDep, + trip_id: int, + current_user: Annotated[str, Depends(get_current_username)], +) -> list[TripChecklistItemRead]: + _verify_trip_member(session, trip_id, current_user) + items = session.exec(select(TripChecklistItem).where(TripChecklistItem.trip_id == trip_id)) + return [TripChecklistItemRead.serialize(i) for i in items] + + +@router.get("/shared/{token}/checklist", response_model=list[TripChecklistItemRead]) +def read_shared_trip_checklist( + session: SessionDep, + token: str, +) -> list[TripChecklistItemRead]: + items = session.exec( + select(TripChecklistItem).where( + TripChecklistItem.trip_id == _trip_from_token_or_404(session, token).trip_id + ) + ) + return [TripChecklistItemRead.serialize(i) for i in items] + + +@router.post("/{trip_id}/checklist", response_model=TripChecklistItemRead) +def create_checklist_item( + session: SessionDep, + trip_id: int, + data: TripChecklistItemCreate, + current_user: Annotated[str, Depends(get_current_username)], +) -> TripChecklistItemRead: + _verify_trip_member(session, trip_id, current_user) + item = TripChecklistItem(**data.model_dump(), trip_id=trip_id) + session.add(item) + session.commit() + session.refresh(item) + return TripChecklistItemRead.serialize(item) + + +@router.put("/{trip_id}/checklist/{id}", response_model=TripChecklistItemRead) +def update_checklist_item( + session: SessionDep, + item: TripChecklistItemUpdate, + trip_id: int, + id: int, + current_user: Annotated[str, Depends(get_current_username)], +) -> TripChecklistItemRead: + _verify_trip_member(session, trip_id, current_user) + db_item = session.exec( + select(TripChecklistItem).where(TripChecklistItem.id == id, TripChecklistItem.trip_id == trip_id) + ).one_or_none() + + if not db_item: + raise HTTPException(status_code=404, detail="Not found") + + item_data = 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 TripChecklistItemRead.serialize(db_item) + + +@router.delete("/{trip_id}/checklist/{id}") +def delete_checklist_item( + session: SessionDep, + trip_id: int, + id: int, + current_user: Annotated[str, Depends(get_current_username)], +): + _verify_trip_member(session, trip_id, current_user) + item = session.exec( + select(TripChecklistItem).where( + TripChecklistItem.id == id, + TripChecklistItem.trip_id == trip_id, + ) + ).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 40c0501..74b4468 100644 --- a/src/src/app/components/trip/trip.component.html +++ b/src/src/app/components/trip/trip.component.html @@ -21,6 +21,7 @@
+ @@ -121,7 +122,7 @@ {{ tripitem.td_label }} + (click)="toggleTripDayHighlight(tripitem.day_id)" /> @@ -174,7 +175,7 @@ @if (tripTableSelectedColumns.includes('day') && rowgroup) { + (click)="toggleTripDayHighlight(tripitem.day_id); $event.stopPropagation()">
{{tripitem.td_label }}
} @@ -476,44 +477,6 @@ } - -
-
-

Watchlist

- {{ trip?.name }} pending/constraints - - -
- - @if (!collapsedTripStatuses) { -
- @defer { - @for (item of getWatchlistData; track item.id) { -
-
- {{ - item.status.label }} -
-
{{ item.text }}
-
- } @empty { -

- Nothing there -

- } - } @placeholder (minimum 0.4s) { -
- -
- } -
- } -
} @@ -589,4 +552,49 @@ } + + + +
+
+ +
+ +
+ @for (item of checklistItems; track item.id) { +
+ +
+ +
+
+ } +
+ +
+ @for (item of getWatchlistData; 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 c613394..f8574e6 100644 --- a/src/src/app/components/trip/trip.component.ts +++ b/src/src/app/components/trip/trip.component.ts @@ -15,6 +15,7 @@ import { TripItem, TripStatus, PackingItem, + ChecklistItem, } from "../../types/trip"; import { Place } from "../../types/poi"; import { @@ -55,6 +56,7 @@ 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"; +import { TripCreateChecklistModalComponent } from "../../modals/trip-create-checklist-modal/trip-create-checklist-modal.component"; @Component({ selector: "app-trip", @@ -95,13 +97,15 @@ export class TripComponent implements AfterViewInit { totalPrice = 0; collapsedTripDays = false; collapsedTripPlaces = false; - collapsedTripStatuses = false; shareDialogVisible = false; packingDialogVisible = false; isExpanded = false; isFilteringMode = false; packingList: PackingItem[] = []; dispPackingList: Record = {}; + checklistDialogVisible = false; + checklistItems: ChecklistItem[] = []; + dispchecklist: ChecklistItem[] = []; map?: L.Map; markerClusterGroup?: L.MarkerClusterGroup; @@ -122,6 +126,14 @@ export class TripComponent implements AfterViewInit { this.openPackingList(); }, }, + { + label: "Checklist", + icon: "pi pi-check-square", + iconClass: "text-purple-500!", + command: () => { + this.openChecklist(); + }, + }, { label: "Edit", icon: "pi pi-pencil", @@ -666,7 +678,7 @@ export class TripComponent implements AfterViewInit { this.tripMapAntLayerDayID = -1; //Hardcoded value for global trace } - toggleTripDayHighlightPathDay(day_id: number) { + toggleTripDayHighlight(day_id: number) { // Click on the currently displayed day: remove if (this.tripMapAntLayerDayID == day_id) { this.resetDayHighlight(); @@ -1447,4 +1459,97 @@ export class TripComponent implements AfterViewInit { {}, ); } + + openChecklist() { + if (!this.trip) return; + + if (!this.checklistItems.length) + this.apiService + .getChecklist(this.trip.id) + .pipe(take(1)) + .subscribe({ + next: (items) => { + this.checklistItems = [...items]; + }, + }); + this.checklistDialogVisible = true; + } + + addChecklistItem() { + if (!this.trip) return; + + const modal: DynamicDialogRef = this.dialogService.open( + TripCreateChecklistModalComponent, + { + header: "Create item", + modal: true, + appendTo: "body", + closable: true, + dismissableMask: true, + width: "40vw", + breakpoints: { + "1260px": "70vw", + "600px": "90vw", + }, + }, + ); + + modal.onClose.pipe(take(1)).subscribe({ + next: (item: ChecklistItem | null) => { + if (!item) return; + + this.apiService + .postChecklistItem(this.trip!.id, item) + .pipe(take(1)) + .subscribe({ + next: (item) => { + this.checklistItems = [...this.checklistItems, item]; + }, + }); + }, + }); + } + + onCheckChecklistItem(e: CheckboxChangeEvent, id: number) { + if (!this.trip) return; + this.apiService + .putChecklistItem(this.trip.id, id, { checked: e.checked }) + .pipe(take(1)) + .subscribe({ + next: (item) => { + const i = this.checklistItems.find((p) => p.id == item.id); + if (i) i.checked = item.checked; + }, + }); + } + + deleteChecklistItem(item: ChecklistItem) { + 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 + .deleteChecklistItem(this.trip!.id, item.id) + .pipe(take(1)) + .subscribe({ + next: () => { + const index = this.checklistItems.findIndex( + (p) => p.id == item.id, + ); + if (index > -1) this.checklistItems.splice(index, 1); + }, + }); + }, + }); + } } diff --git a/src/src/app/modals/trip-create-checklist-modal/trip-create-checklist-modal.component.html b/src/src/app/modals/trip-create-checklist-modal/trip-create-checklist-modal.component.html new file mode 100644 index 0000000..fc9cf8e --- /dev/null +++ b/src/src/app/modals/trip-create-checklist-modal/trip-create-checklist-modal.component.html @@ -0,0 +1,11 @@ +
+ + + + + +
+ {{ + checklistForm.get("id")?.value + !== -1 ? "Update" : "Create" }} +
\ No newline at end of file diff --git a/src/src/app/modals/trip-create-checklist-modal/trip-create-checklist-modal.component.scss b/src/src/app/modals/trip-create-checklist-modal/trip-create-checklist-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/src/app/modals/trip-create-checklist-modal/trip-create-checklist-modal.component.ts b/src/src/app/modals/trip-create-checklist-modal/trip-create-checklist-modal.component.ts new file mode 100644 index 0000000..787f465 --- /dev/null +++ b/src/src/app/modals/trip-create-checklist-modal/trip-create-checklist-modal.component.ts @@ -0,0 +1,52 @@ +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"; + +@Component({ + selector: "app-trip-create-checklist-modal", + imports: [ + FloatLabelModule, + InputTextModule, + ButtonModule, + ReactiveFormsModule, + FocusTrapModule, + ], + standalone: true, + templateUrl: "./trip-create-checklist-modal.component.html", + styleUrl: "./trip-create-checklist-modal.component.scss", +}) +export class TripCreateChecklistModalComponent { + checklistForm: FormGroup; + constructor( + private ref: DynamicDialogRef, + private fb: FormBuilder, + private config: DynamicDialogConfig, + ) { + this.checklistForm = this.fb.group({ + id: -1, + text: ["", { validators: Validators.required }], + }); + + const patchValue = this.config.data?.packing; + if (patchValue) { + this.checklistForm.patchValue(patchValue); + } + } + + closeDialog() { + if (!this.checklistForm.valid) return; + + // Normalize data for API POST + let ret = this.checklistForm.value; + this.ref.close(ret); + } +} diff --git a/src/src/app/services/api.service.ts b/src/src/app/services/api.service.ts index d1cb4e8..4ecf23a 100644 --- a/src/src/app/services/api.service.ts +++ b/src/src/app/services/api.service.ts @@ -1,10 +1,11 @@ import { inject, Injectable } from "@angular/core"; -import { HttpClient, HttpHeaders } from "@angular/common/http"; +import { HttpClient } from "@angular/common/http"; import { Category, Place } from "../types/poi"; import { BehaviorSubject, map, Observable, shareReplay, tap } from "rxjs"; import { Info } from "../types/info"; import { ImportResponse, Settings } from "../types/settings"; import { + ChecklistItem, PackingItem, SharedTripURL, Trip, @@ -272,6 +273,45 @@ export class ApiService { ); } + getChecklist(trip_id: number): Observable { + return this.httpClient.get( + `${this.apiBaseUrl}/trips/${trip_id}/checklist`, + ); + } + + getSharedTripChecklist(token: string): Observable { + return this.httpClient.get( + `${this.apiBaseUrl}/trips/shared/${token}/checklist`, + ); + } + + postChecklistItem( + trip_id: number, + item: ChecklistItem, + ): Observable { + return this.httpClient.post( + `${this.apiBaseUrl}/trips/${trip_id}/checklist`, + item, + ); + } + + putChecklistItem( + trip_id: number, + id: number, + item: Partial, + ): Observable { + return this.httpClient.put( + `${this.apiBaseUrl}/trips/${trip_id}/checklist/${id}`, + item, + ); + } + + deleteChecklistItem(trip_id: number, id: number): Observable { + return this.httpClient.delete( + `${this.apiBaseUrl}/trips/${trip_id}/checklist/${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 826f931..9c297c2 100644 --- a/src/src/app/types/trip.ts +++ b/src/src/app/types/trip.ts @@ -16,11 +16,11 @@ export interface Trip { archived?: boolean; user: string; days: TripDay[]; - shared?: boolean; // POST / PUT places: Place[]; place_ids: number[]; + shared?: boolean; } export interface TripDay { @@ -73,3 +73,9 @@ export interface PackingItem { qt?: number; packed?: boolean; } + +export interface ChecklistItem { + id: number; + text: string; + checked?: boolean; +} \ No newline at end of file