diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 6726140..75c6a8e 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -207,6 +207,7 @@ export const reservationsApi = { create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), + updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).then(r => r.data), } export const weatherApi = { diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 915da19..277662f 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -7,6 +7,7 @@ import ReactDOM from 'react-dom' import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react' const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText } +import { assignmentsApi, reservationsApi } from '../../api/client' import { downloadTripPDF } from '../PDF/TripPDF' import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator' import PlaceAvatar from '../shared/PlaceAvatar' @@ -74,6 +75,7 @@ interface DayPlanSidebarProps { onDeletePlace: (placeId: number) => void reservations?: Reservation[] onAddReservation: () => void + onNavigateToFiles?: () => void } export default function DayPlanSidebar({ @@ -85,6 +87,7 @@ export default function DayPlanSidebar({ onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations = [], onAddReservation, + onNavigateToFiles, }: DayPlanSidebarProps) { const toast = useToast() const { t, language, locale } = useTranslation() @@ -108,11 +111,22 @@ export default function DayPlanSidebar({ const [draggingId, setDraggingId] = useState(null) const [lockedIds, setLockedIds] = useState(new Set()) const [lockHoverId, setLockHoverId] = useState(null) - const [dropTargetKey, setDropTargetKey] = useState(null) + const [dropTargetKey, _setDropTargetKey] = useState(null) + const dropTargetRef = useRef(null) + const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) } const [dragOverDayId, setDragOverDayId] = useState(null) const [hoveredId, setHoveredId] = useState(null) + const [transportDetail, setTransportDetail] = useState(null) + const [timeConfirm, setTimeConfirm] = useState<{ + dayId: number; fromId: number; time: string; + // For drag & drop reorder + fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; + // For arrow reorder + reorderIds?: number[]; + } | null>(null) const inputRef = useRef(null) - const dragDataRef = useRef(null) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren) + const dragDataRef = useRef(null) + const initedTransportIds = useRef(new Set()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren) const currency = trip?.currency || 'EUR' @@ -176,15 +190,94 @@ export default function DayPlanSidebar({ }) } + const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise']) + + const getTransportForDay = (dayId: number) => { + const day = days.find(d => d.id === dayId) + if (!day?.date) return [] + return reservations.filter(r => { + if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false + const resDate = r.reservation_time.split('T')[0] + return resDate === day.date + }) + } + const getDayAssignments = (dayId) => (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) + // Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null + const parseTimeToMinutes = (time?: string | null): number | null => { + if (!time) return null + // ISO-Format "2025-03-30T09:00:00" + if (time.includes('T')) { + const [h, m] = time.split('T')[1].split(':').map(Number) + return h * 60 + m + } + // Einfaches "HH:MM" Format + const parts = time.split(':').map(Number) + if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1] + return null + } + + // Compute initial day_plan_position for a transport based on time + const computeTransportPosition = (r, da) => { + const minutes = parseTimeToMinutes(r.reservation_time) ?? 0 + // Find the last place with time <= transport time + let afterIdx = -1 + for (const a of da) { + const pm = parseTimeToMinutes(a.place?.place_time) + if (pm !== null && pm <= minutes) afterIdx = a.order_index + } + // Position: midpoint between afterIdx and afterIdx+1 (leaves room for other items) + return afterIdx >= 0 ? afterIdx + 0.5 : da.length + 0.5 + } + + // Auto-initialize transport positions on first render if not set + const initTransportPositions = (dayId) => { + const da = getDayAssignments(dayId) + const transport = getTransportForDay(dayId) + const needsInit = transport.filter(r => r.day_plan_position == null && !initedTransportIds.current.has(r.id)) + if (needsInit.length === 0) return + + const sorted = [...needsInit].sort((a, b) => + (parseTimeToMinutes(a.reservation_time) ?? 0) - (parseTimeToMinutes(b.reservation_time) ?? 0) + ) + const positions = sorted.map((r, idx) => ({ + id: r.id, + 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 + } + // Persist to server (fire and forget) + reservationsApi.updatePositions(tripId, positions).catch(() => {}) + } + const getMergedItems = (dayId) => { const da = getDayAssignments(dayId) const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order) + const transport = getTransportForDay(dayId) + + // Initialize positions for transports that don't have one yet + if (transport.some(r => r.day_plan_position == null)) { + initTransportPositions(dayId) + } + + // All items use the same sortKey space: + // - Places: order_index (0, 1, 2, ...) + // - Notes: sort_order (floats between place indices) + // - Transports: day_plan_position (persisted float) return [ - ...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })), - ...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })), + ...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })), + ...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })), + ...transport.map(r => ({ + type: 'transport' as const, + sortKey: r.day_plan_position ?? computeTransportPosition(r, da), + data: r, + })), ].sort((a, b) => a.sortKey - b.sortKey) } @@ -195,6 +288,41 @@ export default function DayPlanSidebar({ }) } + // Check if a proposed reorder of place IDs would break chronological order + // of ALL timed items (places with time + transport bookings) + const wouldBreakChronology = (dayId: number, newPlaceIds: number[]) => { + const da = getDayAssignments(dayId) + const transport = getTransportForDay(dayId) + + // Simulate the merged list with places in new order + transports at their positions + // Places get sequential integer positions + const simItems: { pos: number; minutes: number }[] = [] + newPlaceIds.forEach((id, idx) => { + const a = da.find(x => x.id === id) + const m = parseTimeToMinutes(a?.place?.place_time) + if (m !== null) simItems.push({ pos: idx, minutes: m }) + }) + + // Transports: compute where they'd go with the new place order + for (const r of transport) { + const rMin = parseTimeToMinutes(r.reservation_time) + if (rMin === null) continue + // Find the last place (in new order) with time <= transport time + let afterIdx = -1 + newPlaceIds.forEach((id, idx) => { + const a = da.find(x => x.id === id) + const pm = parseTimeToMinutes(a?.place?.place_time) + if (pm !== null && pm <= rMin) afterIdx = idx + }) + const pos = afterIdx >= 0 ? afterIdx + 0.5 : newPlaceIds.length + 0.5 + simItems.push({ pos, minutes: rMin }) + } + + // Sort by position and check chronological order + simItems.sort((a, b) => a.pos - b.pos) + return !simItems.every((item, i) => i === 0 || item.minutes >= simItems[i - 1].minutes) + } + const openEditNote = (dayId, note, e) => { e?.stopPropagation() _openEditNote(dayId, note) @@ -205,49 +333,180 @@ export default function DayPlanSidebar({ await _deleteNote(dayId, noteId) } - const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => { - const m = getMergedItems(dayId) - const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) - const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId) - if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return + // Unified reorder: assigns positions to ALL item types based on new visual order + const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => { + // Places get sequential integer positions (0, 1, 2, ...) + // Non-place items between place N-1 and place N get fractional positions + const assignmentIds: number[] = [] + const noteUpdates: { id: number; sort_order: number }[] = [] + const transportUpdates: { id: number; day_plan_position: number }[] = [] - // Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standard), oder NACH dem Ziel wenn insertAfter - const newOrder = [...m] - const [moved] = newOrder.splice(fromIdx, 1) - let adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx - if (insertAfter) adjustedTo += 1 - newOrder.splice(adjustedTo, 0, moved) - - // Orte: neuer order_index über onReorder - const assignmentIds = newOrder.filter(i => i.type === 'place').map(i => i.data.id) - - // Notizen: sort_order muss ZWISCHEN den umgebenden order_indices der Orte liegen, niemals gleich sein. - // Formel: Notiz zwischen placesBefore-1 und placesBefore ergibt (placesBefore - 1) + rank/(count+1) - // z.B. einzelne Notiz nach 2 Orten → (2-1) + 0.5 = 1.5 (zwischen order_index 1 und 2) - const groups = {} - let pc = 0 - newOrder.forEach(item => { - if (item.type === 'place') { pc++ } - else { if (!groups[pc]) groups[pc] = []; groups[pc].push(item.data.id) } - }) - const noteChanges = [] - Object.entries(groups).forEach(([pb, ids]) => { - ids.forEach((id, i) => { - noteChanges.push({ id, sort_order: (Number(pb) - 1) + (i + 1) / (ids.length + 1) }) - }) - }) + let placeCount = 0 + let i = 0 + while (i < newOrder.length) { + if (newOrder[i].type === 'place') { + assignmentIds.push(newOrder[i].data.id) + placeCount++ + i++ + } else { + // Collect consecutive non-place items + const group: { type: string; data: any }[] = [] + while (i < newOrder.length && newOrder[i].type !== 'place') { + group.push(newOrder[i]) + i++ + } + // Fractional positions between (placeCount-1) and placeCount + const base = placeCount > 0 ? placeCount - 1 : -1 + group.forEach((g, idx) => { + const pos = base + (idx + 1) / (group.length + 1) + if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos }) + else if (g.type === 'transport') transportUpdates.push({ id: g.data.id, day_plan_position: pos }) + }) + } + } try { if (assignmentIds.length) await onReorder(dayId, assignmentIds) - for (const n of noteChanges) { + for (const n of noteUpdates) { await tripStore.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 + } + await reservationsApi.updatePositions(tripId, transportUpdates) + } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } + } + + 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? + if (fromType === 'place') { + const fromItem = m.find(i => i.type === 'place' && i.data.id === fromId) + const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time) + if (fromItem && fromMinutes !== null) { + const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) + const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId) + if (fromIdx !== -1 && toIdx !== -1) { + const simulated = [...m] + const [moved] = simulated.splice(fromIdx, 1) + let insertIdx = simulated.findIndex(i => i.type === toType && i.data.id === toId) + if (insertIdx === -1) insertIdx = simulated.length + if (insertAfter) insertIdx += 1 + simulated.splice(insertIdx, 0, moved) + + const timedInOrder = simulated + .map(i => { + if (i.type === 'transport') return parseTimeToMinutes(i.data?.reservation_time) + if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time) + return null + }) + .filter(t => t !== null) + const isChronological = timedInOrder.every((t, i) => i === 0 || t >= timedInOrder[i - 1]) + + if (!isChronological) { + const placeTime = fromItem.data.place.place_time + const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime + setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, time: timeStr }) + setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null + return + } + } + } + } + + // Build new order: remove the dragged item, insert at target position + const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) + const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId) + if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) { + setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null + return + } + + const newOrder = [...m] + const [moved] = newOrder.splice(fromIdx, 1) + let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId) + if (adjustedTo === -1) adjustedTo = newOrder.length + if (insertAfter) adjustedTo += 1 + newOrder.splice(adjustedTo, 0, moved) + + await applyMergedOrder(dayId, newOrder) setDraggingId(null) setDropTargetKey(null) dragDataRef.current = null } + const confirmTimeRemoval = async () => { + if (!timeConfirm) return + const saved = { ...timeConfirm } + const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved + setTimeConfirm(null) + + // Remove time from assignment + try { + await assignmentsApi.updateTime(tripId, fromId, { place_time: null, end_time: null }) + const key = String(dayId) + const currentAssignments = { ...assignments } + if (currentAssignments[key]) { + currentAssignments[key] = currentAssignments[key].map(a => + a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a + ) + tripStore.setAssignments(currentAssignments) + } + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Unknown error') + return + } + + // Build new merged order from either arrow reorderIds or drag & drop params + const m = getMergedItems(dayId) + + if (reorderIds) { + // Arrow reorder: rebuild merged list with places in the new order, + // keeping transports and notes at their relative positions + const newMerged: typeof m = [] + let rIdx = 0 + for (const item of m) { + if (item.type === 'place') { + // Replace with the place from reorderIds at this position + const nextId = reorderIds[rIdx++] + const replacement = m.find(i => i.type === 'place' && i.data.id === nextId) + if (replacement) newMerged.push(replacement) + } else { + newMerged.push(item) + } + } + await applyMergedOrder(dayId, newMerged) + return + } + + // Drag & drop reorder + if (fromType && toType) { + const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) + const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId) + if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return + + const newOrder = [...m] + const [moved] = newOrder.splice(fromIdx, 1) + let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId) + if (adjustedTo === -1) adjustedTo = newOrder.length + if (insertAfter) adjustedTo += 1 + newOrder.splice(adjustedTo, 0, moved) + + await applyMergedOrder(dayId, newOrder) + } + } + const moveNote = async (dayId, noteId, direction) => { await _moveNote(dayId, noteId, direction, getMergedItems) } @@ -542,11 +801,34 @@ export default function DayPlanSidebar({ {isExpanded && (
{ e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }} + onDragOver={e => { e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }} onDrop={e => { e.preventDefault() - const { assignmentId, noteId, fromDayId } = getDragData(e) - if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return } + const { placeId, assignmentId, noteId, fromDayId } = getDragData(e) + // Drop on transport card (detected via dropTargetRef for sync accuracy) + if (dropTargetRef.current?.startsWith('transport-')) { + const transportId = Number(dropTargetRef.current.replace('transport-', '')) + + if (placeId) { + onAssignToDay?.(parseInt(placeId), day.id) + } else if (assignmentId && fromDayId !== day.id) { + tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + } else if (assignmentId) { + handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId) + } else if (noteId && fromDayId !== day.id) { + tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + } else if (noteId) { + handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId) + } + setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null + return + } + + if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return } + if (placeId) { + onAssignToDay?.(parseInt(placeId), day.id) + setDropTargetKey(null); window.__dragData = null; return + } if (assignmentId && fromDayId !== day.id) { tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return @@ -577,7 +859,7 @@ export default function DayPlanSidebar({
) : ( merged.map((item, idx) => { - const itemKey = item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}` + const itemKey = item.type === 'transport' ? `transport-${item.data.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`) const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey if (item.type === 'place') { @@ -590,20 +872,39 @@ export default function DayPlanSidebar({ const isHovered = hoveredId === assignment.id const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id) - const moveUp = (e) => { - e.stopPropagation() - if (placeIdx === 0) return - const ids = placeItems.map(i => i.data.id) - ;[ids[placeIdx - 1], ids[placeIdx]] = [ids[placeIdx], ids[placeIdx - 1]] - onReorder(day.id, ids) - } - const moveDown = (e) => { - e.stopPropagation() - if (placeIdx === placeItems.length - 1) return - const ids = placeItems.map(i => i.data.id) - ;[ids[placeIdx], ids[placeIdx + 1]] = [ids[placeIdx + 1], ids[placeIdx]] - onReorder(day.id, ids) + const arrowMove = (direction: 'up' | 'down') => { + const m = getMergedItems(day.id) + const myIdx = m.findIndex(i => i.type === 'place' && i.data.id === assignment.id) + if (myIdx === -1) return + const targetIdx = direction === 'up' ? myIdx - 1 : myIdx + 1 + if (targetIdx < 0 || targetIdx >= m.length) return + + // Build new order: swap this item with its neighbor in the merged list + const newOrder = [...m] + ;[newOrder[myIdx], newOrder[targetIdx]] = [newOrder[targetIdx], newOrder[myIdx]] + + // Check chronological order of all timed items in the new order + const placeTime = place.place_time + if (parseTimeToMinutes(placeTime) !== null) { + const timedInNewOrder = newOrder + .map(i => { + if (i.type === 'transport') return parseTimeToMinutes(i.data?.reservation_time) + if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time) + return null + }) + .filter(t => t !== null) + const isChronological = timedInNewOrder.every((t, i) => i === 0 || t >= timedInNewOrder[i - 1]) + if (!isChronological) { + const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime + // Store the new merged order for confirm action + setTimeConfirm({ dayId: day.id, fromId: assignment.id, time: timeStr, reorderIds: newOrder.filter(i => i.type === 'place').map(i => i.data.id) }) + return + } + } + applyMergedOrder(day.id, newOrder) } + const moveUp = (e) => { e.stopPropagation(); arrowMove('up') } + const moveDown = (e) => { e.stopPropagation(); arrowMove('down') } return ( @@ -773,10 +1074,10 @@ export default function DayPlanSidebar({ )}
- -
@@ -785,6 +1086,90 @@ export default function DayPlanSidebar({ ) } + // Transport booking (flight, train, bus, car, cruise) + if (item.type === 'transport') { + const res = item.data + 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 = '' + if (res.type === 'flight') { + const parts = [meta.airline, meta.flight_number].filter(Boolean) + if (meta.departure_airport || meta.arrival_airport) + parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → ')) + subtitle = parts.join(' · ') + } else if (res.type === 'train') { + subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ') + } + + return ( + + {showDropLine &&
} +
setTransportDetail(res)} + onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`transport-${res.id}`) }} + onDrop={e => { + e.preventDefault(); e.stopPropagation() + const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e) + if (placeId) { + onAssignToDay?.(parseInt(placeId), day.id) + } else if (fromAssignmentId && fromDayId !== day.id) { + tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + } else if (fromAssignmentId) { + handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id) + } else if (noteId && fromDayId !== day.id) { + tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + } else if (noteId) { + handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id) + } + setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null + }} + onMouseEnter={() => setHoveredId(`transport-${res.id}`)} + onMouseLeave={() => setHoveredId(null)} + 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', + transition: 'background 0.1s', + }} + > +
+ +
+
+
+ + {res.title} + + {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?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`} + + )} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
+
+ + ) + } + // Notizkarte const note = item.data const isNoteHovered = hoveredId === `note-${note.id}` @@ -991,6 +1376,186 @@ export default function DayPlanSidebar({ document.body ))} + {/* Confirm: remove time when reordering a timed place */} + {timeConfirm && ReactDOM.createPortal( +
setTimeConfirm(null)}> +
e.stopPropagation()}> +
+
+ +
+
+ {t('dayplan.confirmRemoveTimeTitle')} +
+
+
+ {t('dayplan.confirmRemoveTimeBody', { time: timeConfirm.time })} +
+
+ + +
+
+
, + document.body + )} + + {/* Transport-Detail-Modal */} + {transportDetail && ReactDOM.createPortal( +
setTransportDetail(null)}> +
e.stopPropagation()}> + {(() => { + const res = transportDetail + const TransportIcon = RES_ICONS[res.type] || Ticket + const TRANSPORT_COLORS = { flight: '#3b82f6', train: '#06b6d4', bus: '#f59e0b', car: '#6b7280', cruise: '#0ea5e9' } + const color = TRANSPORT_COLORS[res.type] || 'var(--text-muted)' + const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) + + const detailFields = [] + if (res.type === 'flight') { + if (meta.airline) detailFields.push({ label: t('reservations.meta.airline'), value: meta.airline }) + if (meta.flight_number) detailFields.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number }) + if (meta.departure_airport) detailFields.push({ label: t('reservations.meta.from'), value: meta.departure_airport }) + if (meta.arrival_airport) detailFields.push({ label: t('reservations.meta.to'), value: meta.arrival_airport }) + if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat }) + } else if (res.type === 'train') { + if (meta.train_number) detailFields.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number }) + if (meta.platform) detailFields.push({ label: t('reservations.meta.platform'), value: meta.platform }) + if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat }) + } + if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number }) + if (res.location) detailFields.push({ label: t('reservations.locationAddress'), value: res.location }) + + return ( + <> + {/* Header */} +
+
+ +
+
+
{res.title}
+
+ {res.reservation_time?.includes('T') + ? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) + : res.reservation_time + ? new Date(res.reservation_time + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' }) + : '' + } + {res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`} +
+
+
+ {(res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')} +
+
+ + {/* Detail-Felder */} + {detailFields.length > 0 && ( +
+ {detailFields.map((f, i) => ( +
+
{f.label}
+
{f.value}
+
+ ))} +
+ )} + + {/* Notizen */} + {res.notes && ( +
+
{t('reservations.notes')}
+
{res.notes}
+
+ )} + + {/* Dateien */} + {(() => { + const resFiles = (tripStore.files || []).filter(f => + !f.deleted_at && ( + f.reservation_id === res.id || + (f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id)) + ) + ) + if (resFiles.length === 0) return null + return ( +
+
{t('files.title')}
+
+ {resFiles.map(f => ( +
{ setTransportDetail(null); onNavigateToFiles?.() }} + style={{ + display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', + background: 'var(--bg-tertiary)', borderRadius: 8, cursor: 'pointer', + transition: 'background 0.1s', + }} + onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} + onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'} + > + + + {f.original_name} + + +
+ ))} +
+
+ ) + })()} + + {/* Schließen */} +
+ +
+ + ) + })()} +
+
, + document.body + )} + {/* Budget-Fußzeile */} {totalCost > 0 && (
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 86c844f..cd36f06 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -1,4 +1,5 @@ import { useState, useMemo } from 'react' +import ReactDOM from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' @@ -67,13 +68,14 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo const confirmed = r.status === 'confirmed' const attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id)) const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const handleToggle = async () => { try { await toggleReservationStatus(tripId, r.id) } catch { toast.error(t('reservations.toast.updateError')) } } const handleDelete = async () => { - if (!confirm(t('reservations.confirm.delete', { name: r.title }))) return + setShowDeleteConfirm(false) try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) } } @@ -104,7 +106,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}> -
)} + {/* Delete confirmation popup */} + {showDeleteConfirm && ReactDOM.createPortal( +
setShowDeleteConfirm(false)}> +
e.stopPropagation()}> +
+
+ +
+
+ {t('reservations.confirm.deleteTitle')} +
+
+
+ {t('reservations.confirm.deleteBody', { name: r.title })} +
+
+ + +
+
+
, + document.body + )} ) } diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 840a4f2..09c82d1 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -607,6 +607,12 @@ const de: Record = { // Day Plan Sidebar 'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant', + 'dayplan.cannotReorderTransport': 'Buchungen mit fester Uhrzeit können nicht verschoben werden', + 'dayplan.confirmRemoveTimeTitle': 'Uhrzeit entfernen?', + 'dayplan.confirmRemoveTimeBody': 'Dieser Ort hat eine feste Uhrzeit ({time}). Durch das Verschieben wird die Uhrzeit entfernt und der Ort kann frei sortiert werden.', + 'dayplan.confirmRemoveTimeAction': 'Uhrzeit entfernen & verschieben', + 'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden', + 'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden', 'dayplan.addNote': 'Notiz hinzufügen', 'dayplan.editNote': 'Notiz bearbeiten', 'dayplan.noteAdd': 'Notiz hinzufügen', @@ -735,6 +741,8 @@ const de: Record = { 'reservations.type.tour': 'Tour', 'reservations.type.other': 'Sonstiges', 'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?', + 'reservations.confirm.deleteTitle': 'Buchung löschen?', + 'reservations.confirm.deleteBody': '"{name}" wird unwiderruflich gelöscht.', 'reservations.toast.updated': 'Reservierung aktualisiert', 'reservations.toast.removed': 'Reservierung gelöscht', 'reservations.toast.saveError': 'Fehler beim Speichern', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 18d79ac..21ca7a6 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -607,6 +607,12 @@ const en: Record = { // Day Plan Sidebar 'dayplan.emptyDay': 'No places planned for this day', + 'dayplan.cannotReorderTransport': 'Bookings with a fixed time cannot be reordered', + 'dayplan.confirmRemoveTimeTitle': 'Remove time?', + 'dayplan.confirmRemoveTimeBody': 'This place has a fixed time ({time}). Moving it will remove the time and allow free sorting.', + 'dayplan.confirmRemoveTimeAction': 'Remove time & move', + 'dayplan.cannotDropOnTimed': 'Items cannot be placed between time-bound entries', + 'dayplan.cannotBreakChronology': 'This would break the chronological order of timed items and bookings', 'dayplan.addNote': 'Add Note', 'dayplan.editNote': 'Edit Note', 'dayplan.noteAdd': 'Add Note', @@ -735,6 +741,8 @@ const en: Record = { 'reservations.type.tour': 'Tour', 'reservations.type.other': 'Other', 'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?', + 'reservations.confirm.deleteTitle': 'Delete booking?', + 'reservations.confirm.deleteBody': '"{name}" will be permanently deleted.', 'reservations.toast.updated': 'Reservation updated', 'reservations.toast.removed': 'Reservation deleted', 'reservations.toast.fileUploaded': 'File uploaded', diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index de659f8..b844c1d 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -446,6 +446,7 @@ export default function TripPlannerPage(): React.ReactElement | null { onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} + onNavigateToFiles={() => handleTabChange('dateien')} /> {!leftCollapsed && (
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} /> : { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} /> }
diff --git a/client/src/types.ts b/client/src/types.ts index 7bc49dd..e5913ab 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -118,15 +118,22 @@ export interface Reservation { trip_id: number name: string title?: string - type: string | null + type: string status: 'pending' | 'confirmed' date: string | null time: string | null + reservation_time?: string | null + reservation_end_time?: string | null + location?: string | null confirmation_number: string | null notes: string | null url: string | null + day_id?: number | null + place_id?: number | null + assignment_id?: number | null accommodation_id?: number | null - metadata?: Record | null + day_plan_position?: number | null + metadata?: Record | string | null created_at: string } @@ -148,6 +155,7 @@ export interface TripFile { deleted_at?: string | null created_at: string reservation_title?: string + linked_reservation_ids?: number[] url?: string } @@ -361,7 +369,7 @@ export function getApiErrorMessage(err: unknown, fallback: string): string { // MergedItem used in day notes hook export interface MergedItem { - type: 'assignment' | 'note' + type: 'assignment' | 'note' | 'place' | 'transport' sortKey: number - data: Assignment | DayNote + data: Assignment | DayNote | Reservation } diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 55078fd..f62499d 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -321,6 +321,10 @@ function runMigrations(db: Database.Database): void { UNIQUE(file_id, place_id) )`); }, + () => { + // Add day_plan_position to reservations for persistent transport ordering in day timeline + try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts index 302c1a1..1961fc9 100644 --- a/server/src/routes/reservations.ts +++ b/server/src/routes/reservations.ts @@ -103,6 +103,29 @@ router.post('/', authenticate, (req: Request, res: Response) => { broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string); }); +// Batch update day_plan_position for multiple reservations (must be before /:id) +router.put('/positions', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const { positions } = req.body; + + const trip = verifyTripOwnership(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' }); + + const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?'); + const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => { + for (const item of items) { + stmt.run(item.day_plan_position, item.id, tripId); + } + }); + updateMany(positions); + + res.json({ success: true }); + broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string); +}); + router.put('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params;