import { useState, useEffect, useRef, useMemo } from 'react' import { useParams } from 'react-router-dom' import apiClient from '../../api/client' import { useTripStore } from '../../store/tripStore' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { Plane, Hotel, Utensils, Train, Car, Ship, 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 type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types' const TYPE_OPTIONS = [ { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane }, { value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel }, { value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils }, { value: 'train', labelKey: 'reservations.type.train', Icon: Train }, { value: 'car', labelKey: 'reservations.type.car', Icon: Car }, { value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship }, { 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}` // Group header (non-selectable) 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) => Promise | void reservation: Reservation | null days: Day[] places: Place[] assignments: AssignmentsMap selectedDayId: number | null files?: TripFile[] onFileUpload?: (fd: FormData) => Promise onFileDelete: (fileId: number) => Promise accommodations?: Accommodation[] } export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: 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 budgetItems = useTripStore(s => s.budgetItems) const budgetCategories = useMemo(() => { const cats = new Set() budgetItems.forEach(i => { if (i.category) cats.add(i.category) }) return Array.from(cats).sort() }, [budgetItems]) const [form, setForm] = useState({ title: '', type: 'other', status: 'pending', reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', notes: '', assignment_id: '', accommodation_id: '', price: '', budget_category: '', meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '', meta_departure_timezone: '', meta_arrival_timezone: '', meta_train_number: '', meta_platform: '', meta_seat: '', meta_check_in_time: '', meta_check_out_time: '', hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', }) const [isSaving, setIsSaving] = useState(false) const [uploadingFile, setUploadingFile] = useState(false) const [pendingFiles, setPendingFiles] = useState([]) const [showFilePicker, setShowFilePicker] = useState(false) const [linkedFileIds, setLinkedFileIds] = useState([]) const [unlinkedFileIds, setUnlinkedFileIds] = useState([]) const assignmentOptions = useMemo( () => buildAssignmentOptions(days, assignments, t, locale), [days, assignments, t, locale] ) useEffect(() => { if (reservation) { const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {}) // Parse end_date from reservation_end_time if it's a full ISO datetime 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) || '' } 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_airline: meta.airline || '', meta_flight_number: meta.flight_number || '', meta_departure_airport: meta.departure_airport || '', meta_arrival_airport: meta.arrival_airport || '', meta_departure_timezone: meta.departure_timezone || '', meta_arrival_timezone: meta.arrival_timezone || '', meta_train_number: meta.train_number || '', meta_platform: meta.platform || '', meta_seat: meta.seat || '', meta_check_in_time: meta.check_in_time || '', meta_check_out_time: meta.check_out_time || '', hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(), hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(), hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(), price: meta.price || '', budget_category: meta.budget_category || '', }) } else { setForm({ title: '', type: 'other', status: 'pending', reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', notes: '', assignment_id: '', accommodation_id: '', price: '', budget_category: '', meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '', meta_departure_timezone: '', meta_arrival_timezone: '', meta_train_number: '', meta_platform: '', meta_seat: '', meta_check_in_time: '', meta_check_out_time: '', }) setPendingFiles([]) } }, [reservation, isOpen, selectedDayId]) const set = (field, value) => setForm(prev => ({ ...prev, [field]: value })) // Validate that end datetime is after start datetime 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) => { 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 === 'flight') { if (form.meta_airline) metadata.airline = form.meta_airline if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport if (form.meta_departure_timezone) metadata.departure_timezone = form.meta_departure_timezone if (form.meta_arrival_timezone) metadata.arrival_timezone = form.meta_arrival_timezone } else if (form.type === 'hotel') { 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 } else if (form.type === 'train') { if (form.meta_train_number) metadata.train_number = form.meta_train_number if (form.meta_platform) metadata.platform = form.meta_platform if (form.meta_seat) metadata.seat = form.meta_seat } // Combine end_date + end_time into reservation_end_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 } if (form.price) metadata.price = form.price if (form.budget_category) metadata.budget_category = form.budget_category const saveData: Record = { title: form.title, type: form.type, status: form.status, reservation_time: form.reservation_time, reservation_end_time: combinedEndTime, location: form.location, confirmation_number: form.confirmation_number, notes: form.notes, assignment_id: form.assignment_id || null, accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null, metadata: Object.keys(metadata).length > 0 ? metadata : null, } // Auto-create budget entry if price is set if (form.price && parseFloat(form.price) > 0) { saveData.create_budget_entry = { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other', } } // If hotel with place + days, pass hotel data for auto-creation or update if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) { saveData.create_accommodation = { place_id: form.hotel_place_id, start_day_id: form.hotel_start_day, end_day_id: form.hotel_end_day, check_in: form.meta_check_in_time || null, check_out: form.meta_check_out_time || null, confirmation: form.confirmation_number || null, } } 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', saved.id) fd.append('description', form.title) await onFileUpload(fd) } } } finally { setIsSaving(false) } } 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', 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 inputStyle = { width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)', } const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' } return (
{/* Type selector */}
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => ( ))}
{/* Title */}
set('title', e.target.value)} required placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
{/* 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 [, t] = (form.reservation_time || '').split('T') set('reservation_time', d ? (t ? `${d}T${t}` : d) : '') }} />
{ const [, t] = (form.reservation_time || '').split('T'); return t || '' })()} onChange={t => { const [d] = (form.reservation_time || '').split('T') const date = d || new Date().toISOString().split('T')[0] set('reservation_time', t ? `${date}T${t}` : date) }} />
{form.type === 'flight' && (
set('meta_departure_timezone', e.target.value)} placeholder="e.g. CET, UTC+1" style={inputStyle} />
)}
set('end_date', d || '')} />
set('reservation_end_time', v)} />
{form.type === 'flight' && (
set('meta_arrival_timezone', e.target.value)} placeholder="e.g. JST, UTC+9" style={inputStyle} />
)}
{isEndBeforeStart && (
{t('reservations.validation.endBeforeStart')}
)}
set('status', value)} options={[ { value: 'pending', label: t('reservations.pending') }, { value: 'confirmed', label: t('reservations.confirmed') }, ]} size="sm" />
)} {/* Location + Booking Code */}
set('location', e.target.value)} placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
set('confirmation_number', e.target.value)} placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
{/* Type-specific fields */} {form.type === 'flight' && (
set('meta_airline', e.target.value)} placeholder="Lufthansa" style={inputStyle} />
set('meta_flight_number', e.target.value)} placeholder="LH 123" style={inputStyle} />
set('meta_departure_airport', e.target.value)} placeholder="FRA" style={inputStyle} />
set('meta_arrival_airport', e.target.value)} placeholder="NRT" style={inputStyle} />
)} {form.type === 'hotel' && ( <> {/* Hotel place + day range */}
{ set('hotel_place_id', value) const p = places.find(pl => pl.id === value) if (p) { if (!form.title) set('title', p.name) if (!form.location && p.address) set('location', p.address) } }} placeholder={t('reservations.meta.pickHotel')} options={[ { value: '', label: '—' }, ...places.map(p => ({ value: p.id, label: p.name })), ]} searchable size="sm" />
set('hotel_start_day', value)} placeholder={t('reservations.meta.selectDay')} options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))} size="sm" />
set('hotel_end_day', value)} placeholder={t('reservations.meta.selectDay')} options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))} size="sm" />
{/* Check-in/out times + Status */}
set('meta_check_in_time', v)} />
set('meta_check_out_time', v)} />
set('status', value)} options={[ { value: 'pending', label: t('reservations.pending') }, { value: 'confirmed', label: t('reservations.confirmed') }, ]} size="sm" />
)} {form.type === 'train' && (
set('meta_train_number', e.target.value)} placeholder="ICE 123" style={inputStyle} />
set('meta_platform', e.target.value)} placeholder="12" style={inputStyle} />
set('meta_seat', e.target.value)} placeholder="42A" style={inputStyle} />
)} {/* Notes */}