import { useState, useEffect, useRef, useMemo } from 'react' import { useParams } from 'react-router-dom' import apiClient from '../../api/client' import { useTripStore } from '../../store/tripStore' import { useAddonStore } from '../../store/addonStore' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import CustomTimePicker from '../shared/CustomTimePicker' import { openFile } from '../../utils/fileDownload' import { resolveDayId } from '../../utils/formatters' import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types' import { BookingCostsSection } from './BookingCostsSection' import type { BookingExpenseRequest } from './BookingCostsSection.types' import type { BookingReviewDraft } from './parsedItemToDraft' import { typeToCostCategory } from '@trek/shared' 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: 'other', labelKey: 'reservations.type.other', Icon: FileText }, ] function buildAssignmentOptions(days, assignments, t, locale) { const options = [] for (const day of (days || [])) { const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index) if (da.length === 0) continue const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number }) const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : '' const groupLabel = `${dayLabel}${dateStr}` options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true }) for (let i = 0; i < da.length; i++) { const place = da[i].place if (!place) continue const timeStr = place.place_time ? ` · ${place.place_time}${place.end_time ? ' – ' + place.end_time : ''}` : '' options.push({ value: da[i].id, label: ` ${i + 1}. ${place.name}${timeStr}`, searchLabel: place.name, groupLabel, dayDate: day.date || null, }) } } return options } interface ReservationModalProps { isOpen: boolean onClose: () => void onSave: (data: Record & { title: string }) => Promise reservation: Reservation | null days: Day[] places: Place[] assignments: AssignmentsMap selectedDayId: number | null files?: TripFile[] onFileUpload?: (fd: FormData) => Promise onFileDelete: (fileId: number) => Promise accommodations?: Accommodation[] defaultAssignmentId?: number | null onOpenExpense?: (req: BookingExpenseRequest) => void // Pre-fill a brand-new booking from a parsed import item (review-before-save). // Distinct from `reservation`: the form is populated but stays in create mode. prefill?: BookingReviewDraft | null } export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense, prefill = null }: ReservationModalProps) { const { id: tripId } = useParams<{ id: string }>() const loadFiles = useTripStore(s => s.loadFiles) const toast = useToast() const { t, locale } = useTranslation() const fileInputRef = useRef(null) 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: '', assignment_id: '' as string | number, accommodation_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: '', }) const [isSaving, setIsSaving] = useState(false) const [uploadingFile, setUploadingFile] = useState(false) const [pendingFiles, setPendingFiles] = useState([]) const [showFilePicker, setShowFilePicker] = useState(false) const [linkedFileIds, setLinkedFileIds] = useState([]) const assignmentOptions = useMemo( () => buildAssignmentOptions(days, assignments, t, locale), [days, assignments, t, locale] ) useEffect(() => { // Match an existing place by name (exact, then loose contains) for hotels. const matchPlaceId = (name: string | undefined): string | number => { const n = (name || '').trim().toLowerCase() if (!n) return '' const exact = places.find(p => p.name?.trim().toLowerCase() === n) if (exact) return exact.id const loose = places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase()))) return loose?.id ?? '' } if (reservation) { const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {}) 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 || '', assignment_id: reservation.assignment_id || '', accommodation_id: reservation.accommodation_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 || '', hotel_address: places.find(p => p.id == editAcc?.place_id)?.address || '', }) } 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 || '', assignment_id: defaultAssignmentId ?? '', accommodation_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: '', assignment_id: defaultAssignmentId ?? '', accommodation_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: '', }) setPendingFiles([]) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId, days, places, accommodations]) // Re-hydrate hotel day range when the accommodations prop arrives after the modal opens // (race: tripAccommodations fetch may complete after isOpen fires, leaving hotel fields empty) useEffect(() => { if (!isOpen || !reservation || reservation.type !== 'hotel' || !reservation.accommodation_id) return const acc = accommodations.find(a => a.id == reservation.accommodation_id) if (!acc) return setForm(prev => { if (prev.hotel_place_id !== '' || prev.hotel_start_day !== '' || prev.hotel_end_day !== '') return prev return { ...prev, hotel_place_id: acc.place_id, hotel_start_day: acc.start_day_id, hotel_end_day: acc.end_day_id } }) }, [accommodations, isOpen, reservation]) const set = (field, value) => setForm(prev => ({ ...prev, [field]: value })) const isEndBeforeStart = (() => { if (!form.end_date || !form.reservation_time) return false const startDate = form.reservation_time.split('T')[0] const startTime = form.reservation_time.split('T')[1] || '00:00' const endTime = form.reservation_end_time || '00:00' const startFull = `${startDate}T${startTime}` const endFull = `${form.end_date}T${endTime}` return endFull <= startFull })() const 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), location: form.location, confirmation_number: form.confirmation_number, notes: form.notes, assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null), accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null, metadata: Object.keys(metadata).length > 0 ? metadata : null, 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, // 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, } } // 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 pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : {} const price = Number(pmeta.price) if (Number.isFinite(price) && price > 0) { saveData.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) } } } const saved = await onSave(saveData) 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) } } // 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) } }) } } finally { setIsSaving(false) } } const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() } const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; 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. const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : null const prefillPrice = Number(prefillMeta?.price) const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0 ? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) } : 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 inputClass = 'w-full border border-edge rounded-[10px] px-[12px] py-[8px] text-[13px] font-[inherit] outline-none box-border text-content bg-surface-input' const labelClass = 'block text-[11px] font-semibold text-content-faint mb-[5px] uppercase tracking-[0.03em]' return ( } >
{/* Type selector */}
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => ( ))}
{/* Title */}
set('title', e.target.value)} required placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
{/* Assignment Picker (hidden for hotels) */} {form.type !== 'hotel' && assignmentOptions.length > 0 && (
{ 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 } }) } }} placeholder={t('reservations.pickAssignment')} options={[ { value: '', label: t('reservations.noAssignment') }, ...assignmentOptions, ]} searchable size="sm" />
)} {/* Start Date/Time + End Date/Time + Status (hidden for hotels) */} {form.type !== 'hotel' && ( <>
{ 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) : '') }} />
{ 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 || new Date().toISOString().split('T')[0] set('reservation_time', tm ? `${date}T${tm}` : date) }} />
set('end_date', d || '')} />
set('reservation_end_time', v)} />
{isEndBeforeStart && (
{t('reservations.validation.endBeforeStart')}
)} )} {/* Location */} {form.type !== 'hotel' && (
set('location', e.target.value)} placeholder={t('reservations.locationPlaceholder')} className={inputClass} />
)} {/* Booking Code + Status */}
set('confirmation_number', e.target.value)} placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
set('status', value)} options={[ { value: 'pending', label: t('reservations.pending') }, { value: 'confirmed', label: t('reservations.confirmed') }, ]} size="sm" />
{/* Hotel fields */} {form.type === 'hotel' && ( <>
{ const p = places.find(pl => pl.id === value) setForm(prev => { const next = { ...prev, hotel_place_id: value } if (!value) { next.location = '' } else if (p) { if (!prev.title) next.title = p.name if (!prev.location && p.address) next.location = p.address } return next }) }} placeholder={t('reservations.meta.pickHotel')} options={[ { value: '', label: '—' }, ...places.map(p => ({ value: p.id, label: p.name })), ]} searchable size="sm" />
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, }))} placeholder={t('reservations.meta.selectDay')} options={days.map(d => { const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined return { value: d.id, label: d.title || t('dayplan.dayN', { n: d.day_number }), badge: dateBadge ?? dayBadge, } })} size="sm" />
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, }))} placeholder={t('reservations.meta.selectDay')} options={days.map(d => { const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined return { value: d.id, label: d.title || t('dayplan.dayN', { n: d.day_number }), badge: dateBadge ?? dayBadge, } })} size="sm" />
set('hotel_address', e.target.value)} placeholder={t('reservations.locationPlaceholder')} className={inputClass} />
set('meta_check_in_time', v)} />
set('meta_check_in_end_time', v)} />
set('meta_check_out_time', v)} />
)} {/* Notes */}