diff --git a/client/src/pages/tripPlanner/plannerTypes.ts b/client/src/pages/tripPlanner/plannerTypes.ts new file mode 100644 index 000000000..7a0ba8421 --- /dev/null +++ b/client/src/pages/tripPlanner/plannerTypes.ts @@ -0,0 +1,31 @@ +import type { useSearchParams } from 'react-router' +import type { useToast } from '../../components/shared/Toast' +import type { usePlannerHistory } from '../../hooks/usePlannerHistory' +import type { useTranslation } from '../../i18n' +import type { useCanDo } from '../../store/permissionsStore' +import type { TripStoreState } from '../../store/tripStore' + +/* + * The types the planner's sub-hooks take their options in. Each one is read off the + * hook or store that produces the value rather than written out by hand, so a + * sub-hook sees exactly what useTripPlanner holds and the planner's return type + * cannot drift when a piece of it moves into a sub-hook. + */ + +export type Translate = ReturnType['t'] +export type PlannerToast = ReturnType +export type Can = ReturnType +export type PlannerHistory = ReturnType +/** The `[searchParams, setSearchParams]` pair, read once by useTripPlanner and handed down. */ +export type PlannerSearchParams = ReturnType + +/** The core values most planner sub-hooks read. */ +export interface PlannerBase { + tripId: number + trip: TripStoreState['trip'] + can: Can + /** The store's actions as snapshotted once by useTripPlanner, never read again. */ + tripActions: TripStoreState + toast: PlannerToast + t: Translate +} diff --git a/client/src/pages/tripPlanner/usePlannerDialogs.test.ts b/client/src/pages/tripPlanner/usePlannerDialogs.test.ts new file mode 100644 index 000000000..696702641 --- /dev/null +++ b/client/src/pages/tripPlanner/usePlannerDialogs.test.ts @@ -0,0 +1,251 @@ +// FE-TP-DIALOGS-001 to FE-TP-DIALOGS-012 +// +// The planner's dialogs and the URL intents that open them, driven straight through +// usePlannerDialogs. The search params come in as options, so each test owns the URL +// and can watch what the hook writes back to it. +import { act, renderHook, waitFor } from '@testing-library/react' +import { airtrailApi } from '../../api/client' +import { useTripStore, type TripStoreState } from '../../store/tripStore' +import { resetAllStores } from '../../../tests/helpers/store' +import { buildPlace, buildReservation } from '../../../tests/helpers/factories' +import { usePlannerDialogs } from './usePlannerDialogs' + +const env = vi.hoisted(() => ({ airTrailAvailable: false })) + +vi.mock('../../hooks/useAirtrailConnection', () => ({ + useAirtrailConnection: () => ({ + airtrailEnabled: env.airTrailAvailable, + connected: env.airTrailAvailable, + available: env.airTrailAvailable, + loading: false, + }), +})) + +type Updater = (prev: URLSearchParams) => URLSearchParams + +let url: URLSearchParams +const setSearchParams = vi.fn((update: Updater, _opts?: { replace?: boolean }) => { + url = update(new URLSearchParams(url)) +}) +let tripActions: TripStoreState + +interface Props { tripId: number; searchParams: URLSearchParams } + +function renderDialogs(initial: Partial = {}) { + const initialProps: Props = { tripId: 42, searchParams: new URLSearchParams(), ...initial } + return renderHook( + (props: Props) => usePlannerDialogs({ ...props, tripActions, setSearchParams }), + { initialProps }, + ) +} + +beforeEach(() => { + vi.restoreAllMocks() + resetAllStores() + env.airTrailAvailable = false + url = new URLSearchParams() + setSearchParams.mockClear() + tripActions = { ...useTripStore.getState(), loadReservations: vi.fn(async () => undefined) } +}) + +describe('usePlannerDialogs', () => { + it('FE-TP-DIALOGS-001: opens with every dialog closed and nothing being edited', () => { + const { result } = renderDialogs() + const d = result.current + + expect(d.showPlaceForm).toBe(false) + expect(d.editingPlace).toBeNull() + expect(d.prefillCoords).toBeNull() + expect(d.placeFormDayId).toBeNull() + expect(d.placeFormPosition).toBeNull() + expect(d.serviceStopForm).toBe(false) + expect(d.serviceStopKind).toBeNull() + expect(d.stopDraft).toBeNull() + expect(d.stayRelease).toBeNull() + expect(d.showReservationModal).toBe(false) + expect(d.showTransportModal).toBe(false) + expect(d.bookingImportKind).toBe('bookings') + expect(d.bookingImportAvailable).toBe(false) + expect(d.airTrailAvailable).toBe(false) + expect(d.bookingDetailOpen).toBeNull() + expect(setSearchParams).not.toHaveBeenCalled() + }) + + it('FE-TP-DIALOGS-002: ?create=place opens a blank place form and leaves the rest of the URL alone', () => { + const { result, rerender } = renderDialogs() + act(() => { + result.current.setEditingPlace(buildPlace({ id: 5 })) + result.current.setEditingAssignmentId(9) + result.current.setPlaceFormDayId(3) + }) + + url = new URLSearchParams('create=place&keep=1') + rerender({ tripId: 42, searchParams: url }) + + expect(result.current.showPlaceForm).toBe(true) + expect(result.current.editingPlace).toBeNull() + expect(result.current.editingAssignmentId).toBeNull() + expect(result.current.placeFormDayId).toBeNull() + expect(setSearchParams).toHaveBeenCalledTimes(1) + expect(setSearchParams.mock.calls[0][1]).toEqual({ replace: true }) + expect(url.toString()).toBe('keep=1') + }) + + it('FE-TP-DIALOGS-003: ?create=reservation opens a fresh booking editor', () => { + const { result, rerender } = renderDialogs() + act(() => { + result.current.setEditingReservation(buildReservation({ id: 4 })) + result.current.setBookingForAssignmentId(8) + }) + + url = new URLSearchParams('create=reservation') + rerender({ tripId: 42, searchParams: url }) + + expect(result.current.showReservationModal).toBe(true) + expect(result.current.editingReservation).toBeNull() + expect(result.current.bookingForAssignmentId).toBeNull() + expect(result.current.showTransportModal).toBe(false) + expect(url.has('create')).toBe(false) + }) + + it('FE-TP-DIALOGS-004: ?create=transport opens a fresh transport editor', () => { + const { result, rerender } = renderDialogs() + act(() => { + result.current.setEditingTransport(buildReservation({ id: 6, type: 'train' })) + result.current.setTransportModalDayId(2) + }) + + url = new URLSearchParams('create=transport') + rerender({ tripId: 42, searchParams: url }) + + expect(result.current.showTransportModal).toBe(true) + expect(result.current.editingTransport).toBeNull() + expect(result.current.transportModalDayId).toBeNull() + expect(result.current.showReservationModal).toBe(false) + expect(url.has('create')).toBe(false) + }) + + it('FE-TP-DIALOGS-005: an intent nobody handles opens nothing and stays in the URL', () => { + url = new URLSearchParams('create=expense') + const { result } = renderDialogs({ searchParams: url }) + + expect(result.current.showPlaceForm).toBe(false) + expect(result.current.showReservationModal).toBe(false) + expect(result.current.showTransportModal).toBe(false) + expect(setSearchParams).not.toHaveBeenCalled() + expect(url.get('create')).toBe('expense') + }) + + it('FE-TP-DIALOGS-006: ?tab= is dropped from the URL once the planner has read it', () => { + url = new URLSearchParams('tab=dateien&keep=1') + const { result } = renderDialogs({ searchParams: url }) + + expect(setSearchParams).toHaveBeenCalledTimes(1) + expect(setSearchParams.mock.calls[0][1]).toEqual({ replace: true }) + expect(url.toString()).toBe('keep=1') + expect(result.current.showPlaceForm).toBe(false) + }) + + it('FE-TP-DIALOGS-007: the insert position lives only as long as the place form is open', () => { + const { result } = renderDialogs() + act(() => { + result.current.setShowPlaceForm(true) + result.current.setPlaceFormPosition(3) + }) + expect(result.current.placeFormPosition).toBe(3) + + act(() => { result.current.setShowPlaceForm(false) }) + expect(result.current.placeFormPosition).toBeNull() + }) + + it('FE-TP-DIALOGS-008: an AirTrail sync that changed something reloads the bookings, once per trip', async () => { + env.airTrailAvailable = true + const sync = vi.spyOn(airtrailApi, 'sync').mockResolvedValue({ changed: 2 }) + + const { result, rerender } = renderDialogs() + expect(result.current.airTrailAvailable).toBe(true) + await waitFor(() => expect(tripActions.loadReservations).toHaveBeenCalledWith(42)) + expect(sync).toHaveBeenCalledTimes(1) + + rerender({ tripId: 42, searchParams: new URLSearchParams() }) + expect(sync).toHaveBeenCalledTimes(1) + + rerender({ tripId: 43, searchParams: new URLSearchParams() }) + await waitFor(() => expect(tripActions.loadReservations).toHaveBeenCalledWith(43)) + expect(sync).toHaveBeenCalledTimes(2) + }) + + it('FE-TP-DIALOGS-009: a sync with nothing new, no answer or a failure leaves the bookings alone', async () => { + env.airTrailAvailable = true + const sync = vi.spyOn(airtrailApi, 'sync') + .mockResolvedValueOnce({ changed: 0 }) + .mockResolvedValueOnce(null as unknown as { changed: number }) + .mockRejectedValueOnce(new Error('offline')) + + const { rerender } = renderDialogs({ tripId: 1 }) + rerender({ tripId: 2, searchParams: new URLSearchParams() }) + rerender({ tripId: 3, searchParams: new URLSearchParams() }) + + await waitFor(() => expect(sync).toHaveBeenCalledTimes(3)) + await act(async () => { await Promise.resolve() }) + expect(tripActions.loadReservations).not.toHaveBeenCalled() + }) + + it('FE-TP-DIALOGS-010: without an AirTrail connection or a trip nothing is synced', () => { + const sync = vi.spyOn(airtrailApi, 'sync') + renderDialogs() + expect(sync).not.toHaveBeenCalled() + + env.airTrailAvailable = true + renderDialogs({ tripId: Number.NaN }) + expect(sync).not.toHaveBeenCalled() + }) + + it('FE-TP-DIALOGS-011: the full transport editor opens on a saved entry without a day', () => { + const { result } = renderDialogs() + const entry = buildReservation({ id: 12, type: 'transit' }) + act(() => { + result.current.setTransitPrefill({ from: null, to: null }) + result.current.setTransitJourney(entry) + result.current.setTransportModalAutomated(true) + }) + + act(() => { result.current.openTransportEditor(entry) }) + + expect(result.current.editingTransport).toBe(entry) + expect(result.current.transportModalDayId).toBeNull() + expect(result.current.transportModalAutomated).toBe(false) + expect(result.current.transitPrefill).toBeNull() + expect(result.current.transitJourney).toBeNull() + expect(result.current.showTransportModal).toBe(true) + }) + + it('FE-TP-DIALOGS-012: changing a route seeds whichever end the journey knows', () => { + const { result } = renderDialogs() + const onlyFrom = buildReservation({ + id: 13, type: 'transit', day_id: 4, + endpoints: [{ role: 'from', name: 'Bern', lat: 46.9, lng: 7.4 }] as never, + }) + + act(() => { result.current.changeTransitRoute(onlyFrom) }) + + expect(result.current.transitPrefill).toEqual({ from: { name: 'Bern', lat: 46.9, lng: 7.4 }, to: null }) + expect(result.current.editingTransport).toBe(onlyFrom) + expect(result.current.transportModalDayId).toBe(4) + expect(result.current.transportModalAutomated).toBe(true) + expect(result.current.transitJourney).toBeNull() + expect(result.current.showTransportModal).toBe(true) + + const onlyTo = buildReservation({ + id: 14, type: 'transit', + endpoints: [{ role: 'to', name: 'Thun', lat: 46.75, lng: 7.63 }] as never, + }) + act(() => { result.current.changeTransitRoute(onlyTo) }) + + expect(result.current.transitPrefill).toEqual({ from: null, to: { name: 'Thun', lat: 46.75, lng: 7.63 } }) + expect(result.current.transportModalDayId).toBeNull() + + act(() => { result.current.changeTransitRoute(buildReservation({ id: 15, type: 'transit' })) }) + expect(result.current.transitPrefill).toEqual({ from: null, to: null }) + }) +}) diff --git a/client/src/pages/tripPlanner/usePlannerDialogs.ts b/client/src/pages/tripPlanner/usePlannerDialogs.ts new file mode 100644 index 000000000..11f504df1 --- /dev/null +++ b/client/src/pages/tripPlanner/usePlannerDialogs.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { RoadtripStopType } from '@trek/shared' +import { airtrailApi } from '../../api/client' +import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup' +import { useAirtrailConnection } from '../../hooks/useAirtrailConnection' +import type { Place, Reservation } from '../../types' +import type { PlannerBase, PlannerSearchParams } from './plannerTypes' + +interface PlannerDialogsOptions extends Pick { + searchParams: PlannerSearchParams[0] + /** Passed down rather than read again: a second useSearchParams call is a second setter. */ + setSearchParams: PlannerSearchParams[1] +} + +/** + * The planner's URL intents and the dialogs they open: the place form, the service + * stop form, the stop popup, the stay release question, the trip and member dialogs, + * the booking, transport and transit editors, the two importers and the booking detail. + * + * Its effects run where useTripPlanner calls it, in the order they always had. That is + * also why the ?tab= cleanup sits here although it belongs to the tabs: it runs after + * the ?create=place intent and before the AirTrail sync. + */ +export function usePlannerDialogs(options: PlannerDialogsOptions) { + const { tripId, tripActions, searchParams, setSearchParams } = options + const [showPlaceForm, setShowPlaceForm] = useState(false) + const [editingPlace, setEditingPlace] = useState(null) + const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null>(null) + const [editingAssignmentId, setEditingAssignmentId] = useState(null) + // Day context of the open form. Set only by the day-scoped entry points (the + // mobile day toolbar, a long-press on the mobile map); every other opener + // clears it, so a place added from the pool still lands in the pool (#1998). + const [placeFormDayId, setPlaceFormDayId] = useState(null) + /** + * Where in the day the place being added belongs, when the caller knows. + * Null means the old behaviour: the server appends it at the end. + */ + const [placeFormPosition, setPlaceFormPosition] = useState(null) + // The position belongs to the form it was opened with and to nothing after it. The + // day-scoped openers set the day, the form's close clears the coordinates, but the + // position is written by one opener and read by every save, so a stop handed to the + // form from the corridor popup once left the next add from any day landing at that + // same index. Tied to the form being open, the only time it means anything. + useEffect(() => { + if (!showPlaceForm) setPlaceFormPosition(null) + }, [showPlaceForm]) + /** + * Whether the open place form is asking for a service stop on the drive. + * + * Only the road trip's "add manually" sets it, and everything that opens the form for + * anything else clears it, so the ordinary add, the edit and the corridor hit are the + * form they have always been. + */ + const [serviceStopForm, setServiceStopForm] = useState(false) + /** + * The kind that form opens on, taken from what the corridor panel was looking for. + * + * Beside the flag rather than inside it, because it is written by the same click and + * read by the same memo, and a second piece of state is cheaper to follow than a flag + * that is sometimes a boolean and sometimes an object. + */ + const [serviceStopKind, setServiceStopKind] = useState(null) + /** + * The corridor hit waiting to become a stop, while the small popup is open. + * + * The full place form is the wrong question for a petrol station: category, price, + * photo, notes and files are all empty for one. So in road trip mode a hit opens this + * instead, and the form stays one click away behind "more details". + */ + const [stopDraft, setStopDraft] = useState(null) + /** + * A booked night the popup was asked to turn into a pause, waiting for a yes. + * + * The switch in the popup reads like a change of stop kind, but the night is a + * booking row, and the server takes the reservation and the expense written against + * it down with that row. It is the one write the popup can make that nothing brings + * back, so it is the one that asks first. The name and the booking title are read + * once, here, so the dialog does not have to know where either lives. + */ + const [stayRelease, setStayRelease] = useState<{ + stop: { stopType: RoadtripStopType | null; dwellMinutes: number } + name: string + booking: string | null + } | null>(null) + const [reservationModalDayId, setReservationModalDayId] = useState(null) + + // The bottom-nav "+" opens the new-place form via ?create=place. + useEffect(() => { + if (searchParams.get('create') === 'place') { + setEditingPlace(null); setEditingAssignmentId(null); setPlaceFormDayId(null); setShowPlaceForm(true) + setSearchParams(p => { p.delete('create'); return p }, { replace: true }) + } + }, [searchParams]) + + // ?tab= has done its job in the activeTab initializer in useTripPlanner, so drop it + // and the URL stops claiming a tab the user may have since switched away from. The + // session memory keeps the choice across a reload. It runs here, right after the + // ?create=place intent, because that is the order the two effects have always had. + useEffect(() => { + if (searchParams.get('tab') === null) return + setSearchParams(p => { p.delete('tab'); return p }, { replace: true }) + }, [searchParams]) + const [showTripForm, setShowTripForm] = useState(false) + const [showMembersModal, setShowMembersModal] = useState(false) + const [showReservationModal, setShowReservationModal] = useState(false) + const [editingReservation, setEditingReservation] = useState(null) + const [showBookingImport, setShowBookingImport] = useState(false) + // Which tab opened the importer. Only ever a tie-breaker: see openImportItem. + const [bookingImportKind, setBookingImportKind] = useState<'transports' | 'bookings'>('bookings') + const [bookingImportAvailable, setBookingImportAvailable] = useState(false) + const { available: airTrailAvailable } = useAirtrailConnection() + const [showAirTrailImport, setShowAirTrailImport] = useState(false) + // Pull this user's AirTrail edits as soon as they open the trip, so changes + // made in AirTrail show up without waiting for the background poll. + const airtrailSyncedRef = useRef(null) + useEffect(() => { + if (!airTrailAvailable || !tripId || airtrailSyncedRef.current === tripId) return + airtrailSyncedRef.current = tripId + airtrailApi.sync() + .then(r => { if (r && r.changed > 0) void tripActions.loadReservations(tripId) }) + .catch(() => {}) + }, [airTrailAvailable, tripId, tripActions]) + const [bookingForAssignmentId, setBookingForAssignmentId] = useState(null) + const [showTransportModal, setShowTransportModal] = useState(false) + const [editingTransport, setEditingTransport] = useState(null) + const [transportModalDayId, setTransportModalDayId] = 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; time?: string | null } | null>(null) + const [transitJourney, setTransitJourney] = useState(null) + // The booking whose detail is open over the desktop plan: a day row, a rental pill, + // the inspector's booking card, a map endpoint or the road-trip rail opened it. + // Held by id, so the dialog shows the store's copy and goes away by itself once the + // booking is deleted. `fromDayList` remembers that a row of the day list opened it, + // whose editor also asks for day_edit. + const [bookingDetailOpen, setBookingDetailOpen] = useState<{ id: number; fromDayList: boolean } | null>(null) + + // The full transport editor on a saved entry. For a transit journey that is where + // travellers, costs, files, code and status live; an unchanged-endpoints save keeps + // the stored itinerary (#2148). + const openTransportEditor = useCallback((r: Reservation) => { + setEditingTransport(r) + setTransportModalDayId(r.day_id ?? null) + setTransportModalAutomated(false) + setTransitPrefill(null) + setTransitJourney(null) + setShowTransportModal(true) + }, []) + // Re-enters the transit search seeded with a journey's route; the journey is + // REPLACED on save (editingTransport drives handleSaveTransport's update path). + const changeTransitRoute = useCallback((r: Reservation) => { + const eps = r.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(r) + setTransportModalDayId(r.day_id ?? null) + setTransportModalAutomated(true) + setTransitJourney(null) + setShowTransportModal(true) + }, []) + + // The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs + // it opens the booking / transport modal via ?create=reservation|transport + // (place is handled above, expense in CostsPanel). #1349 + useEffect(() => { + const intent = searchParams.get('create') + if (intent === 'reservation') { + setEditingReservation(null); setBookingForAssignmentId(null); setShowReservationModal(true) + setSearchParams(p => { p.delete('create'); return p }, { replace: true }) + } else if (intent === 'transport') { + setEditingTransport(null); setTransportModalDayId(null); setShowTransportModal(true) + setSearchParams(p => { p.delete('create'); return p }, { replace: true }) + } + }, [searchParams]) + + return { + showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace, prefillCoords, setPrefillCoords, + editingAssignmentId, setEditingAssignmentId, placeFormDayId, setPlaceFormDayId, + placeFormPosition, setPlaceFormPosition, serviceStopForm, setServiceStopForm, serviceStopKind, setServiceStopKind, + stopDraft, setStopDraft, stayRelease, setStayRelease, reservationModalDayId, setReservationModalDayId, + showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, + showReservationModal, setShowReservationModal, editingReservation, setEditingReservation, + showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, + bookingImportAvailable, setBookingImportAvailable, airTrailAvailable, showAirTrailImport, setShowAirTrailImport, + bookingForAssignmentId, setBookingForAssignmentId, showTransportModal, setShowTransportModal, + editingTransport, setEditingTransport, transportModalDayId, setTransportModalDayId, + transportModalAutomated, setTransportModalAutomated, transitPrefill, setTransitPrefill, transitJourney, setTransitJourney, + bookingDetailOpen, setBookingDetailOpen, openTransportEditor, changeTransitRoute, + } +} + +export type PlannerDialogs = ReturnType diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 3bc599396..75e3c07d4 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -12,7 +12,7 @@ import { useToast } from '../../components/shared/Toast' import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Mountain, Route } from 'lucide-react' import { resolvePluginIcon } from '../../components/shared/PluginIcon' import { useTranslation, translateApiError } from '../../i18n' -import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client' +import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, mapsApi, placesApi } from '../../api/client' import { getDayOrder } from '../../utils/dayOrder' import { TRANSPORT_TYPES, timedSlot } from '../../utils/dayMerge' import { isOvernightCategory } from '../../components/Roadtrip/stopKinds' @@ -57,7 +57,6 @@ import { reanchorByStopOrder, isServiceStopType, refuelStopTypeFor, reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel' import type { ManualStopTarget, ServiceStopMode } from '../../components/Roadtrip/manualStop' -import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup' import type { StayDraft } from '../../components/Roadtrip/RoadtripStayModal' import { inspectorStay } from '../../components/Roadtrip/stayReading' import { MAX_TRIP_DAYS, normalizePlaceWebsite, type RoadtripStopType } from '@trek/shared' @@ -65,7 +64,6 @@ import { usePlaceSelection } from '../../hooks/usePlaceSelection' import { useTourPlaceIds } from '../../hooks/useTourPlaceIds' import { useAddonStore } from '../../store/addonStore' import { usePlannerHistory } from '../../hooks/usePlannerHistory' -import { useAirtrailConnection } from '../../hooks/useAirtrailConnection' import { useIsTouch } from '../../hooks/useIsTouch' import { usePluginStore } from '../../store/pluginStore' import type { Accommodation, Assignment, TripMember, Day, Place, Reservation } from '../../types' @@ -87,6 +85,7 @@ import { matchesPlacesFilter } from '../../utils/placesFilter' import { pendingStayPlaceIds } from '../../utils/pendingStays' import { useDayDelete } from './useDayDelete' import { useDayAdd } from './useDayAdd' +import { usePlannerDialogs } from './usePlannerDialogs' import { useIsPhone } from '../../mobile/useIsPhone' /** Stable empty list so the road trip hook stays inert while its mode is off. */ @@ -94,12 +93,16 @@ const EMPTY_DAYS: Day[] = [] const EMPTY_RESERVATIONS: Reservation[] = [] /** - * Trip planner page logic — the big one. Owns the trip store wiring, addon + * Trip planner page logic, the big one. Owns the trip store wiring, addon * gating, accommodations/members loading, the tab + resizable-panel + selection * state, every place/assignment/reservation/transport CRUD handler (with undo), * the map filters/derivations and the splash gate. TripPlannerPage stays a * wiring container that lays out the day/map/places panes and modals. * Behaviour is identical to the previous in-component logic. + * + * Self-contained parts live in the use* sub-hooks beside this file. Each one is + * called where its code used to sit, so React still runs every effect in the + * order it always did. */ export function useTripPlanner() { const { id } = useParams<{ id: string }>() @@ -335,159 +338,26 @@ export function useTripPlanner() { // The day's "+" can ask for a new stay: the details panel opens on that day and // takes the request once, then hands it back so a later opening stays plain. const [stayPickerDayId, setStayPickerDayId] = useState(null) - const [showPlaceForm, setShowPlaceForm] = useState(false) - const [editingPlace, setEditingPlace] = useState(null) - const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number; category?: string } | null>(null) - const [editingAssignmentId, setEditingAssignmentId] = useState(null) - // Day context of the open form. Set only by the day-scoped entry points (the - // mobile day toolbar, a long-press on the mobile map); every other opener - // clears it, so a place added from the pool still lands in the pool (#1998). - const [placeFormDayId, setPlaceFormDayId] = useState(null) - /** - * Where in the day the place being added belongs, when the caller knows. - * Null means the old behaviour: the server appends it at the end. - */ - const [placeFormPosition, setPlaceFormPosition] = useState(null) - // The position belongs to the form it was opened with and to nothing after it. The - // day-scoped openers set the day, the form's close clears the coordinates, but the - // position is written by one opener and read by every save, so a stop handed to the - // form from the corridor popup once left the next add from any day landing at that - // same index. Tied to the form being open, the only time it means anything. - useEffect(() => { - if (!showPlaceForm) setPlaceFormPosition(null) - }, [showPlaceForm]) - /** - * Whether the open place form is asking for a service stop on the drive. - * - * Only the road trip's "add manually" sets it, and everything that opens the form for - * anything else clears it, so the ordinary add, the edit and the corridor hit are the - * form they have always been. - */ - const [serviceStopForm, setServiceStopForm] = useState(false) - /** - * The kind that form opens on, taken from what the corridor panel was looking for. - * - * Beside the flag rather than inside it, because it is written by the same click and - * read by the same memo, and a second piece of state is cheaper to follow than a flag - * that is sometimes a boolean and sometimes an object. - */ - const [serviceStopKind, setServiceStopKind] = useState(null) - /** - * The corridor hit waiting to become a stop, while the small popup is open. - * - * The full place form is the wrong question for a petrol station — category, price, - * photo, notes and files are all empty for one — so in road trip mode a hit opens this - * instead, and the form stays one click away behind "more details". - */ - const [stopDraft, setStopDraft] = useState(null) - /** - * A booked night the popup was asked to turn into a pause, waiting for a yes. - * - * The switch in the popup reads like a change of stop kind, but the night is a - * booking row, and the server takes the reservation and the expense written against - * it down with that row. It is the one write the popup can make that nothing brings - * back, so it is the one that asks first. The name and the booking title are read - * once, here, so the dialog does not have to know where either lives. - */ - const [stayRelease, setStayRelease] = useState<{ - stop: { stopType: RoadtripStopType | null; dwellMinutes: number } - name: string - booking: string | null - } | null>(null) - const [reservationModalDayId, setReservationModalDayId] = useState(null) - - // The bottom-nav "+" opens the new-place form via ?create=place. - useEffect(() => { - if (searchParams.get('create') === 'place') { - setEditingPlace(null); setEditingAssignmentId(null); setPlaceFormDayId(null); setShowPlaceForm(true) - setSearchParams(p => { p.delete('create'); return p }, { replace: true }) - } - }, [searchParams]) - - // ?tab= has done its job in the state initializer above — drop it so the URL - // stops claiming a tab the user may have since switched away from. The session - // memory keeps the choice across a reload. - useEffect(() => { - if (searchParams.get('tab') === null) return - setSearchParams(p => { p.delete('tab'); return p }, { replace: true }) - }, [searchParams]) - const [showTripForm, setShowTripForm] = useState(false) - const [showMembersModal, setShowMembersModal] = useState(false) - const [showReservationModal, setShowReservationModal] = useState(false) - const [editingReservation, setEditingReservation] = useState(null) - const [showBookingImport, setShowBookingImport] = useState(false) - // Which tab opened the importer. Only ever a tie-breaker — see openImportItem. - const [bookingImportKind, setBookingImportKind] = useState<'transports' | 'bookings'>('bookings') - const [bookingImportAvailable, setBookingImportAvailable] = useState(false) - const { available: airTrailAvailable } = useAirtrailConnection() - const [showAirTrailImport, setShowAirTrailImport] = useState(false) - // Pull this user's AirTrail edits as soon as they open the trip, so changes - // made in AirTrail show up without waiting for the background poll. - const airtrailSyncedRef = useRef(null) - useEffect(() => { - if (!airTrailAvailable || !tripId || airtrailSyncedRef.current === tripId) return - airtrailSyncedRef.current = tripId - airtrailApi.sync() - .then(r => { if (r && r.changed > 0) tripActions.loadReservations(tripId) }) - .catch(() => {}) - }, [airTrailAvailable, tripId, tripActions]) - const [bookingForAssignmentId, setBookingForAssignmentId] = useState(null) - const [showTransportModal, setShowTransportModal] = useState(false) - const [editingTransport, setEditingTransport] = useState(null) - const [transportModalDayId, setTransportModalDayId] = 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; time?: string | null } | null>(null) - const [transitJourney, setTransitJourney] = useState(null) - // The booking whose detail is open over the desktop plan: a day row, a rental pill, - // the inspector's booking card, a map endpoint or the road-trip rail opened it. - // Held by id, so the dialog shows the store's copy and goes away by itself once the - // booking is deleted. `fromDayList` remembers that a row of the day list opened it, - // whose editor also asks for day_edit. - const [bookingDetailOpen, setBookingDetailOpen] = useState<{ id: number; fromDayList: boolean } | null>(null) - - // The full transport editor on a saved entry. For a transit journey that is where - // travellers, costs, files, code and status live; an unchanged-endpoints save keeps - // the stored itinerary (#2148). - const openTransportEditor = useCallback((r: Reservation) => { - setEditingTransport(r) - setTransportModalDayId(r.day_id ?? null) - setTransportModalAutomated(false) - setTransitPrefill(null) - setTransitJourney(null) - setShowTransportModal(true) - }, []) - // Re-enters the transit search seeded with a journey's route; the journey is - // REPLACED on save (editingTransport drives handleSaveTransport's update path). - const changeTransitRoute = useCallback((r: Reservation) => { - const eps = r.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(r) - setTransportModalDayId(r.day_id ?? null) - setTransportModalAutomated(true) - setTransitJourney(null) - setShowTransportModal(true) - }, []) - - // The bottom-nav "+" is context-aware per tab: on the Bookings / Transports tabs - // it opens the booking / transport modal via ?create=reservation|transport - // (place is handled above, expense in CostsPanel). #1349 - useEffect(() => { - const intent = searchParams.get('create') - if (intent === 'reservation') { - setEditingReservation(null); setBookingForAssignmentId(null); setShowReservationModal(true) - setSearchParams(p => { p.delete('create'); return p }, { replace: true }) - } else if (intent === 'transport') { - setEditingTransport(null); setTransportModalDayId(null); setShowTransportModal(true) - setSearchParams(p => { p.delete('create'); return p }, { replace: true }) - } - }, [searchParams]) + const plannerDialogs = usePlannerDialogs({ tripId, tripActions, searchParams, setSearchParams }) + // The dialog state this hook reads further down as well as returning it. + const { + setShowPlaceForm, editingPlace, setEditingPlace, setPrefillCoords, editingAssignmentId, setEditingAssignmentId, + placeFormDayId, setPlaceFormDayId, placeFormPosition, setPlaceFormPosition, + serviceStopForm, setServiceStopForm, serviceStopKind, setServiceStopKind, + stopDraft, setStopDraft, stayRelease, setStayRelease, setBookingImportAvailable, + setShowReservationModal, editingReservation, setEditingReservation, + setShowTransportModal, editingTransport, setEditingTransport, setTransportModalDayId, + bookingDetailOpen, setBookingDetailOpen, openTransportEditor, changeTransitRoute, + } = plannerDialogs + // The dialog state that only passes through to the shells. + const { + showPlaceForm, prefillCoords, reservationModalDayId, setReservationModalDayId, + showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, showReservationModal, + showBookingImport, setShowBookingImport, bookingImportKind, setBookingImportKind, bookingImportAvailable, + airTrailAvailable, showAirTrailImport, setShowAirTrailImport, bookingForAssignmentId, setBookingForAssignmentId, + showTransportModal, transportModalDayId, transportModalAutomated, setTransportModalAutomated, + transitPrefill, setTransitPrefill, transitJourney, setTransitJourney, + } = plannerDialogs // Review-before-save import: each parsed item pre-fills the normal edit modal so // the user checks/fixes it, then saves. A ref drives the queue (no stale closures). const [reservationPrefill, setReservationPrefill] = useState(null) @@ -642,7 +512,7 @@ export function useTripPlanner() { // there (booking-import.service.ts), so gating the entry point on kitinerary // alone hid a working feature on LLM-only instances (#2007). healthApi.features().then(f => setBookingImportAvailable(f.bookingImport || f.aiParsing)).catch(() => {}) - }, []) + }, [setBookingImportAvailable]) const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null // Per-trip route-visibility preference — null means "never touched", which @@ -958,7 +828,7 @@ export function useTripPlanner() { setPrefillCoords(prev => prev ? { ...prev, name: data.name || '', address: data.address || '' } : prev) } } catch { /* best effort */ } - }, [placeLang]) + }, [placeLang, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setPrefillCoords, setServiceStopForm, setShowPlaceForm]) // Open the Add-Place form pre-filled from an OSM "explore" POI marker — all the // data already comes from the POI, so no reverse-geocode is needed. @@ -998,7 +868,7 @@ export function useTripPlanner() { setPlaceFormPosition(position ?? null) setServiceStopForm(false) setShowPlaceForm(true) - }, [trip]) + }, [trip, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setPlaceFormPosition, setPrefillCoords, setServiceStopForm, setShowPlaceForm]) /** * Adding a POI straight off the map, with the day it belongs to. @@ -1062,7 +932,7 @@ export function useTripPlanner() { const selected = roadtripRoutes.days.find(d => d.dayId === roadtripDayId) const target = selected && roadtripInsertion(selected, selected.stops.length) openAddPlaceFromPoi(poi, roadtripFeedActive ? target?.dayId ?? roadtripDayId : undefined) - }, [openAddPlaceFromPoi, roadtripFeedActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, roadtripRoutes.days, overnightOptions, can, trip]) + }, [openAddPlaceFromPoi, roadtripFeedActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, roadtripRoutes.days, overnightOptions, can, trip, setStopDraft]) /** * The stops of a day as the road trip counts them, in the order it drives them. @@ -1327,14 +1197,14 @@ export function useTripPlanner() { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations, tripAccommodations, reservations]) + }, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations, tripAccommodations, reservations, setStayRelease, setStopDraft]) /** The yes to the question above: the same save, this time allowed to drop the night. */ const confirmStayRelease = useCallback(async () => { const pending = stayRelease setStayRelease(null) if (pending) await saveStopDraft(pending.stop, { releaseStay: true }) - }, [stayRelease, saveStopDraft]) + }, [stayRelease, saveStopDraft, setStayRelease]) const saveStopDraftAsNight = useCallback(async ({ endDayId, checkIn, checkOut }: { endDayId: number @@ -1396,7 +1266,7 @@ export function useTripPlanner() { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - }, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations]) + }, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations, setStopDraft]) /** * Turns a stop on the drive into a pause, or back into a destination. @@ -2046,7 +1916,7 @@ export function useTripPlanner() { setServiceStopKind(kind) setServiceStopForm(true) setShowPlaceForm(true) - }, [can, trip]) + }, [can, trip, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setPlaceFormPosition, setPrefillCoords, setServiceStopForm, setServiceStopKind, setShowPlaceForm]) /** * What the place form needs to ask for a service stop, or null for every other use. @@ -2189,7 +2059,7 @@ export function useTripPlanner() { if (!anchor) return refuel.close() setStopDraft({ poi, ...roadtripInsertion(day, at)!, dayNumber: day.dayNumber }) - }, [roadtripRoutes.days, refuel, can, trip]) + }, [roadtripRoutes.days, refuel, can, trip, setStopDraft]) /** Removing a via lets the drive take the direct road again. */ const removeRoadtripVia = useCallback(async (dayId: number, id: number) => { @@ -2228,7 +2098,7 @@ export function useTripPlanner() { ...insert, dayNumber: day.dayNumber, }) - }, [roadtripCorridor, can, trip]) + }, [roadtripCorridor, can, trip, setStopDraft]) /** Hands the draft over to the full form, keeping the day and the position it worked out. */ const stopDraftToForm = useCallback((stop?: { stopType: RoadtripStopType | null; dwellMinutes: number }) => { @@ -2248,7 +2118,7 @@ export function useTripPlanner() { // behind is what turned a fuel stop into a numbered destination on the way to the // full form, silently and in every total. openAddPlaceFromPoi(poi, dayId, position, stop ?? null) - }, [stopDraft, openAddPlaceFromPoi, places, assignments]) + }, [stopDraft, openAddPlaceFromPoi, places, assignments, setEditingAssignmentId, setEditingPlace, setShowPlaceForm, setStopDraft]) /** * A place on this trip that came from the same OSM object. @@ -2396,7 +2266,7 @@ export function useTripPlanner() { setPlaceFormDayId(null) setServiceStopForm(false) setShowPlaceForm(true) - }, [isMobile, isTourPlace, handlePlaceClick, can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days]) + }, [isMobile, isTourPlace, handlePlaceClick, can, trip, assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days, setEditingAssignmentId, setEditingPlace, setPlaceFormDayId, setServiceStopForm, setShowPlaceForm, setStopDraft]) /** * How long the drive stands here, for every stop alike.