refactor(planner): share the booking form and its hotel stay between desktop and phone

The booking dialog and the phone booking sheet keep their markup and run on useReservationForm: how a saved booking or an import fills the form, the end before start check, the start date and time, the place and hotel picks, the stay range and the request body with the stay a hotel booking creates come from reservationFormModel. The desktop's check-in window, its link to a stop inside the form and its imported price stay its own, behind options where the code is shared.
This commit is contained in:
Maurice
2026-10-09 10:43:50 +02:00
parent 203e425e7e
commit fc3cc1bbbe
14 changed files with 1360 additions and 571 deletions
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo, useId } from 'react'
import { useEffect, useRef, useMemo, useId } from 'react'
import { localIsoDate } from '../../utils/localDate'
import { useParams } from 'react-router'
import { useTripStore } from '../../store/tripStore'
@@ -7,14 +7,12 @@ import CustomSelect from '../shared/CustomSelect'
import { BookingCodeInput } from '../shared/BookingCode'
import { buildAssignmentOptions } from './assignmentOptions'
import AddressInput from './AddressInput'
import { Hotel, Utensils, Ticket, FileText, Users, Link2, ParkingSquare } from 'lucide-react'
import { Link2 } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import { parseReservationMetadata } from '../../utils/flightLegs'
import { resolveDayId } from '../../utils/formatters'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
import { BookingLinkAndFiles } from './BookingLinkAndFiles'
import { importedPriceEntry } from './importedPrice'
@@ -22,22 +20,27 @@ import { TravelerPicker } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft'
import { typeToCostCategory } from '@trek/shared'
import { stayPlaces } from '../../utils/stayPlaces'
import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell'
import { DialogShell, DialogFooter, FooterSpacer, DialogButton } from '../shared/DialogShell'
import { INPUT, TEXTAREA, LABEL, GRID_2, GRID_3, EditorField, PillSelect } from '../shared/dialogParts'
import { typeInfo } from './bookings/bookingsModel'
import type { StatusTone } from './bookings/bookingParts'
import { RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill } from './reservationFormModel'
import { useReservationForm } from './useReservationForm'
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, uploadBookingFiles } from './bookingFormModel'
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
import { useBookingFileAttach } from './useBookingFileAttach'
const TYPE_OPTIONS = [
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
{ value: 'parking', labelKey: 'reservations.type.parking', Icon: ParkingSquare },
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
]
const EMPTY_FORM = {
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', url: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
place_id: '' as string | number,
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
hotel_address: '',
}
interface ReservationModalProps {
isOpen: boolean
@@ -71,38 +74,24 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
// Set right before submit when the user clicked create/edit expense (see TransportModal).
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', url: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
place_id: '' as string | number,
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
hotel_address: '',
// Set right before submit when the user clicked create/edit expense.
const expense = useBookingExpenseIntent(() => handleSubmit(), {
remove: item => deleteBudgetItem(Number(tripId), item.id),
onError: () => toast.error(t('common.unknownError')),
})
const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
// Travelers assigned to this booking (#1517) — seeded on open, persisted after the save resolves.
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
const {
form, setForm, set, isSaving, setIsSaving, pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler,
isEndBeforeStart, dateBounds: tripDateRange, startDate, startTime, setStartDate, setStartTime, takeStopDay,
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, saveData: buildSaveData,
} = useReservationForm(EMPTY_FORM, days, places)
const attach = useBookingFileAttach({ reservation, files, onFileUpload, setPendingFiles, toast, t })
const assignmentOptions = useMemo(
() => buildAssignmentOptions(days, assignments, t, locale),
[days, assignments, t, locale]
)
// Restrict non-hotel booking dates to the trip's span (#1662). Hotels already
// constrain to trip days via their day dropdowns. Falls back to no limit when
// the trip has no dated days.
const tripDateRange = useMemo(() => {
const dates = (days || []).map(d => d.date).filter((d): d is string => !!d).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
return { min: dates[0], max: dates[dates.length - 1] }
}, [days])
useEffect(() => {
// Match an existing place by name (exact, then loose contains) for hotels.
const matchPlaceId = (name: string | undefined): string | number => {
@@ -114,266 +103,65 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
return loose?.id ?? ''
}
setTravelerIds(new Set((reservation?.travelers || []).map(tv => tv.user_id)))
setTravelerIds(travelerIdsOf(reservation))
if (reservation) {
const meta = parseReservationMetadata(reservation)
const rawEnd = reservation.reservation_end_time || ''
let endDate = ''
let endTime = rawEnd
if (rawEnd.includes('T')) {
endDate = rawEnd.split('T')[0]
endTime = rawEnd.split('T')[1]?.slice(0, 5) || ''
} else if (/^\d{4}-\d{2}-\d{2}$/.test(rawEnd)) {
endDate = rawEnd
endTime = ''
}
const editAcc = accommodations.find(a => a.id == reservation.accommodation_id)
setForm({
title: reservation.title || '',
type: reservation.type || 'other',
status: reservation.status || 'pending',
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
reservation_end_time: endTime,
end_date: endDate,
location: reservation.location || '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
url: reservation.url || '',
assignment_id: reservation.assignment_id || '',
accommodation_id: reservation.accommodation_id || '',
place_id: reservation.place_id || '',
meta_check_in_time: meta.check_in_time || '',
meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '',
hotel_place_id: editAcc?.place_id || '',
hotel_start_day: editAcc?.start_day_id || '',
hotel_end_day: editAcc?.end_day_id || '',
// The linked place carries the address; reservations saved without a
// place (or before the accommodation existed) keep it in location.
hotel_address: places.find(p => p.id == editAcc?.place_id)?.address || reservation.location || '',
})
setForm({ ...reservationFieldsFrom(reservation, accommodations, places, true), assignment_id: reservation.assignment_id || '' })
} else if (prefill) {
// Review-before-save: populate from a parsed import item, stay in create mode.
const meta = (prefill.metadata && typeof prefill.metadata === 'object' ? prefill.metadata : {}) as Record<string, string>
const rawEnd = typeof prefill.reservation_end_time === 'string' ? prefill.reservation_end_time : ''
let endDate = ''
let endTime = rawEnd
if (rawEnd.includes('T')) { endDate = rawEnd.split('T')[0]; endTime = rawEnd.split('T')[1]?.slice(0, 5) || '' }
else if (/^\d{4}-\d{2}-\d{2}$/.test(rawEnd)) { endDate = rawEnd; endTime = '' }
setForm({
title: prefill.title || '',
type: prefill.type || 'other',
status: prefill.status || 'pending',
reservation_time: typeof prefill.reservation_time === 'string' ? prefill.reservation_time.slice(0, 16) : '',
reservation_end_time: endTime,
end_date: endDate,
location: prefill.location || '',
confirmation_number: prefill.confirmation_number || '',
notes: prefill.notes || '',
url: (prefill as { url?: string }).url || '',
...reservationFieldsFromPrefill(prefill, days, true),
assignment_id: defaultAssignmentId ?? '',
accommodation_id: '',
place_id: '',
meta_check_in_time: meta.check_in_time || '',
meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '',
hotel_place_id: matchPlaceId(prefill._venue?.name || prefill.title),
hotel_start_day: resolveDayId(days, prefill._accommodation?.check_in),
hotel_end_day: resolveDayId(days, prefill._accommodation?.check_out),
hotel_address: prefill._venue?.address || '',
})
// Seed the booking's Files with the document this item was parsed from.
setPendingFiles(prefill._sourceFiles ?? [])
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', url: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '', place_id: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_address: '',
})
setForm({ ...EMPTY_FORM, assignment_id: defaultAssignmentId ?? '' })
setPendingFiles([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId, days, places, accommodations])
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const toggleTraveler = (id: number) => setTravelerIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
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.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] || ''
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 }) => {
e?.preventDefault?.()
if (!form.title.trim()) return
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
setIsSaving(true)
try {
const metadata: Record<string, string> = {}
if (form.type === 'hotel') {
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
if (form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
}
let combinedEndTime = form.reservation_end_time
if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
} else if (form.reservation_end_time && form.reservation_time) {
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
}
const saveData: Record<string, any> & { title: string } = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
// Hotels show the address field instead of location — persist it on the
// reservation itself so it survives even without days/place (#1496).
location: form.type === 'hotel' ? form.hotel_address : form.location,
confirmation_number: form.confirmation_number,
notes: form.notes,
url: form.url,
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null),
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
// Hotels link a place through the accommodation record; every other type links
// the picked trip place/activity directly on the reservation (#1353).
place_id: form.type === 'hotel' ? null : (form.place_id || null),
// An empty object, not null: null clears the column outright, and that
// took the mirrored booking price with it on every edit of a type that
// fills no metadata of its own — restaurant, event, tour, parking, other,
// a hotel without check-in times (#2233). An object still clears what the
// form dropped, and lets the server carry the price across.
metadata,
// Omitted on an edit: the server replaces the endpoint set whenever the
// key is present, and this form never edits endpoints, so sending an
// empty list would drop a transit booking's stations (#2216).
...(reservation?.id ? {} : { endpoints: [] }),
needs_review: false,
}
if (form.type === 'hotel' && (form.hotel_start_day || form.hotel_end_day)) {
saveData.create_accommodation = {
place_id: form.hotel_place_id || null,
// No existing place picked but we have an address/name (e.g. a reviewed
// import) → the save handler geocodes it and creates the place.
venue: (!form.hotel_place_id && (form.hotel_address || form.title))
? { name: form.title, address: form.hotel_address || null }
: null,
// The typed address, so the save handler can write it through to a
// linked place — an edited address used to be silently dropped (#1496).
address: form.hotel_address || null,
// Tolerate a single resolved end of the range (a one-night stay or a date
// that only matched one trip day) so the accommodation is still created.
start_day_id: form.hotel_start_day || form.hotel_end_day,
end_day_id: form.hotel_end_day || form.hotel_start_day,
check_in: form.meta_check_in_time || null,
check_in_end: form.meta_check_in_end_time || null,
check_out: form.meta_check_out_time || null,
confirmation: form.confirmation_number || null,
}
}
const saveData = buildSaveData({ assignmentId: form.assignment_id, isEdit: !!reservation?.id, withCheckInEnd: true })
// Imported booking → auto-create the linked cost from the parsed price (what the
// old direct import did). Only on create (not edit) and only when there's a price.
if (!reservation && prefill && isBudgetEnabled) {
const entry = importedPriceEntry(prefill.metadata, form.type)
if (entry) saveData.create_budget_entry = entry
}
const saved = await onSave(saveData)
const saved = await onSave(saveData as Record<string, string | number | null> & { title: string })
// Persist the traveler assignment once we have the reservation id (create → save
// result, edit → existing reservation), and only when it actually changed (#1517).
const savedId = saved?.id ?? reservation?.id
if (savedId && tripId) {
const original = (reservation?.travelers || []).map(tv => tv.user_id)
const nextIds = [...travelerIds]
const changed = original.length !== nextIds.length || nextIds.some(id => !original.includes(id))
const { changed, nextIds } = travelersChanged(reservation, travelerIds)
if (changed) {
try { await setReservationTravelers(tripId, savedId, nextIds) } catch { toast.error(t('common.unknownError')) }
}
}
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(saved.id))
fd.append('description', form.title)
await onFileUpload(fd)
}
await uploadBookingFiles(onFileUpload, saved.id, pendingFiles, form.title)
}
// Open the Costs editor for the saved booking when the user asked to
// create/edit its linked expense (gated on saved?.id).
const intent = expenseIntentRef.current
expenseIntentRef.current = null
if (intent && onOpenExpense && saved?.id) {
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
}
const expenseRequest = expenseRequestAfterSave(expense.take(), saved?.id, form)
if (expenseRequest && onOpenExpense) onOpenExpense(expenseRequest)
} finally {
setIsSaving(false)
}
}
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; void handleSubmit() }
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; void handleSubmit() }
const handleRemoveExpense = async (item: BudgetItem) => {
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
}
// On an import review (not yet saved), preview the parsed price as the cost that will be
// linked: the same entry the save sends, so the two cannot name different currencies.
const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null
const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null
const handleFileChange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
if (reservation?.id) {
setUploadingFile(true)
try {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(reservation.id))
fd.append('description', reservation.title)
await onFileUpload(fd)
toast.success(t('reservations.toast.fileUploaded'))
} catch {
toast.error(t('reservations.toast.uploadError'))
} finally {
setUploadingFile(false)
e.target.value = ''
}
} else {
setPendingFiles(prev => [...prev, file])
e.target.value = ''
}
}
const attachedFiles = reservation?.id
? files.filter(f =>
f.reservation_id === reservation.id ||
linkedFileIds.includes(f.id) ||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
)
: []
const pendingExpense = pendingImportExpense(reservation, prefill, form.type)
const status = form.status === 'confirmed' ? 'confirmed' : 'pending'
const tone: StatusTone = form.type === 'transit' ? 'transit' : status
@@ -455,12 +243,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
onChange={value => {
set('assignment_id', value)
const opt = assignmentOptions.find(o => o.value === value)
if (opt?.dayDate) {
setForm(prev => {
if (prev.reservation_time) return prev
return { ...prev, reservation_time: opt.dayDate }
})
}
if (opt?.dayDate) takeStopDay(opt.dayDate)
}}
placeholder={t('reservations.pickAssignment')}
options={[
@@ -475,24 +258,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<div className={GRID_2}>
<EditorField label={t('reservations.date')}>
<CustomDatePicker
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
onChange={d => {
const [, tm] = (form.reservation_time || '').split('T')
set('reservation_time', d ? (tm ? `${d}T${tm}` : d) : '')
}}
value={startDate}
onChange={setStartDate}
min={tripDateRange.min}
max={tripDateRange.max}
/>
</EditorField>
<EditorField label={t('reservations.startTime')}>
<CustomTimePicker
value={(() => { const [, tm] = (form.reservation_time || '').split('T'); return tm || '' })()}
onChange={tm => {
const [d] = (form.reservation_time || '').split('T')
const selectedDay = days.find(dy => dy.id === selectedDayId)
const date = d || selectedDay?.date || localIsoDate()
set('reservation_time', tm ? `${date}T${tm}` : date)
}}
value={startTime}
onChange={tm => setStartTime(tm, days.find(dy => dy.id === selectedDayId)?.date || localIsoDate())}
/>
</EditorField>
<EditorField label={t('reservations.endDate')} error={isEndBeforeStart ? t('reservations.validation.endBeforeStart') : undefined}>
@@ -517,17 +292,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<EditorField label={t('reservations.meta.linkPlace')}>
<CustomSelect
value={form.place_id}
onChange={value => {
const p = places.find(pl => pl.id === value)
setForm(prev => {
const next = { ...prev, place_id: value }
if (value && p) {
if (!prev.title) next.title = p.name
if (!prev.location && p.address) next.location = p.address
}
return next
})
}}
onChange={pickPlace}
placeholder={t('reservations.meta.pickPlace')}
options={[
{ value: '', label: '—' },
@@ -552,19 +317,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<EditorField label={t('reservations.meta.hotelPlace')}>
<CustomSelect
value={form.hotel_place_id}
onChange={value => {
const p = places.find(pl => pl.id === value)
setForm(prev => {
const next = { ...prev, hotel_place_id: value }
if (value && p) {
if (!prev.title) next.title = p.name
// Show the picked hotel's address; keep a hand-typed one
// if the place has none.
next.hotel_address = p.address || prev.hotel_address
}
return next
})
}}
onChange={pickHotelPlace}
placeholder={t('reservations.meta.pickHotel')}
options={[
{ value: '', label: '—' },
@@ -577,12 +330,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<EditorField label={t('reservations.meta.fromDay')}>
<CustomSelect
value={form.hotel_start_day}
onChange={value => setForm(prev => ({
...prev,
hotel_start_day: value,
hotel_end_day: days.findIndex(d => d.id === value) > days.findIndex(d => d.id === prev.hotel_end_day)
? value : prev.hotel_end_day,
}))}
onChange={pickHotelStart}
placeholder={t('reservations.meta.selectDay')}
options={dayOptions}
size="sm"
@@ -591,12 +339,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<EditorField label={t('reservations.meta.toDay')}>
<CustomSelect
value={form.hotel_end_day}
onChange={value => setForm(prev => ({
...prev,
hotel_start_day: days.findIndex(d => d.id === value) < days.findIndex(d => d.id === prev.hotel_start_day)
? value : prev.hotel_start_day,
hotel_end_day: value,
}))}
onChange={pickHotelEnd}
placeholder={t('reservations.meta.selectDay')}
options={dayOptions}
size="sm"
@@ -645,15 +388,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
inputClass={INPUT}
reservationId={reservation?.id}
tripFiles={files}
attachedFiles={attachedFiles}
attachedFiles={attach.attachedFiles}
pendingFiles={pendingFiles}
onRemovePending={index => setPendingFiles(prev => prev.filter((_, j) => j !== index))}
onRemovePending={attach.removePending}
fileInputRef={fileInputRef}
onFileChange={handleFileChange}
onFileChange={attach.handleFileChange}
canAttach={!!onFileUpload}
uploading={uploadingFile}
onLinked={fileId => setLinkedFileIds(prev => [...prev, fileId])}
onDetached={fileId => setLinkedFileIds(prev => prev.filter(id => id !== fileId))}
uploading={attach.uploadingFile}
onLinked={attach.linkFile}
onDetached={attach.detachFile}
/>
{/* Costs — create / view the expense linked to this booking */}
@@ -661,9 +404,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<BookingCostsSection
reservationId={reservation?.id ?? null}
pendingExpense={pendingExpense}
onCreate={handleCreateExpense}
onEdit={handleEditExpense}
onRemove={handleRemoveExpense}
onCreate={expense.create}
onEdit={expense.edit}
onRemove={expense.remove}
labelClassName={LABEL}
customTooltips
/>
@@ -13,10 +13,9 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import type { Day, Place, Accommodation, Reservation, TripFile, BudgetItem, AssignmentsMap } from '../../types'
import type { Day, Place, Accommodation, Reservation, TripFile, AssignmentsMap } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
import { BookingLinkAndFiles } from './BookingLinkAndFiles'
import { importedPriceEntry } from './importedPrice'
import { TravelerPicker } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -27,13 +26,14 @@ import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, D
import { INPUT, TEXTAREA, READONLY_BOX, LABEL, GRID_2, GRID_3, PANEL, SEARCH_ON_PANEL, EditorField, Segmented, PillSelect, AddRowButton } from '../shared/dialogParts'
import { fs, Eyebrow, type StatusTone } from './bookings/bookingParts'
import { typeInfo } from './bookings/bookingsModel'
import { typeToCostCategory } from '@trek/shared'
import {
EMPTY_TRANSPORT_FIELDS, TRANSPORT_TYPE_OPTIONS as TYPE_OPTIONS, emptyCarStop, emptyStationWaypoint, emptyWaypoint,
transportDayOptions, type StationWaypointForm, type WaypointForm,
} from './transportEndpoints'
import { useTransportForm } from './useTransportForm'
import { travelerIdsOf, travelersChanged, uploadBookingFiles } from './bookingFormModel'
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, uploadBookingFiles } from './bookingFormModel'
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
import { useBookingFileAttach } from './useBookingFileAttach'
const defaultForm = {
...EMPTY_TRANSPORT_FIELDS,
@@ -88,7 +88,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const { id: tripId } = useParams<{ id: string }>()
// Set right before submitting when the user clicked "create/edit expense", so
// the post-save handler knows to open the Costs editor for the saved booking.
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
const expense = useBookingExpenseIntent(() => handleSubmit(), {
remove: item => deleteBudgetItem(Number(tripId), item.id),
onError: () => toast.error(t('common.unknownError')),
})
const {
form, set, stationRoute, automated, setAutomated, fromPick, setFromPick, toPick, setToPick,
waypoints, setWaypoints, trainWaypoints, setTrainWaypoints, carStops, setCarStops, moveCarStop,
@@ -96,8 +99,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
seed, payload, writesFlightLegs, writesTrainLegs,
} = useTransportForm(defaultForm)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const attach = useBookingFileAttach({ reservation, files, onFileUpload, setPendingFiles, toast, t })
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
@@ -152,12 +154,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
// The user asked to create/edit the linked expense — open the Costs editor
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
const intent = expenseIntentRef.current
expenseIntentRef.current = null
if (intent && onOpenExpense && saved?.id) {
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
}
const expenseRequest = expenseRequestAfterSave(expense.take(), saved?.id, form)
if (expenseRequest && onOpenExpense) onOpenExpense(expenseRequest)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
} finally {
@@ -165,48 +163,9 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
}
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; void handleSubmit() }
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; void handleSubmit() }
const handleRemoveExpense = async (item: BudgetItem) => {
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
}
// On an import review (not yet saved), preview the parsed price as the cost that will be
// linked: the same entry the save sends, so the two cannot name different currencies.
const importedEntry = !reservation && prefill ? importedPriceEntry(prefill.metadata, form.type) : null
const pendingExpense = importedEntry ? { ...importedEntry, currency: importedEntry.currency ?? null } : null
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (reservation?.id) {
setUploadingFile(true)
try {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(reservation.id))
fd.append('description', reservation.title)
await onFileUpload!(fd)
toast.success(t('reservations.toast.fileUploaded'))
} catch {
toast.error(t('reservations.toast.uploadError'))
} finally {
setUploadingFile(false)
e.target.value = ''
}
} else {
setPendingFiles(prev => [...prev, file])
e.target.value = ''
}
}
const attachedFiles = reservation?.id
? files.filter(f =>
f.reservation_id === reservation.id ||
linkedFileIds.includes(f.id) ||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
)
: []
const pendingExpense = pendingImportExpense(reservation, prefill, form.type)
const dayOptions = transportDayOptions(days, t, locale)
@@ -593,15 +552,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
inputClass={INPUT}
reservationId={reservation?.id}
tripFiles={files}
attachedFiles={attachedFiles}
attachedFiles={attach.attachedFiles}
pendingFiles={pendingFiles}
onRemovePending={index => setPendingFiles(prev => prev.filter((_, j) => j !== index))}
onRemovePending={attach.removePending}
fileInputRef={fileInputRef}
onFileChange={handleFileChange}
onFileChange={attach.handleFileChange}
canAttach={!!onFileUpload}
uploading={uploadingFile}
onLinked={fileId => setLinkedFileIds(prev => [...prev, fileId])}
onDetached={fileId => setLinkedFileIds(prev => prev.filter(id => id !== fileId))}
uploading={attach.uploadingFile}
onLinked={attach.linkFile}
onDetached={attach.detachFile}
/>
{/* Costs: create or view the expenses linked to this booking */}
@@ -609,9 +568,9 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<BookingCostsSection
reservationId={reservation?.id ?? null}
pendingExpense={pendingExpense}
onCreate={handleCreateExpense}
onEdit={handleEditExpense}
onRemove={handleRemoveExpense}
onCreate={expense.create}
onEdit={expense.edit}
onRemove={expense.remove}
labelClassName={LABEL}
customTooltips
/>
@@ -1,10 +1,19 @@
// FE-PLANNER-BOOKINGFORM-001 to -004: the travellers and picked files of the
// transport and booking forms, desktop and phone alike.
// FE-PLANNER-BOOKINGFORM-001 to -007: the travellers, picked files, attached files
// and expense requests of the transport and booking forms, desktop and phone alike.
import { typeToCostCategory } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { buildReservation } from '../../../tests/helpers/factories';
import { buildBudgetItem, buildReservation, buildTripFile } from '../../../tests/helpers/factories';
import type { Reservation } from '../../types';
import { toggledTraveler, travelerIdsOf, travelersChanged, uploadBookingFiles } from './bookingFormModel';
import {
attachedBookingFiles,
expenseRequestAfterSave,
pendingImportExpense,
toggledTraveler,
travelerIdsOf,
travelersChanged,
uploadBookingFiles,
} from './bookingFormModel';
const withTravelers = (...ids: number[]) =>
buildReservation({ travelers: ids.map((user_id) => ({ user_id, username: `u${user_id}` })) } as Partial<Reservation>);
@@ -50,4 +59,39 @@ describe('bookingFormModel', () => {
await uploadBookingFiles(upload, 42, [], 'none');
expect(upload).not.toHaveBeenCalled();
});
it('FE-PLANNER-BOOKINGFORM-005: an expense wish opens the editor for the saved booking, nothing else does', () => {
const booking = { title: 'Hotel Adlon', type: 'hotel' };
const item = buildBudgetItem({ id: 7 });
expect(expenseRequestAfterSave({ create: true }, 42, booking)).toEqual({
prefill: { reservationId: 42, name: 'Hotel Adlon', category: typeToCostCategory('hotel') },
});
expect(expenseRequestAfterSave({ editItem: item }, 42, booking)).toEqual({ editItem: item });
expect(expenseRequestAfterSave(null, 42, booking)).toBeNull();
expect(expenseRequestAfterSave({ create: true }, undefined, booking)).toBeNull();
expect(expenseRequestAfterSave({ editItem: item }, null, booking)).toBeNull();
});
it('FE-PLANNER-BOOKINGFORM-006: an import review previews the parsed price, a saved booking does not', () => {
const prefill = { metadata: { price: 120, priceCurrency: 'eur' } };
const preview = pendingImportExpense(null, prefill, 'hotel');
expect(preview).toEqual({ total_price: 120, category: typeToCostCategory('hotel'), currency: 'EUR' });
expect(pendingImportExpense(null, { metadata: { price: 50 } }, 'other')?.currency).toBeNull();
expect(pendingImportExpense(buildReservation(), prefill, 'hotel')).toBeNull();
expect(pendingImportExpense(null, null, 'hotel')).toBeNull();
expect(pendingImportExpense(null, { metadata: {} }, 'hotel')).toBeNull();
});
it('FE-PLANNER-BOOKINGFORM-007: a saved booking shows its own, server linked and dialog linked files', () => {
const files = [
buildTripFile({ id: 1, reservation_id: 5 }),
buildTripFile({ id: 2, reservation_id: null, linked_reservation_ids: [5] }),
buildTripFile({ id: 3, reservation_id: null }),
buildTripFile({ id: 4, reservation_id: 9 }),
];
expect(attachedBookingFiles(files, 5, []).map((f) => f.id)).toEqual([1, 2]);
expect(attachedBookingFiles(files, 5, [3]).map((f) => f.id)).toEqual([1, 2, 3]);
expect(attachedBookingFiles(files, null, [3])).toEqual([]);
expect(attachedBookingFiles(files, undefined, [])).toEqual([]);
});
});
@@ -1,4 +1,8 @@
import type { Reservation } from '../../types';
import { typeToCostCategory } from '@trek/shared';
import type { BudgetItem, Reservation, TripFile } from '../../types';
import type { BookingExpenseRequest } from './BookingCostsSection.types';
import { importedPriceEntry } from './importedPrice';
/**
* What the transport and the booking forms share on both shells: the travellers
@@ -45,3 +49,58 @@ export async function uploadBookingFiles(
await upload(fd);
}
}
/**
* Which expense the user asked for while saving a booking: a new one or an edit of
* the linked one. The form saves itself first, so the request can name the saved id.
*/
export interface BookingExpenseIntent {
editItem?: BudgetItem;
create?: boolean;
}
/**
* The expense editor request to open once a booking is saved, or null when the user
* asked for none or the save came back without a record.
*/
export function expenseRequestAfterSave(
intent: BookingExpenseIntent | null | undefined,
savedId: number | null | undefined,
booking: { title: string; type: string }
): BookingExpenseRequest | null {
if (!intent || !savedId) return null;
if (intent.editItem) return { editItem: intent.editItem };
return { prefill: { reservationId: savedId, name: booking.title, category: typeToCostCategory(booking.type) } };
}
/**
* The cost an import review previews before the booking exists: the parsed price the
* save will link, so the preview and the save cannot name different currencies.
* Nothing for a saved booking or a booking that was not imported.
*/
export function pendingImportExpense(
reservation: unknown,
prefill: { metadata?: unknown } | null | undefined,
type: string
): { total_price: number; category: string; currency: string | null } | null {
const entry = !reservation && prefill ? importedPriceEntry(prefill.metadata, type) : null;
return entry ? { ...entry, currency: entry.currency ?? null } : null;
}
/**
* The trip files shown on a saved booking: the ones uploaded to it, the ones linked
* to it on the server and the ones linked in this dialog. Nothing before it is saved.
*/
export function attachedBookingFiles(
files: TripFile[],
reservationId: number | null | undefined,
linkedFileIds: number[]
): TripFile[] {
if (!reservationId) return [];
return files.filter(
(f) =>
f.reservation_id === reservationId ||
linkedFileIds.includes(f.id) ||
f.linked_reservation_ids?.includes(reservationId)
);
}
@@ -0,0 +1,349 @@
// FE-PLANNER-RESFORM-001 to -014: the booking form both the desktop ReservationModal
// and the phone's booking sheet save through.
import { describe, expect, it } from 'vitest';
import { buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import type { Accommodation, Reservation } from '../../types';
import type { BookingReviewDraft } from './parsedItemToDraft';
import {
RESERVATION_TYPE_OPTIONS,
type ReservationFields,
isEndBeforeStart,
reservationFieldsFrom,
reservationFieldsFromPrefill,
reservationSaveData,
splitEndTime,
startParts,
tripDateBounds,
withHotelEnd,
withHotelPlace,
withHotelStart,
withLinkedPlace,
withLinkedStopDay,
withStartDate,
withStartTime,
} from './reservationFormModel';
const days = [
buildDay({ id: 1, date: '2026-07-03' }),
buildDay({ id: 2, date: '2026-07-01' }),
buildDay({ id: 3, date: '2026-07-02' }),
];
const EMPTY: ReservationFields = {
title: '',
type: 'other',
status: 'pending',
reservation_time: '',
reservation_end_time: '',
end_date: '',
location: '',
confirmation_number: '',
notes: '',
url: '',
place_id: '',
accommodation_id: '',
meta_check_in_time: '',
meta_check_out_time: '',
hotel_place_id: '',
hotel_start_day: '',
hotel_end_day: '',
hotel_address: '',
};
describe('reservation form fields', () => {
it('FE-PLANNER-RESFORM-001: the type picker offers the six booking types, hotel first', () => {
expect(RESERVATION_TYPE_OPTIONS.map((o) => o.value)).toEqual([
'hotel',
'restaurant',
'event',
'tour',
'parking',
'other',
]);
});
it('FE-PLANNER-RESFORM-002: a stored end splits into its date and its time', () => {
expect(splitEndTime('2026-07-02T18:30:00')).toEqual({ endDate: '2026-07-02', endTime: '18:30' });
expect(splitEndTime('2026-07-02')).toEqual({ endDate: '2026-07-02', endTime: '' });
expect(splitEndTime('18:30')).toEqual({ endDate: '', endTime: '18:30' });
expect(splitEndTime('')).toEqual({ endDate: '', endTime: '' });
});
it('FE-PLANNER-RESFORM-003: a saved hotel booking reads its stay; only the desktop keeps the check-in window', () => {
const acc = { id: 9, place_id: 4, start_day_id: 2, end_day_id: 3 } as Accommodation;
const res = buildReservation({
type: 'hotel',
status: 'confirmed',
reservation_time: '2026-07-01T15:00:00',
reservation_end_time: '2026-07-02T11:00',
accommodation_id: 9,
location: 'Typed street',
url: 'https://h.test',
metadata: JSON.stringify({ check_in_time: '15:00', check_in_end_time: '22:00', check_out_time: '11:00' }),
} as Partial<Reservation>);
const places = [buildPlace({ id: 4, address: 'Hotel street 1' })];
const desktop = reservationFieldsFrom(res, [acc], places, true);
expect(desktop).toMatchObject({
type: 'hotel',
status: 'confirmed',
reservation_time: '2026-07-01T15:00',
reservation_end_time: '11:00',
end_date: '2026-07-02',
url: 'https://h.test',
accommodation_id: 9,
meta_check_in_time: '15:00',
meta_check_in_end_time: '22:00',
meta_check_out_time: '11:00',
hotel_place_id: 4,
hotel_start_day: 2,
hotel_end_day: 3,
hotel_address: 'Hotel street 1',
});
const phone = reservationFieldsFrom(res, [acc], places, false);
expect(phone).not.toHaveProperty('meta_check_in_end_time');
expect(reservationFieldsFrom(res, [acc], [], false).hotel_address).toBe('Typed street');
expect(reservationFieldsFrom(buildReservation({ type: undefined }), [], [], false)).toMatchObject({
type: 'other',
hotel_place_id: '',
place_id: '',
});
});
it('FE-PLANNER-RESFORM-004: an import under review resolves its stay days and keeps its source details', () => {
const pf = {
title: 'Inn',
type: 'hotel',
reservation_time: '2026-07-01T14:00:00',
reservation_end_time: '2026-07-02',
metadata: { check_in_time: '14:00', check_in_end_time: '20:00' },
url: 'https://inn.test',
_accommodation: { check_in: '2026-07-01', check_out: '2026-07-03' },
_venue: { name: 'Inn', address: 'Lane 2' },
} as unknown as BookingReviewDraft;
expect(reservationFieldsFromPrefill(pf, days, true)).toEqual({
title: 'Inn',
type: 'hotel',
status: 'pending',
reservation_time: '2026-07-01T14:00',
reservation_end_time: '',
end_date: '2026-07-02',
location: '',
confirmation_number: '',
notes: '',
url: 'https://inn.test',
meta_check_in_time: '14:00',
meta_check_in_end_time: '20:00',
meta_check_out_time: '',
hotel_start_day: 2,
hotel_end_day: 1,
hotel_address: 'Lane 2',
});
const bare = reservationFieldsFromPrefill({ metadata: null } as BookingReviewDraft, days, false);
expect(bare).toMatchObject({ type: 'other', reservation_time: '', hotel_start_day: '', hotel_address: '' });
expect(bare).not.toHaveProperty('meta_check_in_end_time');
});
});
describe('reservation dates', () => {
const form = (patch: Partial<ReservationFields>) => ({ ...EMPTY, ...patch });
it('FE-PLANNER-RESFORM-005: an end before the start blocks; an all-day end on the start day does not (#2107)', () => {
expect(
isEndBeforeStart(
form({ reservation_time: '2026-07-02T10:00', end_date: '2026-07-02', reservation_end_time: '09:00' })
)
).toBe(true);
expect(
isEndBeforeStart(
form({ reservation_time: '2026-07-02T10:00', end_date: '2026-07-02', reservation_end_time: '10:00' })
)
).toBe(true);
expect(
isEndBeforeStart(
form({ reservation_time: '2026-07-02T10:00', end_date: '2026-07-02', reservation_end_time: '11:00' })
)
).toBe(false);
expect(
isEndBeforeStart(form({ reservation_time: '2026-07-02', end_date: '2026-07-02', reservation_end_time: '09:00' }))
).toBe(false);
expect(isEndBeforeStart(form({ reservation_time: '2026-07-02T10:00', end_date: '2026-07-01' }))).toBe(true);
expect(isEndBeforeStart(form({ type: 'hotel', reservation_time: '2026-07-02', end_date: '2026-07-01' }))).toBe(
false
);
expect(isEndBeforeStart(form({ end_date: '2026-07-01' }))).toBe(false);
});
it('FE-PLANNER-RESFORM-006: the start is edited as a date and a time', () => {
expect(startParts('2026-07-02T10:00')).toEqual({ date: '2026-07-02', time: '10:00' });
expect(startParts('')).toEqual({ date: '', time: '' });
expect(withStartDate('2026-07-02T10:00', '2026-07-03')).toBe('2026-07-03T10:00');
expect(withStartDate('2026-07-02', '2026-07-03')).toBe('2026-07-03');
expect(withStartDate('2026-07-02T10:00', '')).toBe('');
expect(withStartDate('2026-07-02T10:00', null)).toBe('');
expect(withStartTime('2026-07-02', '09:15', '2026-01-01')).toBe('2026-07-02T09:15');
expect(withStartTime('', '09:15', '2026-01-01')).toBe('2026-01-01T09:15');
expect(withStartTime('2026-07-02T09:15', '', '2026-01-01')).toBe('2026-07-02');
expect(withStartTime('', '09:15', '')).toBe('T09:15');
});
it('FE-PLANNER-RESFORM-007: the pickers are bounded by the earliest and latest dated day', () => {
expect(tripDateBounds(days)).toEqual({ min: '2026-07-01', max: '2026-07-03' });
expect(tripDateBounds([{ date: null }] as { date: string }[])).toEqual({ min: undefined, max: undefined });
});
it('FE-PLANNER-RESFORM-008: linking a stop dates only an undated booking', () => {
const undated = form({});
expect(withLinkedStopDay(undated, '2026-07-02').reservation_time).toBe('2026-07-02');
const dated = form({ reservation_time: '2026-07-03' });
expect(withLinkedStopDay(dated, '2026-07-02')).toBe(dated);
});
});
describe('reservation place and stay picks', () => {
const places = [
buildPlace({ id: 1, name: 'Museum', address: 'Museum road' }),
buildPlace({ id: 2, name: 'Bare', address: null }),
];
it('FE-PLANNER-RESFORM-009: a linked place fills a missing title and address only', () => {
expect(withLinkedPlace(EMPTY, 1, places)).toMatchObject({ place_id: 1, title: 'Museum', location: 'Museum road' });
const typed = { ...EMPTY, title: 'Mine', location: 'Here' };
expect(withLinkedPlace(typed, 1, places)).toMatchObject({ place_id: 1, title: 'Mine', location: 'Here' });
expect(withLinkedPlace(EMPTY, 2, places)).toMatchObject({ place_id: 2, title: 'Bare', location: '' });
expect(withLinkedPlace({ ...EMPTY, place_id: 1 }, '', places)).toMatchObject({ place_id: '', title: '' });
});
it('FE-PLANNER-RESFORM-010: a picked hotel shows its address and keeps a typed one when it has none', () => {
expect(withHotelPlace(EMPTY, 1, places)).toMatchObject({
hotel_place_id: 1,
title: 'Museum',
hotel_address: 'Museum road',
});
const typed = { ...EMPTY, title: 'Stay', hotel_address: 'Typed' };
expect(withHotelPlace(typed, 1, places)).toMatchObject({ title: 'Stay', hotel_address: 'Museum road' });
expect(withHotelPlace(typed, 2, places)).toMatchObject({ hotel_place_id: 2, hotel_address: 'Typed' });
});
it('FE-PLANNER-RESFORM-011: the stay range never ends before it starts', () => {
const order = [{ id: 10 }, { id: 11 }, { id: 12 }];
const stay = { ...EMPTY, hotel_start_day: 10, hotel_end_day: 11 };
expect(withHotelStart(stay, 12, order)).toMatchObject({ hotel_start_day: 12, hotel_end_day: 12 });
expect(withHotelStart(stay, 11, order)).toMatchObject({ hotel_start_day: 11, hotel_end_day: 11 });
expect(withHotelStart({ ...stay, hotel_end_day: 12 }, 11, order)).toMatchObject({ hotel_end_day: 12 });
expect(withHotelEnd({ ...stay, hotel_start_day: 12 }, 11, order)).toMatchObject({
hotel_start_day: 11,
hotel_end_day: 11,
});
expect(withHotelEnd(stay, 12, order)).toMatchObject({ hotel_start_day: 10, hotel_end_day: 12 });
});
});
describe('reservationSaveData', () => {
it('FE-PLANNER-RESFORM-012: a booking sends its times, links and an empty metadata object; an edit leaves the endpoints alone', () => {
const form = {
...EMPTY,
title: 'Dinner',
type: 'restaurant',
reservation_time: '2026-07-02T19:00',
reservation_end_time: '21:00',
location: 'Old town',
place_id: 4,
url: 'u',
};
expect(reservationSaveData(form, { assignmentId: 7, isEdit: false, withCheckInEnd: true })).toEqual({
title: 'Dinner',
type: 'restaurant',
status: 'pending',
reservation_time: '2026-07-02T19:00',
reservation_end_time: '2026-07-02T21:00',
location: 'Old town',
confirmation_number: '',
notes: '',
url: 'u',
assignment_id: 7,
accommodation_id: null,
place_id: 4,
metadata: {},
endpoints: [],
needs_review: false,
});
const edit = reservationSaveData(
{ ...form, end_date: '2026-07-03' },
{ assignmentId: '', isEdit: true, withCheckInEnd: false }
);
expect(edit).not.toHaveProperty('endpoints');
expect(edit).toMatchObject({ assignment_id: null, reservation_end_time: '2026-07-03T21:00' });
expect(
reservationSaveData(
{ ...form, reservation_end_time: '', end_date: '2026-07-03' },
{ assignmentId: '', isEdit: true, withCheckInEnd: false }
).reservation_end_time
).toBe('2026-07-03');
expect(
reservationSaveData(
{ ...form, reservation_time: '', reservation_end_time: '' },
{ assignmentId: '', isEdit: true, withCheckInEnd: false }
)
).toMatchObject({
reservation_time: null,
reservation_end_time: null,
});
});
it('FE-PLANNER-RESFORM-013: a hotel sends its address, its stay and the check-in window only from the desktop', () => {
const hotel = {
...EMPTY,
title: 'Inn',
type: 'hotel',
reservation_time: '2026-07-01T10:00',
location: 'ignored',
hotel_address: 'Lane 2',
hotel_start_day: 2,
hotel_end_day: '',
meta_check_in_time: '15:00',
meta_check_in_end_time: '22:00',
meta_check_out_time: '11:00',
confirmation_number: 'C1',
};
const desktop = reservationSaveData(hotel, { assignmentId: 7, isEdit: false, withCheckInEnd: true });
expect(desktop).toMatchObject({
reservation_time: null,
reservation_end_time: null,
location: 'Lane 2',
assignment_id: null,
accommodation_id: null,
place_id: null,
metadata: { check_in_time: '15:00', check_in_end_time: '22:00', check_out_time: '11:00' },
});
expect(desktop.create_accommodation).toEqual({
place_id: null,
venue: { name: 'Inn', address: 'Lane 2' },
address: 'Lane 2',
start_day_id: 2,
end_day_id: 2,
check_in: '15:00',
check_in_end: '22:00',
check_out: '11:00',
confirmation: 'C1',
});
const phone = reservationSaveData(hotel, { assignmentId: 7, isEdit: false, withCheckInEnd: false });
expect(phone.metadata).toEqual({ check_in_time: '15:00', check_out_time: '11:00' });
expect(phone.create_accommodation).not.toHaveProperty('check_in_end');
});
it('FE-PLANNER-RESFORM-014: a hotel with a picked place and a linked stay keeps both; one without days creates no stay', () => {
const linked = reservationSaveData(
{ ...EMPTY, type: 'hotel', title: 'Inn', hotel_place_id: 4, accommodation_id: 9, hotel_end_day: 3 },
{ assignmentId: 7, isEdit: true, withCheckInEnd: true }
);
expect(linked).toMatchObject({ assignment_id: 7, accommodation_id: 9 });
expect(linked.create_accommodation).toMatchObject({ place_id: 4, venue: null, start_day_id: 3, end_day_id: 3 });
const noDays = reservationSaveData(
{ ...EMPTY, type: 'hotel' },
{ assignmentId: '', isEdit: true, withCheckInEnd: true }
);
expect(noDays).not.toHaveProperty('create_accommodation');
expect(noDays.metadata).toEqual({});
});
});
@@ -0,0 +1,340 @@
import { FileText, Hotel, ParkingSquare, Ticket, Users, Utensils, type LucideIcon } from 'lucide-react';
import type { Accommodation, Day, Place, Reservation } from '../../types';
import { parseReservationMetadata } from '../../utils/flightLegs';
import { resolveDayId } from '../../utils/formatters';
import type { BookingReviewDraft } from './parsedItemToDraft';
/**
* The booking form both shells edit, the desktop ReservationModal and the phone's
* booking sheet: how a booking fills it, the date rules, the place and hotel picks
* and the request body, including the stay a hotel booking creates.
*/
export const RESERVATION_TYPE_OPTIONS: { value: string; labelKey: string; Icon: LucideIcon }[] = [
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
{ value: 'parking', labelKey: 'reservations.type.parking', Icon: ParkingSquare },
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
];
/** The fields of the form both shells share; the desktop keeps a few more of its own. */
export interface ReservationFields {
title: string;
type: string;
status: string;
/** The start as the form holds it: a date, or a date and a time joined by T. */
reservation_time: string;
/** The end time alone; its date lives in end_date. */
reservation_end_time: string;
end_date: string;
location: string;
confirmation_number: string;
notes: string;
url: string;
place_id: string | number;
accommodation_id: string | number;
meta_check_in_time: string;
meta_check_out_time: string;
hotel_place_id: string | number;
hotel_start_day: string | number;
hotel_end_day: string | number;
hotel_address: string;
}
/** The desktop's check-in window, which the phone does not edit. */
export interface CheckInEndField {
meta_check_in_end_time?: string;
}
/**
* A stored end splits into the date and the time the form edits apart: a date and
* time, a date alone, or a bare time.
*/
export function splitEndTime(rawEnd: string): { endDate: string; endTime: string } {
if (rawEnd.includes('T')) return { endDate: rawEnd.split('T')[0], endTime: rawEnd.split('T')[1]?.slice(0, 5) || '' };
if (/^\d{4}-\d{2}-\d{2}$/.test(rawEnd)) return { endDate: rawEnd, endTime: '' };
return { endDate: '', endTime: rawEnd };
}
/**
* The fields of a saved booking. Its stay, when it has one, gives the hotel's place,
* days and address. `withCheckInEnd` adds the desktop's check-in window.
*/
export function reservationFieldsFrom(
res: Reservation,
accommodations: Accommodation[],
places: Pick<Place, 'id' | 'address'>[],
withCheckInEnd: true
): ReservationFields & Required<CheckInEndField>;
export function reservationFieldsFrom(
res: Reservation,
accommodations: Accommodation[],
places: Pick<Place, 'id' | 'address'>[],
withCheckInEnd: false
): ReservationFields;
export function reservationFieldsFrom(
res: Reservation,
accommodations: Accommodation[],
places: Pick<Place, 'id' | 'address'>[],
withCheckInEnd: boolean
): ReservationFields & CheckInEndField {
const meta = parseReservationMetadata(res);
const { endDate, endTime } = splitEndTime(res.reservation_end_time || '');
const acc = accommodations.find((a) => a.id == res.accommodation_id);
return {
title: res.title || '',
type: res.type || 'other',
status: res.status || 'pending',
reservation_time: res.reservation_time ? res.reservation_time.slice(0, 16) : '',
reservation_end_time: endTime,
end_date: endDate,
location: res.location || '',
confirmation_number: res.confirmation_number || '',
notes: res.notes || '',
url: res.url || '',
place_id: res.place_id || '',
accommodation_id: res.accommodation_id || '',
meta_check_in_time: meta.check_in_time || '',
...(withCheckInEnd ? { meta_check_in_end_time: meta.check_in_end_time || '' } : {}),
meta_check_out_time: meta.check_out_time || '',
hotel_place_id: acc?.place_id || '',
hotel_start_day: acc?.start_day_id || '',
hotel_end_day: acc?.end_day_id || '',
// The linked place carries the address; reservations saved without a
// place (or before the accommodation existed) keep it in location.
hotel_address: places.find((p) => p.id == acc?.place_id)?.address || res.location || '',
};
}
type PrefillFields = Omit<ReservationFields, 'place_id' | 'accommodation_id' | 'hotel_place_id'>;
/**
* The fields of a parsed import item under review. It is not linked to a place yet;
* the stay's days are resolved from the parsed check-in and check-out dates.
*/
export function reservationFieldsFromPrefill(
pf: BookingReviewDraft,
days: Day[],
withCheckInEnd: true
): PrefillFields & Required<CheckInEndField>;
export function reservationFieldsFromPrefill(pf: BookingReviewDraft, days: Day[], withCheckInEnd: false): PrefillFields;
export function reservationFieldsFromPrefill(
pf: BookingReviewDraft,
days: Day[],
withCheckInEnd: boolean
): PrefillFields & CheckInEndField {
const meta = (pf.metadata && typeof pf.metadata === 'object' ? pf.metadata : {}) as Record<string, string>;
const { endDate, endTime } = splitEndTime(typeof pf.reservation_end_time === 'string' ? pf.reservation_end_time : '');
return {
title: pf.title || '',
type: pf.type || 'other',
status: pf.status || 'pending',
reservation_time: typeof pf.reservation_time === 'string' ? pf.reservation_time.slice(0, 16) : '',
reservation_end_time: endTime,
end_date: endDate,
location: pf.location || '',
confirmation_number: pf.confirmation_number || '',
notes: pf.notes || '',
url: (pf as { url?: string }).url || '',
meta_check_in_time: meta.check_in_time || '',
...(withCheckInEnd ? { meta_check_in_end_time: meta.check_in_end_time || '' } : {}),
meta_check_out_time: meta.check_out_time || '',
hotel_start_day: resolveDayId(days, pf._accommodation?.check_in),
hotel_end_day: resolveDayId(days, pf._accommodation?.check_out),
hotel_address: pf._venue?.address || '',
};
}
type DateFields = Pick<ReservationFields, 'type' | 'reservation_time' | 'reservation_end_time' | 'end_date'>;
/**
* Whether the end comes before the start, which refuses the save. Without a time on
* either side the booking is all-day, so an end on the start day is fine and only
* the dates are compared (#2107). Hotels hide the date fields, so they never block.
*/
export function isEndBeforeStart(form: DateFields): boolean {
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] || '';
const endTime = form.reservation_end_time || '';
if (!startTime || !endTime) return form.end_date < startDate;
return `${form.end_date}T${endTime}` <= `${startDate}T${startTime}`;
}
/** The start's date and time as the two pickers show them. */
export function startParts(reservationTime: string): { date: string; time: string } {
const [date, time] = (reservationTime || '').split('T');
return { date: date || '', time: time || '' };
}
/** The start with a new date; the time stays, and clearing the date clears the start. */
export function withStartDate(reservationTime: string, date: string | null | undefined): string {
const { time } = startParts(reservationTime);
return date ? (time ? `${date}T${time}` : date) : '';
}
/** The start with a new time, on its own date or `fallbackDate` when it has none. */
export function withStartTime(reservationTime: string, time: string, fallbackDate: string): string {
const date = startParts(reservationTime).date || fallbackDate;
return time ? `${date}T${time}` : date;
}
/** The trip's first and last dated day, which bound the date pickers (#1662). */
export function tripDateBounds(days: Pick<Day, 'date'>[]): { min: string | undefined; max: string | undefined } {
const dates = days
.map((d) => d.date)
.filter((d): d is string => !!d)
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
return { min: dates[0], max: dates[dates.length - 1] };
}
/** A booking linked to a stop takes that stop's day while it has no date of its own. */
export function withLinkedStopDay<F extends Pick<ReservationFields, 'reservation_time'>>(prev: F, dayDate: string): F {
return prev.reservation_time ? prev : { ...prev, reservation_time: dayDate };
}
/** A trip place picked for the booking (#1353) lends it a title and an address it does not have yet. */
export function withLinkedPlace<F extends Pick<ReservationFields, 'place_id' | 'title' | 'location'>>(
prev: F,
value: string | number,
places: Pick<Place, 'id' | 'name' | 'address'>[]
): F {
const p = places.find((pl) => pl.id === value);
const next = { ...prev, place_id: value };
if (value && p) {
if (!prev.title) next.title = p.name;
if (!prev.location && p.address) next.location = p.address;
}
return next;
}
/**
* A hotel picked for the stay lends the booking a missing title and shows its address;
* a typed address stays when the hotel has none.
*/
export function withHotelPlace<F extends Pick<ReservationFields, 'hotel_place_id' | 'title' | 'hotel_address'>>(
prev: F,
value: string | number,
places: Pick<Place, 'id' | 'name' | 'address'>[]
): F {
const p = places.find((pl) => pl.id === value);
const next = { ...prev, hotel_place_id: value };
if (value && p) {
if (!prev.title) next.title = p.name;
next.hotel_address = p.address || prev.hotel_address;
}
return next;
}
const dayIndex = (days: { id: number }[], id: string | number) => days.findIndex((d) => d.id === id);
/** A new first night; a stay that would end before it is pulled along to it. */
export function withHotelStart<F extends Pick<ReservationFields, 'hotel_start_day' | 'hotel_end_day'>>(
prev: F,
value: string | number,
days: { id: number }[]
): F {
return {
...prev,
hotel_start_day: value,
hotel_end_day: dayIndex(days, value) > dayIndex(days, prev.hotel_end_day) ? value : prev.hotel_end_day,
};
}
/** A new last day; a stay that would start after it is pulled back to it. */
export function withHotelEnd<F extends Pick<ReservationFields, 'hotel_start_day' | 'hotel_end_day'>>(
prev: F,
value: string | number,
days: { id: number }[]
): F {
return {
...prev,
hotel_start_day: dayIndex(days, value) < dayIndex(days, prev.hotel_start_day) ? value : prev.hotel_start_day,
hotel_end_day: value,
};
}
export interface ReservationSaveOptions {
/** The stop the booking hangs on; the desktop keeps it in its form, the phone beside it. */
assignmentId: string | number;
/** Whether a saved booking is edited, which leaves its endpoints alone. */
isEdit: boolean;
/** The desktop also saves the end of the check-in window. */
withCheckInEnd: boolean;
}
export type ReservationSaveData = Record<string, unknown> & { title: string };
/** The request body the form saves as, with the stay a hotel booking with days creates or updates. */
export function reservationSaveData(
form: ReservationFields & CheckInEndField,
opts: ReservationSaveOptions
): ReservationSaveData {
const isHotel = form.type === 'hotel';
const metadata: Record<string, string> = {};
if (isHotel) {
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time;
if (opts.withCheckInEnd && form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time;
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time;
}
let combinedEndTime = form.reservation_end_time;
if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date;
} else if (form.reservation_end_time && form.reservation_time) {
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`;
}
const saveData: ReservationSaveData = {
title: form.title,
type: form.type,
status: form.status,
reservation_time: isHotel ? null : form.reservation_time || null,
reservation_end_time: isHotel ? null : combinedEndTime || null,
// Hotels show the address field instead of location; persist it on the
// reservation itself so it survives even without days/place (#1496).
location: isHotel ? form.hotel_address : form.location,
confirmation_number: form.confirmation_number,
notes: form.notes,
url: form.url,
assignment_id: isHotel && !form.accommodation_id ? null : opts.assignmentId || null,
accommodation_id: isHotel ? form.accommodation_id || null : null,
// Hotels link a place through the accommodation record; every other type links
// the picked trip place/activity directly on the reservation (#1353).
place_id: isHotel ? null : form.place_id || null,
// An empty object, not null: null clears the column outright, and that took the
// mirrored booking price with it on every edit of a type that fills no metadata
// of its own (#2233). An object still clears what the form dropped, and lets the
// server carry the price across.
metadata,
// Omitted on an edit: the server replaces the endpoint set whenever the key is
// present, and this form never edits endpoints, so sending an empty list would
// drop a transit booking's stations (#2216).
...(opts.isEdit ? {} : { endpoints: [] }),
needs_review: false,
};
if (isHotel && (form.hotel_start_day || form.hotel_end_day)) {
saveData.create_accommodation = {
place_id: form.hotel_place_id || null,
// No existing place picked but we have an address/name (e.g. a reviewed
// import): the save handler geocodes it and creates the place.
venue:
!form.hotel_place_id && (form.hotel_address || form.title)
? { name: form.title, address: form.hotel_address || null }
: null,
// The typed address, so the save handler can write it through to a linked
// place; an edited address used to be silently dropped (#1496).
address: form.hotel_address || null,
// Tolerate a single resolved end of the range (a one-night stay or a date that
// only matched one trip day) so the accommodation is still created.
start_day_id: form.hotel_start_day || form.hotel_end_day,
end_day_id: form.hotel_end_day || form.hotel_start_day,
check_in: form.meta_check_in_time || null,
...(opts.withCheckInEnd ? { check_in_end: form.meta_check_in_end_time || null } : {}),
check_out: form.meta_check_out_time || null,
confirmation: form.confirmation_number || null,
};
}
return saveData;
}
@@ -0,0 +1,54 @@
// FE-PLANNER-BOOKINGEXPENSE-001 to -004: the expense wish of the booking and
// transport forms, desktop and phone alike.
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { buildBudgetItem } from '../../../tests/helpers/factories';
import { useBookingExpenseIntent } from './useBookingExpenseIntent';
describe('useBookingExpenseIntent', () => {
it('FE-PLANNER-BOOKINGEXPENSE-001: create notes the wish before the save it starts reads it', () => {
const seen: unknown[] = [];
const { result } = renderHook(() =>
useBookingExpenseIntent(async () => {
seen.push(result.current.take());
})
);
act(() => result.current.create());
expect(seen).toEqual([{ create: true }]);
expect(result.current.take()).toBeNull();
});
it('FE-PLANNER-BOOKINGEXPENSE-002: edit saves first with the linked item, and reset forgets an old wish', () => {
const submit = vi.fn(async () => undefined);
const item = buildBudgetItem({ id: 7 });
const { result } = renderHook(() => useBookingExpenseIntent(submit));
act(() => result.current.edit(item));
expect(submit).toHaveBeenCalledTimes(1);
expect(result.current.take()).toEqual({ editItem: item });
expect(result.current.take()).toBeNull();
act(() => result.current.create());
result.current.reset();
expect(result.current.take()).toBeNull();
});
it('FE-PLANNER-BOOKINGEXPENSE-003: remove deletes the linked expense and reports a failure', async () => {
const remove = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('nope'));
const onError = vi.fn();
const item = buildBudgetItem({ id: 3 });
const { result } = renderHook(() => useBookingExpenseIntent(async () => undefined, { remove, onError }));
await result.current.remove(item);
expect(remove).toHaveBeenCalledWith(item);
expect(onError).not.toHaveBeenCalled();
await result.current.remove(item);
expect(onError).toHaveBeenCalledTimes(1);
});
it('FE-PLANNER-BOOKINGEXPENSE-004: without a removal, as on the phone, remove does nothing', async () => {
const submit = vi.fn(async () => undefined);
const { result } = renderHook(() => useBookingExpenseIntent(submit));
await expect(result.current.remove(buildBudgetItem())).resolves.toBeUndefined();
expect(submit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,52 @@
import { useRef } from 'react';
import type { BudgetItem } from '../../types';
import type { BookingExpenseIntent } from './bookingFormModel';
export interface BookingExpenseRemoval {
/** Deletes the expense linked to the booking. */
remove: (item: BudgetItem) => Promise<unknown>;
/** Called when the delete fails. */
onError: () => void;
}
/**
* The expense the user asked for from a booking form, held in a ref (not state) so
* the save started by the same click reads it. Behind the desktop booking and
* transport dialogs and the phone's booking and transport sheets: `create` (and on
* the desktop `edit`) note the wish and save; the form reads it with `take` at the
* point it always did and opens the editor for the saved booking.
*/
export function useBookingExpenseIntent(submit: () => Promise<unknown>, removal?: BookingExpenseRemoval) {
const intentRef = useRef<BookingExpenseIntent | null>(null);
return {
/** Forgets a wish left from an earlier opening. */
reset: () => {
intentRef.current = null;
},
/** Reads the wish and clears it. */
take: (): BookingExpenseIntent | null => {
const intent = intentRef.current;
intentRef.current = null;
return intent;
},
create: () => {
intentRef.current = { create: true };
void submit();
},
/** Saves the booking first, then opens the linked expense. */
edit: (item: BudgetItem) => {
intentRef.current = { editItem: item };
void submit();
},
remove: async (item: BudgetItem) => {
if (!removal) return;
try {
await removal.remove(item);
} catch {
removal.onError();
}
},
};
}
@@ -0,0 +1,96 @@
// FE-PLANNER-BOOKINGFILES-001 to -005: the Files row of the desktop booking and
// transport dialogs.
import { act, renderHook } from '@testing-library/react';
import type React from 'react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../tests/helpers/factories';
import type { TripFile } from '../../types';
import { type BookingFileAttachOptions, useBookingFileAttach } from './useBookingFileAttach';
type FileEvent = React.ChangeEvent<HTMLInputElement> & { target: { value: string } };
function changeEvent(file?: File): FileEvent {
const target = { files: file ? [file] : [], value: 'picked.pdf' };
return { target } as unknown as FileEvent;
}
function setup(overrides: Partial<BookingFileAttachOptions> = {}, files: TripFile[] = []) {
const toast = { success: vi.fn(), error: vi.fn() };
const t = (key: string) => key;
const view = renderHook(() => {
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const attach = useBookingFileAttach({
reservation: { id: 5, title: 'Hotel' },
files,
setPendingFiles,
toast,
t,
...overrides,
});
return { attach, pendingFiles };
});
return { ...view, toast };
}
describe('useBookingFileAttach', () => {
it('FE-PLANNER-BOOKINGFILES-001: a file on a saved booking goes up at once and the input clears', async () => {
const onFileUpload = vi.fn(async (_fd: FormData) => undefined);
const { result, toast } = setup({ onFileUpload });
const e = changeEvent(new File(['a'], 'a.pdf'));
await act(() => result.current.attach.handleFileChange(e));
const fd = onFileUpload.mock.calls[0][0];
expect(fd.get('reservation_id')).toBe('5');
expect(fd.get('description')).toBe('Hotel');
expect(toast.success).toHaveBeenCalledWith('reservations.toast.fileUploaded');
expect(e.target.value).toBe('');
expect(result.current.attach.uploadingFile).toBe(false);
expect(result.current.pendingFiles).toEqual([]);
});
it('FE-PLANNER-BOOKINGFILES-002: a failed upload, or a dialog without one, says so', async () => {
const onFileUpload = vi.fn(async () => {
throw new Error('boom');
});
const failing = setup({ onFileUpload });
await act(() => failing.result.current.attach.handleFileChange(changeEvent(new File(['a'], 'a.pdf'))));
expect(failing.toast.error).toHaveBeenCalledWith('reservations.toast.uploadError');
const without = setup({ onFileUpload: undefined });
await act(() => without.result.current.attach.handleFileChange(changeEvent(new File(['a'], 'a.pdf'))));
expect(without.toast.error).toHaveBeenCalledWith('reservations.toast.uploadError');
expect(without.toast.success).not.toHaveBeenCalled();
});
it('FE-PLANNER-BOOKINGFILES-003: on a new booking the file waits for the save and can be taken back', async () => {
const onFileUpload = vi.fn(async () => undefined);
const { result } = setup({ reservation: null, onFileUpload });
const a = new File(['a'], 'a.pdf');
const b = new File(['b'], 'b.pdf');
const e = changeEvent(a);
await act(() => result.current.attach.handleFileChange(e));
await act(() => result.current.attach.handleFileChange(changeEvent(b)));
await act(() => result.current.attach.handleFileChange(changeEvent()));
expect(onFileUpload).not.toHaveBeenCalled();
expect(e.target.value).toBe('');
expect(result.current.pendingFiles).toEqual([a, b]);
act(() => result.current.attach.removePending(0));
expect(result.current.pendingFiles).toEqual([b]);
});
it('FE-PLANNER-BOOKINGFILES-004: files linked and detached in the dialog show on the booking', () => {
const files = [buildTripFile({ id: 1, reservation_id: 5 }), buildTripFile({ id: 2, reservation_id: null })];
const { result } = setup({}, files);
expect(result.current.attach.attachedFiles.map((f) => f.id)).toEqual([1]);
act(() => result.current.attach.linkFile(2));
expect(result.current.attach.attachedFiles.map((f) => f.id)).toEqual([1, 2]);
act(() => result.current.attach.detachFile(2));
expect(result.current.attach.attachedFiles.map((f) => f.id)).toEqual([1]);
});
it('FE-PLANNER-BOOKINGFILES-005: a new booking lists no attached files', () => {
const { result } = setup({ reservation: null }, [buildTripFile({ id: 1, reservation_id: 5 })]);
expect(result.current.attach.attachedFiles).toEqual([]);
});
});
@@ -0,0 +1,72 @@
import type React from 'react';
import { type Dispatch, type SetStateAction, useState } from 'react';
import type { TripFile } from '../../types';
import { attachedBookingFiles } from './bookingFormModel';
export interface BookingFileAttachOptions {
/** The booking being edited; files picked before it is saved wait in `pendingFiles`. */
reservation: { id: number; title: string } | null | undefined;
/** The trip's files. */
files: TripFile[];
onFileUpload?: (fd: FormData) => Promise<unknown>;
setPendingFiles: Dispatch<SetStateAction<File[]>>;
/** The toast and translate the dialog already holds. */
toast: { success: (message: string) => void; error: (message: string) => void };
t: (key: string) => string;
}
/**
* The Files row of the desktop booking and transport dialogs: a file picked on a
* saved booking goes up at once, one picked on a new booking waits for the save,
* and the files linked or detached in the dialog show on the booking right away.
*/
export function useBookingFileAttach({
reservation,
files,
onFileUpload,
setPendingFiles,
toast,
t,
}: BookingFileAttachOptions) {
const [uploadingFile, setUploadingFile] = useState(false);
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([]);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (reservation?.id) {
setUploadingFile(true);
try {
if (onFileUpload) {
const fd = new FormData();
fd.append('file', file);
fd.append('reservation_id', String(reservation.id));
fd.append('description', reservation.title);
await onFileUpload(fd);
toast.success(t('reservations.toast.fileUploaded'));
} else {
// A dialog without an upload reports a failed upload, as it always did.
toast.error(t('reservations.toast.uploadError'));
}
} catch {
toast.error(t('reservations.toast.uploadError'));
} finally {
setUploadingFile(false);
e.target.value = '';
}
} else {
setPendingFiles((prev) => [...prev, file]);
e.target.value = '';
}
};
return {
uploadingFile,
handleFileChange,
attachedFiles: attachedBookingFiles(files, reservation?.id, linkedFileIds),
removePending: (index: number) => setPendingFiles((prev) => prev.filter((_, j) => j !== index)),
linkFile: (fileId: number) => setLinkedFileIds((prev) => [...prev, fileId]),
detachFile: (fileId: number) => setLinkedFileIds((prev) => prev.filter((id) => id !== fileId)),
};
}
@@ -0,0 +1,94 @@
// FE-PLANNER-RESFORMHOOK-001 to -005: the booking form state behind the desktop
// ReservationModal and the phone's booking sheet.
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { buildDay, buildPlace } from '../../../tests/helpers/factories';
import type { ReservationFields } from './reservationFormModel';
import { useReservationForm } from './useReservationForm';
const EMPTY: ReservationFields = {
title: '',
type: 'other',
status: 'pending',
reservation_time: '',
reservation_end_time: '',
end_date: '',
location: '',
confirmation_number: '',
notes: '',
url: '',
place_id: '',
accommodation_id: '',
meta_check_in_time: '',
meta_check_out_time: '',
hotel_place_id: '',
hotel_start_day: '',
hotel_end_day: '',
hotel_address: '',
};
const days = [buildDay({ id: 1, date: '2026-07-01' }), buildDay({ id: 2, date: '2026-07-02' })];
const places = [buildPlace({ id: 5, name: 'Castle', address: 'Hill 1' })];
describe('useReservationForm', () => {
it('FE-PLANNER-RESFORMHOOK-001: opens on the empty form, bounded by the trip, and fields follow the edits', () => {
const { result } = renderHook(() => useReservationForm(EMPTY, days, places));
expect(result.current.form).toBe(EMPTY);
expect(result.current.dateBounds).toEqual({ min: '2026-07-01', max: '2026-07-02' });
act(() => result.current.set('title', 'Tour'));
act(() => result.current.toggleTraveler(3));
expect(result.current.form.title).toBe('Tour');
expect([...result.current.travelerIds]).toEqual([3]);
act(() => result.current.toggleTraveler(3));
expect(result.current.travelerIds.size).toBe(0);
});
it('FE-PLANNER-RESFORMHOOK-002: the start date and time are set apart and the end check follows', () => {
const { result } = renderHook(() => useReservationForm(EMPTY, days, places));
act(() => result.current.setStartTime('09:00', '2026-07-01'));
expect(result.current.form.reservation_time).toBe('2026-07-01T09:00');
expect([result.current.startDate, result.current.startTime]).toEqual(['2026-07-01', '09:00']);
act(() => result.current.setStartDate('2026-07-02'));
expect(result.current.form.reservation_time).toBe('2026-07-02T09:00');
expect(result.current.isEndBeforeStart).toBe(false);
act(() => result.current.set('end_date', '2026-07-01'));
expect(result.current.isEndBeforeStart).toBe(true);
act(() => result.current.setStartDate(''));
expect(result.current.form.reservation_time).toBe('');
act(() => result.current.takeStopDay('2026-07-01'));
expect(result.current.form.reservation_time).toBe('2026-07-01');
act(() => result.current.takeStopDay('2026-07-02'));
expect(result.current.form.reservation_time).toBe('2026-07-01');
});
it('FE-PLANNER-RESFORMHOOK-003: picking a place or a hotel fills the title and the address', () => {
const { result } = renderHook(() => useReservationForm(EMPTY, days, places));
act(() => result.current.pickPlace(5));
expect(result.current.form).toMatchObject({ place_id: 5, title: 'Castle', location: 'Hill 1' });
act(() => result.current.pickHotelPlace(5));
expect(result.current.form).toMatchObject({ hotel_place_id: 5, hotel_address: 'Hill 1' });
});
it('FE-PLANNER-RESFORMHOOK-004: the stay range keeps its order in the trip', () => {
const { result } = renderHook(() => useReservationForm(EMPTY, days, places));
act(() => result.current.pickHotelStart(1));
act(() => result.current.pickHotelEnd(2));
expect(result.current.form).toMatchObject({ hotel_start_day: 1, hotel_end_day: 2 });
act(() => result.current.pickHotelStart(2));
expect(result.current.form).toMatchObject({ hotel_start_day: 2, hotel_end_day: 2 });
act(() => result.current.pickHotelEnd(1));
expect(result.current.form).toMatchObject({ hotel_start_day: 1, hotel_end_day: 1 });
});
it('FE-PLANNER-RESFORMHOOK-005: the save data is built from what the form holds now', () => {
const { result } = renderHook(() => useReservationForm(EMPTY, days, places));
act(() => result.current.set('title', 'Show'));
act(() => result.current.setStartTime('20:00', '2026-07-02'));
expect(result.current.saveData({ assignmentId: 4, isEdit: false, withCheckInEnd: false })).toMatchObject({
title: 'Show',
reservation_time: '2026-07-02T20:00',
assignment_id: 4,
endpoints: [],
});
});
});
@@ -0,0 +1,74 @@
import { useMemo, useState } from 'react';
import type { Day, Place } from '../../types';
import { toggledTraveler } from './bookingFormModel';
import {
type CheckInEndField,
isEndBeforeStart,
type ReservationFields,
reservationSaveData,
type ReservationSaveOptions,
startParts,
tripDateBounds,
withHotelEnd,
withHotelPlace,
withHotelStart,
withLinkedPlace,
withLinkedStopDay,
withStartDate,
withStartTime,
} from './reservationFormModel';
/**
* The booking form behind the desktop ReservationModal and the phone's booking
* sheet: the fields, the travellers and the files picked before saving, the date
* rules and the place and hotel picks. Each shell decides when to fill it and what
* to do around a save.
*/
export function useReservationForm<F extends ReservationFields & CheckInEndField>(
emptyForm: F,
days: Day[],
places: Pick<Place, 'id' | 'name' | 'address'>[]
) {
const [form, setForm] = useState<F>(emptyForm);
const [isSaving, setIsSaving] = useState(false);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
// Travelers assigned to this booking (#1517), seeded on open and persisted after the save resolves.
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set());
const set = (field: keyof F, value: string | number) => setForm((prev) => ({ ...prev, [field]: value }));
const toggleTraveler = (id: number) => setTravelerIds((prev) => toggledTraveler(prev, id));
// Restrict non-hotel booking dates to the trip's span (#1662). Hotels already
// constrain to trip days via their day dropdowns.
const dateBounds = useMemo(() => tripDateBounds(days || []), [days]);
const start = startParts(form.reservation_time);
return {
form,
setForm,
set,
isSaving,
setIsSaving,
pendingFiles,
setPendingFiles,
travelerIds,
setTravelerIds,
toggleTraveler,
isEndBeforeStart: isEndBeforeStart(form),
dateBounds,
startDate: start.date,
startTime: start.time,
setStartDate: (date: string | null | undefined) =>
set('reservation_time', withStartDate(form.reservation_time, date)),
setStartTime: (time: string, fallbackDate: string) =>
set('reservation_time', withStartTime(form.reservation_time, time, fallbackDate)),
/** An undated booking takes the day of the stop it was just linked to. */
takeStopDay: (dayDate: string) => setForm((prev) => withLinkedStopDay(prev, dayDate)),
pickPlace: (value: string | number) => setForm((prev) => withLinkedPlace(prev, value, places)),
pickHotelPlace: (value: string | number) => setForm((prev) => withHotelPlace(prev, value, places)),
pickHotelStart: (value: string | number) => setForm((prev) => withHotelStart(prev, value, days)),
pickHotelEnd: (value: string | number) => setForm((prev) => withHotelEnd(prev, value, days)),
saveData: (opts: ReservationSaveOptions) => reservationSaveData(form, opts),
};
}