diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index dd7a549b8..8258f2d42 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-080 +// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-093 import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -1437,6 +1437,96 @@ describe('ReservationModal', () => { expect(times.map(i => i.value)).toEqual(['', '', '']); }); + // #2107 — a booking that lasts a whole day has no clock to compare, and filling the + // missing one with midnight made the comparison read as inverted. + it('FE-PLANNER-RESMODAL-089: the same start and end date with no times is accepted', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Park permit'); + const datePickers = screen.getAllByTestId('date-picker'); + fireEvent.change(datePickers[0], { target: { value: '2026-05-02' } }); + fireEvent.change(datePickers[1], { target: { value: '2026-05-02' } }); + + expect(screen.queryByText(/End date\/time must be after start/i)).toBeNull(); + fireEvent.submit(document.querySelector('form')!); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + }); + + it('FE-PLANNER-RESMODAL-090: an all-day booking is saved as bare dates on both ends', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Park permit'); + const datePickers = screen.getAllByTestId('date-picker'); + fireEvent.change(datePickers[0], { target: { value: '2026-05-02' } }); + fireEvent.change(datePickers[1], { target: { value: '2026-05-02' } }); + fireEvent.submit(document.querySelector('form')!); + + // The stored shape matters: the calendar export branches on whether the value + // carries a clock, and a bare date is what makes it an all-day event. + await waitFor(() => expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ reservation_time: '2026-05-02', reservation_end_time: '2026-05-02' }), + )); + }); + + it('FE-PLANNER-RESMODAL-091: an end date before the start is still refused without times', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const addToast = vi.fn(); + window.__addToast = addToast; + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Backwards'); + const datePickers = screen.getAllByTestId('date-picker'); + fireEvent.change(datePickers[0], { target: { value: '2026-05-02' } }); + fireEvent.change(datePickers[1], { target: { value: '2026-05-01' } }); + fireEvent.submit(document.querySelector('form')!); + + expect(onSave).not.toHaveBeenCalled(); + expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/End date\/time must be after start/i), 'error', undefined); + delete window.__addToast; + }); + + it('FE-PLANNER-RESMODAL-092: a stored booking whose end is a bare date on the start day stays editable', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + // The shape the booking import and the mobile sheet both write. Nothing is typed + // here: the row alone used to leave the save button dead. + const res = buildReservation({ + id: 21, type: 'event', title: 'Day permit', + reservation_time: '2026-05-02T10:00:00', reservation_end_time: '2026-05-02', + }); + render(); + + expect(screen.queryByText(/End date\/time must be after start/i)).toBeNull(); + fireEvent.submit(document.querySelector('form')!); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + }); + + it('FE-PLANNER-RESMODAL-093: switching to hotel clears a date error the hidden panel cannot explain', async () => { + render(); + + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Switched'); + const datePickers = screen.getAllByTestId('date-picker'); + const timePickers = screen.getAllByTestId('time-picker'); + fireEvent.change(datePickers[0], { target: { value: '2026-05-02' } }); + fireEvent.change(timePickers[0], { target: { value: '19:00' } }); + fireEvent.change(datePickers[1], { target: { value: '2026-05-01' } }); + expect(screen.getByText(/End date\/time must be after start/i)).toBeTruthy(); + // The footer button reads 'Add' while creating and 'Update' while editing. + const save = () => Array.from(document.querySelectorAll('button')) + .find(b => /^\s*(Add|Update)\s*$/.test(b.textContent || '')) as HTMLButtonElement; + expect(save().disabled).toBe(true); + + // The date panel is hidden for hotels, and the message sits inside it, so + // asserting on the message proves nothing here. The save button is the part + // that stayed dead with no visible reason. + // The hotel type is labelled 'Accommodation' in the picker. + const hotelBtn = Array.from(document.querySelectorAll('button')) + .find(b => /^\s*Accommodation\s*$/.test(b.textContent || ''))!; + fireEvent.click(hotelBtn); + expect(save().disabled).toBe(false); + }); + it('FE-PLANNER-RESMODAL-088: double-encoded metadata still fills the check-in times', () => { const res = buildReservation({ id: 18, title: 'Grand Hotel', type: 'hotel' }); (res as unknown as { metadata: string }).metadata = diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 4b550e6e1..167ba7b0b 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -224,14 +224,25 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p return next }) + // Mirrors the mobile sheet, which has had this shape since 72a82b3c while the + // desktop copy was never pulled across (#2107). Filling a missing clock with '00:00' made a + // day-long booking compare as midnight against midnight, and the comparison is + // strict, so an all-day permit on a single date was refused. It also left every + // booking with a date-only end time uneditable here, which is the shape the booking + // import and the mobile sheet both write. + // + // The hotel guard matters for the same reason it does on mobile: the date panel is + // hidden for hotels, so a type switch after typing dates would leave the save button + // dead with its explanation inside the hidden block. const isEndBeforeStart = (() => { - if (!form.end_date || !form.reservation_time) return false + if (form.type === 'hotel' || !form.end_date || !form.reservation_time) return false const startDate = form.reservation_time.split('T')[0] - const startTime = form.reservation_time.split('T')[1] || '00:00' - const endTime = form.reservation_end_time || '00:00' - const startFull = `${startDate}T${startTime}` - const endFull = `${form.end_date}T${endTime}` - return endFull <= startFull + const startTime = form.reservation_time.split('T')[1] || '' + const endTime = form.reservation_end_time || '' + // Without a time on either side the booking is all-day, so an end on the + // start day is fine — only compare the dates there. + if (!startTime || !endTime) return form.end_date < startDate + return `${form.end_date}T${endTime}` <= `${startDate}T${startTime}` })() const handleSubmit = async (e?: { preventDefault?: () => void }) => {