Reservation end time, route perf overhaul, assignment search fix
- Add reservation_end_time field (DB migration, API, UI) - Split reservation form: separate date, start time, end time, status fields - Fix DateTimePicker forcing 00:00 when no time selected - Show end time across all reservation displays - Link-to-assignment and date on same row (50/50 layout) - Assignment search now shows day headers for filtered results - Auto-fill date when selecting a day assignment - Route segments: single OSRM request instead of N separate calls (~6s → ~1s) - Route labels visible from zoom level 12 (was 16) - Fix stale route labels after place deletion (useEffect triggers recalc) - AbortController cancels outdated route calculations
This commit is contained in:
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-client",
|
"name": "nomad-client",
|
||||||
"version": "2.5.5",
|
"version": "2.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nomad-client",
|
"name": "nomad-client",
|
||||||
"version": "2.5.5",
|
"version": "2.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
|
|||||||
@@ -162,11 +162,11 @@ function MapClickHandler({ onClick }) {
|
|||||||
// ── Route travel time label ──
|
// ── Route travel time label ──
|
||||||
function RouteLabel({ midpoint, walkingText, drivingText }) {
|
function RouteLabel({ midpoint, walkingText, drivingText }) {
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
const [visible, setVisible] = useState(map ? map.getZoom() >= 16 : false)
|
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map) return
|
if (!map) return
|
||||||
const check = () => setVisible(map.getZoom() >= 16)
|
const check = () => setVisible(map.getZoom() >= 12)
|
||||||
check()
|
check()
|
||||||
map.on('zoomend', check)
|
map.on('zoomend', check)
|
||||||
return () => map.off('zoomend', check)
|
return () => map.off('zoomend', check)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
|||||||
* @param {string} profile - 'driving' | 'walking' | 'cycling'
|
* @param {string} profile - 'driving' | 'walking' | 'cycling'
|
||||||
* @returns {Promise<{coordinates: Array<[number,number]>, distance: number, duration: number, distanceText: string, durationText: string}>}
|
* @returns {Promise<{coordinates: Array<[number,number]>, distance: number, duration: number, distanceText: string, durationText: string}>}
|
||||||
*/
|
*/
|
||||||
export async function calculateRoute(waypoints, profile = 'driving') {
|
export async function calculateRoute(waypoints, profile = 'driving', { signal } = {}) {
|
||||||
if (!waypoints || waypoints.length < 2) {
|
if (!waypoints || waypoints.length < 2) {
|
||||||
throw new Error('At least 2 waypoints required')
|
throw new Error('At least 2 waypoints required')
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ export async function calculateRoute(waypoints, profile = 'driving') {
|
|||||||
// OSRM public API only supports driving; we override duration for other modes
|
// OSRM public API only supports driving; we override duration for other modes
|
||||||
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
|
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
|
||||||
|
|
||||||
const response = await fetch(url)
|
const response = await fetch(url, { signal })
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Route could not be calculated')
|
throw new Error('Route could not be calculated')
|
||||||
}
|
}
|
||||||
@@ -100,6 +100,36 @@ export function optimizeRoute(places) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate per-leg travel times in a single OSRM request
|
||||||
|
* Returns array of { mid, walkingText, drivingText } for each leg
|
||||||
|
*/
|
||||||
|
export async function calculateSegments(waypoints, { signal } = {}) {
|
||||||
|
if (!waypoints || waypoints.length < 2) return []
|
||||||
|
|
||||||
|
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
|
||||||
|
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||||
|
|
||||||
|
const response = await fetch(url, { signal })
|
||||||
|
if (!response.ok) throw new Error('Route could not be calculated')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||||
|
|
||||||
|
const legs = data.routes[0].legs
|
||||||
|
return legs.map((leg, i) => {
|
||||||
|
const from = [waypoints[i].lat, waypoints[i].lng]
|
||||||
|
const to = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||||
|
const mid = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
|
||||||
|
const walkingDuration = leg.distance / (5000 / 3600) // 5 km/h
|
||||||
|
return {
|
||||||
|
mid, from, to,
|
||||||
|
walkingText: formatDuration(walkingDuration),
|
||||||
|
drivingText: formatDuration(leg.duration),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function formatDistance(meters) {
|
function formatDistance(meters) {
|
||||||
if (meters < 1000) {
|
if (meters < 1000) {
|
||||||
return `${Math.round(meters)} m`
|
return `${Math.round(meters)} m`
|
||||||
|
|||||||
@@ -248,9 +248,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
||||||
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
{r.reservation_time && (
|
{r.reservation_time?.includes('T') && (
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
{new Date(r.reservation_time).toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
{new Date(r.reservation_time).toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
||||||
|
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -762,9 +762,10 @@ export default function DayPlanSidebar({
|
|||||||
}}>
|
}}>
|
||||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||||
{res.reservation_time && (
|
{res.reservation_time?.includes('T') && (
|
||||||
<span style={{ fontWeight: 400 }}>
|
<span style={{ fontWeight: 400 }}>
|
||||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||||
|
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -347,10 +347,13 @@ export default function PlaceInspector({
|
|||||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
|
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{res.reservation_time && (
|
{res.reservation_time?.includes('T') && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}</div>
|
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||||
|
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||||
|
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{res.confirmation_number && (
|
{res.confirmation_number && (
|
||||||
|
|||||||
@@ -804,6 +804,7 @@ export default function PlannerSidebar({
|
|||||||
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
|
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{formatDateTime(r.reservation_time)}
|
{formatDateTime(r.reservation_time)}
|
||||||
|
{r.reservation_end_time && ` – ${r.reservation_end_time}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{r.location && <div className="text-xs text-gray-500 mt-0.5">📍 {r.location}</div>}
|
{r.location && <div className="text-xs text-gray-500 mt-0.5">📍 {r.location}</div>}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import CustomSelect from '../shared/CustomSelect'
|
|||||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||||
@@ -25,8 +26,9 @@ function buildAssignmentOptions(days, assignments, t, locale) {
|
|||||||
if (da.length === 0) continue
|
if (da.length === 0) continue
|
||||||
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
|
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||||
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
|
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
|
||||||
|
const groupLabel = `${dayLabel}${dateStr}`
|
||||||
// Group header (non-selectable)
|
// Group header (non-selectable)
|
||||||
options.push({ value: `_header_${day.id}`, label: `${dayLabel}${dateStr}`, disabled: true, isHeader: true })
|
options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true })
|
||||||
for (let i = 0; i < da.length; i++) {
|
for (let i = 0; i < da.length; i++) {
|
||||||
const place = da[i].place
|
const place = da[i].place
|
||||||
if (!place) continue
|
if (!place) continue
|
||||||
@@ -34,6 +36,9 @@ function buildAssignmentOptions(days, assignments, t, locale) {
|
|||||||
options.push({
|
options.push({
|
||||||
value: da[i].id,
|
value: da[i].id,
|
||||||
label: ` ${i + 1}. ${place.name}${timeStr}`,
|
label: ` ${i + 1}. ${place.name}${timeStr}`,
|
||||||
|
searchLabel: place.name,
|
||||||
|
groupLabel,
|
||||||
|
dayDate: day.date || null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +71,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
type: reservation.type || 'other',
|
type: reservation.type || 'other',
|
||||||
status: reservation.status || 'pending',
|
status: reservation.status || 'pending',
|
||||||
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
|
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
|
||||||
|
reservation_end_time: reservation.reservation_end_time || '',
|
||||||
location: reservation.location || '',
|
location: reservation.location || '',
|
||||||
confirmation_number: reservation.confirmation_number || '',
|
confirmation_number: reservation.confirmation_number || '',
|
||||||
notes: reservation.notes || '',
|
notes: reservation.notes || '',
|
||||||
@@ -74,7 +80,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
} else {
|
} else {
|
||||||
setForm({
|
setForm({
|
||||||
title: '', type: 'other', status: 'pending',
|
title: '', type: 'other', status: 'pending',
|
||||||
reservation_time: '', location: '', confirmation_number: '',
|
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
|
||||||
notes: '', assignment_id: '',
|
notes: '', assignment_id: '',
|
||||||
})
|
})
|
||||||
setPendingFiles([])
|
setPendingFiles([])
|
||||||
@@ -169,34 +175,66 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assignment Picker */}
|
{/* Assignment Picker + Date */}
|
||||||
{assignmentOptions.length > 0 && (
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<div>
|
{assignmentOptions.length > 0 && (
|
||||||
<label style={labelStyle}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
<label style={labelStyle}>
|
||||||
{t('reservations.linkAssignment')}
|
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||||
</label>
|
{t('reservations.linkAssignment')}
|
||||||
<CustomSelect
|
</label>
|
||||||
value={form.assignment_id}
|
<CustomSelect
|
||||||
onChange={value => set('assignment_id', value)}
|
value={form.assignment_id}
|
||||||
placeholder={t('reservations.pickAssignment')}
|
onChange={value => {
|
||||||
options={[
|
set('assignment_id', value)
|
||||||
{ value: '', label: t('reservations.noAssignment') },
|
const opt = assignmentOptions.find(o => o.value === value)
|
||||||
...assignmentOptions,
|
if (opt?.dayDate) {
|
||||||
]}
|
setForm(prev => {
|
||||||
searchable
|
if (prev.reservation_time) return prev
|
||||||
size="sm"
|
return { ...prev, reservation_time: opt.dayDate }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('reservations.pickAssignment')}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('reservations.noAssignment') },
|
||||||
|
...assignmentOptions,
|
||||||
|
]}
|
||||||
|
searchable
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label style={labelStyle}>{t('reservations.date')}</label>
|
||||||
|
<CustomDatePicker
|
||||||
|
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
|
||||||
|
onChange={d => {
|
||||||
|
const [, t] = (form.reservation_time || '').split('T')
|
||||||
|
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Date/Time + Status */}
|
{/* Start Time + End Time + Status */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<div>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<label style={labelStyle}>{t('reservations.datetime')}</label>
|
<label style={labelStyle}>{t('reservations.startTime')}</label>
|
||||||
<CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} />
|
<CustomTimePicker
|
||||||
|
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
||||||
|
onChange={t => {
|
||||||
|
const [d] = (form.reservation_time || '').split('T')
|
||||||
|
const date = d || new Date().toISOString().split('T')[0]
|
||||||
|
set('reservation_time', t ? `${date}T${t}` : date)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label style={labelStyle}>{t('reservations.endTime')}</label>
|
||||||
|
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={form.status}
|
value={form.status}
|
||||||
|
|||||||
@@ -103,10 +103,12 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{r.reservation_time && (
|
{r.reservation_time?.includes('T') && (
|
||||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtTime(r.reservation_time)}</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||||
|
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time}` : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{r.confirmation_number && (
|
{r.confirmation_number && (
|
||||||
|
|||||||
@@ -514,6 +514,7 @@ export function RightPanel({
|
|||||||
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
|
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{formatDateTime(reservation.reservation_time)}
|
{formatDateTime(reservation.reservation_time)}
|
||||||
|
{reservation.reservation_end_time && ` – ${reservation.reservation_end_time}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{reservation.location && (
|
{reservation.location && (
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
|
|||||||
}
|
}
|
||||||
const handleTimeChange = (t) => {
|
const handleTimeChange = (t) => {
|
||||||
const d = datePart || new Date().toISOString().split('T')[0]
|
const d = datePart || new Date().toISOString().split('T')[0]
|
||||||
onChange(t ? `${d}T${t}` : `${d}T00:00`)
|
onChange(t ? `${d}T${t}` : d)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -33,7 +33,29 @@ export default function CustomSelect({
|
|||||||
|
|
||||||
const selected = options.find(o => o.value === value)
|
const selected = options.find(o => o.value === value)
|
||||||
const filtered = searchable && search
|
const filtered = searchable && search
|
||||||
? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()))
|
? (() => {
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
const result = []
|
||||||
|
let currentHeader = null
|
||||||
|
let headerAdded = false
|
||||||
|
for (const o of options) {
|
||||||
|
if (o.isHeader) {
|
||||||
|
currentHeader = o
|
||||||
|
headerAdded = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Match against label, searchLabel, or groupLabel
|
||||||
|
const haystack = [o.label, o.searchLabel, o.groupLabel].filter(Boolean).join(' ').toLowerCase()
|
||||||
|
if (haystack.includes(q)) {
|
||||||
|
if (currentHeader && !headerAdded) {
|
||||||
|
result.push(currentHeader)
|
||||||
|
headerAdded = true
|
||||||
|
}
|
||||||
|
result.push(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})()
|
||||||
: options
|
: options
|
||||||
|
|
||||||
const sm = size === 'sm'
|
const sm = size === 'sm'
|
||||||
|
|||||||
@@ -577,6 +577,8 @@ const de = {
|
|||||||
'reservations.editTitle': 'Reservierung bearbeiten',
|
'reservations.editTitle': 'Reservierung bearbeiten',
|
||||||
'reservations.status': 'Status',
|
'reservations.status': 'Status',
|
||||||
'reservations.datetime': 'Datum & Uhrzeit',
|
'reservations.datetime': 'Datum & Uhrzeit',
|
||||||
|
'reservations.startTime': 'Startzeit',
|
||||||
|
'reservations.endTime': 'Endzeit',
|
||||||
'reservations.date': 'Datum',
|
'reservations.date': 'Datum',
|
||||||
'reservations.time': 'Uhrzeit',
|
'reservations.time': 'Uhrzeit',
|
||||||
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
|
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
|
||||||
|
|||||||
@@ -577,6 +577,8 @@ const en = {
|
|||||||
'reservations.editTitle': 'Edit Reservation',
|
'reservations.editTitle': 'Edit Reservation',
|
||||||
'reservations.status': 'Status',
|
'reservations.status': 'Status',
|
||||||
'reservations.datetime': 'Date & Time',
|
'reservations.datetime': 'Date & Time',
|
||||||
|
'reservations.startTime': 'Start time',
|
||||||
|
'reservations.endTime': 'End time',
|
||||||
'reservations.date': 'Date',
|
'reservations.date': 'Date',
|
||||||
'reservations.time': 'Time',
|
'reservations.time': 'Time',
|
||||||
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
|
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen
|
|||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
|
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
|
||||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client'
|
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client'
|
||||||
import { calculateRoute } from '../components/Map/RouteCalculator'
|
import { calculateRoute, calculateSegments } from '../components/Map/RouteCalculator'
|
||||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||||
|
|
||||||
const MIN_SIDEBAR = 200
|
const MIN_SIDEBAR = 200
|
||||||
@@ -188,31 +188,29 @@ export default function TripPlannerPage() {
|
|||||||
}, [places])
|
}, [places])
|
||||||
|
|
||||||
const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false
|
const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false
|
||||||
|
const routeAbortRef = useRef(null)
|
||||||
|
|
||||||
const updateRouteForDay = useCallback(async (dayId) => {
|
const updateRouteForDay = useCallback(async (dayId) => {
|
||||||
|
// Abort any previous calculation
|
||||||
|
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||||
|
|
||||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
||||||
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||||
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
if (waypoints.length >= 2) {
|
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
|
||||||
setRoute(waypoints.map(p => [p.lat, p.lng]))
|
|
||||||
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
setRoute(waypoints.map(p => [p.lat, p.lng]))
|
||||||
// Calculate per-segment travel times
|
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
||||||
const segments = []
|
|
||||||
for (let i = 0; i < waypoints.length - 1; i++) {
|
// Single OSRM request for all segments
|
||||||
const from = [waypoints[i].lat, waypoints[i].lng]
|
const controller = new AbortController()
|
||||||
const to = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
routeAbortRef.current = controller
|
||||||
const mid = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
|
|
||||||
try {
|
try {
|
||||||
const result = await calculateRoute([{ lat: from[0], lng: from[1] }, { lat: to[0], lng: to[1] }], 'walking')
|
const segments = await calculateSegments(waypoints, { signal: controller.signal })
|
||||||
segments.push({ mid, from, to, walkingText: result.walkingText, drivingText: result.drivingText })
|
if (!controller.signal.aborted) setRouteSegments(segments)
|
||||||
} catch {
|
} catch (err) {
|
||||||
segments.push({ mid, from, to, walkingText: '?', drivingText: '?' })
|
if (err.name !== 'AbortError') setRouteSegments([])
|
||||||
}
|
|
||||||
}
|
|
||||||
setRouteSegments(segments)
|
|
||||||
} else {
|
|
||||||
setRoute(null)
|
|
||||||
setRouteSegments([])
|
|
||||||
}
|
}
|
||||||
}, [tripStore, routeCalcEnabled])
|
}, [tripStore, routeCalcEnabled])
|
||||||
|
|
||||||
@@ -231,8 +229,7 @@ export default function TripPlannerPage() {
|
|||||||
setSelectedPlaceId(placeId)
|
setSelectedPlaceId(placeId)
|
||||||
}
|
}
|
||||||
if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) }
|
if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) }
|
||||||
updateRouteForDay(selectedDayId)
|
}, [selectAssignment, setSelectedPlaceId])
|
||||||
}, [selectedDayId, updateRouteForDay, selectAssignment, setSelectedPlaceId])
|
|
||||||
|
|
||||||
const handleMarkerClick = useCallback((placeId) => {
|
const handleMarkerClick = useCallback((placeId) => {
|
||||||
const opening = placeId !== undefined
|
const opening = placeId !== undefined
|
||||||
@@ -366,16 +363,10 @@ export default function TripPlannerPage() {
|
|||||||
return map
|
return map
|
||||||
}, [selectedDayId, assignments])
|
}, [selectedDayId, assignments])
|
||||||
|
|
||||||
// Auto-update route when assignments change
|
// Auto-update route + segments when assignments change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDayId) return
|
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||||
const da = (assignments[String(selectedDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
updateRouteForDay(selectedDayId)
|
||||||
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
|
||||||
if (waypoints.length >= 2) {
|
|
||||||
setRoute(waypoints.map(p => [p.lat, p.lng]))
|
|
||||||
} else {
|
|
||||||
setRoute(null)
|
|
||||||
}
|
|
||||||
}, [selectedDayId, assignments])
|
}, [selectedDayId, assignments])
|
||||||
|
|
||||||
// Places assigned to selected day (with coords) — used for map fitting
|
// Places assigned to selected day (with coords) — used for map fitting
|
||||||
|
|||||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.5.5",
|
"version": "2.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nomad-server",
|
"name": "nomad-server",
|
||||||
"version": "2.5.5",
|
"version": "2.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ function initDb() {
|
|||||||
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
|
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
reservation_time TEXT,
|
reservation_time TEXT,
|
||||||
|
reservation_end_time TEXT,
|
||||||
location TEXT,
|
location TEXT,
|
||||||
confirmation_number TEXT,
|
confirmation_number TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
@@ -606,6 +607,10 @@ function initDb() {
|
|||||||
try { _db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); } catch {}
|
try { _db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); } catch {}
|
||||||
try { _db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); } catch {}
|
try { _db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); } catch {}
|
||||||
},
|
},
|
||||||
|
// 28: Add end_time to reservations
|
||||||
|
() => {
|
||||||
|
try { _db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch {}
|
||||||
|
},
|
||||||
// Future migrations go here (append only, never reorder)
|
// Future migrations go here (append only, never reorder)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ router.get('/', authenticate, (req, res) => {
|
|||||||
// POST /api/trips/:tripId/reservations
|
// POST /api/trips/:tripId/reservations
|
||||||
router.post('/', authenticate, (req, res) => {
|
router.post('/', authenticate, (req, res) => {
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
|
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
@@ -39,8 +39,8 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||||
|
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, location, confirmation_number, notes, status, type)
|
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
tripId,
|
tripId,
|
||||||
day_id || null,
|
day_id || null,
|
||||||
@@ -48,6 +48,7 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
assignment_id || null,
|
assignment_id || null,
|
||||||
title,
|
title,
|
||||||
reservation_time || null,
|
reservation_time || null,
|
||||||
|
reservation_end_time || null,
|
||||||
location || null,
|
location || null,
|
||||||
confirmation_number || null,
|
confirmation_number || null,
|
||||||
notes || null,
|
notes || null,
|
||||||
@@ -70,7 +71,7 @@ router.post('/', authenticate, (req, res) => {
|
|||||||
// PUT /api/trips/:tripId/reservations/:id
|
// PUT /api/trips/:tripId/reservations/:id
|
||||||
router.put('/:id', authenticate, (req, res) => {
|
router.put('/:id', authenticate, (req, res) => {
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
const { title, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
|
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
|
||||||
|
|
||||||
const trip = verifyTripOwnership(tripId, req.user.id);
|
const trip = verifyTripOwnership(tripId, req.user.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
@@ -82,6 +83,7 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
UPDATE reservations SET
|
UPDATE reservations SET
|
||||||
title = COALESCE(?, title),
|
title = COALESCE(?, title),
|
||||||
reservation_time = ?,
|
reservation_time = ?,
|
||||||
|
reservation_end_time = ?,
|
||||||
location = ?,
|
location = ?,
|
||||||
confirmation_number = ?,
|
confirmation_number = ?,
|
||||||
notes = ?,
|
notes = ?,
|
||||||
@@ -94,6 +96,7 @@ router.put('/:id', authenticate, (req, res) => {
|
|||||||
`).run(
|
`).run(
|
||||||
title || null,
|
title || null,
|
||||||
reservation_time !== undefined ? (reservation_time || null) : reservation.reservation_time,
|
reservation_time !== undefined ? (reservation_time || null) : reservation.reservation_time,
|
||||||
|
reservation_end_time !== undefined ? (reservation_end_time || null) : reservation.reservation_end_time,
|
||||||
location !== undefined ? (location || null) : reservation.location,
|
location !== undefined ? (location || null) : reservation.location,
|
||||||
confirmation_number !== undefined ? (confirmation_number || null) : reservation.confirmation_number,
|
confirmation_number !== undefined ? (confirmation_number || null) : reservation.confirmation_number,
|
||||||
notes !== undefined ? (notes || null) : reservation.notes,
|
notes !== undefined ? (notes || null) : reservation.notes,
|
||||||
|
|||||||
Reference in New Issue
Block a user