diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index 6ece1ecb..7a6a4c16 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -124,7 +124,7 @@ describe('MapView', () => { }) it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => { - render() + render() expect(screen.getByTestId('polyline')).toBeTruthy() }) @@ -134,7 +134,7 @@ describe('MapView', () => { }) it('FE-COMP-MAPVIEW-008: does not render polyline for single-point route', () => { - render() + render() expect(screen.queryByTestId('polyline')).toBeNull() }) @@ -153,7 +153,7 @@ describe('MapView', () => { }) it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => { - const route = [[48.0, 2.0], [49.0, 3.0]] as [number, number][] + const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][] const routeSegments = [ { mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' }, ] diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index c28a2fb2..c79452d3 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -603,15 +603,18 @@ export const MapView = memo(function MapView({ {markers} - {route && route.length > 1 && ( + {route && route.length > 0 && ( <> - + {route.map((seg, i) => seg.length > 1 && ( + + ))} {routeSegments.map((seg, i) => ( ))} diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 84103c53..07125d9e 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -440,26 +440,27 @@ describe('DayPlanSidebar', () => { type: 'flight', title: 'Paris to London', reservation_time: '2025-06-01T08:00:00', + day_id: 10, }) render() expect(screen.getByText('Paris to London')).toBeInTheDocument() }) - it('FE-PLANNER-DAYPLAN-031: clicking transport item shows detail modal', async () => { + it('FE-PLANNER-DAYPLAN-031: clicking transport item calls onEditTransport', async () => { const user = userEvent.setup() + const onEditTransport = vi.fn() const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel Day' }) const reservation = buildReservation({ id: 200, type: 'flight', title: 'Air France 123', reservation_time: '2025-06-01T08:00:00', + day_id: 10, }) - render() + render() await user.click(screen.getByText('Air France 123')) - // Detail modal should appear (shows the title again in the modal) await waitFor(() => { - const titles = screen.getAllByText('Air France 123') - expect(titles.length).toBeGreaterThan(1) + expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 200 })) }) }) @@ -664,6 +665,7 @@ describe('DayPlanSidebar', () => { const reservation = buildReservation({ id: 200, type: 'flight', title: 'CDG to LHR', reservation_time: '2025-06-01T08:00:00', + day_id: 10, }) render( { id: 201, type: 'flight', title: 'Transatlantic', reservation_time: '2025-06-01T22:00:00', reservation_end_time: '2025-06-02T06:00:00', + day_id: 10, + end_day_id: 11, } as any) render( { id: 300, type: 'car', title: 'Renault Rental', reservation_time: '2025-06-01T09:00:00', reservation_end_time: '2025-06-03T17:00:00', + day_id: 10, + end_day_id: 12, } as any) render( { // ── Transport detail modal with metadata ─────────────────────────────── - it('FE-PLANNER-DAYPLAN-051: transport detail modal shows flight metadata', async () => { + it('FE-PLANNER-DAYPLAN-051: clicking flight transport calls onEditTransport with reservation', async () => { const user = userEvent.setup() + const onEditTransport = vi.fn() const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel' }) const reservation = { ...buildReservation({ id: 202, type: 'flight', title: 'Paris to Berlin', reservation_time: '2025-06-01T07:30:00', + day_id: 10, }), metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH1234', departure_airport: 'CDG', arrival_airport: 'BER' }), } - render() + render() await user.click(screen.getByText('Paris to Berlin')) await waitFor(() => { - expect(screen.getByText('Lufthansa')).toBeInTheDocument() + expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 202, type: 'flight' })) }) }) @@ -1124,6 +1132,7 @@ describe('DayPlanSidebar', () => { const flight = buildReservation({ id: 201, type: 'flight', title: 'Afternoon Flight', reservation_time: '2025-06-01T14:00:00', + day_id: 10, }) render( { // Optimize button should not be visible when no day is selected expect(screen.queryByRole('button', { name: /optimize/i })).not.toBeInTheDocument() }) + + // ── Edit reservation pencil button ─────────────────────────────────────── + + it('FE-PLANNER-DAYPLAN-097: pencil button on non-transport reservation calls onEditReservation', async () => { + const user = userEvent.setup() + const place = buildPlace({ id: 1, name: 'Hotel du Lac' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) + const res = buildReservation({ id: 77, trip_id: 1, type: 'hotel', status: 'pending', assignment_id: 99 } as any) + const onEditReservation = vi.fn() + const onEditTransport = vi.fn() + render() + const pencil = screen.getByTitle(/edit/i) + await user.click(pencil) + expect(onEditReservation).toHaveBeenCalledWith(res) + expect(onEditTransport).not.toHaveBeenCalled() + }) + + it('FE-PLANNER-DAYPLAN-098: pencil button on transport reservation calls onEditTransport', async () => { + const user = userEvent.setup() + const place = buildPlace({ id: 1, name: 'Geneva Airport' }) + const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }) + const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place }) + const res = buildReservation({ id: 88, trip_id: 1, type: 'flight', status: 'pending', assignment_id: 99 } as any) + const onEditReservation = vi.fn() + const onEditTransport = vi.fn() + render() + const pencil = screen.getByTitle(/edit/i) + await user.click(pencil) + expect(onEditTransport).toHaveBeenCalledWith(res) + expect(onEditReservation).not.toHaveBeenCalled() + }) }) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index e8bcc109..023ce932 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ -interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string } +interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' } declare global { interface Window { __dragData: DragDataPayload | null } } import React, { useState, useEffect, useRef, useMemo } from 'react' @@ -183,6 +183,11 @@ interface DayPlanSidebarProps { canUndo?: boolean lastActionLabel?: string | null onUndo?: () => void + onRouteRefresh?: () => void + onAddTransport?: (dayId: number) => void + onEditTransport?: (reservation: Reservation) => void + onEditReservation?: (reservation: Reservation) => void + onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void } const DayPlanSidebar = React.memo(function DayPlanSidebar({ @@ -206,6 +211,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ canUndo = false, lastActionLabel = null, onUndo, + onRouteRefresh, + onAddTransport, + onEditTransport, + onEditReservation, + onAddBookingToAssignment, }: DayPlanSidebarProps) { const toast = useToast() const { t, language, locale } = useTranslation() @@ -235,6 +245,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const [undoHover, setUndoHover] = useState(false) const [pdfHover, setPdfHover] = useState(false) const [icsHover, setIcsHover] = useState(false) + const [hoveredAssignmentId, setHoveredAssignmentId] = useState(null) const [dropTargetKey, _setDropTargetKey] = useState(null) const dropTargetRef = useRef(null) const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) } @@ -264,19 +275,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ // Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren) const getDragData = (e) => { const dt = e?.dataTransfer - // Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId gesetzt) + // Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId/reservationId gesetzt) if (dragDataRef.current) { return { placeId: '', assignmentId: dragDataRef.current.assignmentId || '', noteId: dragDataRef.current.noteId || '', + reservationId: dragDataRef.current.reservationId || '', fromDayId: parseInt(dragDataRef.current.fromDayId) || 0, + phase: (dragDataRef.current.phase || 'single') as 'single' | 'start' | 'middle' | 'end', } } // Externer Drag (aus PlacesSidebar) const ext = window.__dragData || {} const placeId = dt?.getData('placeId') || ext.placeId || '' - return { placeId, assignmentId: '', noteId: '', fromDayId: 0 } + return { placeId, assignmentId: '', noteId: '', reservationId: '', fromDayId: 0, phase: 'single' as const } } // Only auto-expand genuinely new days (not on initial load from storage) @@ -323,26 +336,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise']) - // Determine if a reservation's end_time represents a different date (multi-day) - const getEndDate = (r: Reservation) => { - const endStr = r.reservation_end_time || '' - return endStr.includes('T') ? endStr.split('T')[0] : null - } - - // Get span phase: how a reservation relates to a specific day's date - const getSpanPhase = (r: Reservation, dayDate: string): 'single' | 'start' | 'middle' | 'end' => { - if (!r.reservation_time) return 'single' - const startDate = r.reservation_time.split('T')[0] - const endDate = getEndDate(r) || startDate - if (startDate === endDate) return 'single' - if (dayDate === startDate) return 'start' - if (dayDate === endDate) return 'end' + // Get span phase: how a reservation relates to a specific day (by id) + const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => { + const startDayId = r.day_id + const endDayId = r.end_day_id ?? startDayId + if (!startDayId || startDayId === endDayId) return 'single' + if (dayId === startDayId) return 'start' + if (dayId === endDayId) return 'end' return 'middle' } // Get the appropriate display time for a reservation on a specific day - const getDisplayTimeForDay = (r: Reservation, dayDate: string): string | null => { - const phase = getSpanPhase(r, dayDate) + const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => { + const phase = getSpanPhase(r, dayId) if (phase === 'end') return r.reservation_end_time || null if (phase === 'middle') return null return r.reservation_time || null @@ -356,36 +362,56 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`) } + const getDayOrder = (day: (typeof days)[number]) => (day as any).day_number ?? days.indexOf(day) + + const computeMultiDayMove = (r: Reservation, targetDayId: number, phase: 'single' | 'start' | 'middle' | 'end') => { + const startId = r.day_id ?? targetDayId + const endId = r.end_day_id ?? startId + const order = (id: number) => { const d = days.find(x => x.id === id); return d ? getDayOrder(d) : 0 } + if (phase === 'single' || startId === endId) return { day_id: targetDayId, end_day_id: targetDayId } + if (phase === 'start') { + if (order(targetDayId) > order(endId)) return { day_id: targetDayId, end_day_id: targetDayId } + return { day_id: targetDayId, end_day_id: endId } + } + // phase === 'end' + if (order(targetDayId) < order(startId)) return { day_id: targetDayId, end_day_id: targetDayId } + return { day_id: startId, end_day_id: targetDayId } + } + const getTransportForDay = (dayId: number) => { - const day = days.find(d => d.id === dayId) - if (!day?.date) return [] const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id) return reservations.filter(r => { - if (!r.reservation_time || r.type === 'hotel') return false + if (r.type === 'hotel') return false if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false - const startDate = r.reservation_time.split('T')[0] - const endDate = getEndDate(r) - if (endDate && endDate !== startDate) { - // Multi-day: show on any day in range (car middle handled elsewhere) - return day.date >= startDate && day.date <= endDate - } else { - // Single-day: show all non-hotel reservations that match this day's date - return startDate === day.date + const startDayId = r.day_id + const endDayId = r.end_day_id ?? startDayId + + if (startDayId == null) return false + + if (endDayId !== startDayId) { + const startDay = days.find(d => d.id === startDayId) + const endDay = days.find(d => d.id === endDayId) + const thisDay = days.find(d => d.id === dayId) + if (!startDay || !endDay || !thisDay) return false + return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay) } + return startDayId === dayId }) } // Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline const getActiveRentalsForDay = (dayId: number) => { - const day = days.find(d => d.id === dayId) - if (!day?.date) return [] return reservations.filter(r => { - if (r.type !== 'car' || !r.reservation_time) return false - const startDate = r.reservation_time.split('T')[0] - const endDate = getEndDate(r) - if (!endDate || endDate === startDate) return false - return day.date > startDate && day.date < endDate + if (r.type !== 'car') return false + const startDayId = r.day_id + const endDayId = r.end_day_id + if (!startDayId || !endDayId || endDayId === startDayId) return false + const startDay = days.find(d => d.id === startDayId) + const endDay = days.find(d => d.id === endDayId) + const thisDay = days.find(d => d.id === dayId) + if (!startDay || !endDay || !thisDay) return false + return getDayOrder(thisDay) > getDayOrder(startDay) && getDayOrder(thisDay) < getDayOrder(endDay) }) } @@ -434,11 +460,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ day_plan_position: computeTransportPosition(r, da) + idx * 0.01, })) // Mark as initialized immediately to prevent re-entry - for (const p of positions) { - initedTransportIds.current.add(p.id) - const res = reservations.find(x => x.id === p.id) - if (res) res.day_plan_position = p.day_plan_position - } + for (const p of positions) initedTransportIds.current.add(p.id) + // Update store so subscribers see the new positions + useTripStore.setState(state => ({ + reservations: state.reservations.map(r => { + const p = positions.find(x => x.id === r.id) + if (!p) return r + return { ...r, day_plan_position: p.day_plan_position } + }) + })) // Persist to server (fire and forget) reservationsApi.updatePositions(tripId, positions).catch(() => {}) } @@ -447,7 +477,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const da = getDayAssignments(dayId) const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order) const transport = getTransportForDay(dayId) - const dayDate = days.find(d => d.id === dayId)?.date || '' // Initialize positions for transports that don't have one yet if (transport.some(r => r.day_plan_position == null)) { @@ -464,7 +493,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const timedTransports = transport.map(r => ({ type: 'transport' as const, data: r, - minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayDate)) ?? 0, + minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0, })).sort((a, b) => a.minutes - b.minutes) if (timedTransports.length === 0) return baseItems @@ -606,23 +635,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ } try { + // Update transport positions in store FIRST so the useEffect triggered by + // onReorder's optimistic assignment update reads the correct positions. + if (transportUpdates.length) { + useTripStore.setState(state => ({ + reservations: state.reservations.map(r => { + const tu = transportUpdates.find(u => u.id === r.id) + if (!tu) return r + const day_positions = { ...(r.day_positions || {}), [dayId]: tu.day_plan_position } + return { ...r, day_plan_position: tu.day_plan_position, day_positions } + }) + })) + setTransportPosVersion(v => v + 1) + } if (assignmentIds.length) await onReorder(dayId, assignmentIds) + if (transportUpdates.length) { + onRouteRefresh?.() + await reservationsApi.updatePositions(tripId, transportUpdates, dayId) + } for (const n of noteUpdates) { await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order }) } - if (transportUpdates.length) { - for (const tu of transportUpdates) { - const res = reservations.find(r => r.id === tu.id) - if (res) { - res.day_plan_position = tu.day_plan_position - // Update per-day position for multi-day reservations - if (!res.day_positions) res.day_positions = {} - res.day_positions[dayId] = tu.day_plan_position - } - } - setTransportPosVersion(v => v + 1) - await reservationsApi.updatePositions(tripId, transportUpdates, dayId) - } if (prevAssignmentIds.length) { const capturedDayId = dayId const capturedPrevIds = prevAssignmentIds @@ -634,13 +667,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ } const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => { - // Transport bookings themselves cannot be dragged - if (fromType === 'transport') { - toast.error(t('dayplan.cannotReorderTransport')) - setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null - return - } - const m = getMergedItems(dayId) // Check if a timed place is being moved → would it break chronological order? @@ -853,7 +879,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ e.preventDefault() e.stopPropagation() setDragOverDayId(null) - const { placeId, assignmentId, noteId, fromDayId } = getDragData(e) + const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) + if (fromReservationId && fromDayId !== dayId) { + const r = reservations.find(x => x.id === Number(fromReservationId)) + if (r) { const update = computeMultiDayMove(r, dayId, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } + setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return + } if (placeId) { onAssignToDay?.(parseInt(placeId), dayId) } else if (assignmentId && fromDayId !== dayId) { @@ -1111,6 +1142,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ > } + {canEditDays && onAddTransport && ( + + )} {(() => { const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id) // Sort: check-out first, then ongoing stays, then check-in last @@ -1190,7 +1242,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ onDrop={e => { e.preventDefault() e.stopPropagation() - const { placeId, assignmentId, noteId, fromDayId } = getDragData(e) + const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) // Drop on transport card (detected via dropTargetRef for sync accuracy) if (dropTargetRef.current?.startsWith('transport-')) { const isAfter = dropTargetRef.current.startsWith('transport-after-') @@ -1199,6 +1251,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) + } else if (fromReservationId && fromDayId !== day.id) { + 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'))) } + } else if (fromReservationId) { + handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter) } 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'))) } else if (assignmentId) { @@ -1212,6 +1269,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ return } + if (fromReservationId && fromDayId !== day.id) { + 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'))) } + setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return + } if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return } if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) @@ -1310,11 +1372,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() - const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e) + const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) if (placeId) { const pos = placeItems.findIndex(i => i.data.id === assignment.id) onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined) setDropTargetKey(null); window.__dragData = null + } else if (fromReservationId && fromDayId !== day.id) { + 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'))) } + setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null + } else if (fromReservationId) { + handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id) } else if (fromAssignmentId && fromDayId !== day.id) { const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id) tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) @@ -1346,12 +1414,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ e.currentTarget.style.background = 'var(--bg-hover)' const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null if (grip) grip.style.opacity = '1' + setHoveredAssignmentId(assignment.id) }} onMouseLeave={e => { if (!isPlaceSelected && !lockedIds.has(assignment.id)) e.currentTarget.style.background = 'transparent' const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null if (grip) grip.style.opacity = '0.3' + setHoveredAssignmentId(null) }} style={{ display: 'flex', alignItems: 'center', gap: 8, @@ -1428,26 +1498,74 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const res = reservations.find(r => r.assignment_id === assignment.id) if (!res) return null const confirmed = res.status === 'confirmed' + const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2 + const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false return ( -
- {(() => { const RI = RES_ICONS[res.type] || Ticket; return })()} - {confirmed ? t('planner.resConfirmed') : t('planner.resPending')} - {res.reservation_time?.includes('T') && ( - - {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} - {res.reservation_end_time && ` – ${res.reservation_end_time}`} - +
+
+ {(() => { const RI = RES_ICONS[res.type] || Ticket; return })()} + {confirmed ? t('planner.resConfirmed') : t('planner.resPending')} + {res.reservation_time?.includes('T') && ( + + {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} + {res.reservation_end_time && ` – ${res.reservation_end_time}`} + + )} + {(() => { + const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) + if (!meta) return null + if (meta.airline && meta.flight_number) return {meta.airline} {meta.flight_number} + if (meta.flight_number) return {meta.flight_number} + if (meta.train_number) return {meta.train_number} + return null + })()} +
+ {hasEndpoints && ( + )} - {(() => { - const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) - if (!meta) return null - if (meta.airline && meta.flight_number) return {meta.airline} {meta.flight_number} - if (meta.flight_number) return {meta.flight_number} - if (meta.train_number) return {meta.train_number} - return null + {canEditDays && (() => { + const isTransport = ['flight','train','car','cruise','bus'].includes(res.type) + const handler = isTransport ? onEditTransport : onEditReservation + if (!handler) return null + return ( + + ) })()}
) @@ -1478,6 +1596,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
} + {canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && ( + + )} ) @@ -1486,7 +1630,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ // Transport booking (flight, train, bus, car, cruise) if (item.type === 'transport') { const res = item.data - const spanPhase = getSpanPhase(res, day.date) + const spanPhase = getSpanPhase(res, day.id) // Car "active" (middle) days are shown in the day header, skip here if (res.type === 'car' && spanPhase === 'middle') return null @@ -1508,13 +1652,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ // Multi-day span phase const spanLabel = getSpanLabel(res, spanPhase) - const displayTime = getDisplayTimeForDay(res, day.date) + const displayTime = getDisplayTimeForDay(res, day.id) return ( {showDropLine &&
}
setTransportDetail(res)} + onClick={() => canEditDays && onEditTransport?.(res)} onDragOver={e => { e.preventDefault(); e.stopPropagation() const rect = e.currentTarget.getBoundingClientRect() @@ -1522,13 +1666,26 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}` if (dropTargetRef.current !== key) setDropTargetKey(key) }} + draggable={canEditDays && spanPhase !== 'middle'} + onDragStart={e => { + if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return } + e.dataTransfer.effectAllowed = 'move' + dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase } + setDraggingId(res.id) + }} + onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} onDrop={e => { 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, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) + } else if (fromReservationId && fromDayId !== day.id) { + 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'))) } + } else if (fromReservationId) { + handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter) } 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'))) } else if (fromAssignmentId) { @@ -1549,11 +1706,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ borderRadius: 6, border: `1px solid ${color}33`, background: `${color}08`, - cursor: 'pointer', userSelect: 'none', + cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none', transition: 'background 0.1s', - opacity: spanPhase === 'middle' ? 0.65 : 1, + opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1, }} > + {canEditDays && spanPhase !== 'middle' && ( +
+ +
+ )}
{ e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() - const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e) - if (fromNoteId && fromDayId !== day.id) { + const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) + if (fromReservationId && fromDayId !== day.id) { + 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'))) } + setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null + } else if (fromReservationId) { + handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'note', note.id) + } else if (fromNoteId && fromDayId !== day.id) { const tm = getMergedItems(day.id) const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 @@ -1715,12 +1883,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() - const { placeId, assignmentId, noteId, fromDayId } = getDragData(e) + const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) // Neuer Ort von der Orte-Liste if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) setDropTargetKey(null); window.__dragData = null; return } + if (fromReservationId && fromDayId !== day.id) { + 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'))) } + setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return + } if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return } 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'))) diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index 66c99f82..7710a188 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -87,7 +87,7 @@ describe('ReservationModal', () => { }); it('FE-PLANNER-RESMODAL-003: shows "Edit Reservation" title when editing', () => { - const res = buildReservation({ title: 'Flight NY', type: 'flight' }); + const res = buildReservation({ title: 'Nice Dinner', type: 'restaurant' }); render(); expect(screen.getByText(/Edit Reservation/i)).toBeInTheDocument(); }); @@ -101,34 +101,26 @@ describe('ReservationModal', () => { expect(onSave).not.toHaveBeenCalled(); }); - it('FE-PLANNER-RESMODAL-005: all 9 type buttons are visible', () => { + it('FE-PLANNER-RESMODAL-005: all 5 type buttons are visible (transport types removed)', () => { render(); - expect(screen.getByRole('button', { name: /Flight/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Accommodation/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Restaurant/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Train/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Cruise/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Event/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Tour/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Other/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Flight$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Train$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Car$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Cruise$/i })).not.toBeInTheDocument(); }); // ── Type selection ────────────────────────────────────────────────────────── - it('FE-PLANNER-RESMODAL-006: clicking Flight type button shows flight-specific fields', async () => { + it('FE-PLANNER-RESMODAL-006: clicking Event type button activates it', async () => { render(); - await userEvent.click(screen.getByRole('button', { name: /Flight/i })); - // Flight-specific airline field has placeholder="Lufthansa" (exact, not the title placeholder) - expect(screen.getByPlaceholderText('Lufthansa')).toBeInTheDocument(); - }); - - it('FE-PLANNER-RESMODAL-007: flight type shows airline/airport fields', async () => { - render(); - await userEvent.click(screen.getByRole('button', { name: /Flight/i })); - expect(screen.getByText(/Airline/i)).toBeInTheDocument(); - expect(screen.getByText(/^From$/i)).toBeInTheDocument(); - expect(screen.getByText(/^To$/i)).toBeInTheDocument(); + const eventBtn = screen.getByRole('button', { name: /Event/i }); + await userEvent.click(eventBtn); + expect(eventBtn).toHaveStyle({ background: 'var(--text-primary)' }); }); it('FE-PLANNER-RESMODAL-008: hotel type shows check-in/check-out time fields', async () => { @@ -139,12 +131,10 @@ describe('ReservationModal', () => { expect(screen.getByText(/Check-out/i)).toBeInTheDocument(); }); - it('FE-PLANNER-RESMODAL-009: train type shows train number/platform/seat fields', async () => { + it('FE-PLANNER-RESMODAL-009: restaurant type shows location field', async () => { render(); - await userEvent.click(screen.getByRole('button', { name: /Train/i })); - expect(screen.getByText(/Train No\./i)).toBeInTheDocument(); - expect(screen.getByText(/Platform/i)).toBeInTheDocument(); - expect(screen.getByText(/Seat/i)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /Restaurant/i })); + expect(screen.getByPlaceholderText(/Address, Airport/i)).toBeInTheDocument(); }); it('FE-PLANNER-RESMODAL-010: hotel type hides assignment picker', async () => { @@ -183,13 +173,10 @@ describe('ReservationModal', () => { expect(screen.getByDisplayValue('Breakfast included')).toBeInTheDocument(); }); - it('FE-PLANNER-RESMODAL-014: editing pre-fills type — train type does not show flight fields', () => { - const res = buildReservation({ type: 'train' }); + it('FE-PLANNER-RESMODAL-014: editing pre-fills type — restaurant type shows location field', () => { + const res = buildReservation({ type: 'restaurant', location: 'Via Roma 1' }); render(); - // Flight-specific airline input has placeholder="Lufthansa" (exact) — should NOT appear for train type - expect(screen.queryByPlaceholderText('Lufthansa')).not.toBeInTheDocument(); - // Train fields should appear - expect(screen.getByText(/Train No\./i)).toBeInTheDocument(); + expect(screen.getByDisplayValue('Via Roma 1')).toBeInTheDocument(); }); // ── Validation ────────────────────────────────────────────────────────────── @@ -232,18 +219,18 @@ describe('ReservationModal', () => { // ── Submit flow ───────────────────────────────────────────────────────────── - it('FE-PLANNER-RESMODAL-016: submitting valid flight calls onSave with correct shape', async () => { + it('FE-PLANNER-RESMODAL-016: submitting valid restaurant booking calls onSave with correct shape', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); - await userEvent.click(screen.getByRole('button', { name: /Flight/i })); - await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Air France 777'); + await userEvent.click(screen.getByRole('button', { name: /Restaurant/i })); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Le Jules Verne'); await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Air France 777', type: 'flight' }) + expect.objectContaining({ title: 'Le Jules Verne', type: 'restaurant' }) ); }); @@ -439,17 +426,17 @@ describe('ReservationModal', () => { ); }); - it('FE-PLANNER-RESMODAL-031: train type — saving calls onSave with train type', async () => { + it('FE-PLANNER-RESMODAL-031: event type — saving calls onSave with event type', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); - await userEvent.click(screen.getByRole('button', { name: /Train/i })); - await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar Paris'); + await userEvent.click(screen.getByRole('button', { name: /Event/i })); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Louvre Museum'); await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Eurostar Paris', type: 'train' }) + expect.objectContaining({ title: 'Louvre Museum', type: 'event' }) ); }); @@ -473,7 +460,7 @@ describe('ReservationModal', () => { it('FE-PLANNER-RESMODAL-036: file upload to existing reservation calls onFileUpload', async () => { const onFileUpload = vi.fn().mockResolvedValue(undefined); - const res = buildReservation({ id: 10, title: 'My Trip', type: 'flight' }); + const res = buildReservation({ id: 10, title: 'My Trip', type: 'other' }); render( { expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument(); }); - it('FE-PLANNER-RESMODAL-042: flight type metadata saved with airline and flight number', async () => { + it('FE-PLANNER-RESMODAL-042: hotel type metadata saved with check-in time', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); - await userEvent.click(screen.getByRole('button', { name: /Flight/i })); - await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'AF 447 CDG → JFK'); - await userEvent.type(screen.getByPlaceholderText('Lufthansa'), 'Air France'); - await userEvent.type(screen.getByPlaceholderText('LH 123'), 'AF 447'); + await userEvent.click(screen.getByRole('button', { name: /Accommodation/i })); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Grand Hotel'); await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'flight', - metadata: expect.objectContaining({ - airline: 'Air France', - flight_number: 'AF 447', - }), - }) + expect.objectContaining({ title: 'Grand Hotel', type: 'hotel' }) ); }); @@ -634,22 +613,21 @@ describe('ReservationModal', () => { expect(screen.getByText(/Budget category/i)).toBeInTheDocument(); }); - it('FE-PLANNER-RESMODAL-045: car type shows date/time section', async () => { + it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => { render(); - await userEvent.click(screen.getByRole('button', { name: /^Car$/i })); - // Car type still shows date fields (not hotel which hides them) + await userEvent.click(screen.getByRole('button', { name: /^Tour$/i })); await waitFor(() => { - expect(screen.getAllByTestId('date-picker').length).toBeGreaterThan(0); + expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0); }); }); - it('FE-PLANNER-RESMODAL-046: cruise type renders and saves correctly', async () => { + it('FE-PLANNER-RESMODAL-046: other type renders and saves correctly', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); - await userEvent.click(screen.getByRole('button', { name: /Cruise/i })); - await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Caribbean Cruise'); + await userEvent.click(screen.getByRole('button', { name: /^Other$/i })); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Misc item'); await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); - await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'cruise' }))); + await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' }))); }); it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => { @@ -730,23 +708,17 @@ describe('ReservationModal', () => { }); }); - it('FE-PLANNER-RESMODAL-035: flight with train number metadata saved correctly', async () => { + it('FE-PLANNER-RESMODAL-035: hotel type saves correctly', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); - await userEvent.click(screen.getByRole('button', { name: /Train/i })); - await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 792'); - await userEvent.type(screen.getByPlaceholderText(/ICE 123/i), 'ICE 792'); - await userEvent.type(screen.getByPlaceholderText(/^12$/i), '5'); - await userEvent.type(screen.getByPlaceholderText(/42A/i), '14B'); + await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i })); + await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Test'); await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'train', - metadata: expect.objectContaining({ train_number: 'ICE 792', platform: '5', seat: '14B' }), - }) + expect.objectContaining({ type: 'hotel' }) ); }); }); diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 50c3165c..e0e797c2 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -5,72 +5,17 @@ import { useTripStore } from '../../store/tripStore' import { useAddonStore } from '../../store/addonStore' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' -import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react' +import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import CustomTimePicker from '../shared/CustomTimePicker' import { openFile } from '../../utils/fileDownload' -import AirportSelect, { type Airport } from './AirportSelect' -import LocationSelect, { type LocationPoint } from './LocationSelect' -import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, ReservationEndpoint } from '../../types' - -const TRANSPORT_TYPES = ['flight', 'train', 'cruise', 'car'] as const -type TransportType = typeof TRANSPORT_TYPES[number] -const isTransport = (t: string): t is TransportType => (TRANSPORT_TYPES as readonly string[]).includes(t) - -interface EndpointPick { - airport?: Airport - location?: LocationPoint -} - -function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit { - return { - role, sequence, - name: a.city ? `${a.city} (${a.iata})` : a.name, - code: a.iata, - lat: a.lat, lng: a.lng, - timezone: a.tz, - local_date: date, - local_time: time, - } -} - -function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit { - return { - role, sequence, - name: l.name, - code: null, - lat: l.lat, lng: l.lng, - timezone: null, - local_date: date, - local_time: time, - } -} - -function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null { - if (!e || !e.code) return null - return { - iata: e.code, icao: null, - name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''), - country: '', - lat: e.lat, lng: e.lng, - tz: e.timezone || '', - } -} - -function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null { - if (!e) return null - return { name: e.name, lat: e.lat, lng: e.lng, address: null } -} +import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types' const TYPE_OPTIONS = [ - { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane }, { value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel }, { value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils }, - { value: 'train', labelKey: 'reservations.type.train', Icon: Train }, - { value: 'car', labelKey: 'reservations.type.car', Icon: Car }, - { value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship }, { value: 'event', labelKey: 'reservations.type.event', Icon: Ticket }, { value: 'tour', labelKey: 'reservations.type.tour', Icon: Users }, { value: 'other', labelKey: 'reservations.type.other', Icon: FileText }, @@ -84,7 +29,6 @@ function buildAssignmentOptions(days, assignments, t, locale) { const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number }) const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : '' const groupLabel = `${dayLabel}${dateStr}` - // Group header (non-selectable) options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true }) for (let i = 0; i < da.length; i++) { const place = da[i].place @@ -115,9 +59,10 @@ interface ReservationModalProps { onFileUpload?: (fd: FormData) => Promise onFileDelete: (fileId: number) => Promise accommodations?: Accommodation[] + defaultAssignmentId?: number | null } -export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) { +export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) { const { id: tripId } = useParams<{ id: string }>() const loadFiles = useTripStore(s => s.loadFiles) const toast = useToast() @@ -135,22 +80,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p const [form, setForm] = useState({ title: '', type: 'other', status: 'pending', reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', - notes: '', assignment_id: '', accommodation_id: '', + notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number, price: '', budget_category: '', - meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '', - meta_departure_timezone: '', meta_arrival_timezone: '', - meta_train_number: '', meta_platform: '', meta_seat: '', meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '', - hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', + hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number, }) const [isSaving, setIsSaving] = useState(false) const [uploadingFile, setUploadingFile] = useState(false) const [pendingFiles, setPendingFiles] = useState([]) const [showFilePicker, setShowFilePicker] = useState(false) const [linkedFileIds, setLinkedFileIds] = useState([]) - const [unlinkedFileIds, setUnlinkedFileIds] = useState([]) - const [fromPick, setFromPick] = useState({}) - const [toPick, setToPick] = useState({}) const assignmentOptions = useMemo( () => buildAssignmentOptions(days, assignments, t, locale), @@ -160,7 +99,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p useEffect(() => { if (reservation) { const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {}) - // Parse end_date from reservation_end_time if it's a full ISO datetime const rawEnd = reservation.reservation_end_time || '' let endDate = '' let endTime = rawEnd @@ -183,15 +121,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p notes: reservation.notes || '', assignment_id: reservation.assignment_id || '', accommodation_id: reservation.accommodation_id || '', - meta_airline: meta.airline || '', - meta_flight_number: meta.flight_number || '', - meta_departure_airport: meta.departure_airport || '', - meta_arrival_airport: meta.arrival_airport || '', - meta_departure_timezone: meta.departure_timezone || '', - meta_arrival_timezone: meta.arrival_timezone || '', - meta_train_number: meta.train_number || '', - meta_platform: meta.platform || '', - meta_seat: meta.seat || '', meta_check_in_time: meta.check_in_time || '', meta_check_in_end_time: meta.check_in_end_time || '', meta_check_out_time: meta.check_out_time || '', @@ -201,61 +130,26 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p price: meta.price || '', budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '', }) - - const eps = reservation.endpoints || [] - const from = eps.find(e => e.role === 'from') - const to = eps.find(e => e.role === 'to') - if (reservation.type === 'flight') { - setFromPick({ airport: airportFromEndpoint(from) || undefined }) - setToPick({ airport: airportFromEndpoint(to) || undefined }) - } else if (isTransport(reservation.type)) { - setFromPick({ location: locationFromEndpoint(from) || undefined }) - setToPick({ location: locationFromEndpoint(to) || undefined }) - } else { - setFromPick({}) - setToPick({}) - } } else { setForm({ title: '', type: 'other', status: 'pending', reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', - notes: '', assignment_id: '', accommodation_id: '', + notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '', price: '', budget_category: '', - meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '', - meta_departure_timezone: '', meta_arrival_timezone: '', - meta_train_number: '', meta_platform: '', meta_seat: '', meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '', + hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', }) setPendingFiles([]) - setFromPick({}) - setToPick({}) } - }, [reservation, isOpen, selectedDayId]) + }, [reservation, isOpen, selectedDayId, defaultAssignmentId]) const set = (field, value) => setForm(prev => ({ ...prev, [field]: value })) - // Validate that end datetime is after start datetime const isEndBeforeStart = (() => { if (!form.end_date || !form.reservation_time) return false const startDate = form.reservation_time.split('T')[0] const startTime = form.reservation_time.split('T')[1] || '00:00' const endTime = form.reservation_end_time || '00:00' - // For flights, compare in UTC using timezone offsets - if (form.type === 'flight') { - const parseOffset = (tz: string): number | null => { - if (!tz) return null - const m = tz.trim().match(/^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::(\d{2}))?$/i) - if (!m) return null - const sign = m[1] === '+' ? 1 : -1 - return sign * (parseInt(m[2]) * 60 + parseInt(m[3] || '0')) - } - const depOffset = parseOffset(form.meta_departure_timezone) - const arrOffset = parseOffset(form.meta_arrival_timezone) - if (depOffset === null || arrOffset === null) return false - const depMinutes = new Date(`${startDate}T${startTime}`).getTime() - depOffset * 60000 - const arrMinutes = new Date(`${form.end_date}T${endTime}`).getTime() - arrOffset * 60000 - return arrMinutes <= depMinutes - } const startFull = `${startDate}T${startTime}` const endFull = `${form.end_date}T${endTime}` return endFull <= startFull @@ -268,27 +162,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p setIsSaving(true) try { const metadata: Record = {} - if (form.type === 'flight') { - if (form.meta_airline) metadata.airline = form.meta_airline - if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number - if (fromPick.airport) { - metadata.departure_airport = fromPick.airport.iata - metadata.departure_timezone = fromPick.airport.tz - } - if (toPick.airport) { - metadata.arrival_airport = toPick.airport.iata - metadata.arrival_timezone = toPick.airport.tz - } - } else if (form.type === 'hotel') { + if (form.type === 'hotel') { if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time if (form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time - } else if (form.type === 'train') { - if (form.meta_train_number) metadata.train_number = form.meta_train_number - if (form.meta_platform) metadata.platform = form.meta_platform - if (form.meta_seat) metadata.seat = form.meta_seat } - // Combine end_date + end_time into reservation_end_time let combinedEndTime = form.reservation_end_time if (form.end_date) { combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date @@ -297,40 +175,24 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p if (form.price) metadata.price = form.price if (form.budget_category) metadata.budget_category = form.budget_category } - const endpoints: ReturnType[] = [] - if (isTransport(form.type)) { - const startDate = (form.reservation_time || '').split('T')[0] || null - const startTime = (form.reservation_time || '').split('T')[1]?.slice(0, 5) || null - const endDate = form.end_date || null - const endTime = form.reservation_end_time || null - if (form.type === 'flight') { - if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, startTime)) - if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, endTime)) - } else { - if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, startTime)) - if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, endTime)) - } - } const saveData: Record = { title: form.title, type: form.type, status: form.status, - reservation_time: form.type === 'hotel' ? null : form.reservation_time, - reservation_end_time: form.type === 'hotel' ? null : combinedEndTime, + reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null), + reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null), location: form.location, confirmation_number: form.confirmation_number, notes: form.notes, assignment_id: form.assignment_id || null, accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null, metadata: Object.keys(metadata).length > 0 ? metadata : null, - endpoints: isTransport(form.type) ? endpoints : [], + endpoints: [], needs_review: false, } - // Auto-create/update budget entry if price is set, or signal removal if cleared if (isBudgetEnabled) { saveData.create_budget_entry = form.price && parseFloat(form.price) > 0 ? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' } : { total_price: 0 } } - // If hotel with place + days, pass hotel data for auto-creation or update if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) { saveData.create_accommodation = { place_id: form.hotel_place_id, @@ -428,7 +290,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p {/* Assignment Picker (hidden for hotels) */} {form.type !== 'hotel' && assignmentOptions.length > 0 && ( -
+
-
+
)} {/* Start Date/Time + End Date/Time + Status (hidden for hotels) */} {form.type !== 'hotel' && ( - <> -
-
- - { const [d] = (form.reservation_time || '').split('T'); return d || '' })()} - onChange={d => { - const [, t] = (form.reservation_time || '').split('T') - set('reservation_time', d ? (t ? `${d}T${t}` : d) : '') - }} - /> -
-
- - { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()} - onChange={t => { - const [d] = (form.reservation_time || '').split('T') - const selectedDay = days.find(dy => dy.id === selectedDayId) - const date = d || selectedDay?.date || new Date().toISOString().split('T')[0] - set('reservation_time', t ? `${date}T${t}` : date) - }} - /> -
- {form.type === 'flight' && fromPick.airport && ( + <> +
- -
- {fromPick.airport.tz} -
+ + { const [d] = (form.reservation_time || '').split('T'); return d || '' })()} + onChange={d => { + const [, tm] = (form.reservation_time || '').split('T') + set('reservation_time', d ? (tm ? `${d}T${tm}` : d) : '') + }} + />
- )} -
-
-
- - set('end_date', d || '')} - /> -
-
- - set('reservation_end_time', v)} /> -
- {form.type === 'flight' && toPick.airport && (
- -
- {toPick.airport.tz} -
+ + { const [, tm] = (form.reservation_time || '').split('T'); return tm || '' })()} + onChange={tm => { + const [d] = (form.reservation_time || '').split('T') + const selectedDay = days.find(dy => dy.id === selectedDayId) + const date = d || selectedDay?.date || new Date().toISOString().split('T')[0] + set('reservation_time', tm ? `${date}T${tm}` : date) + }} + />
+
+
+
+ + set('end_date', d || '')} + /> +
+
+ + set('reservation_end_time', v)} /> +
+
+ {isEndBeforeStart && ( +
{t('reservations.validation.endBeforeStart')}
)} -
- {isEndBeforeStart && ( -
{t('reservations.validation.endBeforeStart')}
- )} - + )} - {/* Location (own row for non-transport, non-hotel types) */} - {!isTransport(form.type) && form.type !== 'hotel' && ( + {/* Location */} + {form.type !== 'hotel' && (
set('location', e.target.value)} @@ -550,46 +396,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
- {/* From / To endpoints for transport bookings */} - {isTransport(form.type) && ( -
-
- - {form.type === 'flight' ? ( - setFromPick({ airport: a || undefined })} /> - ) : ( - setFromPick({ location: l || undefined })} /> - )} -
-
- - {form.type === 'flight' ? ( - setToPick({ airport: a || undefined })} /> - ) : ( - setToPick({ location: l || undefined })} /> - )} -
-
- )} - - {form.type === 'flight' && ( -
-
- - set('meta_airline', e.target.value)} - placeholder="Lufthansa" style={inputStyle} /> -
-
- - set('meta_flight_number', e.target.value)} - placeholder="LH 123" style={inputStyle} /> -
-
- )} - + {/* Hotel fields */} {form.type === 'hotel' && ( <> - {/* Hotel place + day range */}
@@ -633,7 +442,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p />
- {/* Check-in / check-in-until / check-out */}
@@ -651,26 +459,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p )} - {form.type === 'train' && ( -
-
- - set('meta_train_number', e.target.value)} - placeholder="ICE 123" style={inputStyle} /> -
-
- - set('meta_platform', e.target.value)} - placeholder="12" style={inputStyle} /> -
-
- - set('meta_seat', e.target.value)} - placeholder="42A" style={inputStyle} /> -
-
- )} - {/* Notes */}
@@ -689,12 +477,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p {f.original_name} { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}> } - {/* Link existing file picker */} {reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
- {/* Price + Budget Category — only shown when budget addon is enabled */} + {/* Price + Budget Category */} {isBudgetEnabled && ( <>
@@ -779,7 +563,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }} - onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } set('price', t) }} + onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }} placeholder="0.00" style={inputStyle} />
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 32639a83..990d9928 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -69,9 +69,10 @@ interface ReservationCardProps { onNavigateToFiles: () => void assignmentLookup: Record canEdit: boolean + days?: Day[] } -function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) { +function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [] }: ReservationCardProps) { const { toggleReservationStatus } = useTripStore() const toast = useToast() const { t, locale } = useTranslation() @@ -109,6 +110,21 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo const hasCode = !!r.confirmation_number const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length + const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise']) + const isTransportType = TRANSPORT_TYPES_SET.has(r.type) + const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined + const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined + const dayLabel = (day: typeof startDay): string => { + if (!day) return '' + const base = day.title || t('dayplan.dayN', { n: day.day_number }) + if (day.date) { + const d = new Date(day.date + 'T00:00:00Z') + const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) + return `${base} · ${dateStr}` + } + return base + } + return (
+ {/* Day label for transport reservations linked to a day */} + {isTransportType && startDay && ( +
+
{t('reservations.date')}
+
+ {dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` – ${dayLabel(endDay)}` : ''} +
+
+ )} {/* Date / Time row */} {hasDate && (
@@ -430,9 +455,11 @@ interface ReservationsPanelProps { onEdit: (reservation: Reservation) => void onDelete: (id: number) => void onNavigateToFiles: () => void + titleKey?: string + addManualKey?: string } -export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) { +export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) { const { t, locale } = useTranslation() const can = useCanDo() const trip = useTripStore((s) => s.trip) @@ -483,7 +510,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', }}>

- {t('reservations.title')} + {t(titleKey)}

{reservations.length > 0 && ( @@ -557,7 +584,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme onMouseLeave={e => e.currentTarget.style.opacity = '1'} > - {t('reservations.addManual')} + {t(addManualKey)} )}
@@ -579,12 +606,12 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme <> {allPending.length > 0 && (
- {allPending.map(r => )} + {allPending.map(r => )}
)} {allConfirmed.length > 0 && (
- {allConfirmed.map(r => )} + {allConfirmed.map(r => )}
)} diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx new file mode 100644 index 00000000..567de9fb --- /dev/null +++ b/client/src/components/Planner/TransportModal.tsx @@ -0,0 +1,422 @@ +import { useState, useEffect } from 'react' +import { Plane, Train, Car, Ship } from 'lucide-react' +import Modal from '../shared/Modal' +import CustomSelect from '../shared/CustomSelect' +import CustomTimePicker from '../shared/CustomTimePicker' +import AirportSelect, { type Airport } from './AirportSelect' +import LocationSelect, { type LocationPoint } from './LocationSelect' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import { formatDate } from '../../utils/formatters' +import type { Day, Reservation, ReservationEndpoint } from '../../types' + +const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const +type TransportType = typeof TRANSPORT_TYPES[number] + +interface EndpointPick { + airport?: Airport + location?: LocationPoint +} + +function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit { + return { + role, sequence, + name: a.city ? `${a.city} (${a.iata})` : a.name, + code: a.iata, + lat: a.lat, lng: a.lng, + timezone: a.tz, + local_date: date, + local_time: time, + } +} + +function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit { + return { + role, sequence, + name: l.name, + code: null, + lat: l.lat, lng: l.lng, + timezone: null, + local_date: date, + local_time: time, + } +} + +function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null { + if (!e || !e.code) return null + return { + iata: e.code, icao: null, + name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''), + country: '', + lat: e.lat, lng: e.lng, + tz: e.timezone || '', + } +} + +function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null { + if (!e) return null + return { name: e.name, lat: e.lat, lng: e.lng, address: null } +} + +const TYPE_OPTIONS = [ + { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane }, + { value: 'train', labelKey: 'reservations.type.train', Icon: Train }, + { value: 'car', labelKey: 'reservations.type.car', Icon: Car }, + { value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship }, +] + +const defaultForm = { + title: '', + type: 'flight' as TransportType, + status: 'pending' as 'pending' | 'confirmed', + start_day_id: '' as string | number, + end_day_id: '' as string | number, + departure_time: '', + arrival_time: '', + confirmation_number: '', + notes: '', + meta_airline: '', + meta_flight_number: '', + meta_train_number: '', + meta_platform: '', + meta_seat: '', +} + +interface TransportModalProps { + isOpen: boolean + onClose: () => void + onSave: (data: Record) => Promise + reservation: Reservation | null + days: Day[] + selectedDayId: number | null +} + +export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) { + const { t, locale } = useTranslation() + const toast = useToast() + const [form, setForm] = useState({ ...defaultForm }) + const [isSaving, setIsSaving] = useState(false) + const [fromPick, setFromPick] = useState({}) + const [toPick, setToPick] = useState({}) + + useEffect(() => { + if (!isOpen) return + if (reservation) { + const meta = typeof reservation.metadata === 'string' + ? JSON.parse(reservation.metadata || '{}') + : (reservation.metadata || {}) + const eps = reservation.endpoints || [] + const from = eps.find(e => e.role === 'from') + const to = eps.find(e => e.role === 'to') + const type = (TRANSPORT_TYPES as readonly string[]).includes(reservation.type) + ? reservation.type as TransportType + : 'flight' + setForm({ + title: reservation.title || '', + type, + status: reservation.status || 'pending', + start_day_id: reservation.day_id ?? '', + end_day_id: reservation.end_day_id ?? '', + departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '', + arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '', + confirmation_number: reservation.confirmation_number || '', + notes: reservation.notes || '', + meta_airline: meta.airline || '', + meta_flight_number: meta.flight_number || '', + meta_train_number: meta.train_number || '', + meta_platform: meta.platform || '', + meta_seat: meta.seat || '', + }) + if (type === 'flight') { + setFromPick({ airport: airportFromEndpoint(from) || undefined }) + setToPick({ airport: airportFromEndpoint(to) || undefined }) + } else { + setFromPick({ location: locationFromEndpoint(from) || undefined }) + setToPick({ location: locationFromEndpoint(to) || undefined }) + } + } else { + setForm({ ...defaultForm, start_day_id: selectedDayId ?? '' }) + setFromPick({}) + setToPick({}) + } + }, [isOpen, reservation, selectedDayId]) + + const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value })) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!form.title.trim()) return + setIsSaving(true) + try { + const startDay = days.find(d => d.id === Number(form.start_day_id)) + const endDay = days.find(d => d.id === Number(form.end_day_id)) + + const buildTime = (day: Day | undefined, time: string): string | null => { + if (!time) return null + return day?.date ? `${day.date}T${time}` : `T${time}` + } + + const metadata: Record = {} + if (form.type === 'flight') { + if (form.meta_airline) metadata.airline = form.meta_airline + if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number + if (fromPick.airport) { + metadata.departure_airport = fromPick.airport.iata + metadata.departure_timezone = fromPick.airport.tz + } + if (toPick.airport) { + metadata.arrival_airport = toPick.airport.iata + metadata.arrival_timezone = toPick.airport.tz + } + } else if (form.type === 'train') { + if (form.meta_train_number) metadata.train_number = form.meta_train_number + if (form.meta_platform) metadata.platform = form.meta_platform + if (form.meta_seat) metadata.seat = form.meta_seat + } + + const startDate = startDay?.date ?? null + const endDate = (endDay ?? startDay)?.date ?? null + const endpoints: ReturnType[] = [] + if (form.type === 'flight') { + if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null)) + if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null)) + } else { + if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null)) + if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null)) + } + + const payload = { + title: form.title, + type: form.type, + status: form.status, + day_id: form.start_day_id ? Number(form.start_day_id) : null, + end_day_id: form.end_day_id ? Number(form.end_day_id) : null, + reservation_time: buildTime(startDay, form.departure_time), + reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time), + location: null, + confirmation_number: form.confirmation_number || null, + notes: form.notes || null, + metadata: Object.keys(metadata).length > 0 ? metadata : null, + endpoints, + needs_review: false, + } + await onSave(payload) + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.unknownError')) + } finally { + setIsSaving(false) + } + } + + const inputStyle = { + width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, + padding: '8px 12px', fontSize: 13, fontFamily: 'inherit', + outline: 'none', boxSizing: 'border-box' as const, color: 'var(--text-primary)', background: 'var(--bg-input)', + } + const labelStyle = { + display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', + marginBottom: 5, textTransform: 'uppercase' as const, letterSpacing: '0.03em', + } + + const dayOptions = [ + { value: '', label: '—' }, + ...days.map(d => ({ + value: d.id, + label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale) ?? ''}` : ''}`, + })), + ] + + return ( + +
+ + {/* Type selector */} +
+ +
+ {TYPE_OPTIONS.map(({ value, labelKey, Icon }) => ( + + ))} +
+
+ + {/* Title */} +
+ + set('title', e.target.value)} required + placeholder={t('reservations.titlePlaceholder')} style={inputStyle} /> +
+ + {/* From / To endpoints */} +
+
+ + {form.type === 'flight' ? ( + setFromPick({ airport: a || undefined })} /> + ) : ( + setFromPick({ location: l || undefined })} /> + )} +
+
+ + {form.type === 'flight' ? ( + setToPick({ airport: a || undefined })} /> + ) : ( + setToPick({ location: l || undefined })} /> + )} +
+
+ + {/* Departure row */} +
+
+ + set('start_day_id', value)} + placeholder={t('dayplan.dayN', { n: '?' })} + options={dayOptions} + size="sm" + /> +
+
+ + set('departure_time', v)} /> +
+ {form.type === 'flight' && fromPick.airport && ( +
+ +
+ {fromPick.airport.tz} +
+
+ )} +
+ + {/* Arrival row */} +
+
+ + set('end_day_id', value)} + placeholder={t('dayplan.dayN', { n: '?' })} + options={dayOptions} + size="sm" + /> +
+
+ + set('arrival_time', v)} /> +
+ {form.type === 'flight' && toPick.airport && ( +
+ +
+ {toPick.airport.tz} +
+
+ )} +
+ + {/* Flight-specific fields */} + {form.type === 'flight' && ( +
+
+ + set('meta_airline', e.target.value)} + placeholder="Lufthansa" style={inputStyle} /> +
+
+ + set('meta_flight_number', e.target.value)} + placeholder="LH 123" style={inputStyle} /> +
+
+ )} + + {/* Train-specific fields */} + {form.type === 'train' && ( +
+
+ + set('meta_train_number', e.target.value)} + placeholder="ICE 123" style={inputStyle} /> +
+
+ + set('meta_platform', e.target.value)} + placeholder="12" style={inputStyle} /> +
+
+ + set('meta_seat', e.target.value)} + placeholder="42A" style={inputStyle} /> +
+
+ )} + + {/* Booking Code + Status */} +
+
+ + set('confirmation_number', e.target.value)} + placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} /> +
+
+ + set('status', value)} + options={[ + { value: 'pending', label: t('reservations.pending') }, + { value: 'confirmed', label: t('reservations.confirmed') }, + ]} + size="sm" + /> +
+
+ + {/* Notes */} +
+ +