mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
9f0caf8539
"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.
641 lines
31 KiB
TypeScript
641 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 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>
|
||
)
|
||
}
|