From af7dbce105be44a90bbabebf06e4813ff4909acb Mon Sep 17 00:00:00 2001 From: elmocito <42518790+elmocito@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:53:11 +0200 Subject: [PATCH] fix(tours): prevent duplicate day assignments --- .../components/Tours/ToursSidebar.test.tsx | 30 +++++ client/src/components/Tours/ToursSidebar.tsx | 2 +- .../Tours/planner/TourPlannerPanels.tsx | 2 + client/src/components/Tours/tourParts.tsx | 19 ++- .../components/shared/ContextMenu.test.tsx | 9 ++ client/src/components/shared/ContextMenu.tsx | 9 +- .../trip/sheets/MBrowseActionsSheet.tsx | 19 ++- .../pages/tripPlanner/useTripPlanner.test.tsx | 31 +++++ .../src/pages/tripPlanner/useTripPlanner.ts | 1 + .../trip/MBrowseActionsSheet.tours.test.tsx | 109 ++++++++++++++++++ .../nest/assignments/assignments.service.ts | 11 +- server/tests/e2e/assignments.e2e.test.ts | 32 +++++ .../unit/nest/assignments.controller.test.ts | 14 ++- .../unit/nest/assignments.service.test.ts | 40 +++++++ 14 files changed, 313 insertions(+), 15 deletions(-) create mode 100644 client/tests/unit/mobile/trip/MBrowseActionsSheet.tours.test.tsx diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx index 0879cbf7e..cad5e72f9 100644 --- a/client/src/components/Tours/ToursSidebar.test.tsx +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -129,6 +129,36 @@ describe('ToursSidebar', () => { expect(onToursChanged).not.toHaveBeenCalled() }) + it('disables already-assigned days and refreshes availability when the picker is reopened', async () => { + const dayOne = { id: 7, trip_id: 1, day_number: 1, title: 'Day one', date: null } as never + const dayTwo = { id: 8, trip_id: 1, day_number: 2, title: 'Day two', date: null } as never + const dayThree = { id: 9, trip_id: 1, day_number: 3, title: 'Day three', date: null } as never + useTripStore.setState({ assignments: { '7': [{ id: 1, day_id: 7, place_id: tour.place_id }] } as never }) + const onAssignToDay = vi.fn((placeId: number, dayId: number) => { + useTripStore.setState(state => ({ + assignments: { ...state.assignments, [String(dayId)]: [{ id: 2, day_id: dayId, place_id: placeId }] } as never, + })) + return true + }) + render() + + const openPicker = () => fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ })) + openPicker() + expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled() + expect(screen.getByRole('button', { name: /Day two/ })).toBeEnabled() + fireEvent.click(screen.getByRole('button', { name: /Day two/ })) + expect(onAssignToDay).toHaveBeenCalledTimes(1) + expect(onAssignToDay).toHaveBeenCalledWith(tour.place_id, 8) + + openPicker() + expect(screen.getByRole('button', { name: /Day one/ })).toBeDisabled() + expect(screen.getByRole('button', { name: /Day two/ })).toBeDisabled() + expect(screen.getByRole('button', { name: /Day three/ })).toBeEnabled() + fireEvent.click(screen.getByRole('button', { name: /Day three/ })) + expect(onAssignToDay).toHaveBeenCalledTimes(2) + expect(onAssignToDay).toHaveBeenLastCalledWith(tour.place_id, 9) + }) + it('neither refreshes nor reports success when assignment fails', async () => { toast.success.mockClear() const onToursChanged = vi.fn() diff --git a/client/src/components/Tours/ToursSidebar.tsx b/client/src/components/Tours/ToursSidebar.tsx index 4157efc02..f61b1a907 100644 --- a/client/src/components/Tours/ToursSidebar.tsx +++ b/client/src/components/Tours/ToursSidebar.tsx @@ -178,7 +178,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel tour={tour} selected={tour.place_id === selectedPlaceId} onSelect={(selectedTour, opener) => onSelectTour?.(selectedTour, opener)} - action={ { void handleAssign(tour, day, index) }} />} + action={ { void handleAssign(tour, day, index) }} />} /> ))} diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index 8f1cd1c78..82c0dde17 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -471,6 +471,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo variant="button" primary days={days} + placeId={planner.saveOutcome.place_id} label={t('tours.planner.assignToDay')} disabled={!canAssign || assigning} onPick={day => { void assignSavedTour(day.id) }} @@ -498,6 +499,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo action={canAssign && days.length > 0 ? ( { if (canAssign) void onAssignToDay(tour.place_id, day.id) }} /> diff --git a/client/src/components/Tours/tourParts.tsx b/client/src/components/Tours/tourParts.tsx index 27376056c..2449f8eab 100644 --- a/client/src/components/Tours/tourParts.tsx +++ b/client/src/components/Tours/tourParts.tsx @@ -10,6 +10,7 @@ import { formatDate } from '../../utils/formatters' import { ContextMenu, useContextMenu } from '../shared/ContextMenu' import { DialogButton, fs } from '../shared/DialogShell' import { Tooltip } from '../shared/Tooltip' +import { useTripStore } from '../../store/tripStore' import { BOX, Eyebrow } from '../Planner/bookings/bookingParts' import { SoftPill } from '../Planner/planParts' import { hikeSourceBadgeLabel } from './tourPresentation' @@ -73,8 +74,9 @@ function dayMenuLabel(day: Day, index: number, t: Translate, locale: string): st * Putting a tour on a day: a round "+" on a row, or a footer button with a * chevron, both opening the trip's days as a menu. */ -export function TourDayMenu({ days, onPick, disabled = false, label, variant = 'round', primary = false }: { +export function TourDayMenu({ days, placeId, onPick, disabled = false, label, variant = 'round', primary = false }: { days: Day[] + placeId?: number onPick: (day: Day, index: number) => void disabled?: boolean label: string @@ -83,9 +85,16 @@ export function TourDayMenu({ days, onPick, disabled = false, label, variant = ' }) { const { t, locale } = useTranslation() const menu = useContextMenu() - const items = days.map((day, index) => ({ label: dayMenuLabel(day, index, t, locale), onClick: () => onPick(day, index) })) + const assignments = useTripStore(state => state.assignments ?? {}) + const assigned = (dayId: number) => placeId != null && (assignments[String(dayId)] ?? []).some(assignment => assignment.place_id === placeId) + const items = days.map((day, index) => ({ + label: dayMenuLabel(day, index, t, locale), + disabled: assigned(day.id), + onClick: () => onPick(day, index), + })) + const hasAvailableDay = items.some(item => !item.disabled) const open = (event: React.MouseEvent) => { - if (disabled || items.length === 0) return + if (disabled || !hasAvailableDay) return if (menu.menu) { event.stopPropagation(); menu.close(); return } menu.open(event, items, true) } @@ -93,13 +102,13 @@ export function TourDayMenu({ days, onPick, disabled = false, label, variant = ' <> {variant === 'round' ? ( - ) : ( - }> {label} diff --git a/client/src/components/shared/ContextMenu.test.tsx b/client/src/components/shared/ContextMenu.test.tsx index 5f00397f6..4838ac09a 100644 --- a/client/src/components/shared/ContextMenu.test.tsx +++ b/client/src/components/shared/ContextMenu.test.tsx @@ -79,4 +79,13 @@ describe('ContextMenu', () => { }); expect(onClose).toHaveBeenCalledOnce(); }); + + it('does not activate a disabled menu item', () => { + const onClick = vi.fn() + render() + const item = screen.getByRole('button', { name: 'Already assigned' }) + expect(item).toBeDisabled() + fireEvent.click(item) + expect(onClick).not.toHaveBeenCalled() + }) }); diff --git a/client/src/components/shared/ContextMenu.tsx b/client/src/components/shared/ContextMenu.tsx index 3558a9398..6cdb6a22f 100644 --- a/client/src/components/shared/ContextMenu.tsx +++ b/client/src/components/shared/ContextMenu.tsx @@ -6,6 +6,7 @@ interface MenuItem { label?: string icon?: LucideIcon onClick?: () => void + disabled?: boolean danger?: boolean divider?: boolean } @@ -84,15 +85,15 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) { if (item.divider) return
const Icon = item.icon return ( -