fix(dayplan): improve drag-and-drop for items around transport bookings

- Allow dropping places above or below transport cards (top/bottom half detection)
- Fix visual re-render after transport position changes (useMemo invalidation)
- Fix drop indicator showing on all days for multi-day transports (scope key to day)
- Keep all places in order_index order so untimed places can be positioned between timed items
This commit is contained in:
mauriceboe
2026-04-04 14:49:16 +02:00
parent 1aea2fcee8
commit 3f612c4d26

View File

@@ -136,6 +136,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const [dragOverDayId, setDragOverDayId] = useState(null) const [dragOverDayId, setDragOverDayId] = useState(null)
const [hoveredId, setHoveredId] = useState(null) const [hoveredId, setHoveredId] = useState(null)
const [transportDetail, setTransportDetail] = useState(null) const [transportDetail, setTransportDetail] = useState(null)
const [transportPosVersion, setTransportPosVersion] = useState(0)
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
@@ -340,46 +341,38 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
initTransportPositions(dayId) initTransportPositions(dayId)
} }
// Build base list: untimed places + notes sorted by order_index/sort_order // Build base list: ALL places (timed and untimed) + notes sorted by order_index/sort_order
const timedPlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) !== null) // Places keep their order_index ordering — only transports are inserted based on time.
const freePlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) === null)
const baseItems = [ const baseItems = [
...freePlaces.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })), ...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })), ...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
].sort((a, b) => a.sortKey - b.sortKey) ].sort((a, b) => a.sortKey - b.sortKey)
// Timed places + transports: compute sortKeys based on time, inserted among base items // Only transports are inserted among base items based on time/position
// For multi-day transports, use the appropriate display time for this day const timedTransports = transport.map(r => ({
const allTimed = [ type: 'transport' as const,
...timedPlaces.map(a => ({ type: 'place' as const, data: a, minutes: parseTimeToMinutes(a.place?.place_time)! })), data: r,
...transport.map(r => ({ minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayDate)) ?? 0,
type: 'transport' as const, })).sort((a, b) => a.minutes - b.minutes)
data: r,
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayDate)) ?? 0,
})),
].sort((a, b) => a.minutes - b.minutes)
if (allTimed.length === 0) return baseItems if (timedTransports.length === 0) return baseItems
if (baseItems.length === 0) { if (baseItems.length === 0) {
return allTimed.map((item, i) => ({ ...item, sortKey: i })) return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
} }
// Insert timed items among base items using time-to-position mapping. // Insert transports among base items using persisted position or time-to-position mapping.
// Each timed item finds the last base place whose order_index corresponds
// to a reasonable position, then gets a fractional sortKey after it.
const result = [...baseItems] const result = [...baseItems]
for (let ti = 0; ti < allTimed.length; ti++) { for (let ti = 0; ti < timedTransports.length; ti++) {
const timed = allTimed[ti] const timed = timedTransports[ti]
const minutes = timed.minutes const minutes = timed.minutes
// For transports, use persisted position if available // Use persisted position if available
if (timed.type === 'transport' && timed.data.day_plan_position != null) { if (timed.data.day_plan_position != null) {
result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data }) result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data })
continue continue
} }
// Find insertion position: after the last base item with time <= this item's time // Find insertion position: after the last base item with time <= this transport's time
let insertAfterKey = -Infinity let insertAfterKey = -Infinity
for (const item of result) { for (const item of result) {
if (item.type === 'place') { if (item.type === 'place') {
@@ -410,7 +403,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return map return map
// getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure // getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [days, assignments, dayNotes, reservations]) }, [days, assignments, dayNotes, reservations, transportPosVersion])
const openAddNote = (dayId, e) => { const openAddNote = (dayId, e) => {
e?.stopPropagation() e?.stopPropagation()
@@ -509,6 +502,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const res = reservations.find(r => r.id === tu.id) const res = reservations.find(r => r.id === tu.id)
if (res) res.day_plan_position = tu.day_plan_position if (res) res.day_plan_position = tu.day_plan_position
} }
setTransportPosVersion(v => v + 1)
await reservationsApi.updatePositions(tripId, transportUpdates) await reservationsApi.updatePositions(tripId, transportUpdates)
} }
if (prevAssignmentIds.length) { if (prevAssignmentIds.length) {
@@ -1081,18 +1075,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e) const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
// Drop on transport card (detected via dropTargetRef for sync accuracy) // Drop on transport card (detected via dropTargetRef for sync accuracy)
if (dropTargetRef.current?.startsWith('transport-')) { if (dropTargetRef.current?.startsWith('transport-')) {
const transportId = Number(dropTargetRef.current.replace('transport-', '')) const isAfter = dropTargetRef.current.startsWith('transport-after-')
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
const transportId = Number(parts[0])
if (placeId) { if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id) onAssignToDay?.(parseInt(placeId), day.id)
} 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 : 'Unknown error')) tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (assignmentId) { } else if (assignmentId) {
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId) handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
} 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 : 'Unknown error')) tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId) { } else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId) handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter)
} }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
return return
@@ -1133,8 +1129,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div> </div>
) : ( ) : (
merged.map((item, idx) => { merged.map((item, idx) => {
const itemKey = item.type === 'transport' ? `transport-${item.data.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`) const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${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}`
if (item.type === 'place') { if (item.type === 'place') {
const assignment = item.data const assignment = item.data
@@ -1392,20 +1389,28 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />} {showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div <div
onClick={() => setTransportDetail(res)} onClick={() => setTransportDetail(res)}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`transport-${res.id}`) }} onDragOver={e => {
e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
const inBottom = e.clientY > rect.top + rect.height / 2
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
if (dropTargetRef.current !== key) setDropTargetKey(key)
}}
onDrop={e => { onDrop={e => {
e.preventDefault(); e.stopPropagation() e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
const insertAfter = e.clientY > rect.top + rect.height / 2
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e) const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
if (placeId) { if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id) onAssignToDay?.(parseInt(placeId), day.id)
} 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 : 'Unknown error')) tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (fromAssignmentId) { } else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id) handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
} 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 : 'Unknown error')) tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
} else if (noteId) { } else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id) handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter)
} }
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
}} }}
@@ -1462,6 +1467,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
)} )}
</div> </div>
</div> </div>
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
</React.Fragment> </React.Fragment>
) )
} }