diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 8183b1753..f82d171d5 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -86,6 +86,8 @@ interface DayPlanSidebarProps { onAddTransport?: (dayId: number) => void /** Opens the public-transit route search for a day (#1065). */ onPlanTransit?: (dayId: number) => void + /** Opens the journey view for a saved transit entry (#1065). */ + onOpenTransit?: (reservation: Reservation) => void onEditTransport?: (reservation: Reservation) => void onEditReservation?: (reservation: Reservation) => void onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void @@ -130,6 +132,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { onRouteRefresh, onAddTransport, onPlanTransit, + onOpenTransit, onEditTransport, onEditReservation, onAddBookingToAssignment, @@ -1004,6 +1007,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { onRouteRefresh, onAddTransport, onPlanTransit, + onOpenTransit, onEditTransport, onEditReservation, onAddBookingToAssignment, @@ -1166,6 +1170,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP onRouteRefresh, onAddTransport, onPlanTransit, + onOpenTransit, onEditTransport, onEditReservation, onAddBookingToAssignment, @@ -1971,10 +1976,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
{ const target = reservations.find(x => x.id === res.id) ?? res - // A transit journey opens its itinerary view — the rich - // stop-by-stop breakdown, not the generic edit form (#1065). - // Editing stays reachable from inside that view. - if (transitMeta) { setTransportDetail(target); return } + // A transit journey opens its own journey view — the rich + // stop-by-stop breakdown with its booking fields, never the + // generic edit form (#1065). + if (transitMeta) { + if (onOpenTransit) onOpenTransit(target) + else setTransportDetail(target) + return + } if (!canEditDays) return if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target) else onEditReservation?.(target) diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index c83c40ea9..2bc6e103d 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -9,6 +9,7 @@ import { Plane, Hotel, Utensils, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Ticket, FileText, MapPin, Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users, ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle, Download, + TramFront, Footprints, } from 'lucide-react' import { openFile } from '../../utils/fileDownload' import Markdown from 'react-markdown' @@ -37,6 +38,7 @@ const TYPE_OPTIONS = [ { value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike, color: '#84cc16' }, { value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' }, { value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat, color: '#0d9488' }, + { value: 'transit', labelKey: 'reservations.type.transit', Icon: TramFront, color: '#7c3aed' }, { value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route, color: '#6b7280' }, { value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' }, { value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' }, @@ -305,6 +307,38 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo ) })()} + {/* Transit itinerary chips (#1065) — the journey at a glance */} + {(() => { + if (r.type !== 'transit') return null + const meta = typeof r.metadata === 'string' ? (() => { try { return JSON.parse(r.metadata || '{}') } catch { return {} } })() : (r.metadata || {}) + const transit = meta.transit + if (!transit || !Array.isArray(transit.legs) || transit.legs.length === 0) return null + return ( +
+ {transit.legs.map((leg: { mode?: string; line?: string | null; line_color?: string | null; line_text_color?: string | null }, li: number) => ( + + {li > 0 && ›} + {leg.mode === 'WALK' + ? + : ( + + {leg.line || leg.mode} + + )} + + ))} + + {transit.transfers > 0 ? t('transit.transfers', { count: transit.transfers }) : t('transit.direct')} + +
+ ) + })()} + {/* Type-specific metadata */} {(() => { const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) @@ -539,8 +573,12 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)), [reservations, typeFilters]) - const allPending = filtered.filter(r => r.status !== 'confirmed') - const allConfirmed = filtered.filter(r => r.status === 'confirmed') + // Automated public transit (#1065) gets its own section — journeys planned via + // the transit search live alongside manual transports without mixing in. + const transitEntries = filtered.filter(r => r.type === 'transit') + const nonTransit = filtered.filter(r => r.type !== 'transit') + const allPending = nonTransit.filter(r => r.status !== 'confirmed') + const allConfirmed = nonTransit.filter(r => r.status === 'confirmed') const total = filtered.length const usedTypes = useMemo(() => new Set(reservations.map(r => r.type)), [reservations]) @@ -678,6 +716,11 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
) : ( <> + {transitEntries.length > 0 && ( +
+ {transitEntries.map(r => )} +
+ )} {allPending.length > 0 && (
{allPending.map(r => )} diff --git a/client/src/components/Planner/TransitJourneyModal.test.tsx b/client/src/components/Planner/TransitJourneyModal.test.tsx new file mode 100644 index 000000000..182002807 --- /dev/null +++ b/client/src/components/Planner/TransitJourneyModal.test.tsx @@ -0,0 +1,97 @@ +// FE-PLANNER-TRANSITJOURNEY-001 to 005 — the journey view for a saved transit entry. +import { render, screen, waitFor } from '../../../tests/helpers/render' +import userEvent from '@testing-library/user-event' +import { resetAllStores, seedStore } from '../../../tests/helpers/store' +import { useAuthStore } from '../../store/authStore' +import { useSettingsStore } from '../../store/settingsStore' +import { buildUser, buildReservation } from '../../../tests/helpers/factories' +import TransitJourneyModal from './TransitJourneyModal' + +function makeReservation() { + return { + ...buildReservation({ id: 7, type: 'transit', title: 'Fernsehturm → Zoo', reservation_time: '2025-06-01T08:30:00', status: 'confirmed' }), + metadata: { + transit: { + provider: 'transitous', duration: 1800, transfers: 1, walk_seconds: 240, + legs: [ + { mode: 'WALK', duration: 240, from: { name: 'Start' }, to: { name: 'Alexanderplatz' } }, + { mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', line_text_color: '#FFFFFF', headsign: 'Ruhleben', agency: 'BVG', duration: 1440, stops: 6, from: { name: 'Alexanderplatz', time: '08:36', track: '2' }, to: { name: 'Zoo', time: '09:00' } }, + ], + }, + }, + endpoints: [ + { role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.52, lng: 13.4, timezone: 'Europe/Berlin', local_date: null, local_time: null }, + { role: 'to', sequence: 1, name: 'Zoo', code: null, lat: 52.5, lng: 13.33, timezone: 'Europe/Berlin', local_date: null, local_time: null }, + ], + } as any +} + +function makeProps(overrides = {}) { + return { + reservation: makeReservation(), + onClose: vi.fn(), + onSave: vi.fn().mockResolvedValue({}), + onDelete: vi.fn().mockResolvedValue({}), + onChangeRoute: vi.fn(), + canEdit: true, + ...overrides, + } +} + +beforeEach(() => { + resetAllStores() + vi.clearAllMocks() + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }) + seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any) +}) + +describe('TransitJourneyModal', () => { + it('FE-PLANNER-TRANSITJOURNEY-001: shows summary, line badge, platform and legs', () => { + render() + expect(screen.getByText('U2')).toBeInTheDocument() + expect(screen.getByText('1 transfers')).toBeInTheDocument() + expect(screen.getByText(/Platform 2/)).toBeInTheDocument() + expect(screen.getByText(/Ruhleben/)).toBeInTheDocument() + expect(screen.getByText(/BVG/)).toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSITJOURNEY-002: saving edited fields calls onSave with the field payload only', async () => { + const user = userEvent.setup() + const onSave = vi.fn().mockResolvedValue({}) + render() + const titleInput = screen.getByDisplayValue('Fernsehturm → Zoo') + await user.clear(titleInput) + await user.type(titleInput, 'Zum Zoo') + await user.click(screen.getByRole('button', { name: /^Save$/ })) + await waitFor(() => expect(onSave).toHaveBeenCalled()) + expect(onSave).toHaveBeenCalledWith({ title: 'Zum Zoo', status: 'confirmed', confirmation_number: null, notes: null }) + }) + + it('FE-PLANNER-TRANSITJOURNEY-003: change route triggers onChangeRoute', async () => { + const user = userEvent.setup() + const onChangeRoute = vi.fn() + render() + await user.click(screen.getByRole('button', { name: /Change route/ })) + expect(onChangeRoute).toHaveBeenCalled() + }) + + it('FE-PLANNER-TRANSITJOURNEY-004: delete asks for confirmation, then calls onDelete', async () => { + const user = userEvent.setup() + const onDelete = vi.fn().mockResolvedValue({}) + render() + await user.click(screen.getByRole('button', { name: /^Delete$/ })) + expect(onDelete).not.toHaveBeenCalled() + // Confirm dialog appears — confirm it. + const confirmBtns = await screen.findAllByRole('button', { name: /Delete/ }) + await user.click(confirmBtns[confirmBtns.length - 1]) + await waitFor(() => expect(onDelete).toHaveBeenCalled()) + }) + + it('FE-PLANNER-TRANSITJOURNEY-005: read-only without edit rights — no delete/save/change-route', () => { + render() + expect(screen.queryByRole('button', { name: /^Delete$/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Change route/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^Save$/ })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Close/ })).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Planner/TransitJourneyModal.tsx b/client/src/components/Planner/TransitJourneyModal.tsx new file mode 100644 index 000000000..cd6e0c4fd --- /dev/null +++ b/client/src/components/Planner/TransitJourneyModal.tsx @@ -0,0 +1,256 @@ +import { useEffect, useState } from 'react' +import { ArrowRight, Footprints, Pencil, RefreshCw, TramFront, Trash2 } from 'lucide-react' +import Modal from '../shared/Modal' +import ConfirmDialog from '../shared/ConfirmDialog' +import { useTranslation } from '../../i18n' +import { useSettingsStore } from '../../store/settingsStore' +import { splitReservationDateTime, formatTime } from '../../utils/formatters' +import type { Reservation } from '../../types' + +/** + * The journey view for an automated public-transit entry (#1065): a roomy modal + * that shows the full itinerary (summary + stop-by-stop legs) together with the + * editable booking fields — the generic transport form never opens for transit. + * "Change route" re-enters the transit search pre-seeded with this journey's + * origin/destination and replaces the itinerary on save. + */ + +interface TransitLegMeta { + mode?: string + line?: string | null + line_color?: string | null + line_text_color?: string | null + headsign?: string | null + agency?: string | null + duration?: number + stops?: number + from?: { name?: string; time?: string | null; track?: string | null } + to?: { name?: string; time?: string | null; track?: string | null } +} + +interface TransitJourneyModalProps { + reservation: Reservation + onClose: () => void + /** Partial field update (title/status/confirmation/notes) — endpoints + itinerary stay untouched. */ + onSave: (fields: { title: string; status: string; confirmation_number: string | null; notes: string | null }) => Promise + onDelete: () => Promise + onChangeRoute: () => void + canEdit: boolean +} + +export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, canEdit }: TransitJourneyModalProps) { + const { t, locale } = useTranslation() + const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h' + const res = reservation + const meta = typeof res.metadata === 'string' ? (() => { try { return JSON.parse(res.metadata || '{}') } catch { return {} } })() : (res.metadata || {}) + const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null + + const [title, setTitle] = useState(res.title || '') + const [status, setStatus] = useState(res.status || 'confirmed') + const [confirmation, setConfirmation] = useState(res.confirmation_number || '') + const [notes, setNotes] = useState(res.notes || '') + const [saving, setSaving] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) + + useEffect(() => { + setTitle(res.title || '') + setStatus(res.status || 'confirmed') + setConfirmation(res.confirmation_number || '') + setNotes(res.notes || '') + }, [res.id]) + + const dirty = title !== (res.title || '') || status !== (res.status || 'confirmed') + || confirmation !== (res.confirmation_number || '') || notes !== (res.notes || '') + + const save = async () => { + if (!title.trim()) return + setSaving(true) + try { + await onSave({ title: title.trim(), status, confirmation_number: confirmation.trim() || null, notes: notes.trim() || null }) + onClose() + } finally { setSaving(false) } + } + + const { date, time } = splitReservationDateTime(res.reservation_time) + const { time: endTime } = splitReservationDateTime(res.reservation_end_time) + const dateStr = date ? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }) : '' + + const inputClass = 'w-full border border-edge rounded-[10px] px-[12px] py-[9px] text-[13px] font-[inherit] outline-none box-border text-content bg-surface-input' + const labelClass = 'block text-[11px] font-semibold text-content-faint mb-[5px] uppercase tracking-[0.03em]' + + return ( + + {canEdit && ( + + )} +
+ {canEdit && ( + + )} + {canEdit ? ( + + ) : ( + + )} +
+ } + > +
+ {/* header: route + date/time + status badge */} +
+
+ +
+
+
{res.title}
+
+ {[dateStr, time ? `${formatTime(time, locale, timeFormat)}${endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}` : ''].filter(Boolean).join(' · ')} +
+
+
+ {(status === 'confirmed' ? t('planner.resConfirmed') : t('planner.resPending')).replace(/\s*·\s*$/, '')} +
+
+ + {transit && ( + <> + {/* journey summary */} +
+ {transit.duration > 0 && ( + {Math.floor(transit.duration / 3600) > 0 ? `${Math.floor(transit.duration / 3600)} h ${Math.round((transit.duration % 3600) / 60)} min` : t('transit.min', { count: Math.round(transit.duration / 60) })} + )} + · + {transit.transfers > 0 ? t('transit.transfers', { count: transit.transfers }) : t('transit.direct')} + {transit.walk_seconds > 59 && ( + <> + · + {t('transit.min', { count: Math.round(transit.walk_seconds / 60) })} + + )} +
+ + {/* stop-by-stop itinerary — roomy */} +
+
+ {t('transit.itinerary')} +
+
+ {(transit.legs as TransitLegMeta[]).map((leg, i) => { + const isWalk = leg.mode === 'WALK' + const mins = leg.duration ? Math.round(leg.duration / 60) : null + return ( +
+
+ {!isWalk && leg.from?.time ? leg.from.time : ''} +
+ {isWalk ? ( + + + + ) : ( + + {leg.line || leg.mode} + + )} +
+
+ {isWalk + ? {t('transit.walkTo', { name: leg.to?.name || '' })} + : <> + {leg.from?.name} + {leg.from?.track && ({t('transit.platform', { track: leg.from.track })})} + + {leg.to?.name} + } +
+
+ {[ + !isWalk && leg.from?.time ? `${leg.from.time}${leg.to?.time ? ` – ${leg.to.time}` : ''}` : null, + mins ? t('transit.min', { count: mins }) : null, + !isWalk && leg.stops ? t('transit.stops', { count: leg.stops }) : null, + !isWalk && leg.headsign ? `→ ${leg.headsign}` : null, + !isWalk ? leg.agency : null, + ].filter(Boolean).join(' · ')} +
+
+
+ ) + })} +
+
+ + )} + + {/* editable booking fields */} +
+
+ + setTitle(e.target.value)} disabled={!canEdit} className={inputClass} /> +
+
+ +
+ {(['pending', 'confirmed'] as const).map(sv => ( + + ))} +
+
+
+
+
+ + setConfirmation(e.target.value)} disabled={!canEdit} className={inputClass} /> +
+
+ + setNotes(e.target.value)} disabled={!canEdit} placeholder={canEdit ? t('reservations.notesPlaceholder') : undefined} className={inputClass} /> +
+
+ + {canEdit && !transit && ( +
+ {t('transit.noItinerary')} +
+ )} +
+ + setConfirmDelete(false)} + onConfirm={async () => { setConfirmDelete(false); await onDelete(); onClose() }} + title={t('reservations.confirm.deleteTitle')} + message={t('reservations.confirm.deleteBody', { name: res.title })} + /> +
+ ) +} diff --git a/client/src/components/Planner/TransitSearchModal.test.tsx b/client/src/components/Planner/TransitSearchPanel.test.tsx similarity index 92% rename from client/src/components/Planner/TransitSearchModal.test.tsx rename to client/src/components/Planner/TransitSearchPanel.test.tsx index c98aea427..fc3213f4b 100644 --- a/client/src/components/Planner/TransitSearchModal.test.tsx +++ b/client/src/components/Planner/TransitSearchPanel.test.tsx @@ -1,11 +1,12 @@ -// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-006 +// FE-PLANNER-TRANSIT-001 to FE-PLANNER-TRANSIT-006 — the transit search panel +// (embedded as the TransportModal's Automated mode). import { render, screen, waitFor } from '../../../tests/helpers/render' import userEvent from '@testing-library/user-event' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { buildUser, buildDay, buildPlace } from '../../../tests/helpers/factories' -import TransitSearchModal from './TransitSearchModal' +import TransitSearchPanel from './TransitSearchPanel' const { transitApiMock } = vi.hoisted(() => ({ transitApiMock: { geocode: vi.fn(), plan: vi.fn() }, @@ -35,8 +36,6 @@ const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Da function makeProps(overrides = {}) { return { - isOpen: true, - onClose: vi.fn(), day, days: [day], places: [buildPlace({ id: 1, name: 'Fernsehturm', lat: 52.5208, lng: 13.4094 })], @@ -65,10 +64,9 @@ beforeEach(() => { seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any) }) -describe('TransitSearchModal', () => { +describe('TransitSearchPanel', () => { it('FE-PLANNER-TRANSIT-001: renders from/to pickers, modes and preferences', () => { - render() - expect(screen.getByText('Public transit')).toBeInTheDocument() + render() expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2) expect(screen.getByText('Subway')).toBeInTheDocument() expect(screen.getByText('Fewer transfers')).toBeInTheDocument() @@ -77,7 +75,7 @@ describe('TransitSearchModal', () => { it('FE-PLANNER-TRANSIT-002: searching lists itineraries with times, transfers and line badges', async () => { const user = userEvent.setup() transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] }) - render() + render() await pickFromAndTo(user) await user.click(screen.getByRole('button', { name: /^Search$/ })) // Local Berlin times, U2 badge, 1 transfer. @@ -90,7 +88,7 @@ describe('TransitSearchModal', () => { const user = userEvent.setup() const onAdd = vi.fn().mockResolvedValue({}) transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY] }) - render() + render() await pickFromAndTo(user) await user.click(screen.getByRole('button', { name: /^Search$/ })) await user.click(await screen.findByText(/08:30 – 09:00/)) @@ -117,7 +115,7 @@ describe('TransitSearchModal', () => { it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => { const user = userEvent.setup() transitApiMock.plan.mockRejectedValueOnce(new Error('boom')) - render() + render() await pickFromAndTo(user) await user.click(screen.getByRole('button', { name: /^Search$/ })) expect(await screen.findByText(/No connections found/)).toBeInTheDocument() @@ -127,7 +125,7 @@ describe('TransitSearchModal', () => { const user = userEvent.setup() const direct = { ...ITINERARY, startTime: '2025-06-01T06:40:00Z', endTime: '2025-06-01T07:20:00Z', duration: 2400, transfers: 0 } transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY, direct] }) - render() + render() await pickFromAndTo(user) await user.click(screen.getByRole('button', { name: /^Search$/ })) await screen.findByText(/08:30 – 09:00/) @@ -139,7 +137,7 @@ describe('TransitSearchModal', () => { it('FE-PLANNER-TRANSIT-006: swap exchanges from and to', async () => { const user = userEvent.setup() - render() + render() const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…') await user.click(fromInput) await user.click(await screen.findByText('Fernsehturm')) diff --git a/client/src/components/Planner/TransitSearchModal.tsx b/client/src/components/Planner/TransitSearchPanel.tsx similarity index 97% rename from client/src/components/Planner/TransitSearchModal.tsx rename to client/src/components/Planner/TransitSearchPanel.tsx index f370662dc..8b9404a0c 100644 --- a/client/src/components/Planner/TransitSearchModal.tsx +++ b/client/src/components/Planner/TransitSearchPanel.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import tzlookup from 'tz-lookup' import { ArrowLeftRight, ArrowRight, Bus, CableCar, ChevronDown, ChevronUp, Clock, Footprints, MapPin, Sailboat, Search, Train, TramFront, TrainFront } from 'lucide-react' -import Modal from '../shared/Modal' import CustomTimePicker from '../shared/CustomTimePicker' import { transitApi } from '../../api/client' import { useSettingsStore } from '../../store/settingsStore' @@ -33,7 +32,7 @@ export interface TransitItinerary { interface TransitPlaceResult { name: string; lat: number; lng: number; type: string; area: string | null } -interface PickedPlace { name: string; lat: number; lng: number } +export interface PickedPlace { name: string; lat: number; lng: number } // ── helpers ────────────────────────────────────────────────────────────────── @@ -294,26 +293,30 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add ) } -// ── the modal ──────────────────────────────────────────────────────────────── +// ── the search panel ───────────────────────────────────────────────────────── +// Modal-less on purpose: it renders as the "Automated transport" mode inside +// the TransportModal (and could embed anywhere else). The host owns the modal +// chrome and closes itself once onAdd resolves. -interface TransitSearchModalProps { - isOpen: boolean - onClose: () => void +interface TransitSearchPanelProps { day: Day days: Day[] places: Place[] accommodations?: Accommodation[] /** Persist the built reservation payload; resolves when saved. */ onAdd: (payload: Record) => Promise + /** Pre-seed from/to — used by "change route" on an existing journey. */ + initialFrom?: PickedPlace | null + initialTo?: PickedPlace | null } -export default function TransitSearchModal({ isOpen, onClose, day, days, places, accommodations = [], onAdd }: TransitSearchModalProps) { +export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null }: TransitSearchPanelProps) { const { t } = useTranslation() const toast = useToast() const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const [from, setFrom] = useState(null) - const [to, setTo] = useState(null) + const [from, setFrom] = useState(initialFrom) + const [to, setTo] = useState(initialTo) const [time, setTime] = useState('09:00') const [arriveBy, setArriveBy] = useState(false) const [activeModes, setActiveModes] = useState>(() => new Set(MODE_GROUPS.map(m => m.key))) @@ -442,7 +445,6 @@ export default function TransitSearchModal({ isOpen, onClose, day, days, places, } await onAdd(payload) - onClose() } catch { toast.error(t('common.unknownError')) } finally { @@ -461,8 +463,7 @@ export default function TransitSearchModal({ isOpen, onClose, day, days, places, }) return ( - -
+
{/* from / to */}
@@ -568,6 +569,5 @@ export default function TransitSearchModal({ isOpen, onClose, day, days, places,
)}
- ) } diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index 6abe36301..c258d75d0 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -366,4 +366,28 @@ describe('TransportModal', () => { expect(payload.metadata?.transit).toBeUndefined(); expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']); }); + + // ── Manual / Automated creation switch (#1065) ───────────────────────────── + + it('FE-PLANNER-TRANSMODAL-022: creating shows the Manual/Automated switch; Automated opens the transit search', async () => { + render(); + expect(screen.getByRole('button', { name: 'Manual transport' })).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Automated transport' })); + // No day selected in defaultProps (days: []) — the pick-day hint shows. + expect(screen.getByText(/Pick a day/)).toBeInTheDocument(); + // The manual form is gone in automated mode. + expect(screen.queryByPlaceholderText(/e\.g\. Lufthansa/i)).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-TRANSMODAL-023: initialAutomated opens straight in the transit search with the day preset', () => { + const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as any; + render(); + expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2); + }); + + it('FE-PLANNER-TRANSMODAL-024: editing shows no Manual/Automated switch', () => { + const res = buildReservation({ title: 'My Train', type: 'train' }); + render(); + expect(screen.queryByRole('button', { name: 'Automated transport' })).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 79e07733b..6738d85ce 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -13,11 +13,12 @@ import { useAddonStore } from '../../store/addonStore' import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters' import { openFile } from '../../utils/fileDownload' import apiClient from '../../api/client' -import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types' +import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types' import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs' import { BookingCostsSection } from './BookingCostsSection' import type { BookingExpenseRequest } from './BookingCostsSection.types' import type { BookingReviewDraft } from './parsedItemToDraft' +import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel' import { typeToCostCategory } from '@trek/shared' const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] as const @@ -96,7 +97,6 @@ const TYPE_OPTIONS = [ { value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike }, { value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship }, { value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat }, - { value: 'transit', labelKey: 'reservations.type.transit', Icon: TramFront }, { value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route }, ] @@ -131,9 +131,16 @@ interface TransportModalProps { // Pre-fill a brand-new transport booking from a parsed import item (review- // before-save); like `reservation` for the form but stays in create mode. prefill?: BookingReviewDraft | null + /** Data for the Automated (public transit) mode's quick picks. */ + places?: Place[] + accommodations?: Accommodation[] + /** Open directly in the Automated public-transit mode (day-header tram button, "change route"). */ + initialAutomated?: boolean + /** Pre-seed the transit search — used by "change route" on an existing journey. */ + transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null } | null } -export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null }: TransportModalProps) { +export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], accommodations = [], initialAutomated = false, transitPrefill = null }: TransportModalProps) { const { t, locale } = useTranslation() const toast = useToast() const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) @@ -145,6 +152,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel // the post-save handler knows to open the Costs editor for the saved booking. const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null) const [form, setForm] = useState({ ...defaultForm }) + // Manual vs Automated (public transit search) creation mode (#1065). + const [automated, setAutomated] = useState(false) const [isSaving, setIsSaving] = useState(false) const [fromPick, setFromPick] = useState({}) const [toPick, setToPick] = useState({}) @@ -162,6 +171,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel // Either way the init reads the same fields — `reservation` still decides // edit-vs-create at submit time. const src = (reservation ?? prefill) as Reservation | null + if (src) setAutomated(initialAutomated) // On a review-import, seed the booking's Files with the parsed source document. setPendingFiles(!reservation && prefill?._sourceFiles ? prefill._sourceFiles : []) if (src) { @@ -233,6 +243,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel } } else { setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' }) + setAutomated(initialAutomated) setFromPick({}) setToPick({}) setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')]) @@ -465,19 +476,62 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel + {!automated && ( + )}
} > + {/* Manual vs Automated creation switch (#1065) — creating only; editing a + journey re-enters via "change route" with the switch hidden. */} + {!reservation && ( +
+ {([['manual', t('transport.modeManual')], ['automated', t('transport.modeAutomated')]] as const).map(([m, label]) => { + const active = (m === 'automated') === automated + return ( + + ) + })} +
+ )} + + {automated ? ( + /* ── Automated: public transit search (#1065) ── */ +
+
+ + set('start_day_id', v)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" /> +
+ {(() => { + const transitDay = days.find(d => d.id === Number(form.start_day_id)) + if (!transitDay) return
{t('transit.pickDay')}
+ return ( + onSave(payload as Record & { title: string })} + initialFrom={transitPrefill?.from ?? null} + initialTo={transitPrefill?.to ?? null} + /> + ) + })()} +
+ ) : (
{/* Type selector */} @@ -781,6 +835,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel )}
+ )}
) } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 2bf50d9fa..5bd0c3b73 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -17,7 +17,7 @@ import SlidingTabs from '../components/shared/SlidingTabs' import TripMembersModal from '../components/Trips/TripMembersModal' import { ReservationModal } from '../components/Planner/ReservationModal' import { TransportModal } from '../components/Planner/TransportModal' -import TransitSearchModal from '../components/Planner/TransitSearchModal' +import TransitJourneyModal from '../components/Planner/TransitJourneyModal' import BookingImportModal from '../components/Planner/BookingImportModal' import AirTrailImportModal from '../components/Planner/AirTrailImportModal' // MemoriesPanel moved to Journey addon @@ -195,7 +195,7 @@ export default function TripPlannerPage(): React.ReactElement | null { bookingForAssignmentId, setBookingForAssignmentId, showTransportModal, setShowTransportModal, editingTransport, setEditingTransport, transportModalDayId, setTransportModalDayId, - transitModalDayId, setTransitModalDayId, handleAddTransitRoute, + transportModalAutomated, setTransportModalAutomated, transitPrefill, setTransitPrefill, transitJourney, setTransitJourney, reservationPrefill, transportPrefill, importReviewActive, advanceImportReview, routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey, mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef, @@ -398,8 +398,9 @@ export default function TripPlannerPage(): React.ReactElement | null { externalTransportDetail={mapTransportDetail} onExternalTransportDetailHandled={() => setMapTransportDetail(null)} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }} - onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true) } : undefined} - onPlanTransit={can('day_edit', trip) ? (dayId) => setTransitModalDayId(dayId) : undefined} + onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) } : undefined} + onOpenTransit={(r) => setTransitJourney(r)} + onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true) } : undefined} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} @@ -622,7 +623,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransitModalDayId(dayId); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded /> : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> }
@@ -641,12 +642,12 @@ export default function TripPlannerPage(): React.ReactElement | null { days={days} assignments={assignments} files={files} - onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }} + onAdd={() => { setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true) }} onImport={() => setShowBookingImport(true)} bookingImportAvailable={bookingImportAvailable} onAirTrailImport={() => setShowAirTrailImport(true)} airTrailAvailable={airTrailAvailable} - onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }} + onEdit={(r) => { if (r.type === 'transit') { setTransitJourney(r) } else { setEditingTransport(r); setTransportModalAutomated(false); setShowTransportModal(true) } }} onDelete={handleDeleteReservation} onNavigateToFiles={() => handleTabChange('dateien')} titleKey="transport.title" @@ -719,23 +720,34 @@ export default function TripPlannerPage(): React.ReactElement | null { /> setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} /> - {showTransportModal && { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />} - {/* Public transit route search (#1065) */} - {transitModalDayId != null && (() => { - const transitDay = days.find(d => d.id === transitModalDayId) - if (!transitDay) return null - return ( - setTransitModalDayId(null)} - day={transitDay} - days={days} - places={places} - accommodations={tripAccommodations} - onAdd={handleAddTransitRoute} - /> - ) - })()} + {showTransportModal && { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null); setTransportModalAutomated(false); setTransitPrefill(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} places={places} accommodations={tripAccommodations} initialAutomated={transportModalAutomated} transitPrefill={transitPrefill} />} + {/* Journey view for a saved public-transit entry (#1065) */} + {transitJourney && ( + setTransitJourney(null)} + onSave={async (fields) => { await tripActions.updateReservation(tripId, transitJourney.id, fields); setTransitJourney(null) }} + onDelete={async () => { await handleDeleteReservation(transitJourney.id); setTransitJourney(null) }} + onChangeRoute={() => { + // Re-enter the transit search seeded with this journey's route; the + // existing reservation is REPLACED on save (editingTransport drives + // handleSaveTransport's update path). + const eps = transitJourney.endpoints || [] + const from = eps.find(e => e.role === 'from') + const to = eps.find(e => e.role === 'to') + setTransitPrefill({ + from: from ? { name: from.name, lat: from.lat, lng: from.lng } : null, + to: to ? { name: to.name, lat: to.lat, lng: to.lng } : null, + }) + setEditingTransport(transitJourney) + setTransportModalDayId(transitJourney.day_id ?? null) + setTransportModalAutomated(true) + setTransitJourney(null) + setShowTransportModal(true) + }} + /> + )} {bookingExpense && ( (false) const [editingTransport, setEditingTransport] = useState(null) const [transportModalDayId, setTransportModalDayId] = useState(null) - // Public transit route search (#1065) — non-null while the modal is open for that day. - const [transitModalDayId, setTransitModalDayId] = useState(null) + // Public transit (#1065): open the TransportModal in its Automated mode, seed + // the search (change-route), and show the journey view for a saved entry. + const [transportModalAutomated, setTransportModalAutomated] = useState(false) + const [transitPrefill, setTransitPrefill] = useState<{ from?: { name: string; lat: number; lng: number } | null; to?: { name: string; lat: number; lng: number } | null } | null>(null) + const [transitJourney, setTransitJourney] = useState(null) // The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs // it opens the booking / transport modal via ?create=reservation|transport @@ -691,14 +694,6 @@ export function useTripPlanner() { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } } - // A chosen transit itinerary is persisted as a regular transport reservation, - // so it slots into the timeline by time and inherits edit/delete/drag (#1065). - const handleAddTransitRoute = async (payload: Record & { title: string }) => { - const r = await tripActions.addReservation(tripId, payload) - toast.success(t('trip.toast.reservationAdded')) - return r - } - const handleDeleteReservation = async (id) => { try { await tripActions.deleteReservation(tripId, id) @@ -869,7 +864,7 @@ export function useTripPlanner() { bookingForAssignmentId, setBookingForAssignmentId, showTransportModal, setShowTransportModal, editingTransport, setEditingTransport, transportModalDayId, setTransportModalDayId, - transitModalDayId, setTransitModalDayId, handleAddTransitRoute, + transportModalAutomated, setTransportModalAutomated, transitPrefill, setTransitPrefill, transitJourney, setTransitJourney, reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview, routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey, mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef, diff --git a/shared/src/i18n/ar/trip.ts b/shared/src/i18n/ar/trip.ts index b400d893e..a937ff784 100644 --- a/shared/src/i18n/ar/trip.ts +++ b/shared/src/i18n/ar/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'إضافة إلى اليوم', 'transit.itinerary': 'خط سير الرحلة', 'transit.attribution': 'بيانات المسارات عبر', + 'transport.modeManual': 'نقل يدوي', + 'transport.modeAutomated': 'نقل تلقائي', + 'transit.sectionTitle': 'النقل العام التلقائي', + 'transit.changeRoute': 'تغيير المسار', + 'transit.journey': 'رحلة بالنقل العام', + 'transit.pickDay': 'اختر يومًا للبحث عن الرحلات المتاحة.', + 'transit.noItinerary': 'لا يحتوي هذا الإدخال على خط سير محفوظ — استخدم "تغيير المسار" للبحث عن أحدها.', }; export default trip; diff --git a/shared/src/i18n/br/trip.ts b/shared/src/i18n/br/trip.ts index 6d7126ffa..db26e5822 100644 --- a/shared/src/i18n/br/trip.ts +++ b/shared/src/i18n/br/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Adicionar ao dia', 'transit.itinerary': 'Itinerário', 'transit.attribution': 'Dados de rota via', + 'transport.modeManual': 'Transporte manual', + 'transport.modeAutomated': 'Transporte automático', + 'transit.sectionTitle': 'Transporte público automático', + 'transit.changeRoute': 'Alterar trajeto', + 'transit.journey': 'Viagem de transporte público', + 'transit.pickDay': 'Escolha um dia para buscar conexões.', + 'transit.noItinerary': 'Esta entrada não tem itinerário salvo — use "Alterar trajeto" para buscar um.', }; export default trip; diff --git a/shared/src/i18n/cs/trip.ts b/shared/src/i18n/cs/trip.ts index 9171b3939..f51a23ea0 100644 --- a/shared/src/i18n/cs/trip.ts +++ b/shared/src/i18n/cs/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Přidat ke dni', 'transit.itinerary': 'Itinerář', 'transit.attribution': 'Data o spojení přes', + 'transport.modeManual': 'Ruční doprava', + 'transport.modeAutomated': 'Automatická doprava', + 'transit.sectionTitle': 'Automatické spojení MHD', + 'transit.changeRoute': 'Změnit trasu', + 'transit.journey': 'Cesta veřejnou dopravou', + 'transit.pickDay': 'Vyberte den, pro který chcete vyhledat spojení.', + 'transit.noItinerary': 'Tato položka nemá uložené spojení – vyhledejte ho pomocí tlačítka „Změnit trasu“.', }; export default trip; diff --git a/shared/src/i18n/de/trip.ts b/shared/src/i18n/de/trip.ts index f0b0f48c1..0f8d63b43 100644 --- a/shared/src/i18n/de/trip.ts +++ b/shared/src/i18n/de/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Zum Tag hinzufügen', 'transit.itinerary': 'Verbindung', 'transit.attribution': 'Routing-Daten via', + 'transport.modeManual': 'Manueller Transport', + 'transport.modeAutomated': 'Automatischer Transport', + 'transit.sectionTitle': 'Automatische ÖPNV-Verbindungen', + 'transit.changeRoute': 'Route ändern', + 'transit.journey': 'ÖPNV-Verbindung', + 'transit.pickDay': 'Wähle einen Tag, für den Verbindungen gesucht werden sollen.', + 'transit.noItinerary': 'Dieser Eintrag hat keine gespeicherte Verbindung — über „Route ändern" eine suchen.', }; export default trip; diff --git a/shared/src/i18n/en/trip.ts b/shared/src/i18n/en/trip.ts index 247dbb649..eb7eb0ea5 100644 --- a/shared/src/i18n/en/trip.ts +++ b/shared/src/i18n/en/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Add to day', 'transit.itinerary': 'Itinerary', 'transit.attribution': 'Routing data via', + 'transport.modeManual': 'Manual transport', + 'transport.modeAutomated': 'Automated transport', + 'transit.sectionTitle': 'Automated public transit', + 'transit.changeRoute': 'Change route', + 'transit.journey': 'Public transit journey', + 'transit.pickDay': 'Pick a day to search connections for.', + 'transit.noItinerary': 'This entry has no stored itinerary — use "Change route" to search one.', }; export default trip; diff --git a/shared/src/i18n/es/trip.ts b/shared/src/i18n/es/trip.ts index edf412436..5de230585 100644 --- a/shared/src/i18n/es/trip.ts +++ b/shared/src/i18n/es/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Añadir al día', 'transit.itinerary': 'Itinerario', 'transit.attribution': 'Datos de rutas vía', + 'transport.modeManual': 'Transporte manual', + 'transport.modeAutomated': 'Transporte automático', + 'transit.sectionTitle': 'Transporte público automático', + 'transit.changeRoute': 'Cambiar ruta', + 'transit.journey': 'Trayecto en transporte público', + 'transit.pickDay': 'Elige un día para buscar conexiones.', + 'transit.noItinerary': 'Esta entrada no tiene ningún itinerario guardado: usa «Cambiar ruta» para buscar uno.', }; export default trip; diff --git a/shared/src/i18n/fr/trip.ts b/shared/src/i18n/fr/trip.ts index 4b6c48f92..816375d05 100644 --- a/shared/src/i18n/fr/trip.ts +++ b/shared/src/i18n/fr/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Ajouter au jour', 'transit.itinerary': 'Itinéraire', 'transit.attribution': 'Données d\'itinéraire via', + 'transport.modeManual': 'Transport manuel', + 'transport.modeAutomated': 'Transport automatisé', + 'transit.sectionTitle': 'Transports en commun automatisés', + 'transit.changeRoute': 'Modifier l\'itinéraire', + 'transit.journey': 'Trajet en transports en commun', + 'transit.pickDay': 'Choisissez un jour pour rechercher des connexions.', + 'transit.noItinerary': 'Cette entrée n\'a aucun itinéraire enregistré — utilisez « Modifier l\'itinéraire » pour en rechercher un.', }; export default trip; diff --git a/shared/src/i18n/gr/trip.ts b/shared/src/i18n/gr/trip.ts index 17b5b40e1..3c84a2123 100644 --- a/shared/src/i18n/gr/trip.ts +++ b/shared/src/i18n/gr/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Προσθήκη στην ημέρα', 'transit.itinerary': 'Δρομολόγιο', 'transit.attribution': 'Δεδομένα διαδρομών μέσω', + 'transport.modeManual': 'Χειροκίνητη μεταφορά', + 'transport.modeAutomated': 'Αυτόματη μεταφορά', + 'transit.sectionTitle': 'Αυτόματες δημόσιες συγκοινωνίες', + 'transit.changeRoute': 'Αλλαγή διαδρομής', + 'transit.journey': 'Διαδρομή με δημόσιες συγκοινωνίες', + 'transit.pickDay': 'Επιλέξτε μια ημέρα για αναζήτηση συνδέσεων.', + 'transit.noItinerary': 'Αυτή η καταχώριση δεν έχει αποθηκευμένο δρομολόγιο — χρησιμοποιήστε την «Αλλαγή διαδρομής» για να αναζητήσετε ένα.', }; export default trip; diff --git a/shared/src/i18n/hu/trip.ts b/shared/src/i18n/hu/trip.ts index 311317635..7d16fd513 100644 --- a/shared/src/i18n/hu/trip.ts +++ b/shared/src/i18n/hu/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Hozzáadás a naphoz', 'transit.itinerary': 'Útiterv', 'transit.attribution': 'Útvonaladatok innen:', + 'transport.modeManual': 'Kézi közlekedés', + 'transport.modeAutomated': 'Automatikus közlekedés', + 'transit.sectionTitle': 'Automatikus tömegközlekedés', + 'transit.changeRoute': 'Útvonal módosítása', + 'transit.journey': 'Tömegközlekedési útvonal', + 'transit.pickDay': 'Válassz egy napot a kapcsolatok kereséséhez.', + 'transit.noItinerary': 'Ehhez a bejegyzéshez nincs mentett útiterv – az „Útvonal módosítása” gombbal kereshetsz egyet.', }; export default trip; diff --git a/shared/src/i18n/id/trip.ts b/shared/src/i18n/id/trip.ts index 76828eda4..18522070f 100644 --- a/shared/src/i18n/id/trip.ts +++ b/shared/src/i18n/id/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Tambahkan ke hari', 'transit.itinerary': 'Itinerari', 'transit.attribution': 'Data rute melalui', + 'transport.modeManual': 'Transportasi manual', + 'transport.modeAutomated': 'Transportasi otomatis', + 'transit.sectionTitle': 'Transportasi umum otomatis', + 'transit.changeRoute': 'Ubah rute', + 'transit.journey': 'Perjalanan transportasi umum', + 'transit.pickDay': 'Pilih hari untuk mencari koneksi.', + 'transit.noItinerary': 'Entri ini tidak memiliki rencana perjalanan tersimpan — gunakan "Ubah rute" untuk mencarinya.', }; export default trip; diff --git a/shared/src/i18n/it/trip.ts b/shared/src/i18n/it/trip.ts index ef0e4a949..1a91986ae 100644 --- a/shared/src/i18n/it/trip.ts +++ b/shared/src/i18n/it/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Aggiungi al giorno', 'transit.itinerary': 'Itinerario', 'transit.attribution': 'Dati di routing tramite', + 'transport.modeManual': 'Trasporto manuale', + 'transport.modeAutomated': 'Trasporto automatico', + 'transit.sectionTitle': 'Trasporto pubblico automatico', + 'transit.changeRoute': 'Cambia percorso', + 'transit.journey': 'Viaggio in trasporto pubblico', + 'transit.pickDay': 'Scegli un giorno per cui cercare i collegamenti.', + 'transit.noItinerary': 'Questa voce non ha un itinerario salvato: usa "Cambia percorso" per cercarne uno.', }; export default trip; diff --git a/shared/src/i18n/ja/trip.ts b/shared/src/i18n/ja/trip.ts index 2c0c5be08..db63ef65d 100644 --- a/shared/src/i18n/ja/trip.ts +++ b/shared/src/i18n/ja/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': '日程に追加', 'transit.itinerary': '行程', 'transit.attribution': 'ルートデータ提供:', + 'transport.modeManual': '手動で入力', + 'transport.modeAutomated': '自動で検索', + 'transit.sectionTitle': '公共交通機関の自動検索', + 'transit.changeRoute': '経路を変更', + 'transit.journey': '公共交通機関の経路', + 'transit.pickDay': '接続を検索する日を選択してください。', + 'transit.noItinerary': 'この項目には保存された行程がありません。「経路を変更」から検索してください。', }; export default trip; diff --git a/shared/src/i18n/ko/trip.ts b/shared/src/i18n/ko/trip.ts index 45a9659b3..4a516f521 100644 --- a/shared/src/i18n/ko/trip.ts +++ b/shared/src/i18n/ko/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': '일정에 추가', 'transit.itinerary': '여정', 'transit.attribution': '경로 데이터 제공:', + 'transport.modeManual': '수동 교통편', + 'transport.modeAutomated': '자동 교통편', + 'transit.sectionTitle': '자동 대중교통', + 'transit.changeRoute': '경로 변경', + 'transit.journey': '대중교통 경로', + 'transit.pickDay': '연결편을 검색할 날짜를 선택하세요.', + 'transit.noItinerary': '이 항목에는 저장된 경로가 없습니다. "경로 변경"을 사용하여 검색하세요.', }; export default trip; diff --git a/shared/src/i18n/nl/trip.ts b/shared/src/i18n/nl/trip.ts index 82d3df77c..86f075e15 100644 --- a/shared/src/i18n/nl/trip.ts +++ b/shared/src/i18n/nl/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Toevoegen aan dag', 'transit.itinerary': 'Reisplan', 'transit.attribution': 'Routegegevens via', + 'transport.modeManual': 'Handmatig vervoer', + 'transport.modeAutomated': 'Automatisch vervoer', + 'transit.sectionTitle': 'Automatisch openbaar vervoer', + 'transit.changeRoute': 'Route wijzigen', + 'transit.journey': 'Reis met openbaar vervoer', + 'transit.pickDay': 'Kies een dag om verbindingen voor te zoeken.', + 'transit.noItinerary': 'Deze invoer heeft geen opgeslagen reisplan — gebruik "Route wijzigen" om er een te zoeken.', }; export default trip; diff --git a/shared/src/i18n/pl/trip.ts b/shared/src/i18n/pl/trip.ts index 4305bde15..80d07d3dd 100644 --- a/shared/src/i18n/pl/trip.ts +++ b/shared/src/i18n/pl/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Dodaj do dnia', 'transit.itinerary': 'Plan podróży', 'transit.attribution': 'Dane tras z', + 'transport.modeManual': 'Transport ręczny', + 'transport.modeAutomated': 'Transport automatyczny', + 'transit.sectionTitle': 'Automatyczny transport publiczny', + 'transit.changeRoute': 'Zmień trasę', + 'transit.journey': 'Podróż transportem publicznym', + 'transit.pickDay': 'Wybierz dzień, dla którego wyszukać połączenia.', + 'transit.noItinerary': 'Ten wpis nie ma zapisanej trasy — użyj opcji „Zmień trasę”, aby ją wyszukać.', }; export default trip; diff --git a/shared/src/i18n/ru/trip.ts b/shared/src/i18n/ru/trip.ts index e50575c6e..0e63821f4 100644 --- a/shared/src/i18n/ru/trip.ts +++ b/shared/src/i18n/ru/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Добавить в день', 'transit.itinerary': 'Маршрут', 'transit.attribution': 'Данные о маршрутах через', + 'transport.modeManual': 'Транспорт вручную', + 'transport.modeAutomated': 'Автоматический транспорт', + 'transit.sectionTitle': 'Автоматический общественный транспорт', + 'transit.changeRoute': 'Изменить маршрут', + 'transit.journey': 'Поездка на общественном транспорте', + 'transit.pickDay': 'Выберите день для поиска соединений.', + 'transit.noItinerary': 'Для этой записи нет сохранённого маршрута — нажмите «Изменить маршрут», чтобы найти его.', }; export default trip; diff --git a/shared/src/i18n/sv/trip.ts b/shared/src/i18n/sv/trip.ts index 94ba2e4b4..91d11c98d 100644 --- a/shared/src/i18n/sv/trip.ts +++ b/shared/src/i18n/sv/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Lägg till dag', 'transit.itinerary': 'Reseplan', 'transit.attribution': 'Ruttdata via', + 'transport.modeManual': 'Manuell transport', + 'transport.modeAutomated': 'Automatisk transport', + 'transit.sectionTitle': 'Automatisk kollektivtrafik', + 'transit.changeRoute': 'Ändra resväg', + 'transit.journey': 'Kollektivtrafikresa', + 'transit.pickDay': 'Välj en dag att söka förbindelser för.', + 'transit.noItinerary': 'Den här posten har ingen sparad resplan – använd ”Ändra resväg” för att söka en.', }; export default trip; diff --git a/shared/src/i18n/tr/trip.ts b/shared/src/i18n/tr/trip.ts index de6ed7b74..fed6015a1 100644 --- a/shared/src/i18n/tr/trip.ts +++ b/shared/src/i18n/tr/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Güne ekle', 'transit.itinerary': 'Güzergah', 'transit.attribution': 'Rota verileri:', + 'transport.modeManual': 'Manuel ulaşım', + 'transport.modeAutomated': 'Otomatik ulaşım', + 'transit.sectionTitle': 'Otomatik toplu taşıma', + 'transit.changeRoute': 'Rotayı değiştir', + 'transit.journey': 'Toplu taşıma yolculuğu', + 'transit.pickDay': 'Bağlantı aramak için bir gün seçin.', + 'transit.noItinerary': 'Bu kaydın kayıtlı bir güzergâhı yok — bir tane aramak için "Rotayı değiştir" seçeneğini kullanın.', }; export default trip; diff --git a/shared/src/i18n/uk/trip.ts b/shared/src/i18n/uk/trip.ts index b79e4ce21..0c3990665 100644 --- a/shared/src/i18n/uk/trip.ts +++ b/shared/src/i18n/uk/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Додати до дня', 'transit.itinerary': 'Маршрут', 'transit.attribution': 'Дані маршрутизації через', + 'transport.modeManual': 'Транспорт вручну', + 'transport.modeAutomated': 'Автоматичний транспорт', + 'transit.sectionTitle': 'Автоматичний громадський транспорт', + 'transit.changeRoute': 'Змінити маршрут', + 'transit.journey': 'Поїздка громадським транспортом', + 'transit.pickDay': 'Виберіть день для пошуку сполучень.', + 'transit.noItinerary': 'Для цього запису немає збереженого маршруту — натисніть «Змінити маршрут», щоб знайти його.', }; export default trip; diff --git a/shared/src/i18n/vi/trip.ts b/shared/src/i18n/vi/trip.ts index 1002579b5..d9cec75e5 100644 --- a/shared/src/i18n/vi/trip.ts +++ b/shared/src/i18n/vi/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': 'Thêm vào ngày', 'transit.itinerary': 'Lộ trình', 'transit.attribution': 'Dữ liệu tìm tuyến từ', + 'transport.modeManual': 'Phương tiện thủ công', + 'transport.modeAutomated': 'Phương tiện tự động', + 'transit.sectionTitle': 'Giao thông công cộng tự động', + 'transit.changeRoute': 'Đổi tuyến', + 'transit.journey': 'Hành trình giao thông công cộng', + 'transit.pickDay': 'Chọn một ngày để tìm chuyến kết nối.', + 'transit.noItinerary': 'Mục này chưa có lộ trình được lưu — dùng “Đổi tuyến” để tìm một lộ trình.', }; export default trip; diff --git a/shared/src/i18n/zh-TW/trip.ts b/shared/src/i18n/zh-TW/trip.ts index 56c4d6449..e8c644f85 100644 --- a/shared/src/i18n/zh-TW/trip.ts +++ b/shared/src/i18n/zh-TW/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': '加入當日行程', 'transit.itinerary': '行程', 'transit.attribution': '路線資料來自', + 'transport.modeManual': '手動交通', + 'transport.modeAutomated': '自動交通', + 'transit.sectionTitle': '自動大眾運輸', + 'transit.changeRoute': '變更路線', + 'transit.journey': '大眾運輸行程', + 'transit.pickDay': '請選擇要查詢班次的日期。', + 'transit.noItinerary': '此項目尚未儲存行程——請使用「變更路線」查詢。', }; export default trip; diff --git a/shared/src/i18n/zh/trip.ts b/shared/src/i18n/zh/trip.ts index 310408dba..06844330a 100644 --- a/shared/src/i18n/zh/trip.ts +++ b/shared/src/i18n/zh/trip.ts @@ -69,5 +69,12 @@ const trip: TranslationStrings = { 'transit.addToDay': '添加到当天', 'transit.itinerary': '行程', 'transit.attribution': '路线数据来自', + 'transport.modeManual': '手动添加交通', + 'transport.modeAutomated': '自动查询交通', + 'transit.sectionTitle': '自动公共交通', + 'transit.changeRoute': '更改路线', + 'transit.journey': '公共交通行程', + 'transit.pickDay': '请选择要查询班次的日期。', + 'transit.noItinerary': '此条目尚未保存行程 — 请使用"更改路线"查询班次。', }; export default trip; diff --git a/wiki/Transport-Flights-Trains-Cars.md b/wiki/Transport-Flights-Trains-Cars.md index 6f32bfd45..99cd392c7 100644 --- a/wiki/Transport-Flights-Trains-Cars.md +++ b/wiki/Transport-Flights-Trains-Cars.md @@ -8,12 +8,12 @@ Open the **Transports** tab in the trip planner and click **Add**, or open the p ## Public transit search -Each day header in the plan sidebar has a **transit button** (tram icon) that opens a public-transit route search, powered by [Transitous](https://transitous.org/) — free, open data, no API key or paid provider. (The rename pencil this button replaced moved next to the day name in the day detail panel.) +The **Add transport** dialog has two modes: **Manual transport** (the classic form) and **Automated transport** — a public-transit route search powered by [Transitous](https://transitous.org/), free open data with no API key or paid provider. The **transit button** (tram icon) on each day header opens the dialog straight in the Automated mode. (The rename pencil this button replaced moved next to the day name in the day detail panel.) - Pick **from** and **to** (stop/station search; the day's own places and hotels appear as quick picks), a **depart/arrive** time, and filter by mode: train, subway, tram, bus, ferry, cable car. - Rank the results by **best route**, **fewer transfers**, or **less walking**. - Each result shows local departure/arrival times, duration, transfers, walking time and the line badges in their official colors; expand it for the stop-by-stop breakdown. -- **Add to day** saves the chosen connection as a first-class **transit** entry. It slots into the day timeline at its departure time and shows its line badges, transfers and walking time right in the plan; clicking it opens the full stop-by-stop itinerary (with an Edit action for times/notes). It can be deleted and drag-reordered like any other transport, and editing keeps the stored itinerary as long as the origin and destination are unchanged. +- **Add to day** saves the chosen connection as a first-class **transit** entry. It slots into the day timeline at its departure time and shows its line badges, transfers and walking time right in the plan. Clicking it opens the **journey view**: the full stop-by-stop itinerary together with the editable booking fields (title, status, confirmation code, notes), a **Change route** action that re-runs the search and replaces the itinerary, and delete. In the Transports tab these journeys appear in their own **Automated public transit** section. Self-hosters can point the `TRANSIT_API_URL` environment variable at their own MOTIS instance.