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 }) => {