diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 1db64b8bf..99be43329 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -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([]) - const [linkedFileIds, setLinkedFileIds] = useState([]) - // Travelers assigned to this booking (#1517) — seeded on open, persisted after the save resolves. - const [travelerIds, setTravelerIds] = useState>(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 - 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 = {} - 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 & { 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 & { 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
{ 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} /> { 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())} /> @@ -517,17 +292,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p { - 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 { - 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 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 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 diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 413ccfc4c..1f3ce6959 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -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([]) + const attach = useBookingFileAttach({ reservation, files, onFileUpload, setPendingFiles, toast, t }) const fileInputRef = useRef(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) => { - 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 diff --git a/client/src/components/Planner/bookingFormModel.test.ts b/client/src/components/Planner/bookingFormModel.test.ts index 553b0d343..c6be97fc7 100644 --- a/client/src/components/Planner/bookingFormModel.test.ts +++ b/client/src/components/Planner/bookingFormModel.test.ts @@ -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); @@ -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([]); + }); }); diff --git a/client/src/components/Planner/bookingFormModel.ts b/client/src/components/Planner/bookingFormModel.ts index 5723ed84d..eab9b82b2 100644 --- a/client/src/components/Planner/bookingFormModel.ts +++ b/client/src/components/Planner/bookingFormModel.ts @@ -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) + ); +} diff --git a/client/src/components/Planner/reservationFormModel.test.ts b/client/src/components/Planner/reservationFormModel.test.ts new file mode 100644 index 000000000..c68387751 --- /dev/null +++ b/client/src/components/Planner/reservationFormModel.test.ts @@ -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); + 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) => ({ ...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({}); + }); +}); diff --git a/client/src/components/Planner/reservationFormModel.ts b/client/src/components/Planner/reservationFormModel.ts new file mode 100644 index 000000000..2e1fdba8c --- /dev/null +++ b/client/src/components/Planner/reservationFormModel.ts @@ -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[], + withCheckInEnd: true +): ReservationFields & Required; +export function reservationFieldsFrom( + res: Reservation, + accommodations: Accommodation[], + places: Pick[], + withCheckInEnd: false +): ReservationFields; +export function reservationFieldsFrom( + res: Reservation, + accommodations: Accommodation[], + places: Pick[], + 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; + +/** + * 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; +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; + 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; + +/** + * 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[]): { 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>(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>( + prev: F, + value: string | number, + places: Pick[] +): 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>( + prev: F, + value: string | number, + places: Pick[] +): 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>( + 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>( + 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 & { 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 = {}; + 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; +} diff --git a/client/src/components/Planner/useBookingExpenseIntent.test.ts b/client/src/components/Planner/useBookingExpenseIntent.test.ts new file mode 100644 index 000000000..c3c0f2f30 --- /dev/null +++ b/client/src/components/Planner/useBookingExpenseIntent.test.ts @@ -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(); + }); +}); diff --git a/client/src/components/Planner/useBookingExpenseIntent.ts b/client/src/components/Planner/useBookingExpenseIntent.ts new file mode 100644 index 000000000..01937019f --- /dev/null +++ b/client/src/components/Planner/useBookingExpenseIntent.ts @@ -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; + /** 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, removal?: BookingExpenseRemoval) { + const intentRef = useRef(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(); + } + }, + }; +} diff --git a/client/src/components/Planner/useBookingFileAttach.test.ts b/client/src/components/Planner/useBookingFileAttach.test.ts new file mode 100644 index 000000000..c63f756f2 --- /dev/null +++ b/client/src/components/Planner/useBookingFileAttach.test.ts @@ -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 & { 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 = {}, files: TripFile[] = []) { + const toast = { success: vi.fn(), error: vi.fn() }; + const t = (key: string) => key; + const view = renderHook(() => { + const [pendingFiles, setPendingFiles] = useState([]); + 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([]); + }); +}); diff --git a/client/src/components/Planner/useBookingFileAttach.ts b/client/src/components/Planner/useBookingFileAttach.ts new file mode 100644 index 000000000..211eeec18 --- /dev/null +++ b/client/src/components/Planner/useBookingFileAttach.ts @@ -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; + setPendingFiles: Dispatch>; + /** 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([]); + + const handleFileChange = async (e: React.ChangeEvent) => { + 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)), + }; +} diff --git a/client/src/components/Planner/useReservationForm.test.ts b/client/src/components/Planner/useReservationForm.test.ts new file mode 100644 index 000000000..72334f5c4 --- /dev/null +++ b/client/src/components/Planner/useReservationForm.test.ts @@ -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: [], + }); + }); +}); diff --git a/client/src/components/Planner/useReservationForm.ts b/client/src/components/Planner/useReservationForm.ts new file mode 100644 index 000000000..2d94a9018 --- /dev/null +++ b/client/src/components/Planner/useReservationForm.ts @@ -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( + emptyForm: F, + days: Day[], + places: Pick[] +) { + const [form, setForm] = useState(emptyForm); + const [isSaving, setIsSaving] = useState(false); + const [pendingFiles, setPendingFiles] = useState([]); + // Travelers assigned to this booking (#1517), seeded on open and persisted after the save resolves. + const [travelerIds, setTravelerIds] = useState>(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), + }; +} diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx index e035fc913..9d861ec27 100644 --- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx @@ -1,18 +1,20 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { Check, FileText, Hotel, Link2, ParkingSquare, Ticket, Users, Utensils } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { Check, Link2, Ticket } from 'lucide-react' import MSheet from '../../../components/MSheet' import { useAddonStore } from '../../../../store/addonStore' import { useTranslation } from '../../../../i18n' -import { resolveDayId } from '../../../../utils/formatters' -import { parseReservationMetadata } from '../../../../utils/flightLegs' -import { typeToCostCategory } from '@trek/shared' import CustomSelect from '../../../../components/shared/CustomSelect' import CustomTimePicker from '../../../../components/shared/CustomTimePicker' import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePicker' import { BookingCodeInput } from '../../../../components/shared/BookingCode' import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome' import MBookingFilesCosts from './MBookingFilesCosts' -import { uploadBookingFiles } from '../../../../components/Planner/bookingFormModel' +import { expenseRequestAfterSave, travelerIdsOf, travelersChanged, uploadBookingFiles } from '../../../../components/Planner/bookingFormModel' +import { useBookingExpenseIntent } from '../../../../components/Planner/useBookingExpenseIntent' +import { + RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill, +} from '../../../../components/Planner/reservationFormModel' +import { useReservationForm } from '../../../../components/Planner/useReservationForm' import { buildAssignmentOptions } from '../../../../components/Planner/assignmentOptions' import GuestBadge from '../../../../components/shared/GuestBadge' import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants' @@ -27,15 +29,6 @@ export interface MReservationSheetProps { onOpenExpense: (req: BookingExpenseRequest) => void } -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 }, -] - // Traveler picker row — same surface as the cost-split rows (bg on --m-ic). const TRAVELER_ROW_CLS = 'flex w-full items-center gap-[9px] rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[9px] text-start' @@ -70,19 +63,18 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) - const [form, setForm] = useState(EMPTY) - const [pendingFiles, setPendingFiles] = useState([]) + const { + form, setForm, set, isSaving, setIsSaving, pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler, + isEndBeforeStart, dateBounds, startDate, startTime, setStartDate, setStartTime, takeStopDay, + pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, saveData: buildSaveData, + } = useReservationForm(EMPTY, days, places) const assignmentOptions = useMemo( () => buildAssignmentOptions(days, assignments, t, locale), [days, assignments, t, locale], ) - // Travelers assigned to this booking (#1517) — seeded from the editing - // reservation on open, persisted separately after the save resolves. - const [travelerIds, setTravelerIds] = useState>(new Set()) - const [isSaving, setIsSaving] = useState(false) // Ref (not state) so handleSubmit reads the intent set by the same click. - const expenseIntentRef = useRef(false) + const expense = useBookingExpenseIntent(() => handleSubmit()) // Open-time snapshot so the sheet content survives the exit animation. const [snap, setSnap] = useState<{ res: typeof editingReservation; assignmentId: number | null }>( { res: null, assignmentId: null }, @@ -96,49 +88,16 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati if (!showReservationModal) return setSnap({ res: editingReservation, assignmentId: bookingForAssignmentId ?? null }) setAssignmentId(bookingForAssignmentId ?? editingReservation?.assignment_id ?? '') - expenseIntentRef.current = false + expense.reset() setPendingFiles([]) - setTravelerIds(new Set((editingReservation?.travelers || []).map(tv => tv.user_id))) + setTravelerIds(travelerIdsOf(editingReservation)) const res = editingReservation if (res) { - const meta = parseReservationMetadata(res) - const rawEnd = res.reservation_end_time || '' - let endDate = '', 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 acc = tripAccommodations.find(a => a.id == res.accommodation_id) - setForm({ - ...EMPTY, - 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 || '', 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 || '', - hotel_address: places.find(p => p.id == acc?.place_id)?.address || res.location || '', - }) + setForm({ ...EMPTY, ...reservationFieldsFrom(res, tripAccommodations, places, false) }) } else if (reservationPrefill) { const pf = reservationPrefill - const meta = (pf.metadata && typeof pf.metadata === 'object' ? pf.metadata : {}) as Record - const rawEnd = typeof pf.reservation_end_time === 'string' ? pf.reservation_end_time : '' - let endDate = '', 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({ - ...EMPTY, - 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 || '', 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 || '', - }) + setForm({ ...EMPTY, ...reservationFieldsFromPrefill(pf, days, false) }) setPendingFiles(pf._sourceFiles ?? []) } else { // Opened from a day's toolbar: start on that day rather than on a blank @@ -163,13 +122,6 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati const res = snap.res const isHotel = form.type === 'hotel' - const set = (field: keyof typeof EMPTY, value: string | number) => 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 - }) const TravelerAvatar = ({ m, idx, dim }: { m: TripMember; idx: number; dim: boolean }) => m.avatar_url @@ -185,20 +137,6 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati ) - const isEndBeforeStart = (() => { - if (isHotel || !form.end_date || !form.reservation_time) return false - const sDate = form.reservation_time.split('T')[0] - const sTime = form.reservation_time.split('T')[1] || '' - const eTime = 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 (!sTime || !eTime) return form.end_date < sDate - return `${form.end_date}T${eTime}` <= `${sDate}T${sTime}` - })() - - const startDate = (form.reservation_time || '').split('T')[0] || '' - const startTime = (form.reservation_time || '').split('T')[1] || '' - const fmtDate = (d?: string | null) => d ? new Date(`${d.slice(0, 10)}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : undefined @@ -212,9 +150,8 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati // Restrict non-hotel booking dates to the trip's span (#1662); hotels already // constrain to trip days via their day dropdowns. - const tripDates = days.map(d => d.date).filter((d): d is string => !!d).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) - const tripMinDate = tripDates[0] - const tripMaxDate = tripDates[tripDates.length - 1] + const tripMinDate = dateBounds.min + const tripMaxDate = dateBounds.max const handleClose = () => { if (importReviewActive) { advanceImportReview(); return } @@ -227,72 +164,23 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati const handleSubmit = async () => { // Only the costs button reaches this without the footer's date check. if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return } - const withExpense = expenseIntentRef.current - expenseIntentRef.current = false + const withExpense = expense.take() setIsSaving(true) try { - const metadata: Record = {} - if (isHotel) { - if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time - if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time - } - let combinedEndTime: string = 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 = `${startDate}T${form.reservation_end_time}` - } - const saveData: Record & { title: string } = { - title: form.title, type: form.type, status: form.status, - reservation_time: isHotel ? null : (form.reservation_time || null), - reservation_end_time: isHotel ? null : (combinedEndTime || null), - 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 : (assignmentId || null), - accommodation_id: isHotel ? (form.accommodation_id || null) : null, - 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 — 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 for the same reason as the desktop dialog: the - // server swaps the whole endpoint set when the key is present, and this - // sheet never edits endpoints (#2216). - ...(snap.res?.id ? {} : { endpoints: [] }), - needs_review: false, - } - if (isHotel && (form.hotel_start_day || form.hotel_end_day)) { - saveData.create_accommodation = { - place_id: form.hotel_place_id || null, - venue: (!form.hotel_place_id && (form.hotel_address || form.title)) - ? { name: form.title, address: form.hotel_address || null } : null, - address: form.hotel_address || null, - 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_out: form.meta_check_out_time || null, - confirmation: form.confirmation_number || null, - } - } + const saveData = buildSaveData({ assignmentId, isEdit: !!snap.res?.id, withCheckInEnd: false }) const saved = await handleSaveReservation(saveData as never) // Persist the traveler assignment once we have the reservation id (from the // save result on create, or the edited reservation) — only when it changed. const savedId = saved?.id ?? res?.id if (savedId) { - const original = (res?.travelers || []).map(tv => tv.user_id) - const next = [...travelerIds] - const changed = original.length !== next.length || next.some(id => !original.includes(id)) - if (changed) await setReservationTravelers(tripId, savedId, next) + const { changed, nextIds } = travelersChanged(res, travelerIds) + if (changed) await setReservationTravelers(tripId, savedId, nextIds) } if (saved?.id && canUploadFiles) { await uploadBookingFiles(fd => planner.tripActions.addFile(tripId, fd), saved.id, pendingFiles, form.title) } - if (withExpense && saved?.id) { - onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } }) - } + const expenseRequest = expenseRequestAfterSave(withExpense, saved?.id, form) + if (expenseRequest) onOpenExpense(expenseRequest) if (importReviewActive && saved) advanceImportReview() } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) @@ -355,7 +243,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati {t('reservations.date')} set('reservation_time', d ? (startTime ? `${d}T${startTime}` : d) : '')} + onChange={setStartDate} min={tripMinDate} max={tripMaxDate} /> @@ -364,10 +252,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati {t('reservations.startTime')} { - const d = startDate || days.find(dy => dy.id === selectedDayId)?.date || '' - set('reservation_time', tm ? `${d}T${tm}` : d) - }} + onChange={tm => setStartTime(tm, days.find(dy => dy.id === selectedDayId)?.date || '')} />
@@ -391,17 +276,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati {t('reservations.meta.linkPlace')} { - 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} options={placeOptions} placeholder={t('reservations.meta.pickPlace')} searchable @@ -425,17 +300,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati {t('reservations.meta.hotelPlace')} { - 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 - next.hotel_address = p.address || prev.hotel_address - } - return next - }) - }} + onChange={pickHotelPlace} options={hotelPlaceOptions} placeholder={t('reservations.meta.pickHotel')} searchable @@ -447,12 +312,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati {t('reservations.meta.fromDay')} 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} options={dayOptions} placeholder={t('reservations.meta.selectDay')} size="sm" @@ -462,12 +322,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati {t('reservations.meta.toDay')} 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} options={dayOptions} placeholder={t('reservations.meta.selectDay')} size="sm" @@ -586,7 +441,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati const opt = assignmentOptions.find(o => o.value === value) // Same courtesy as the desktop dialog: an undated booking takes // the day of the stop it was just linked to. - if (opt?.dayDate) setForm(prev => (prev.reservation_time ? prev : { ...prev, reservation_time: opt.dayDate! })) + if (opt?.dayDate) takeStopDay(opt.dayDate) }} placeholder={t('reservations.pickAssignment')} options={[{ value: '', label: t('reservations.noAssignment') }, ...assignmentOptions]} @@ -605,7 +460,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati canUploadFiles={canUploadFiles} showCosts={isBudgetEnabled} createDisabled={!form.title.trim() || isSaving} - onCreate={() => { expenseIntentRef.current = true; void handleSubmit() }} + onCreate={expense.create} onEdit={item => onOpenExpense({ editItem: item })} /> diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx index 8df8d56f7..81473481c 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx @@ -1,9 +1,8 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Check, ChevronDown, ChevronUp, Plus, TrainFront, TramFront, Trash2, X } from 'lucide-react' import MSheet from '../../../components/MSheet' import { useAddonStore } from '../../../../store/addonStore' import { useTranslation } from '../../../../i18n' -import { typeToCostCategory } from '@trek/shared' import CustomSelect from '../../../../components/shared/CustomSelect' import CustomTimePicker from '../../../../components/shared/CustomTimePicker' import { BookingCodeInput } from '../../../../components/shared/BookingCode' @@ -16,7 +15,8 @@ import { transportDayOptions, type StationWaypointForm, type WaypointForm, } from '../../../../components/Planner/transportEndpoints' import { useTransportForm } from '../../../../components/Planner/useTransportForm' -import { travelerIdsOf, travelersChanged, uploadBookingFiles } from '../../../../components/Planner/bookingFormModel' +import { expenseRequestAfterSave, travelerIdsOf, travelersChanged, uploadBookingFiles } from '../../../../components/Planner/bookingFormModel' +import { useBookingExpenseIntent } from '../../../../components/Planner/useBookingExpenseIntent' import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome' import MBookingFilesCosts from './MBookingFilesCosts' import GuestBadge from '../../../../components/shared/GuestBadge' @@ -73,7 +73,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo } = useTransportForm(EMPTY_TRANSPORT_FIELDS) // Ref (not state) so handleSubmit reads the intent set by the same click — a // state value would be stale in that render's closure and never open the editor. - const expenseIntentRef = useRef(false) + const expense = useBookingExpenseIntent(() => handleSubmit()) const [deleteArmed, setDeleteArmed] = useState(false) // Open-time snapshot so the sheet content survives the exit animation. const [snap, setSnap] = useState<{ res: Reservation | null; prefill: BookingReviewDraft | null }>({ res: null, prefill: null }) @@ -82,7 +82,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo if (!showTransportModal) return setSnap({ res: editingTransport, prefill: transportPrefill }) setAutomated(transportModalAutomated) - expenseIntentRef.current = false + expense.reset() setDeleteArmed(false) // On a review-import, seed the booking's Files with the parsed source document. setPendingFiles(!editingTransport && transportPrefill?._sourceFiles ? transportPrefill._sourceFiles : []) @@ -142,8 +142,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo const handleSubmit = async () => { if (!form.title.trim() || isSaving) return - const withExpense = expenseIntentRef.current - expenseIntentRef.current = false + const withExpense = expense.take() setIsSaving(true) try { const saved = await saveTransport(buildPayload(days, { @@ -162,9 +161,8 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo if (saved?.id && canUploadFiles) { await uploadBookingFiles(fd => planner.tripActions.addFile(tripId, fd), saved.id, pendingFiles, form.title) } - if (withExpense && saved?.id) { - onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } }) - } + const expenseRequest = expenseRequestAfterSave(withExpense, saved?.id, form) + if (expenseRequest) onOpenExpense(expenseRequest) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } finally { @@ -664,7 +662,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo canUploadFiles={canUploadFiles} showCosts={isBudgetEnabled} createDisabled={!form.title.trim() || isSaving} - onCreate={() => { expenseIntentRef.current = true; void handleSubmit() }} + onCreate={expense.create} onEdit={item => onOpenExpense({ editItem: item })} />