feat: show transport bookings in day plan timeline — closes #37
Transport reservations (flights, trains, buses, cars, cruises) now appear directly in the day plan timeline based on their reservation date/time. - Transport cards display inline with places and notes, sorted by time - Click to open detail modal with all booking data and linked files - Persistent positioning via new day_plan_position field on reservations - Free drag & drop: places can be moved between/around transport entries - Arrow reorder works on the full visual list including transports - Timed places show confirmation popup when reorder breaks chronology - Custom delete confirmation popup for reservations - DB migration adds day_plan_position column to reservations table - New batch endpoint PUT /reservations/positions for position updates - i18n keys added for DE and EN
This commit is contained in:
@@ -207,6 +207,7 @@ export const reservationsApi = {
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => 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 = {
|
||||
|
||||
@@ -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<number>()) // 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 && (
|
||||
<div
|
||||
style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
|
||||
onDragOver={e => { 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({
|
||||
</div>
|
||||
) : (
|
||||
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 (
|
||||
<React.Fragment key={`place-${assignment.id}`}>
|
||||
@@ -773,10 +1074,10 @@ export default function DayPlanSidebar({
|
||||
)}
|
||||
</div>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronUp size={12} strokeWidth={2} />
|
||||
</button>
|
||||
<button onClick={moveDown} disabled={placeIdx === placeItems.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === placeItems.length - 1 ? 'default' : 'pointer', color: placeIdx === placeItems.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<button onClick={moveDown} disabled={idx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', color: idx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronDown size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<React.Fragment key={`transport-${res.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: `${color}18`,
|
||||
}}>
|
||||
<TransportIcon size={14} strokeWidth={1.8} color={color} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{res.title}
|
||||
</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||
<Clock size={9} strokeWidth={2} />
|
||||
{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' })}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// 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(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setTimeConfirm(null)}>
|
||||
<div style={{
|
||||
width: 340, background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: 'rgba(239,68,68,0.12)',
|
||||
}}>
|
||||
<Clock size={18} strokeWidth={1.8} color="#ef4444" />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{t('dayplan.confirmRemoveTimeTitle')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
{t('dayplan.confirmRemoveTimeBody', { time: timeConfirm.time })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<button onClick={() => setTimeConfirm(null)} style={{
|
||||
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
|
||||
}}>{t('common.cancel')}</button>
|
||||
<button onClick={confirmTimeRemoval} style={{
|
||||
fontSize: 12, background: '#ef4444', color: 'white',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>{t('common.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Transport-Detail-Modal */}
|
||||
{transportDetail && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setTransportDetail(null)}>
|
||||
<div style={{
|
||||
width: 380, maxHeight: '80vh', overflowY: 'auto',
|
||||
background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 14,
|
||||
}} onClick={e => 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 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: `${color}18`,
|
||||
}}>
|
||||
<TransportIcon size={18} strokeWidth={1.8} color={color} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
|
||||
{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' })}`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '3px 8px', borderRadius: 6, fontSize: 10, fontWeight: 600,
|
||||
background: res.status === 'confirmed' ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
|
||||
color: res.status === 'confirmed' ? '#16a34a' : '#d97706',
|
||||
}}>
|
||||
{(res.status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail-Felder */}
|
||||
{detailFields.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
{detailFields.map((f, i) => (
|
||||
<div key={i} style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{f.label}</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word' }}>{f.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notizen */}
|
||||
{res.notes && (
|
||||
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{res.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6 }}>{t('files.title')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{resFiles.map(f => (
|
||||
<div key={f.id}
|
||||
onClick={() => { 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)'}
|
||||
>
|
||||
<FileText size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{f.original_name}
|
||||
</span>
|
||||
<ExternalLink size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Schließen */}
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<button onClick={() => setTransportDetail(null)} style={{
|
||||
fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Budget-Fußzeile */}
|
||||
{totalCost > 0 && (
|
||||
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
|
||||
@@ -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)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button onClick={handleDelete} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
<button onClick={() => setShowDeleteConfirm(true)} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={11} />
|
||||
@@ -227,6 +229,46 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Delete confirmation popup */}
|
||||
{showDeleteConfirm && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div style={{
|
||||
width: 340, background: 'var(--bg-card)', borderRadius: 16,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12,
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: 'rgba(239,68,68,0.12)',
|
||||
}}>
|
||||
<Trash2 size={18} strokeWidth={1.8} color="#ef4444" />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{t('reservations.confirm.deleteTitle')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
{t('reservations.confirm.deleteBody', { name: r.title })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<button onClick={() => setShowDeleteConfirm(false)} style={{
|
||||
fontSize: 12, background: 'none', border: '1px solid var(--border-primary)',
|
||||
borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit',
|
||||
}}>{t('common.cancel')}</button>
|
||||
<button onClick={handleDelete} style={{
|
||||
fontSize: 12, background: '#ef4444', color: 'white',
|
||||
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>{t('common.confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -607,6 +607,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// 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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -607,6 +607,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// 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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -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 && (
|
||||
<div
|
||||
@@ -602,7 +603,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { 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} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { 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') }} />
|
||||
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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<string, string> | null
|
||||
day_plan_position?: number | null
|
||||
metadata?: Record<string, string> | 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user