fix(tours): prevent duplicate day assignments

This commit is contained in:
elmocito
2026-10-05 23:53:11 +02:00
parent a9f9a23908
commit af7dbce105
14 changed files with 313 additions and 15 deletions
@@ -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(<ToursSidebar canAssign tripId={1} days={[dayOne, dayTwo, dayThree]} tours={[tour]} onAssignToDay={onAssignToDay} onSelectTour={vi.fn()} />)
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()
+1 -1
View File
@@ -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={<TourDayMenu days={days} label={t('tours.addToDay')} disabled={!canAssign} onPick={(day, index) => { void handleAssign(tour, day, index) }} />}
action={<TourDayMenu days={days} placeId={tour.place_id} label={t('tours.addToDay')} disabled={!canAssign} onPick={(day, index) => { void handleAssign(tour, day, index) }} />}
/>
))}
</ul>
@@ -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 ? (
<TourDayMenu
days={days}
placeId={tour.place_id}
label={`${t('tours.addToDay')}: ${tour.name}`}
onPick={day => { if (canAssign) void onAssignToDay(tour.place_id, day.id) }}
/>
+14 -5
View File
@@ -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<HTMLButtonElement>) => {
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' ? (
<Tooltip label={label} disabled={!!menu.menu}>
<button type="button" onClick={open} disabled={disabled || items.length === 0} aria-label={label} aria-haspopup="menu" aria-expanded={!!menu.menu}
<button type="button" onClick={open} disabled={disabled || !hasAvailableDay} aria-label={label} aria-haspopup="menu" aria-expanded={!!menu.menu}
className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full shadow-sm ring-1 transition-colors disabled:cursor-default disabled:opacity-40 ${menu.menu ? 'bg-accent text-accent-text ring-transparent' : 'bg-surface-card text-content-muted ring-edge-faint enabled:hover:bg-accent enabled:hover:text-accent-text enabled:hover:ring-transparent'}`}>
<Plus size={13} strokeWidth={2.4} />
</button>
</Tooltip>
) : (
<DialogButton variant={primary ? 'primary' : 'secondary'} onClick={open} disabled={disabled || items.length === 0}
<DialogButton variant={primary ? 'primary' : 'secondary'} onClick={open} disabled={disabled || !hasAvailableDay}
aria-haspopup="menu" aria-expanded={!!menu.menu} icon={<Plus size={14} strokeWidth={2} />}>
{label}
<ChevronDown size={13} strokeWidth={2.2} className={`transition-transform ${menu.menu ? 'rotate-180' : ''}`} />