diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 50862eea..97ce6281 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -190,18 +190,27 @@ export const placesApi = { update: (tripId: number | string, id: number | string, data: Record) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), - importGpx: (tripId: number | string, file: File) => { - const fd = new FormData(); fd.append('file', file) + importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => { + const fd = new FormData() + fd.append('file', file) + if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints)) + if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes)) + if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks)) return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, - importMapFile: (tripId: number | string, file: File) => { - const fd = new FormData(); fd.append('file', file) + importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => { + const fd = new FormData() + fd.append('file', file) + if (opts?.points !== undefined) fd.append('importPoints', String(opts.points)) + if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths)) return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, importGoogleList: (tripId: number | string, url: string) => apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data), importNaverList: (tripId: number | string, url: string) => apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data), + bulkDelete: (tripId: number | string, ids: number[]) => + apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data), } export const assignmentsApi = { diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index af0b5449..7a6a4c16 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -1,7 +1,8 @@ import React from 'react' import { describe, it, expect, vi, afterEach } from 'vitest' import { render, screen } from '../../../tests/helpers/render' -import { fireEvent } from '@testing-library/react' +import { fireEvent, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { resetAllStores } from '../../../tests/helpers/store' import { buildPlace } from '../../../tests/helpers/factories' import * as photoService from '../../services/photoService' @@ -16,10 +17,13 @@ vi.mock('react-leaflet', () => ({ data-lng={position[1]} onClick={() => eventHandlers?.click?.()} > + } + {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 @@ -1191,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-') @@ -1200,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) { @@ -1213,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) @@ -1259,7 +1320,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const cat = categories.find(c => c.id === place.category_id) const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId const isDraggingThis = draggingId === assignment.id - const isHovered = hoveredId === assignment.id const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id) const arrowMove = (direction: 'up' | 'down') => { @@ -1312,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'))) @@ -1343,15 +1409,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ { divider: true }, canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, ])} - onMouseEnter={() => setHoveredId(assignment.id)} - onMouseLeave={() => setHoveredId(null)} + onMouseEnter={e => { + if (!isPlaceSelected && !lockedIds.has(assignment.id)) + 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, padding: '7px 8px 7px 10px', cursor: 'pointer', background: lockedIds.has(assignment.id) ? 'rgba(220,38,38,0.08)' - : isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'), + : isPlaceSelected ? 'var(--bg-hover)' : 'transparent', borderLeft: lockedIds.has(assignment.id) ? '3px solid #dc2626' : '3px solid transparent', @@ -1359,7 +1437,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ opacity: isDraggingThis ? 0.4 : 1, }} > - {canEditDays &&
+ {canEditDays &&
}
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 ( + + ) })()}
) @@ -1462,7 +1588,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
)}
- {canEditDays &&
+ {canEditDays &&
@@ -1470,6 +1596,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
} + {canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && ( + + )}
) @@ -1478,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 @@ -1486,7 +1638,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const TransportIcon = RES_ICONS[res.type] || Ticket const color = '#3b82f6' const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) - const isTransportHovered = hoveredId === `transport-${res.id}` // Subtitle aus Metadaten zusammensetzen let subtitle = '' @@ -1501,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() @@ -1515,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) { @@ -1533,20 +1697,25 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null }} - onMouseEnter={() => setHoveredId(`transport-${res.id}`)} - onMouseLeave={() => setHoveredId(null)} + onMouseEnter={e => { e.currentTarget.style.background = `${color}12` }} + onMouseLeave={e => { e.currentTarget.style.background = `${color}08` }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px 7px 10px', margin: '1px 8px', borderRadius: 6, border: `1px solid ${color}33`, - background: isTransportHovered ? `${color}12` : `${color}08`, - cursor: 'pointer', userSelect: 'none', + background: `${color}08`, + 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 @@ -1653,20 +1827,30 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ { divider: true }, { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) }, ]) : undefined} - onMouseEnter={() => setHoveredId(`note-${note.id}`)} - onMouseLeave={() => setHoveredId(null)} + onMouseEnter={e => { + const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null + if (grip) grip.style.opacity = '1' + const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null + if (editBtns) editBtns.style.opacity = '1' + }} + onMouseLeave={e => { + const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null + if (grip) grip.style.opacity = '0.3' + const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null + if (editBtns) editBtns.style.opacity = '0' + }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px 7px 2px', margin: '1px 8px', borderRadius: 6, border: '1px solid var(--border-faint)', - background: isNoteHovered ? 'var(--bg-hover)' : 'var(--bg-hover)', + background: 'var(--bg-hover)', opacity: draggingId === `note-${note.id}` ? 0.4 : 1, transition: 'background 0.1s', cursor: 'grab', userSelect: 'none', }} > - {canEditDays &&
+ {canEditDays &&
}
@@ -1680,11 +1864,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{note.time}
)}
- {canEditDays &&
+ {canEditDays &&
} - {canEditDays &&
+ {canEditDays &&
} @@ -1699,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/FileImportModal.tsx b/client/src/components/Planner/FileImportModal.tsx index 687e1d14..3be41691 100644 --- a/client/src/components/Planner/FileImportModal.tsx +++ b/client/src/components/Planner/FileImportModal.tsx @@ -36,6 +36,8 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [summary, setSummary] = useState(null) + const [gpxOpts, setGpxOpts] = useState({ waypoints: true, routes: true, tracks: true }) + const [kmlOpts, setKmlOpts] = useState({ points: true, paths: true }) const validateFile = (f: File): string | null => { const ext = f.name.toLowerCase().split('.').pop() @@ -127,7 +129,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini try { if (ext === 'gpx') { - const result = await placesApi.importGpx(tripId, file) + const result = await placesApi.importGpx(tripId, file, gpxOpts) await loadTrip(tripId) if (result.count === 0 && result.skipped > 0) { toast.warning(t('places.importAllSkipped')) @@ -137,15 +139,13 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini if (result.places?.length > 0) { const importedIds: number[] = result.places.map((p: { id: number }) => p.id) pushUndo?.(t('undo.importGpx'), async () => { - for (const id of importedIds) { - try { await placesApi.delete(tripId, id) } catch {} - } + try { await placesApi.bulkDelete(tripId, importedIds) } catch {} await loadTrip(tripId) }) } handleClose() } else { - const result = await placesApi.importMapFile(tripId, file) + const result = await placesApi.importMapFile(tripId, file, kmlOpts) await loadTrip(tripId) setSummary(result.summary || null) if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) { @@ -159,9 +159,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini if (result.places?.length > 0) { const importedIds: number[] = result.places.map((p: { id: number }) => p.id) pushUndo?.(t('undo.importKeyholeMarkup'), async () => { - for (const id of importedIds) { - try { await placesApi.delete(tripId, id) } catch {} - } + try { await placesApi.bulkDelete(tripId, importedIds) } catch {} await loadTrip(tripId) }) } @@ -177,7 +175,12 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini } } - const canImport = !!file && !loading + const fileExt = file?.name.toLowerCase().split('.').pop() ?? '' + const isGpx = fileExt === 'gpx' + const isKml = fileExt === 'kml' || fileExt === 'kmz' + const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks + const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths + const canImport = !!file && !loading && !gpxNoneSelected && !kmlNoneSelected if (!isOpen) return null @@ -242,6 +245,58 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini )}
+ {isGpx && ( +
+
+ {t('places.gpxImportTypes')} +
+ {(['waypoints', 'routes', 'tracks'] as const).map(key => ( + + ))} + {gpxNoneSelected && ( +
{t('places.gpxImportNoneSelected')}
+ )} +
+ )} + + {isKml && ( +
+
+ {t('places.kmlImportTypes')} +
+ {(['points', 'paths'] as const).map(key => ( + + ))} + {kmlNoneSelected && ( +
{t('places.kmlImportNoneSelected')}
+ )} +
+ )} + {summary && (
void onEditPlace: (place: Place) => void onDeletePlace: (placeId: number) => void + onBulkDeletePlaces?: (ids: number[]) => void + onBulkDeleteConfirm?: (ids: number[]) => void days: Day[] isMobile: boolean onCategoryFilterChange?: (categoryIds: Set) => void @@ -32,9 +35,115 @@ interface PlacesSidebarProps { pushUndo?: (label: string, undoFn: () => Promise | void) => void } +interface MemoPlaceRowProps { + place: Place + category: Category | undefined + isSelected: boolean + isPlanned: boolean + inDay: boolean + isChecked: boolean + selectMode: boolean + selectedDayId: number | null + canEditPlaces: boolean + isMobile: boolean + t: (key: string, params?: Record) => string + onPlaceClick: (id: number | null) => void + onContextMenu: (e: React.MouseEvent, place: Place) => void + onAssignToDay: (placeId: number, dayId?: number) => void + toggleSelected: (id: number) => void + setDayPickerPlace: (place: any) => void +} + +const MemoPlaceRow = React.memo(function MemoPlaceRow({ + place, category: cat, isSelected, isPlanned, inDay, isChecked, + selectMode, selectedDayId, canEditPlaces, isMobile, t, + onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, +}: MemoPlaceRowProps) { + const hasGeometry = Boolean(place.route_geometry) + return ( +
{ + e.dataTransfer.setData('placeId', String(place.id)) + e.dataTransfer.effectAllowed = 'copy' + window.__dragData = { placeId: String(place.id) } + }} + onClick={() => { + if (selectMode) { + toggleSelected(place.id) + } else if (isMobile) { + setDayPickerPlace(place) + } else { + onPlaceClick(isSelected ? null : place.id) + } + }} + onContextMenu={selectMode ? undefined : e => onContextMenu(e, place)} + style={{ + display: 'flex', alignItems: 'center', gap: 10, + padding: '9px 14px 9px 16px', + cursor: selectMode ? 'pointer' : 'grab', + background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent', + borderBottom: '1px solid var(--border-faint)', + transition: 'background 0.1s', + contentVisibility: 'auto', + containIntrinsicSize: '0 52px', + }} + onMouseEnter={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'var(--bg-hover)' }} + onMouseLeave={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'transparent' }} + > + {selectMode && ( +
+ {isChecked && } +
+ )} + +
+
+ {hasGeometry && } + {cat && (() => { + const CatIcon = getCategoryIcon(cat.icon) + return + })()} + + {place.name} + +
+ {(place.description || place.address || cat?.name) && ( +
+ + {place.description || place.address || cat?.name} + +
+ )} +
+
+ {!selectMode && !inDay && selectedDayId && ( + + )} +
+
+ ) +}) + const PlacesSidebar = React.memo(function PlacesSidebar({ tripId, places, categories, assignments, selectedDayId, selectedPlaceId, - onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo, + onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo, }: PlacesSidebarProps) { const { t } = useTranslation() const toast = useToast() @@ -110,9 +219,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ if (result.places?.length > 0) { const importedIds: number[] = result.places.map((p: { id: number }) => p.id) pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => { - for (const id of importedIds) { - try { await placesApi.delete(tripId, id) } catch {} - } + try { await placesApi.bulkDelete(tripId, importedIds) } catch {} await loadTrip(tripId) }) } @@ -126,6 +233,28 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const [search, setSearch] = useState('') const [filter, setFilter] = useState('all') const [categoryFilters, setCategoryFiltersLocal] = useState>(new Set()) + const [selectMode, setSelectMode] = useState(false) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [pendingDeleteIds, setPendingDeleteIds] = useState(null) + + const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) } + + // Auto-exit when all selected places have been removed from the store (e.g. after bulk delete) + useEffect(() => { + if (!selectMode || selectedIds.size === 0) return + const placeIdSet = new Set(places.map(p => p.id)) + if ([...selectedIds].every(id => !placeIdSet.has(id))) { + setSelectMode(false) + setSelectedIds(new Set()) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [places]) + + const toggleSelected = useCallback((id: number) => setSelectedIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }), []) const toggleCategoryFilter = (catId: string) => { setCategoryFiltersLocal(prev => { @@ -140,12 +269,16 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const [mobileShowDays, setMobileShowDays] = useState(false) // Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen) + const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places]) + useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter]) + const plannedIds = useMemo(() => new Set( Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean)) ), [assignments]) const filtered = useMemo(() => places.filter(p => { if (filter === 'unplanned' && plannedIds.has(p.id)) return false + if (filter === 'tracks' && !p.route_geometry) return false if (categoryFilters.size > 0) { if (p.category_id == null) { if (!categoryFilters.has('uncategorized')) return false @@ -159,6 +292,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const isAssignedToSelectedDay = (placeId) => selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId) + const selectedDayIdRef = useRef(selectedDayId) + useEffect(() => { selectedDayIdRef.current = selectedDayId }, [selectedDayId]) + + const inDaySet = useMemo(() => { + if (!selectedDayId) return new Set() + return new Set((assignments[String(selectedDayId)] || []).map((a: any) => a.place?.id).filter(Boolean)) + }, [assignments, selectedDayId]) + + const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => { + const selDayId = selectedDayIdRef.current + ctxMenu.open(e, [ + canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) }, + selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selDayId) }, + place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, + (place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') }, + { divider: true }, + canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, + ]) + }, [ctxMenu.open, canEditPlaces, t, onEditPlace, onAssignToDay, onDeletePlace]) + return (
{t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')} +
+ {selectMode && ( +
+ + {t('places.selectionCount', { count: selectedIds.size })} + + + + +
+ )} } {/* Filter-Tabs */}
- {[{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }].map(f => ( - - )} -
-
+ place={place} + category={cat} + isSelected={isSelected} + isPlanned={isPlanned} + inDay={inDay} + isChecked={isChecked} + selectMode={selectMode} + selectedDayId={selectedDayId} + canEditPlaces={canEditPlaces} + isMobile={isMobile} + t={t} + onPlaceClick={onPlaceClick} + onContextMenu={openContextMenu} + onAssignToDay={onAssignToDay} + toggleSelected={toggleSelected} + setDayPickerPlace={setDayPickerPlace} + /> ) }) )} @@ -602,6 +756,14 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ initialFile={sidebarDropFile} /> + {isMobile && ( + setPendingDeleteIds(null)} + onConfirm={() => { onBulkDeleteConfirm?.(pendingDeleteIds!); setPendingDeleteIds(null) }} + message={t('trip.confirm.deletePlaces', { count: pendingDeleteIds?.length ?? 0 })} + /> + )}
) }) 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 */} +
+ +