mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
64ceecefb3
Every booking and stay write went straight to the API, so offline a new
booking failed with "Network Error" and was lost, and a status tick, the
travellers or a moved transport snapped back. Bookings (add, edit, status,
travellers, delete, day order) and stays (add, edit, delete) now go through
reservationRepo and accommodationRepo and the mutation queue like places and
costs: a temporary row offline, held behind an older queued write online,
since neither carries a version token. Desktop, the phone sheets, the day
plan, the road trip nights and the PDF all write and read through them.
The replay reads the booking from `reservation` only, so the travellers
answer ({ travelers, reservation }) lands on the booking it names. A booking
or its stay that names a place or stay created offline is sent with the real
id once that one has synced, inside create_accommodation too, so an imported
hotel reviewed offline can be linked to a place made for it.
What only works online says so instead of failing: an AirTrail import, a
booking file import, the AI retry and an attachment on a saved booking show
that they need a connection, and a save with newly picked files is stopped
with a hint, since an upload cannot wait in the queue.
Refs #2601
639 lines
31 KiB
TypeScript
639 lines
31 KiB
TypeScript
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
|
||
import { useParams } from 'react-router'
|
||
import { X, Trash2, ChevronUp, ChevronDown, CalendarDays } from 'lucide-react'
|
||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||
import CustomSelect from '../shared/CustomSelect'
|
||
import { BookingCodeInput } from '../shared/BookingCode'
|
||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||
import { Tooltip } from '../shared/Tooltip'
|
||
import AirportSelect from './AirportSelect'
|
||
import LocationSelect from './LocationSelect'
|
||
import { toLocationPicks } from './locationPicks'
|
||
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, AssignmentsMap } from '../../types'
|
||
import { BookingNotesFilesCosts } from './BookingNotesFilesCosts'
|
||
import { TravelerPicker } from './TravelerPicker'
|
||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||
import type { BookingReviewDraft } from './parsedItemToDraft'
|
||
import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
|
||
import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell'
|
||
import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, DeleteButton } from '../shared/DialogShell'
|
||
import { INPUT, READONLY_BOX, 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 {
|
||
EMPTY_TRANSPORT_FIELDS, TRANSPORT_TYPE_OPTIONS as TYPE_OPTIONS, emptyCarStop, emptyStationWaypoint, emptyWaypoint,
|
||
transportDayOptions, type StationWaypointForm, type WaypointForm,
|
||
} from './transportEndpoints'
|
||
import { useTransportForm } from './useTransportForm'
|
||
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, filesNeedConnection, uploadBookingFiles } from './bookingFormModel'
|
||
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
|
||
import { useBookingFileAttach } from './useBookingFileAttach'
|
||
|
||
const defaultForm = {
|
||
...EMPTY_TRANSPORT_FIELDS,
|
||
url: '',
|
||
meta_airline: '',
|
||
meta_flight_number: '',
|
||
meta_train_number: '',
|
||
meta_platform: '',
|
||
meta_seat: '',
|
||
}
|
||
|
||
interface TransportModalProps {
|
||
isOpen: boolean
|
||
onClose: () => void
|
||
onSave: (data: Record<string, any> & { title: string }) => Promise<Reservation | undefined>
|
||
reservation: Reservation | null
|
||
days: Day[]
|
||
selectedDayId: number | null
|
||
files?: TripFile[]
|
||
onFileUpload?: (fd: FormData) => Promise<unknown>
|
||
onFileDelete?: (fileId: number) => Promise<void>
|
||
/** Deletes the reservation being edited. Omit to hide the delete action (create mode, or no permission). */
|
||
onDelete?: () => void | Promise<void>
|
||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||
// Pre-fill a brand-new transport booking from a parsed import item (review-
|
||
// before-save); like `reservation` for the form but stays in create mode.
|
||
prefill?: BookingReviewDraft | null
|
||
/** Data for the Automated (public transit) mode's quick picks. */
|
||
places?: Place[]
|
||
/** Day→assignments map, used to scope the quick picks to the chosen day (#1460). */
|
||
assignments?: AssignmentsMap
|
||
accommodations?: Accommodation[]
|
||
/** Open directly in the Automated public-transit mode (day-header tram button, "change route"). */
|
||
initialAutomated?: boolean
|
||
/** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */
|
||
tripHasDates?: boolean
|
||
/** Pre-seed the transit search — used by "change route" and by per-leg planning. */
|
||
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null; time?: string | null } | null
|
||
/** Trip members + guests, for the traveler picker (#1517). */
|
||
tripMembers?: TripMember[]
|
||
}
|
||
|
||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true, tripMembers = [] }: TransportModalProps) {
|
||
const { t, locale } = useTranslation()
|
||
const toast = useToast()
|
||
// The trip's places, offered by every location field of the manual tab (#2468).
|
||
const locationPicks = useMemo(() => toLocationPicks(places), [places])
|
||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||
const budgetItems = useTripStore(s => s.budgetItems)
|
||
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||
const setReservationTravelers = useTripStore(s => s.setReservationTravelers)
|
||
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 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,
|
||
pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler, isSaving, setIsSaving,
|
||
seed, payload, writesFlightLegs, writesTrainLegs,
|
||
} = useTransportForm(defaultForm)
|
||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||
const attach = useBookingFileAttach({ reservation, files, onFileUpload, setPendingFiles, toast, t })
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return
|
||
setTravelerIds(travelerIdsOf(reservation))
|
||
// Edit uses the saved `reservation`; a review-import populates from `prefill`.
|
||
// Either way the init reads the same fields — `reservation` still decides
|
||
// edit-vs-create at submit time.
|
||
const src = (reservation ?? prefill) as Reservation | null
|
||
setAutomated(initialAutomated)
|
||
// On a review-import, seed the booking's Files with the parsed source document.
|
||
setPendingFiles(!reservation && prefill?._sourceFiles ? prefill._sourceFiles : [])
|
||
// An import whose type could not be read opens as 'transport_other', not
|
||
// 'flight': it has to arrive as something the user corrects, and a wrong flight
|
||
// looks right enough to be saved unnoticed (#2076).
|
||
seed(src, days, {
|
||
isEdit: !!reservation,
|
||
fallbackType: 'transport_other',
|
||
dayId: selectedDayId ?? '',
|
||
stopDaysFromEndpoints: false,
|
||
extraFields: (s, meta) => ({
|
||
url: s.url || '',
|
||
meta_airline: meta.airline || '',
|
||
meta_flight_number: meta.flight_number || '',
|
||
meta_train_number: meta.train_number || '',
|
||
meta_platform: meta.platform || '',
|
||
meta_seat: meta.seat || '',
|
||
}),
|
||
})
|
||
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
|
||
|
||
const handleSubmit = (e?: React.FormEvent) => {
|
||
e?.preventDefault()
|
||
if (!form.title.trim()) return
|
||
if (filesNeedConnection(pendingFiles, !!onFileUpload)) { toast.error(t('reservations.filesNeedConnection')); return }
|
||
return saveTransport()
|
||
}
|
||
|
||
const saveTransport = async () => {
|
||
setIsSaving(true)
|
||
try {
|
||
const saved = await onSave(payload(days, {
|
||
reservation, prefill, budgetEnabled: isBudgetEnabled, anchorOnStations: false, url: form.url,
|
||
}))
|
||
// 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 { 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 && onFileUpload) {
|
||
await uploadBookingFiles(onFileUpload, saved.id, pendingFiles, form.title)
|
||
}
|
||
// 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 expenseRequest = expenseRequestAfterSave(expense.take(), saved, form, days)
|
||
if (expenseRequest && onOpenExpense) onOpenExpense(expenseRequest)
|
||
} catch (err: unknown) {
|
||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
// 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 pendingExpense = pendingImportExpense(reservation, prefill, form.type)
|
||
|
||
const dayOptions = transportDayOptions(days, t, locale)
|
||
|
||
const titleId = 'transport-editor-title'
|
||
// The head band takes the colour of the card the booking will sit on.
|
||
const tone: StatusTone = automated || form.type === 'transit' ? 'transit' : form.status
|
||
const isCar = form.type === 'car'
|
||
const routeNames = (form.type === 'flight'
|
||
? waypoints.map(w => w.airport?.iata)
|
||
: stationRoute
|
||
? trainWaypoints.map(w => w.location?.name)
|
||
: [fromPick.location?.name, ...(isCar ? carStops.map(s => s.location?.name) : []), toPick.location?.name]
|
||
).filter(Boolean)
|
||
const shownType = typeInfo(form.type)
|
||
|
||
const dayField = (label: string, value: string | number, onChange: (value: string | number) => void) => (
|
||
<EditorField label={label}>
|
||
<CustomSelect value={value} onChange={onChange} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||
</EditorField>
|
||
)
|
||
const timeField = (label: string, value: string, onChange: (value: string) => void) => (
|
||
<EditorField label={label}>
|
||
<CustomTimePicker value={value} onChange={onChange} />
|
||
</EditorField>
|
||
)
|
||
const zoneField = (label: string, tz: string) => (
|
||
<EditorField label={label}>
|
||
<Tooltip label={tz}>
|
||
<div className={READONLY_BOX}>{tz || ' '}</div>
|
||
</Tooltip>
|
||
</EditorField>
|
||
)
|
||
|
||
// Manual vs Automated creation (#1065), only while creating: a journey is
|
||
// edited again through "change route" with the switch hidden. A trip without
|
||
// dates has no day to depart on, so it gets the manual form alone. The switch
|
||
// sits in the head band, which stays put while the body swaps modes.
|
||
const modeSwitch = !reservation && tripHasDates && (
|
||
<span className="ms-auto">
|
||
<Segmented
|
||
label={`${t('transport.modeManual')} / ${t('transport.modeAutomated')}`}
|
||
value={automated ? 'automated' : 'manual'}
|
||
onChange={mode => setAutomated(mode === 'automated')}
|
||
options={[
|
||
{ value: 'manual', label: t('transport.modeManual') },
|
||
{ value: 'automated', label: t('transport.modeAutomated') },
|
||
]}
|
||
/>
|
||
</span>
|
||
)
|
||
|
||
// Until there is a title, the line under it says the field is required:
|
||
// Add stays greyed out without one, and a disabled button cannot say why.
|
||
let headerSub: string | undefined
|
||
if (automated) headerSub = t('transit.searchHint')
|
||
else if (!form.title.trim()) headerSub = `${t('reservations.titleLabel')} *`
|
||
else if (routeNames.length >= 2) headerSub = routeNames.join(' → ')
|
||
|
||
const header = (
|
||
<BookingDialogHeader
|
||
tone={tone}
|
||
type={automated ? 'transit' : form.type}
|
||
labelId={titleId}
|
||
onClose={onClose}
|
||
eyebrow={automated ? undefined : reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
||
title={automated ? t('transit.title') : undefined}
|
||
titleInput={automated ? undefined : {
|
||
value: form.title,
|
||
onChange: value => set('title', value),
|
||
label: t('reservations.titleLabel'),
|
||
placeholder: t('reservations.titlePlaceholder'),
|
||
required: true,
|
||
}}
|
||
sub={headerSub}
|
||
subWraps={automated}
|
||
pills={(
|
||
<>
|
||
{/* The search runs against one day; it heads the band next to the mode switch. */}
|
||
{automated && (
|
||
<PillSelect
|
||
label={t('reservations.date')}
|
||
value={String(form.start_day_id)}
|
||
onChange={value => set('start_day_id', value === '' ? '' : Number(value))}
|
||
options={dayOptions.map(o => ({ value: String(o.value), label: o.label, hint: 'badge' in o ? o.badge : undefined, icon: <CalendarDays size={13} strokeWidth={2.2} className="text-content-faint" /> }))}
|
||
/>
|
||
)}
|
||
{!automated && (
|
||
<StatusPill status={form.status} onToggle={() => set('status', form.status === 'confirmed' ? 'pending' : 'confirmed')} />
|
||
)}
|
||
{!automated && (
|
||
<PillSelect
|
||
label={t('reservations.bookingType')}
|
||
value={form.type}
|
||
onChange={value => set('type', value)}
|
||
options={TYPE_OPTIONS.map(o => ({ value: o.value, label: t(o.labelKey), icon: <o.Icon size={14} style={{ color: typeInfo(o.value).color }} /> }))}
|
||
fallback={{ label: t(shownType.chipKey), icon: <shownType.Icon size={14} style={{ color: shownType.color }} /> }}
|
||
/>
|
||
)}
|
||
{modeSwitch}
|
||
</>
|
||
)}
|
||
/>
|
||
)
|
||
|
||
const footer = (
|
||
<DialogFooter>
|
||
{!automated && reservation?.id && onDelete && <DeleteButton onClick={() => setShowDeleteConfirm(true)} />}
|
||
<FooterSpacer />
|
||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||
{!automated && (
|
||
<DialogButton variant="primary" onClick={() => handleSubmit()} disabled={isSaving || !form.title.trim()}>
|
||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||
</DialogButton>
|
||
)}
|
||
</DialogFooter>
|
||
)
|
||
|
||
const transitSearch = () => {
|
||
const transitDay = days.find(d => d.id === Number(form.start_day_id))
|
||
if (!transitDay) return <p className="m-0 text-content-faint" style={fs(13, 'body')}>{t('transit.pickDay')}</p>
|
||
// Quick picks offer the chosen day's itinerary, not the whole trip (#1460).
|
||
const dayPlaces = (assignments[String(transitDay.id)] || [])
|
||
.slice().sort((a, b) => a.order_index - b.order_index)
|
||
.map(a => places.find(p => p.id === a.place_id))
|
||
.filter((p): p is Place => p != null)
|
||
return (
|
||
<TransitSearchPanel
|
||
day={transitDay}
|
||
days={days}
|
||
places={dayPlaces}
|
||
accommodations={accommodations}
|
||
onAdd={(payload) => onSave(payload as Record<string, any> & { title: string })}
|
||
initialFrom={transitPrefill?.from ?? null}
|
||
initialTo={transitPrefill?.to ?? null}
|
||
initialTime={transitPrefill?.time ?? null}
|
||
/>
|
||
)
|
||
}
|
||
|
||
const roleLabel = (i: number, count: number) =>
|
||
i === 0 ? t(form.type === 'cruise' ? 'reservations.cruise.embark' : 'reservations.meta.from')
|
||
: i === count - 1 ? t(form.type === 'cruise' ? 'reservations.cruise.disembark' : 'reservations.meta.to')
|
||
: t(form.type === 'cruise' ? 'reservations.cruise.port' : 'reservations.layover.stop')
|
||
|
||
// Flight route: ordered airports (origin, stops, destination) on one rail.
|
||
const flightRoute = () => (
|
||
<DialogSection key="flight" label={t('reservations.layover.route')}>
|
||
<ol>
|
||
{waypoints.map((wp, i) => {
|
||
const isFirst = i === 0
|
||
const isLast = i === waypoints.length - 1
|
||
const updateWp = (patch: Partial<WaypointForm>) => setWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
|
||
return (
|
||
<RailStop key={i} first={isFirst} last={isLast}>
|
||
<div className={PANEL}>
|
||
<StopHead label={roleLabel(i, waypoints.length)} onRemove={!isFirst && !isLast ? () => setWaypoints(prev => prev.filter((_, j) => j !== i)) : undefined}>
|
||
<div className={SEARCH_ON_PANEL}>
|
||
<AirportSelect value={wp.airport} onChange={a => updateWp({ airport: a || null })} />
|
||
</div>
|
||
</StopHead>
|
||
{!isFirst && (
|
||
<div className={wp.airport ? GRID_3 : GRID_2}>
|
||
{dayField(t('reservations.arrivalDate'), wp.arrDayId, v => updateWp({ arrDayId: v }))}
|
||
{timeField(t('reservations.arrivalTime'), wp.arrTime, v => updateWp({ arrTime: v }))}
|
||
{wp.airport && zoneField(t('reservations.meta.arrivalTimezone'), wp.airport.tz)}
|
||
</div>
|
||
)}
|
||
{!isLast && (
|
||
<>
|
||
<div className={wp.airport ? GRID_3 : GRID_2}>
|
||
{dayField(t('reservations.departureDate'), wp.depDayId, v => updateWp({ depDayId: v }))}
|
||
{timeField(t('reservations.departureTime'), wp.depTime, v => updateWp({ depTime: v }))}
|
||
{wp.airport && zoneField(t('reservations.meta.departureTimezone'), wp.airport.tz)}
|
||
</div>
|
||
<div className={writesFlightLegs ? GRID_4 : GRID_3}>
|
||
<EditorField label={t('reservations.meta.airline')}>
|
||
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={INPUT} />
|
||
</EditorField>
|
||
<EditorField label={t('reservations.meta.flightNumber')}>
|
||
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={INPUT} />
|
||
</EditorField>
|
||
<EditorField label={t('reservations.meta.seat')}>
|
||
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={INPUT} />
|
||
</EditorField>
|
||
{writesFlightLegs && (
|
||
<EditorField label={t('reservations.confirmationCode')}>
|
||
<BookingCodeInput value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
|
||
placeholder={t('reservations.confirmationPlaceholder')} className={INPUT} />
|
||
</EditorField>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
{!isLast && (
|
||
<div className="py-2.5">
|
||
<AddRowButton onClick={() => setWaypoints(prev => [...prev.slice(0, i + 1), emptyWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}>
|
||
{t('reservations.layover.addStop')}
|
||
</AddRowButton>
|
||
</div>
|
||
)}
|
||
</RailStop>
|
||
)
|
||
})}
|
||
</ol>
|
||
</DialogSection>
|
||
)
|
||
|
||
// Train route: ordered stations on the same rail, per-leg train fields.
|
||
const trainRoute = () => (
|
||
<DialogSection key="train" label={t('reservations.layover.route')}>
|
||
<ol>
|
||
{trainWaypoints.map((wp, i) => {
|
||
const isFirst = i === 0
|
||
const isLast = i === trainWaypoints.length - 1
|
||
const updateWp = (patch: Partial<StationWaypointForm>) => setTrainWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
|
||
return (
|
||
<RailStop key={i} first={isFirst} last={isLast}>
|
||
<div className={PANEL}>
|
||
<StopHead label={roleLabel(i, trainWaypoints.length)} onRemove={!isFirst && !isLast ? () => setTrainWaypoints(prev => prev.filter((_, j) => j !== i)) : undefined}>
|
||
<div className={SEARCH_ON_PANEL}>
|
||
<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} places={locationPicks} />
|
||
</div>
|
||
</StopHead>
|
||
{!isFirst && (
|
||
<div className={GRID_2}>
|
||
{dayField(t('reservations.arrivalDate'), wp.arrDayId, v => updateWp({ arrDayId: v }))}
|
||
{timeField(t('reservations.arrivalTime'), wp.arrTime, v => updateWp({ arrTime: v }))}
|
||
</div>
|
||
)}
|
||
{!isLast && (
|
||
<>
|
||
<div className={GRID_2}>
|
||
{dayField(t('reservations.departureDate'), wp.depDayId, v => updateWp({ depDayId: v }))}
|
||
{timeField(t('reservations.departureTime'), wp.depTime, v => updateWp({ depTime: v }))}
|
||
</div>
|
||
{/* A cruise's ports carry only their times: number, platform and seat are a train's. */}
|
||
{form.type === 'train' && <div className={writesTrainLegs ? GRID_4 : GRID_3}>
|
||
<EditorField label={t('reservations.meta.trainNumber')}>
|
||
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={INPUT} />
|
||
</EditorField>
|
||
<EditorField label={t('reservations.meta.platform')}>
|
||
<input type="text" value={wp.platform} onChange={e => updateWp({ platform: e.target.value })} placeholder="12" className={INPUT} />
|
||
</EditorField>
|
||
<EditorField label={t('reservations.meta.seat')}>
|
||
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={INPUT} />
|
||
</EditorField>
|
||
{writesTrainLegs && (
|
||
<EditorField label={t('reservations.confirmationCode')}>
|
||
<BookingCodeInput value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
|
||
placeholder={t('reservations.confirmationPlaceholder')} className={INPUT} />
|
||
</EditorField>
|
||
)}
|
||
</div>}
|
||
</>
|
||
)}
|
||
</div>
|
||
{!isLast && (
|
||
<div className="py-2.5">
|
||
<AddRowButton onClick={() => setTrainWaypoints(prev => [...prev.slice(0, i + 1), emptyStationWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}>
|
||
{t(form.type === 'cruise' ? 'reservations.cruise.addPort' : 'reservations.layover.addStop')}
|
||
</AddRowButton>
|
||
</div>
|
||
)}
|
||
</RailStop>
|
||
)
|
||
})}
|
||
</ol>
|
||
</DialogSection>
|
||
)
|
||
|
||
// Every other type: one From and one To, the day and time rows under them.
|
||
const plainRoute = () => (
|
||
<DialogSection key="plain" label={t('reservations.layover.route')}>
|
||
<div className={PANEL}>
|
||
<div className={GRID_2}>
|
||
<EditorField label={t('reservations.meta.from')}>
|
||
<div className={SEARCH_ON_PANEL}>
|
||
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} places={locationPicks} />
|
||
</div>
|
||
</EditorField>
|
||
<EditorField label={t('reservations.meta.to')}>
|
||
<div className={SEARCH_ON_PANEL}>
|
||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} places={locationPicks} />
|
||
</div>
|
||
</EditorField>
|
||
</div>
|
||
|
||
{/* Stops along the drive, cars only (#1797). The rental frame above stays the
|
||
pick-up and return; these are the places in between, in order. */}
|
||
{isCar && (
|
||
<div>
|
||
<Eyebrow className="mb-[5px]">{t('roadtrip.stops.label')}</Eyebrow>
|
||
<div className="flex flex-col gap-2">
|
||
{carStops.map((stop, i) => (
|
||
<div key={i} className="flex items-center gap-2">
|
||
{/* The order of the stops IS the route (sequence is the index at save
|
||
time), and the row has no room left to drag by, hence buttons. */}
|
||
{carStops.length > 1 && (
|
||
<div className="flex flex-none flex-col">
|
||
<IconAction label={t('dayplan.moveUp')} onClick={() => moveCarStop(i, -1)} disabled={i === 0} shape="h-[18px] w-6 rounded-md">
|
||
<ChevronUp size={13} />
|
||
</IconAction>
|
||
<IconAction label={t('dayplan.moveDown')} onClick={() => moveCarStop(i, 1)} disabled={i === carStops.length - 1} shape="h-[18px] w-6 rounded-md">
|
||
<ChevronDown size={13} />
|
||
</IconAction>
|
||
</div>
|
||
)}
|
||
<div className={`${SEARCH_ON_PANEL} flex-1`}>
|
||
<LocationSelect
|
||
value={stop.location}
|
||
onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))}
|
||
places={locationPicks}
|
||
/>
|
||
</div>
|
||
<div className="w-[110px] flex-none">
|
||
<CustomTimePicker
|
||
value={stop.time}
|
||
onChange={v => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, time: v } : s)))}
|
||
/>
|
||
</div>
|
||
<IconAction label={t('roadtrip.stops.remove')} onClick={() => setCarStops(prev => prev.filter((_, j) => j !== i))} danger>
|
||
<X size={14} />
|
||
</IconAction>
|
||
</div>
|
||
))}
|
||
<AddRowButton onClick={() => setCarStops(prev => [...prev, emptyCarStop()])}>{t('reservations.layover.addStop')}</AddRowButton>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className={GRID_2}>
|
||
{dayField(isCar ? t('reservations.pickupDate') : t('reservations.date'), form.start_day_id, value => set('start_day_id', value))}
|
||
{timeField(isCar ? t('reservations.pickupTime') : t('reservations.startTime'), form.departure_time, v => set('departure_time', v))}
|
||
</div>
|
||
<div className={GRID_2}>
|
||
{dayField(isCar ? t('reservations.returnDate') : t('reservations.endDate'), form.end_day_id, value => set('end_day_id', value))}
|
||
{timeField(isCar ? t('reservations.returnTime') : t('reservations.endTime'), form.arrival_time, v => set('arrival_time', v))}
|
||
</div>
|
||
</div>
|
||
</DialogSection>
|
||
)
|
||
|
||
return (
|
||
<DialogShell
|
||
open={isOpen}
|
||
onClose={onClose}
|
||
labelledBy={titleId}
|
||
width="editor"
|
||
align="top"
|
||
blocked={showDeleteConfirm}
|
||
// A stray click beside the editor asks before the typing is lost (#2253).
|
||
discardGuard={{ form, waypoints, trainWaypoints, carStops, files: pendingFiles.length, travelers: [...travelerIds] }}
|
||
onSubmit={automated ? undefined : handleSubmit}
|
||
header={header}
|
||
footer={footer}
|
||
>
|
||
{automated ? (
|
||
/* Automated: public transit search (#1065) for the day picked in the head band. */
|
||
transitSearch()
|
||
) : (
|
||
<>
|
||
{/* Travelers: trip members and guests on this booking (#1517) */}
|
||
<DialogSection label={t('reservations.travelers.label')}>
|
||
<TravelerPicker tripMembers={tripMembers} selectedIds={travelerIds} onToggle={toggleTraveler} />
|
||
</DialogSection>
|
||
|
||
{form.type === 'flight' ? flightRoute() : stationRoute ? trainRoute() : plainRoute()}
|
||
|
||
<EditorField label={t('reservations.confirmationCode')}>
|
||
<BookingCodeInput value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
||
placeholder={t('reservations.confirmationPlaceholder')} className={INPUT} />
|
||
</EditorField>
|
||
|
||
<BookingNotesFilesCosts
|
||
notes={form.notes}
|
||
onNotesChange={value => set('notes', value)}
|
||
url={form.url}
|
||
onUrlChange={value => set('url', value)}
|
||
reservationId={reservation?.id}
|
||
tripFiles={files}
|
||
pendingFiles={pendingFiles}
|
||
fileInputRef={fileInputRef}
|
||
attach={attach}
|
||
canAttach={!!onFileUpload}
|
||
showCosts={isBudgetEnabled}
|
||
pendingExpense={pendingExpense}
|
||
expense={expense}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
<ConfirmDialog
|
||
isOpen={showDeleteConfirm}
|
||
onClose={() => setShowDeleteConfirm(false)}
|
||
title={t('reservations.confirm.deleteTitle')}
|
||
message={t('reservations.confirm.deleteBody', { name: reservation?.title ?? '' })}
|
||
confirmLabel={t('common.delete')}
|
||
cancelLabel={t('common.cancel')}
|
||
onConfirm={async () => {
|
||
setShowDeleteConfirm(false)
|
||
await onDelete?.()
|
||
onClose()
|
||
}}
|
||
/>
|
||
</DialogShell>
|
||
)
|
||
}
|
||
|
||
const GRID_4 = 'grid grid-cols-4 items-start gap-3 max-sm:grid-cols-1'
|
||
|
||
/** One stop on the route: a dot on the line from origin to destination, its fields beside it. */
|
||
function RailStop({ first, last, children }: { first: boolean; last: boolean; children: ReactNode }) {
|
||
return (
|
||
<li className="relative ps-7">
|
||
{!first && <span aria-hidden="true" className="absolute start-[7px] top-0 h-6 w-0.5 bg-edge" />}
|
||
{!last && <span aria-hidden="true" className="absolute bottom-0 start-[7px] top-6 w-0.5 bg-edge" />}
|
||
<span
|
||
aria-hidden="true"
|
||
className={`absolute start-[3px] top-[19px] h-2.5 w-2.5 rounded-full border-2 bg-surface-card ${first || last ? 'border-content-muted' : 'border-content-faint'}`}
|
||
/>
|
||
{children}
|
||
</li>
|
||
)
|
||
}
|
||
|
||
/** The role of a stop over its place field, and the remove action an intermediate stop has. */
|
||
function StopHead({ label, onRemove, children }: { label: string; onRemove?: () => void; children: ReactNode }) {
|
||
const { t } = useTranslation()
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
<div className="flex min-h-[24px] items-center gap-2">
|
||
<Eyebrow className="min-w-0 flex-1 truncate">{label}</Eyebrow>
|
||
{onRemove && (
|
||
<IconAction label={t('common.delete')} onClick={onRemove} danger>
|
||
<Trash2 size={13} />
|
||
</IconAction>
|
||
)}
|
||
</div>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** A small icon button inside a route, named by its tooltip. */
|
||
function IconAction({ label, onClick, disabled, danger = false, shape = 'h-6 w-6 rounded-full', children }: {
|
||
label: string
|
||
onClick: () => void
|
||
disabled?: boolean
|
||
danger?: boolean
|
||
shape?: string
|
||
children: ReactNode
|
||
}) {
|
||
return (
|
||
<Tooltip label={label}>
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
aria-label={label}
|
||
className={`grid flex-none place-items-center text-content-faint enabled:hover:bg-surface-hover disabled:cursor-default disabled:opacity-30 ${danger ? 'enabled:hover:text-danger' : 'enabled:hover:text-content'} ${shape}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
</Tooltip>
|
||
)
|
||
}
|