fix(reservations): keep a car booking's stops when it is saved on a phone

The desktop form has offered stops between pick-up and return since the road trip
release (#1797). The phone sheet never knew about them: its car branch rebuilt the
endpoint list from from/to alone, and because the server replaces every endpoint row
of a booking, saving there silently dropped whatever the desktop had set.

Its car branch now reads the stop endpoints back into an editor and writes them out
again, the same shape TransportModal uses: location, time, reorder arrows, remove.
This commit is contained in:
Maurice
2026-09-09 14:43:50 +02:00
committed by Maurice
parent 8acfaea3c4
commit f283254bb0
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Bike, Bus, Car, CarTaxiFront, Check, Plane, Plus, Route, Sailboat, Ship, Train, TrainFront, TramFront, Trash2 } from 'lucide-react'
import { Bike, Bus, Car, CarTaxiFront, Check, ChevronDown, ChevronUp, Plane, Plus, Route, Sailboat, Ship, Train, TrainFront, TramFront, Trash2, X } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import { useAddonStore } from '../../../../store/addonStore'
import { useTranslation } from '../../../../i18n'
@@ -70,6 +70,16 @@ function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
}
// The places a driver planned between pick-up and return (#1797), the same shape the
// desktop TransportModal edits. Without an editor here the save below would rebuild the
// endpoint list from from/to alone and the server, which replaces every endpoint row of a
// booking, would drop the stops a phone never showed.
interface CarStopForm {
location: LocationPoint | null
time: string
}
const emptyCarStop = (): CarStopForm => ({ location: null, time: '' })
// A flight is an ordered list of airports; N waypoints = N-1 legs. The origin
// only departs, the destination only arrives, each stop does both.
interface WaypointForm {
@@ -154,6 +164,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
const [toPick, setToPick] = useState<EndpointPick>({})
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
const [carStops, setCarStops] = useState<CarStopForm[]>([])
const [pendingFiles, setPendingFiles] = useState<File[]>([])
// Travelers assigned to this booking (#1517) — seeded from the editing
// reservation on open, persisted separately after the save resolves.
@@ -296,6 +307,17 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
setToPick({ location: locationFromEndpoint(to) || undefined })
setWaypoints([emptyWaypoint(), emptyWaypoint()])
setTrainWaypoints([emptyStationWaypoint(), emptyStationWaypoint()])
// Stops persist for every type; only a car offers an editor for them, so only a
// car reads them back into one. The others keep passing theirs through untouched.
setCarStops(
src.type === 'car'
? (src.endpoints ?? [])
.filter(e => e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
.map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' }))
: [],
)
}
} else {
setForm({ ...EMPTY, start_day_id: transportModalDayId ?? '', end_day_id: transportModalDayId ?? '' })
@@ -303,6 +325,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
setToPick({})
setWaypoints([emptyWaypoint(transportModalDayId ?? ''), emptyWaypoint(transportModalDayId ?? '')])
setTrainWaypoints([emptyStationWaypoint(transportModalDayId ?? ''), emptyStationWaypoint(transportModalDayId ?? '')])
setCarStops([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showTransportModal])
@@ -311,6 +334,16 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
const prefill = snap.prefill
const set = (field: keyof typeof EMPTY, value: string | number) => setForm(prev => ({ ...prev, [field]: value }))
const moveCarStop = (index: number, delta: number): void => {
setCarStops(prev => {
const to = index + delta
if (to < 0 || to >= prev.length) return prev
const next = [...prev]
;[next[index], next[to]] = [next[to], next[index]]
return next
})
}
const toggleTraveler = (id: number) => setTravelerIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
@@ -491,7 +524,14 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
})
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
const stops = keepTransit
// A car writes the stops the driver planned; every other type keeps passing the
// itinerary's transfer stops through while the route is unchanged (#1065).
const carEndpoints = form.type === 'car'
? carStops
.filter(s => s.location)
.map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null))
: []
const stops = keepTransit && form.type !== 'car'
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
: []
stops.forEach((s, i) => endpoints.push({
@@ -499,7 +539,9 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
}))
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
carEndpoints.forEach(e => endpoints.push(e))
const stopCount = stops.length + carEndpoints.length
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null))
}
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
@@ -890,6 +932,72 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
<Eyebrow className="mb-[5px] mt-3 uppercase">{t('reservations.meta.to')}</Eyebrow>
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
{/* 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. */}
{form.type === 'car' && (
<>
<Eyebrow className="mb-[5px] mt-3 uppercase">{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 these stops IS the route: `sequence` is the array
index at save time. Arrows rather than dragging, because the
row already carries a location picker and a time picker. */}
{carStops.length > 1 && (
<div className="flex shrink-0 flex-col">
<button
type="button"
onClick={() => moveCarStop(i, -1)}
disabled={i === 0}
aria-label={t('dayplan.moveUp')}
className="flex items-center px-1 py-[2px] text-m-muted disabled:opacity-30"
>
<ChevronUp size={14} strokeWidth={2.2} />
</button>
<button
type="button"
onClick={() => moveCarStop(i, 1)}
disabled={i === carStops.length - 1}
aria-label={t('dayplan.moveDown')}
className="flex items-center px-1 py-[2px] text-m-muted disabled:opacity-30"
>
<ChevronDown size={14} strokeWidth={2.2} />
</button>
</div>
)}
<div className="min-w-0 flex-1">
<LocationSelect
value={stop.location}
onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))}
/>
</div>
<div className="w-[92px] shrink-0">
<CustomTimePicker
value={stop.time}
onChange={v => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, time: v } : s)))}
/>
</div>
<button
type="button"
onClick={() => setCarStops(prev => prev.filter((_, j) => j !== i))}
aria-label={t('roadtrip.stops.remove')}
className="flex shrink-0 items-center p-1 text-m-muted"
>
<X size={15} strokeWidth={2.2} />
</button>
</div>
))}
<button
type="button"
onClick={() => setCarStops(prev => [...prev, emptyCarStop()])}
className="flex w-full items-center justify-center gap-[5px] rounded-full border-[1.5px] border-dashed border-[color:var(--m-rowbr)] py-2 font-geist text-[0.6875rem] font-semibold text-m-muted"
>
<Plus size={12} strokeWidth={2.2} /> {t('reservations.layover.addStop')}
</button>
</div>
</>
)}
{/* Departure row */}
<div className="mt-3 flex gap-2">
<div className="min-w-0 flex-1">