Trip: checklist dialog

This commit is contained in:
itskovacs 2025-08-19 19:47:29 +02:00
parent b4936c5b53
commit 63c9b751eb
10 changed files with 431 additions and 51 deletions

View File

@ -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")

View File

@ -260,6 +260,7 @@ class Trip(TripBase, table=True):
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) 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): class TripCreate(TripBase):
@ -297,7 +298,6 @@ class TripRead(TripBase):
image_id: int | None image_id: int | None
days: list["TripDayRead"] days: list["TripDayRead"]
places: list["PlaceRead"] places: list["PlaceRead"]
shared: bool
@classmethod @classmethod
def serialize(cls, obj: Trip) -> "TripRead": def serialize(cls, obj: Trip) -> "TripRead":
@ -309,7 +309,6 @@ class TripRead(TripBase):
image_id=obj.image_id, image_id=obj.image_id,
days=[TripDayRead.serialize(day) for day in obj.days], days=[TripDayRead.serialize(day) for day in obj.days],
places=[PlaceRead.serialize(place) for place in obj.places], places=[PlaceRead.serialize(place) for place in obj.places],
shared=bool(obj.shares),
) )
@ -446,3 +445,34 @@ class TripPackingListItemRead(TripPackingListItemBase):
category=obj.category, category=obj.category,
packed=obj.packed, 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,
)

View File

@ -5,14 +5,15 @@ from sqlmodel import select
from ..config import settings from ..config import settings
from ..deps import SessionDep, get_current_username 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, TripDayBase, TripDayRead, TripItem,
TripItemCreate, TripItemRead, TripItemUpdate, TripItemCreate, TripItemRead, TripItemUpdate,
TripPackingListItem, TripPackingListItemCreate, TripPackingListItem, TripPackingListItemCreate,
TripPackingListItemRead, TripPackingListItemRead, TripPackingListItemUpdate,
TripPackingListItemUpdate, TripPlaceLink, TripRead, TripReadBase, TripShare,
TripRead, TripReadBase, TripShare, TripShareURL, TripShareURL, TripUpdate)
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)
@ -495,3 +496,91 @@ def delete_packing_item(
session.delete(item) session.delete(item)
session.commit() session.commit()
return {} 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 {}

View File

@ -21,6 +21,7 @@
<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="Checklist" text (click)="openChecklist()" icon="pi pi-check-square" severity="help" />
<p-button pTooltip="Packing list" tooltipPosition="left" text (click)="openPackingList()" icon="pi pi-briefcase" <p-button pTooltip="Packing list" tooltipPosition="left" text (click)="openPackingList()" icon="pi pi-briefcase"
severity="help" /> severity="help" />
</div> </div>
@ -121,7 +122,7 @@
<span class="font-bold ml-2">{{ tripitem.td_label }}</span> <span class="font-bold ml-2">{{ tripitem.td_label }}</span>
<p-button class="ml-2" text icon="pi pi-directions" <p-button class="ml-2" text icon="pi pi-directions"
[severity]="tripMapAntLayerDayID == tripitem.day_id ? 'help' : 'primary'" [severity]="tripMapAntLayerDayID == tripitem.day_id ? 'help' : 'primary'"
(click)="toggleTripDayHighlightPathDay(tripitem.day_id)" /> (click)="toggleTripDayHighlight(tripitem.day_id)" />
</td> </td>
</tr> </tr>
</ng-template> </ng-template>
@ -174,7 +175,7 @@
@if (tripTableSelectedColumns.includes('day') && rowgroup) { @if (tripTableSelectedColumns.includes('day') && rowgroup) {
<td [attr.rowspan]="rowspan" class="font-normal! max-w-20 truncate cursor-pointer" <td [attr.rowspan]="rowspan" class="font-normal! max-w-20 truncate cursor-pointer"
[class.text-blue-500]="tripMapAntLayerDayID == tripitem.day_id" [class.text-blue-500]="tripMapAntLayerDayID == tripitem.day_id"
(click)="toggleTripDayHighlightPathDay(tripitem.day_id); $event.stopPropagation()"> (click)="toggleTripDayHighlight(tripitem.day_id); $event.stopPropagation()">
<div class="truncate">{{tripitem.td_label }}</div> <div class="truncate">{{tripitem.td_label }}</div>
</td> </td>
} }
@ -476,44 +477,6 @@
</div> </div>
} }
</div> </div>
<div class="p-4 shadow rounded-md w-full min-h-20">
<div class="group relative p-2 mb-2 flex flex-col items-start">
<h1 class="font-semibold tracking-tight text-xl">Watchlist</h1>
<span class="text-xs text-gray-500 line-clamp-1">{{ trip?.name }} pending/constraints</span>
<div
class="bg-white rounded py-2 absolute top-1/2 -translate-y-1/2 left-0 hidden group-hover:block slide-x dark:bg-surface-900">
<p-button [icon]="collapsedTripStatuses ? 'pi pi-chevron-down' : 'pi pi-chevron-up'" text
(click)="collapsedTripStatuses = !collapsedTripStatuses" />
</div>
</div>
@if (!collapsedTripStatuses) {
<div [class.max-h-40!]="!isExpanded" class="max-h-[340px] overflow-y-auto">
@defer {
@for (item of getWatchlistData; track item.id) {
<div class="flex items-center gap-2 h-10 px-4 py-2 w-full max-w-full">
<div class="flex flex-none">
<span [style.background]="item.status.color+'1A'" [style.color]="item.status.color"
class="text-xs font-medium me-2 px-2.5 py-0.5 rounded">{{
item.status.label }}</span>
</div>
<div class="line-clamp-1">{{ item.text }}</div>
</div>
} @empty {
<p class="p-4 font-light text-gray-500">
Nothing there
</p>
}
} @placeholder (minimum 0.4s) {
<div class="h-16">
<p-skeleton height="100%" />
</div>
}
</div>
}
</div>
} }
</div> </div>
</section> </section>
@ -589,4 +552,49 @@
} }
</div> </div>
</section> </section>
</p-dialog>
<p-dialog header="Checklist" [draggable]="false" [dismissableMask]="true" [modal]="true"
[(visible)]="checklistDialogVisible" styleClass="w-[95%] md:w-[50%] lg:w-[30%]">
<section class="p-4 max-w-full max-h-[80%] md:max-h-[600px]">
<div class="flex justify-center">
<p-button (click)="addChecklistItem()" icon="pi pi-plus" label="Add item" text />
</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 mt-4 pb-4">
@for (item of checklistItems; 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.checked"
class="flex items-center gap-2 w-full cursor-pointer">
<p-checkbox (onChange)="onCheckChecklistItem($event, item.id)" [binary]="true" [inputId]="item.id.toString()"
[(ngModel)]="item.checked" />
<div class="pr-6 md:pr-0 truncate select-none flex-1">
<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)="deleteChecklistItem(item)" severity="danger" />
</div>
</div>
}
</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 mt-4 pb-4">
@for (item of getWatchlistData; track item.id) {
<div class="flex items-center gap-3 rounded-md p-2 hover:bg-gray-100 dark:hover:bg-white/5">
<label [for]="item.id" class="flex items-center gap-2 w-full">
<div class="relative">
@if (item.status) {<div class="z-50 block absolute top-0 left-3 size-2.5 rounded-full"
[style.background]="item.status.color"></div>}
<p-checkbox disabled />
</div>
<div class="pr-6 md:pr-0 truncate select-none flex-1">
<span>{{ item.text }}</span>
</div>
</label>
</div>
}
</div>
</section>
</p-dialog> </p-dialog>

View File

@ -15,6 +15,7 @@ import {
TripItem, TripItem,
TripStatus, TripStatus,
PackingItem, PackingItem,
ChecklistItem,
} from "../../types/trip"; } from "../../types/trip";
import { Place } from "../../types/poi"; import { Place } from "../../types/poi";
import { import {
@ -55,6 +56,7 @@ 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 { CheckboxChangeEvent, CheckboxModule } from "primeng/checkbox";
import { TripCreatePackingModalComponent } from "../../modals/trip-create-packing-modal/trip-create-packing-modal.component"; 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({ @Component({
selector: "app-trip", selector: "app-trip",
@ -95,13 +97,15 @@ export class TripComponent implements AfterViewInit {
totalPrice = 0; totalPrice = 0;
collapsedTripDays = false; collapsedTripDays = false;
collapsedTripPlaces = false; collapsedTripPlaces = false;
collapsedTripStatuses = false;
shareDialogVisible = false; shareDialogVisible = false;
packingDialogVisible = false; packingDialogVisible = false;
isExpanded = false; isExpanded = false;
isFilteringMode = false; isFilteringMode = false;
packingList: PackingItem[] = []; packingList: PackingItem[] = [];
dispPackingList: Record<string, PackingItem[]> = {}; dispPackingList: Record<string, PackingItem[]> = {};
checklistDialogVisible = false;
checklistItems: ChecklistItem[] = [];
dispchecklist: ChecklistItem[] = [];
map?: L.Map; map?: L.Map;
markerClusterGroup?: L.MarkerClusterGroup; markerClusterGroup?: L.MarkerClusterGroup;
@ -122,6 +126,14 @@ export class TripComponent implements AfterViewInit {
this.openPackingList(); this.openPackingList();
}, },
}, },
{
label: "Checklist",
icon: "pi pi-check-square",
iconClass: "text-purple-500!",
command: () => {
this.openChecklist();
},
},
{ {
label: "Edit", label: "Edit",
icon: "pi pi-pencil", icon: "pi pi-pencil",
@ -666,7 +678,7 @@ export class TripComponent implements AfterViewInit {
this.tripMapAntLayerDayID = -1; //Hardcoded value for global trace this.tripMapAntLayerDayID = -1; //Hardcoded value for global trace
} }
toggleTripDayHighlightPathDay(day_id: number) { toggleTripDayHighlight(day_id: number) {
// Click on the currently displayed day: remove // Click on the currently displayed day: remove
if (this.tripMapAntLayerDayID == day_id) { if (this.tripMapAntLayerDayID == day_id) {
this.resetDayHighlight(); 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);
},
});
},
});
}
} }

View File

@ -0,0 +1,11 @@
<div pFocusTrap class="grid items-center gap-4" [formGroup]="checklistForm">
<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>
<div class="mt-4 text-right">
<p-button (click)="closeDialog()" [disabled]="!checklistForm.dirty || !checklistForm.valid">{{
checklistForm.get("id")?.value
!== -1 ? "Update" : "Create" }}</p-button>
</div>

View File

@ -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);
}
}

View File

@ -1,10 +1,11 @@
import { inject, Injectable } from "@angular/core"; 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 { Category, Place } from "../types/poi";
import { BehaviorSubject, map, Observable, shareReplay, tap } from "rxjs"; 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 {
ChecklistItem,
PackingItem, PackingItem,
SharedTripURL, SharedTripURL,
Trip, Trip,
@ -272,6 +273,45 @@ export class ApiService {
); );
} }
getChecklist(trip_id: number): Observable<ChecklistItem[]> {
return this.httpClient.get<ChecklistItem[]>(
`${this.apiBaseUrl}/trips/${trip_id}/checklist`,
);
}
getSharedTripChecklist(token: string): Observable<ChecklistItem[]> {
return this.httpClient.get<ChecklistItem[]>(
`${this.apiBaseUrl}/trips/shared/${token}/checklist`,
);
}
postChecklistItem(
trip_id: number,
item: ChecklistItem,
): Observable<ChecklistItem> {
return this.httpClient.post<ChecklistItem>(
`${this.apiBaseUrl}/trips/${trip_id}/checklist`,
item,
);
}
putChecklistItem(
trip_id: number,
id: number,
item: Partial<ChecklistItem>,
): Observable<ChecklistItem> {
return this.httpClient.put<ChecklistItem>(
`${this.apiBaseUrl}/trips/${trip_id}/checklist/${id}`,
item,
);
}
deleteChecklistItem(trip_id: number, id: number): Observable<null> {
return this.httpClient.delete<null>(
`${this.apiBaseUrl}/trips/${trip_id}/checklist/${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

@ -16,11 +16,11 @@ export interface Trip {
archived?: boolean; archived?: boolean;
user: string; user: string;
days: TripDay[]; days: TripDay[];
shared?: boolean;
// POST / PUT // POST / PUT
places: Place[]; places: Place[];
place_ids: number[]; place_ids: number[];
shared?: boolean;
} }
export interface TripDay { export interface TripDay {
@ -73,3 +73,9 @@ export interface PackingItem {
qt?: number; qt?: number;
packed?: boolean; packed?: boolean;
} }
export interface ChecklistItem {
id: number;
text: string;
checked?: boolean;
}