Files
TREK/client/src/components/Planner/TransportModal.tsx
T
Maurice 9f0caf8539 feat(tours): public transit between two city walks in the day plan, from the first walk's end at the time it ends (CW-08)
"Public transit" on the connector between two city walks in the day plan,
on the desktop and on the phone, now opens the existing search from where
the first walk really ends to where the next one starts, at the
coordinates the day's route draws, named after the first walk's last stop
and the next walk's first stop. A loop ends at its first stop again. A stop
without a name takes the name of the trip place it stands for, and a walk
whose stops cannot be read on this device names that end "End of" or
"Start of" the walk. The search opens on the walks' day and departs when
the first walk ends: its start that day, the time on the day's entry
before the place's own, plus its planned total, a total entered by hand
before walking and stays. The assignment's own end time is not used.

Above the search a note says when the first walk ends, "at the earliest"
where a stay or the walking time is not known yet, and when the next walk
starts. Without a start or a total no time is made up: the time field
stays empty and Search waits until one is chosen. A walk that only ends
after midnight says so, and the time field stays empty as well. On any other day, or for
any other leg, the search opens as before, with 09:00 as its default. A
connection that arrives after the next walk starts says so in its card,
before its legs are opened and before it is taken. Nothing is saved until
"Add to day", which keeps the connection as its own journey in the plan as
before; both walks stay two walks on foot and nothing is moved.

A journey saved before a walk that now arrives after the walk starts says
so on its row in the plan, on the desktop and the phone, while the plan is
looked at. Nothing is rewritten. The desktop no longer draws a connector of
nothing between a stop and a booking at the same spot, as the phone already
did, so a journey saved at the walks' ends adds no walk on either side. A
leg leaving a loop tour no longer departs at the tour's start time.

The leg is told by the walk it leaves, so two walks of a day that end at
one spot, such as two loops from the hotel, each search from their own end
and time, and a stop between two walks keeps the leg an ordinary one. Only
the leg asked for last opens the search, and none opens once the plan is
left while the walks' stops are still being read.

Both views run on one shared hook, the walk's stops are read through one
helper the stop list uses as well, and the walk's start on a day is worked
out in one place for the times shown in the detail and here.
2026-10-20 07:09:57 +00:00

641 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 from './TransitSearchPanel'
import type { TransitPrefill } from './transitLeg'
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 { deleteBodyKey, 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?: TransitPrefill | 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 = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!form.title.trim()) return
if (filesNeedConnection(pendingFiles, !!onFileUpload)) { toast.error(t('reservations.filesNeedConnection')); return }
await 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}
walk={transitPrefill?.walk ?? 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(deleteBodyKey(reservation?.id, budgetItems), { 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>
)
}