mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
feat(planner): show each flight leg as its own day-plan entry, ordered by time
A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA -> HND), each on its own day with its own times, instead of a single span. Each leg is an addressable slot (reservation id + leg index) so places and notes can be dropped into the layover gap between legs; the per-leg position is persisted in metadata.legs[i].day_positions and survives a reload. Day-plan items are now ordered chronologically: anything with a time (a place's time, a flight leg, a timed note) sorts by that time, and untimed items inherit the time of the item before them so they stay where they were placed.
This commit is contained in:
@@ -171,7 +171,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const [timeConfirm, setTimeConfirm] = useState<{
|
const [timeConfirm, setTimeConfirm] = useState<{
|
||||||
dayId: number; fromId: number; time: string;
|
dayId: number; fromId: number; time: string;
|
||||||
// For drag & drop reorder
|
// For drag & drop reorder
|
||||||
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean;
|
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null;
|
||||||
// For arrow reorder
|
// For arrow reorder
|
||||||
reorderIds?: number[];
|
reorderIds?: number[];
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
@@ -471,6 +471,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const assignmentIds: number[] = []
|
const assignmentIds: number[] = []
|
||||||
const noteUpdates: { id: number; sort_order: number }[] = []
|
const noteUpdates: { id: number; sort_order: number }[] = []
|
||||||
const transportUpdates: { id: number; day_plan_position: number }[] = []
|
const transportUpdates: { id: number; day_plan_position: number }[] = []
|
||||||
|
// Multi-leg flight legs share a reservation id, so their positions can't live in
|
||||||
|
// the single per-booking slot — collect them per leg, keyed reservationId → legIndex → pos.
|
||||||
|
const legPosUpdates: Record<number, Record<number, number>> = {}
|
||||||
|
|
||||||
let placeCount = 0
|
let placeCount = 0
|
||||||
let i = 0
|
let i = 0
|
||||||
@@ -491,7 +494,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
group.forEach((g, idx) => {
|
group.forEach((g, idx) => {
|
||||||
const pos = base + (idx + 1) / (group.length + 1)
|
const pos = base + (idx + 1) / (group.length + 1)
|
||||||
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
|
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
|
||||||
else if (g.type === 'transport') transportUpdates.push({ id: g.data.id, day_plan_position: pos })
|
else if (g.type === 'transport') {
|
||||||
|
if (g.data.__leg) ((legPosUpdates[g.data.id] ??= {})[g.data.__leg.index] = pos)
|
||||||
|
else transportUpdates.push({ id: g.data.id, day_plan_position: pos })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,6 +516,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
}))
|
}))
|
||||||
setTransportPosVersion(v => v + 1)
|
setTransportPosVersion(v => v + 1)
|
||||||
}
|
}
|
||||||
|
// Per-leg positions of multi-leg flights live in metadata.legs[i].day_positions
|
||||||
|
// (the single per-booking slot can't hold one position per leg).
|
||||||
|
const legResIds = Object.keys(legPosUpdates)
|
||||||
|
if (legResIds.length) {
|
||||||
|
for (const ridStr of legResIds) {
|
||||||
|
const rid = Number(ridStr)
|
||||||
|
const r = useTripStore.getState().reservations.find(x => x.id === rid)
|
||||||
|
if (!r) continue
|
||||||
|
let parsed: any = {}
|
||||||
|
try { parsed = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) } catch { parsed = {} }
|
||||||
|
if (!Array.isArray(parsed.legs)) continue
|
||||||
|
const legs = parsed.legs.map((leg: any, i: number) => {
|
||||||
|
const pos = legPosUpdates[rid][i]
|
||||||
|
return pos == null ? leg : { ...leg, day_positions: { ...(leg.day_positions || {}), [dayId]: pos } }
|
||||||
|
})
|
||||||
|
// Send metadata as an OBJECT (like the form does) — passing a JSON string
|
||||||
|
// here double-encodes it on the server, which wipes metadata.legs on read
|
||||||
|
// and collapses the flight back to a single span.
|
||||||
|
const newMeta = { ...parsed, legs }
|
||||||
|
useTripStore.setState(state => ({ reservations: state.reservations.map(x => (x.id === rid ? { ...x, metadata: newMeta } : x)) }))
|
||||||
|
await tripActions.updateReservation(tripId, rid, { metadata: newMeta })
|
||||||
|
}
|
||||||
|
setTransportPosVersion(v => v + 1)
|
||||||
|
}
|
||||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||||
if (transportUpdates.length) {
|
if (transportUpdates.length) {
|
||||||
onRouteRefresh?.()
|
onRouteRefresh?.()
|
||||||
@@ -528,8 +558,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false, toLegIndex = null) => {
|
||||||
const m = getMergedItems(dayId)
|
const m = getMergedItems(dayId)
|
||||||
|
// Multi-leg flights expose one item per leg sharing the same reservation id;
|
||||||
|
// disambiguate the drop target by leg index so you can drop BETWEEN legs.
|
||||||
|
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
|
||||||
|
|
||||||
// Check if a timed place is being moved → would it break chronological order?
|
// Check if a timed place is being moved → would it break chronological order?
|
||||||
if (fromType === 'place') {
|
if (fromType === 'place') {
|
||||||
@@ -537,11 +570,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
|
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
|
||||||
if (fromItem && fromMinutes !== null) {
|
if (fromItem && fromMinutes !== null) {
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
const toIdx = m.findIndex(matchTo)
|
||||||
if (fromIdx !== -1 && toIdx !== -1) {
|
if (fromIdx !== -1 && toIdx !== -1) {
|
||||||
const simulated = [...m]
|
const simulated = [...m]
|
||||||
const [moved] = simulated.splice(fromIdx, 1)
|
const [moved] = simulated.splice(fromIdx, 1)
|
||||||
let insertIdx = simulated.findIndex(i => i.type === toType && i.data.id === toId)
|
let insertIdx = simulated.findIndex(matchTo)
|
||||||
if (insertIdx === -1) insertIdx = simulated.length
|
if (insertIdx === -1) insertIdx = simulated.length
|
||||||
if (insertAfter) insertIdx += 1
|
if (insertAfter) insertIdx += 1
|
||||||
simulated.splice(insertIdx, 0, moved)
|
simulated.splice(insertIdx, 0, moved)
|
||||||
@@ -558,7 +591,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (!isChronological) {
|
if (!isChronological) {
|
||||||
const placeTime = fromItem.data.place.place_time
|
const placeTime = fromItem.data.place.place_time
|
||||||
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
||||||
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, time: timeStr })
|
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, toLegIndex, time: timeStr })
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -568,7 +601,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
// Build new order: remove the dragged item, insert at target position
|
// Build new order: remove the dragged item, insert at target position
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
const toIdx = m.findIndex(matchTo)
|
||||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
return
|
return
|
||||||
@@ -576,7 +609,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
const newOrder = [...m]
|
const newOrder = [...m]
|
||||||
const [moved] = newOrder.splice(fromIdx, 1)
|
const [moved] = newOrder.splice(fromIdx, 1)
|
||||||
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
let adjustedTo = newOrder.findIndex(matchTo)
|
||||||
if (adjustedTo === -1) adjustedTo = newOrder.length
|
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||||
if (insertAfter) adjustedTo += 1
|
if (insertAfter) adjustedTo += 1
|
||||||
newOrder.splice(adjustedTo, 0, moved)
|
newOrder.splice(adjustedTo, 0, moved)
|
||||||
@@ -590,7 +623,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const confirmTimeRemoval = async () => {
|
const confirmTimeRemoval = async () => {
|
||||||
if (!timeConfirm) return
|
if (!timeConfirm) return
|
||||||
const saved = { ...timeConfirm }
|
const saved = { ...timeConfirm }
|
||||||
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved
|
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter, toLegIndex } = saved
|
||||||
setTimeConfirm(null)
|
setTimeConfirm(null)
|
||||||
|
|
||||||
// Remove time from assignment
|
// Remove time from assignment
|
||||||
@@ -633,13 +666,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
// Drag & drop reorder
|
// Drag & drop reorder
|
||||||
if (fromType && toType) {
|
if (fromType && toType) {
|
||||||
|
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
const toIdx = m.findIndex(matchTo)
|
||||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
||||||
|
|
||||||
const newOrder = [...m]
|
const newOrder = [...m]
|
||||||
const [moved] = newOrder.splice(fromIdx, 1)
|
const [moved] = newOrder.splice(fromIdx, 1)
|
||||||
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
let adjustedTo = newOrder.findIndex(matchTo)
|
||||||
if (adjustedTo === -1) adjustedTo = newOrder.length
|
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||||
if (insertAfter) adjustedTo += 1
|
if (insertAfter) adjustedTo += 1
|
||||||
newOrder.splice(adjustedTo, 0, moved)
|
newOrder.splice(adjustedTo, 0, moved)
|
||||||
@@ -1311,6 +1345,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
||||||
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
|
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
|
||||||
const transportId = Number(parts[0])
|
const transportId = Number(parts[0])
|
||||||
|
const legPart = parts.find(p => /^leg\d+$/.test(p))
|
||||||
|
const toLegIndex = legPart ? Number(legPart.slice(3)) : null
|
||||||
|
|
||||||
if (placeId) {
|
if (placeId) {
|
||||||
onAssignToDay?.(parseInt(placeId), day.id)
|
onAssignToDay?.(parseInt(placeId), day.id)
|
||||||
@@ -1318,15 +1354,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||||
} else if (fromReservationId) {
|
} else if (fromReservationId) {
|
||||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter)
|
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter, toLegIndex)
|
||||||
} else if (assignmentId && fromDayId !== day.id) {
|
} else if (assignmentId && fromDayId !== day.id) {
|
||||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (assignmentId) {
|
} else if (assignmentId) {
|
||||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
|
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter, toLegIndex)
|
||||||
} else if (noteId && fromDayId !== day.id) {
|
} else if (noteId && fromDayId !== day.id) {
|
||||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (noteId) {
|
} else if (noteId) {
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter)
|
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter, toLegIndex)
|
||||||
}
|
}
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
return
|
return
|
||||||
@@ -1372,9 +1408,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
merged.map((item, idx) => {
|
merged.map((item, idx) => {
|
||||||
const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
const legSuffix = item.data?.__leg ? `-leg${item.data.__leg.index}` : ''
|
||||||
|
const itemKey = item.type === 'transport' ? `transport-${item.data.id}${legSuffix}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
||||||
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
||||||
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}-${day.id}`
|
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}${legSuffix}-${day.id}`
|
||||||
|
|
||||||
if (item.type === 'place') {
|
if (item.type === 'place') {
|
||||||
const assignment = item.data
|
const assignment = item.data
|
||||||
@@ -1722,7 +1759,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
|
|
||||||
// Subtitle aus Metadaten zusammensetzen
|
// Subtitle aus Metadaten zusammensetzen
|
||||||
let subtitle = ''
|
let subtitle = ''
|
||||||
if (res.type === 'flight') {
|
if (res.__leg) {
|
||||||
|
// One leg of a multi-leg flight — show this segment's own route.
|
||||||
|
const parts = [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||||
|
if (res.__leg.from || res.__leg.to)
|
||||||
|
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
||||||
|
subtitle = parts.join(' · ')
|
||||||
|
} else if (res.type === 'flight') {
|
||||||
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||||
if (meta.departure_airport || meta.arrival_airport)
|
if (meta.departure_airport || meta.arrival_airport)
|
||||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||||
@@ -1731,28 +1774,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-day span phase
|
// Multi-day span phase (single-leg / non-flight only — a
|
||||||
const spanLabel = getSpanLabel(res, spanPhase)
|
// multi-leg flight is shown as one row per leg, see below).
|
||||||
|
const spanLabel = res.__leg ? null : getSpanLabel(res, spanPhase)
|
||||||
const displayTime = getDisplayTimeForDay(res, day.id)
|
const displayTime = getDisplayTimeForDay(res, day.id)
|
||||||
|
const legKey = res.__leg ? `leg${res.__leg.index}` : 'x'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canEditDays) return
|
if (!canEditDays) return
|
||||||
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
|
const target = reservations.find(x => x.id === res.id) ?? res
|
||||||
else onEditReservation?.(res)
|
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
|
||||||
|
else onEditReservation?.(target)
|
||||||
}}
|
}}
|
||||||
onDragOver={e => {
|
onDragOver={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
const inBottom = e.clientY > rect.top + rect.height / 2
|
const inBottom = e.clientY > rect.top + rect.height / 2
|
||||||
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
|
const ls = res.__leg ? `-leg${res.__leg.index}` : ''
|
||||||
|
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
|
||||||
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||||
}}
|
}}
|
||||||
draggable={canEditDays && spanPhase !== 'middle'}
|
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg}
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
if (!canEditDays || spanPhase === 'middle' || res.__leg) { e.preventDefault(); return }
|
||||||
// setData is required for the drag to start reliably (Firefox) and
|
// setData is required for the drag to start reliably (Firefox) and
|
||||||
// matches how place/note items initiate their drag.
|
// matches how place/note items initiate their drag.
|
||||||
e.dataTransfer.setData('reservationId', String(res.id))
|
e.dataTransfer.setData('reservationId', String(res.id))
|
||||||
@@ -1773,15 +1820,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const r2 = reservations.find(x => x.id === Number(fromReservationId))
|
const r2 = reservations.find(x => x.id === Number(fromReservationId))
|
||||||
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||||
} else if (fromReservationId) {
|
} else if (fromReservationId) {
|
||||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter)
|
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
||||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (fromAssignmentId) {
|
} else if (fromAssignmentId) {
|
||||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
|
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
||||||
} else if (noteId && fromDayId !== day.id) {
|
} else if (noteId && fromDayId !== day.id) {
|
||||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (noteId) {
|
} else if (noteId) {
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter)
|
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
||||||
}
|
}
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
}}
|
}}
|
||||||
@@ -1801,7 +1848,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{canEditDays && spanPhase !== 'middle' && (
|
{canEditDays && spanPhase !== 'middle' && !res.__leg && (
|
||||||
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||||
<GripVertical size={13} strokeWidth={1.8} />
|
<GripVertical size={13} strokeWidth={1.8} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1846,7 +1893,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
|
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
|
||||||
const active = visibleConnectionIds.includes(res.id)
|
const active = visibleConnectionIds.includes(res.id)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -126,18 +126,18 @@ describe('getMergedItems', () => {
|
|||||||
expect(types).toEqual(['place', 'transport', 'place'])
|
expect(types).toEqual(['place', 'transport', 'place'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('per-day position overrides time-based insertion', () => {
|
it('orders a timed transport chronologically regardless of a stale per-day position', () => {
|
||||||
const dayAssignments = [
|
const dayAssignments = [
|
||||||
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||||
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||||
]
|
]
|
||||||
// Transport at 10:30 would normally go between the two places
|
// The train is at 10:30, so it sorts between the 08:00 and 13:00 places by time —
|
||||||
// but per-day position 1.5 puts it after the second place
|
// timed items are arranged chronologically even if an old manual position exists.
|
||||||
const dayTransports = [
|
const dayTransports = [
|
||||||
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
|
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
|
||||||
]
|
]
|
||||||
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||||
const types = result.map(i => i.type)
|
const types = result.map(i => i.type)
|
||||||
expect(types).toEqual(['place', 'place', 'transport'])
|
expect(types).toEqual(['place', 'transport', 'place'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,12 +39,66 @@ export function getDisplayTimeForDay(
|
|||||||
return r.reservation_time || null
|
return r.reservation_time || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-leg detail of a multi-leg flight, or null for single-leg / non-flight. */
|
||||||
|
function parseFlightLegs(r: any): any[] | null {
|
||||||
|
if (r?.type !== 'flight') return null
|
||||||
|
let meta = r.metadata
|
||||||
|
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
|
||||||
|
// Defensive: recover metadata that was accidentally double-encoded by an earlier
|
||||||
|
// bug (a JSON string of a JSON string) so already-saved flights heal on read.
|
||||||
|
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
|
||||||
|
if (meta && Array.isArray(meta.legs) && meta.legs.length > 1) return meta.legs
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand a multi-leg flight into one synthetic reservation per leg that touches
|
||||||
|
* `dayId`, each with its own day span + departure/arrival time so it slots into
|
||||||
|
* the timeline independently. A single-leg flight (or any other reservation) is
|
||||||
|
* returned untouched, so existing behaviour is unchanged.
|
||||||
|
*/
|
||||||
|
export function expandFlightLegsForDay(
|
||||||
|
r: any,
|
||||||
|
dayId: number,
|
||||||
|
getDayOrder: (id: number) => number,
|
||||||
|
days: Array<{ id: number; date?: string | null }>
|
||||||
|
): any[] {
|
||||||
|
const legs = parseFlightLegs(r)
|
||||||
|
if (!legs) return [r]
|
||||||
|
const dateOf = (id: number | null): string | null => (id == null ? null : (days.find(d => d.id === id)?.date ?? null))
|
||||||
|
const thisOrder = getDayOrder(dayId)
|
||||||
|
const out: any[] = []
|
||||||
|
legs.forEach((leg, i) => {
|
||||||
|
const dep = leg.dep_day_id ?? r.day_id ?? null
|
||||||
|
const arr = leg.arr_day_id ?? dep
|
||||||
|
if (dep == null) return
|
||||||
|
const depOrder = getDayOrder(dep)
|
||||||
|
const arrOrder = getDayOrder(arr ?? dep)
|
||||||
|
if (!(thisOrder >= depOrder && thisOrder <= arrOrder)) return
|
||||||
|
const depDate = dateOf(dep)
|
||||||
|
const arrDate = dateOf(arr ?? dep)
|
||||||
|
out.push({
|
||||||
|
...r,
|
||||||
|
day_id: dep,
|
||||||
|
end_day_id: arr ?? dep,
|
||||||
|
reservation_time: leg.dep_time ? (depDate ? `${depDate}T${leg.dep_time}` : leg.dep_time) : null,
|
||||||
|
reservation_end_time: leg.arr_time ? (arrDate ? `${arrDate}T${leg.arr_time}` : leg.arr_time) : null,
|
||||||
|
// Each leg carries its OWN saved position (not the booking's) so items can be
|
||||||
|
// dropped between legs and persist; absent → falls back to time ordering.
|
||||||
|
day_positions: leg.day_positions || undefined,
|
||||||
|
day_plan_position: undefined,
|
||||||
|
__leg: { index: i, total: legs.length, from: leg.from ?? null, to: leg.to ?? null, airline: leg.airline ?? null, flight_number: leg.flight_number ?? null },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
|
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
|
||||||
export function getTransportForDay(opts: {
|
export function getTransportForDay(opts: {
|
||||||
reservations: any[]
|
reservations: any[]
|
||||||
dayId: number
|
dayId: number
|
||||||
dayAssignmentIds: number[]
|
dayAssignmentIds: number[]
|
||||||
days: Array<{ id: number; day_number?: number }>
|
days: Array<{ id: number; day_number?: number; date?: string | null }>
|
||||||
}): any[] {
|
}): any[] {
|
||||||
const { reservations, dayId, dayAssignmentIds, days } = opts
|
const { reservations, dayId, dayAssignmentIds, days } = opts
|
||||||
|
|
||||||
@@ -69,7 +123,34 @@ export function getTransportForDay(opts: {
|
|||||||
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
||||||
}
|
}
|
||||||
return startDayId === dayId
|
return startDayId === dayId
|
||||||
})
|
}).flatMap(r => expandFlightLegsForDay(r, dayId, getDayOrder, days))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order items chronologically: anything with a time (a place's place_time, a
|
||||||
|
* transport/leg display time, a timed note) sorts by that time. An item WITHOUT a
|
||||||
|
* time inherits the time of the timed item before it, so untimed items stay where
|
||||||
|
* they were manually placed. Stable on the incoming order for ties.
|
||||||
|
*/
|
||||||
|
function applyChronoOrder(
|
||||||
|
items: MergedItem[],
|
||||||
|
dayId: number,
|
||||||
|
getDisplayTime: (r: any, dayId: number) => string | null
|
||||||
|
): MergedItem[] {
|
||||||
|
const timeOf = (it: MergedItem): number | null => {
|
||||||
|
if (it.type === 'place') return parseTimeToMinutes(it.data?.place?.place_time)
|
||||||
|
if (it.type === 'note') return parseTimeToMinutes(it.data?.time)
|
||||||
|
return parseTimeToMinutes(getDisplayTime(it.data, dayId))
|
||||||
|
}
|
||||||
|
let last = -Infinity
|
||||||
|
return items
|
||||||
|
.map((it, i) => {
|
||||||
|
const t = timeOf(it)
|
||||||
|
if (t != null) last = t
|
||||||
|
return { it, i, eff: t != null ? t : last }
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.eff - b.eff || a.i - b.i)
|
||||||
|
.map(k => k.it)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge places, notes, and transports into a single ordered day timeline. */
|
/** Merge places, notes, and transports into a single ordered day timeline. */
|
||||||
@@ -94,9 +175,9 @@ export function getMergedItems(opts: {
|
|||||||
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
|
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
|
||||||
})).sort((a, b) => a.minutes - b.minutes)
|
})).sort((a, b) => a.minutes - b.minutes)
|
||||||
|
|
||||||
if (timedTransports.length === 0) return baseItems
|
if (timedTransports.length === 0) return applyChronoOrder(baseItems, dayId, getDisplayTime)
|
||||||
if (baseItems.length === 0) {
|
if (baseItems.length === 0) {
|
||||||
return timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data }))
|
return applyChronoOrder(timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data })), dayId, getDisplayTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert transports among base items based on per-day position or time
|
// Insert transports among base items based on per-day position or time
|
||||||
@@ -132,5 +213,5 @@ export function getMergedItems(opts: {
|
|||||||
result.push({ type: timed.type, sortKey, data: timed.data })
|
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
return applyChronoOrder(result.sort((a, b) => a.sortKey - b.sortKey), dayId, getDisplayTime)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user