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