feat: add multi-day transport reservations with dedicated modal and route segmentation

Introduces a TransportModal for creating/editing flight, train, car, and cruise
reservations that span multiple days. Transport entries now break the map route
into disconnected segments so the polyline reflects actual travel legs.

- Add TransportModal with airport/location pickers, multi-day date range, and all transport types
- Extend DB schema with end_day_id on reservations (migration 110) and backfill from existing dates
- Refactor useRouteCalculation to emit [][][number,number] segments split at transport boundaries
- Update MapView, DayPlanSidebar, ReservationsPanel, TripPlannerPage to wire up transport flow
- Add transport i18n keys across all 15 languages
This commit is contained in:
jubnl
2026-04-18 06:10:33 +02:00
parent 8e04deb0f5
commit 3f61e1ca38
32 changed files with 1188 additions and 501 deletions
@@ -440,26 +440,27 @@ describe('DayPlanSidebar', () => {
type: 'flight',
title: 'Paris to London',
reservation_time: '2025-06-01T08:00:00',
day_id: 10,
})
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />)
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(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />)
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation], onEditTransport })} />)
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(<DayPlanSidebar {...makeDefaultProps({
days: [day],
@@ -684,6 +686,8 @@ describe('DayPlanSidebar', () => {
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(<DayPlanSidebar {...makeDefaultProps({
days: [day1, day2],
@@ -704,6 +708,8 @@ describe('DayPlanSidebar', () => {
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(<DayPlanSidebar {...makeDefaultProps({
days: [day1, day2, day3],
@@ -786,20 +792,22 @@ describe('DayPlanSidebar', () => {
// ── 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(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any] })} />)
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any], onEditTransport })} />)
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(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [flight],
@@ -1683,4 +1692,42 @@ describe('DayPlanSidebar', () => {
// 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(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
onEditReservation, onEditTransport,
})} />)
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(<DayPlanSidebar {...makeDefaultProps({
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
onEditReservation, onEditTransport,
})} />)
const pencil = screen.getByTitle(/edit/i)
await user.click(pencil)
expect(onEditTransport).toHaveBeenCalledWith(res)
expect(onEditReservation).not.toHaveBeenCalled()
})
})
+267 -94
View File
@@ -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<number | null>(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({
>
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
</button>}
{canEditDays && onAddTransport && (
<button
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
title={t('transport.addTransport')}
style={{
flexShrink: 0,
background: 'none',
border: 'none',
padding: '4px',
cursor: 'pointer',
opacity: 0.45,
display: 'flex',
alignItems: 'center',
borderRadius: 4,
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '1' }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '0.45' }}
>
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
</button>
)}
{(() => {
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 (
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
color: confirmed ? '#16a34a' : '#d97706',
}}>
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
{res.reservation_time?.includes('T') && (
<span style={{ fontWeight: 400 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{res.reservation_end_time && ` ${res.reservation_end_time}`}
</span>
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
color: confirmed ? '#16a34a' : '#d97706',
}}>
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
{res.reservation_time?.includes('T') && (
<span style={{ fontWeight: 400 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{res.reservation_end_time && ` ${res.reservation_end_time}`}
</span>
)}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta) return null
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
return null
})()}
</div>
{hasEndpoints && (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
background: active ? '#3b82f6' : 'transparent',
color: active ? '#fff' : 'var(--text-faint)',
transition: 'all 0.12s',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={11} />
</button>
)}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta) return null
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
return null
{canEditDays && (() => {
const isTransport = ['flight','train','car','cruise','bus'].includes(res.type)
const handler = isTransport ? onEditTransport : onEditReservation
if (!handler) return null
return (
<button
type="button"
onClick={e => { e.stopPropagation(); handler(res) }}
title={t('common.edit')}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none', background: 'transparent',
color: 'var(--text-faint)',
transition: 'all 0.12s',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Pencil size={11} />
</button>
)
})()}
</div>
)
@@ -1478,6 +1596,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>}
{canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && (
<button
onClick={e => {
e.stopPropagation()
onAddBookingToAssignment(day.id, assignment.id)
}}
title={t('reservations.addBooking')}
style={{
flexShrink: 0,
background: 'none',
border: '1px solid var(--border-primary)',
borderRadius: 5,
padding: '2px 6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 3,
fontSize: 10,
fontWeight: 500,
color: 'var(--text-muted)',
fontFamily: 'inherit',
}}
>
<Plus size={11} strokeWidth={2} />
</button>
)}
</div>
</React.Fragment>
)
@@ -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 (
<React.Fragment key={`transport-${res.id}-${day.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
onClick={() => 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' && (
<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} />
</div>
)}
<div style={{
width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: '50%', background: `${color}18`,
@@ -1635,8 +1797,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onDragOver={e => { 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')))
@@ -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(<ReservationModal {...defaultProps} reservation={res} />);
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(<ReservationModal {...defaultProps} />);
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(<ReservationModal {...defaultProps} />);
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(<ReservationModal {...defaultProps} />);
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(<ReservationModal {...defaultProps} />);
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(<ReservationModal {...defaultProps} reservation={res} />);
// 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(<ReservationModal {...defaultProps} onSave={onSave} />);
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(<ReservationModal {...defaultProps} onSave={onSave} />);
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(
<ReservationModal
{...defaultProps}
@@ -575,26 +562,18 @@ describe('ReservationModal', () => {
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(<ReservationModal {...defaultProps} onSave={onSave} />);
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(<ReservationModal {...defaultProps} />);
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(<ReservationModal {...defaultProps} onSave={onSave} />);
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(<ReservationModal {...defaultProps} onSave={onSave} />);
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' })
);
});
});
@@ -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<ReservationEndpoint, 'id' | 'reservation_id'> {
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<ReservationEndpoint, 'id' | 'reservation_id'> {
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<void>
onFileDelete: (fileId: number) => Promise<void>
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<number[]>([])
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
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<string, string> = {}
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<typeof endpointFromAirport>[] = []
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<string, any> = {
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 && (
<div>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
@@ -455,73 +317,57 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
size="sm"
/>
</div>
</div>
</div>
)}
{/* Start Date/Time + End Date/Time + Status (hidden for hotels) */}
{form.type !== 'hotel' && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
<CustomDatePicker
value={(() => { 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) : '')
}}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
<CustomTimePicker
value={(() => { 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)
}}
/>
</div>
{form.type === 'flight' && fromPick.airport && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{fromPick.airport.tz}
</div>
<label style={labelStyle}>{t('reservations.date')}</label>
<CustomDatePicker
value={(() => { 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) : '')
}}
/>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
<CustomDatePicker
value={form.end_date}
onChange={d => set('end_date', d || '')}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
</div>
{form.type === 'flight' && toPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{toPick.airport.tz}
</div>
<label style={labelStyle}>{t('reservations.startTime')}</label>
<CustomTimePicker
value={(() => { 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)
}}
/>
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.endDate')}</label>
<CustomDatePicker
value={form.end_date}
onChange={d => set('end_date', d || '')}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.endTime')}</label>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
</div>
</div>
{isEndBeforeStart && (
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
)}
</div>
{isEndBeforeStart && (
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
)}
</>
</>
)}
{/* Location (own row for non-transport, non-hotel types) */}
{!isTransport(form.type) && form.type !== 'hotel' && (
{/* Location */}
{form.type !== 'hotel' && (
<div>
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
@@ -550,46 +396,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* From / To endpoints for transport bookings */}
{isTransport(form.type) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.from')}</label>
{form.type === 'flight' ? (
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
) : (
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
)}
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.to')}</label>
{form.type === 'flight' ? (
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
) : (
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
)}
</div>
</div>
)}
{form.type === 'flight' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
placeholder="Lufthansa" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.flightNumber') || 'Flight No.'}</label>
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
placeholder="LH 123" style={inputStyle} />
</div>
</div>
)}
{/* Hotel fields */}
{form.type === 'hotel' && (
<>
{/* Hotel place + day range */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.hotelPlace')}</label>
@@ -633,7 +442,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
/>
</div>
</div>
{/* Check-in / check-in-until / check-out */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
@@ -651,26 +459,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</>
)}
{form.type === 'train' && (
<div className="grid grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.trainNumber') || 'Train No.'}</label>
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
placeholder="ICE 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.platform') || 'Platform'}</label>
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
placeholder="12" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.seat') || 'Seat'}</label>
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
placeholder="42A" style={inputStyle} />
</div>
</div>
)}
{/* Notes */}
<div>
<label style={labelStyle}>{t('reservations.notes')}</label>
@@ -689,12 +477,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => {
// Always unlink, never delete the file
// Clear primary reservation_id if it points to this reservation
if (f.reservation_id === reservation?.id) {
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
}
// Remove from file_links if linked there
try {
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
@@ -727,7 +512,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<Paperclip size={11} />
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>}
{/* Link existing file picker */}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}>
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
@@ -771,7 +555,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* Price + Budget Category — only shown when budget addon is enabled */}
{/* Price + Budget Category */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
@@ -779,7 +563,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { 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} />
</div>
@@ -69,9 +69,10 @@ interface ReservationCardProps {
onNavigateToFiles: () => void
assignmentLookup: Record<number, AssignmentLookupEntry>
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 (
<div style={{
borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column',
@@ -186,6 +202,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* Body */}
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
{/* Day label for transport reservations linked to a day */}
{isTransportType && startDay && (
<div>
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` ${dayLabel(endDay)}` : ''}
</div>
</div>
)}
{/* Date / Time row */}
{hasDate && (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
@@ -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',
}}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('reservations.title')}
{t(titleKey)}
</h2>
{reservations.length > 0 && (
@@ -557,7 +584,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Plus size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">{t('reservations.addManual')}</span>
<span className="hidden sm:inline">{t(addManualKey)}</span>
</button>
)}
</div>
@@ -579,12 +606,12 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
<>
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} accent="gray" storageKey={`trek:bookings-pending-open:${tripId}`}>
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)}
</Section>
)}
{allConfirmed.length > 0 && (
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green" storageKey={`trek:bookings-confirmed-open:${tripId}`}>
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)}
</Section>
)}
</>
@@ -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<ReservationEndpoint, 'id' | 'reservation_id'> {
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<ReservationEndpoint, 'id' | 'reservation_id'> {
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<string, any>) => Promise<void>
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<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
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<string, string> = {}
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<typeof endpointFromAirport>[] = []
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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
size="2xl"
>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type selector */}
<div>
<label style={labelStyle}>{t('reservations.bookingType')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
<button key={value} type="button" onClick={() => set('type', value)} style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '5px 10px', borderRadius: 99, border: '1px solid',
fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
<Icon size={11} /> {t(labelKey)}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div>
{/* From / To endpoints */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.from')}</label>
{form.type === 'flight' ? (
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
) : (
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
)}
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.to')}</label>
{form.type === 'flight' ? (
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
) : (
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
)}
</div>
</div>
{/* Departure row */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}
</label>
<CustomSelect
value={form.start_day_id}
onChange={value => set('start_day_id', value)}
placeholder={t('dayplan.dayN', { n: '?' })}
options={dayOptions}
size="sm"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}
</label>
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
</div>
{form.type === 'flight' && fromPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{fromPick.airport.tz}
</div>
</div>
)}
</div>
{/* Arrival row */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}
</label>
<CustomSelect
value={form.end_day_id}
onChange={value => set('end_day_id', value)}
placeholder={t('dayplan.dayN', { n: '?' })}
options={dayOptions}
size="sm"
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>
{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}
</label>
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
</div>
{form.type === 'flight' && toPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{toPick.airport.tz}
</div>
</div>
)}
</div>
{/* Flight-specific fields */}
{form.type === 'flight' && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.airline')}</label>
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
placeholder="Lufthansa" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.flightNumber')}</label>
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
placeholder="LH 123" style={inputStyle} />
</div>
</div>
)}
{/* Train-specific fields */}
{form.type === 'train' && (
<div className="grid grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.trainNumber')}</label>
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
placeholder="ICE 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.platform')}</label>
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
placeholder="12" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.seat')}</label>
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
placeholder="42A" style={inputStyle} />
</div>
</div>
)}
{/* Booking Code + Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
{/* Notes */}
<div>
<label style={labelStyle}>{t('reservations.notes')}</label>
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={2}
placeholder={t('reservations.notesPlaceholder')}
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
</form>
</Modal>
)
}