diff --git a/client/src/pages/tripPlanner/useBookingEdits.test.ts b/client/src/pages/tripPlanner/useBookingEdits.test.ts new file mode 100644 index 000000000..1055dd90b --- /dev/null +++ b/client/src/pages/tripPlanner/useBookingEdits.test.ts @@ -0,0 +1,380 @@ +// FE-TP-BOOKING-001 to FE-TP-BOOKING-013 +// +// Booking writes and the review before an import is saved, driven straight through +// useBookingEdits. Every write goes to a recorded stand-in for the store's actions and +// every editor switch to a recorded setter, so each case can read which editor the +// review opened and with what. The bridge reads the real background task store and the +// real IndexedDB copy of an import's files. +import { act, renderHook, waitFor } from '@testing-library/react' +import type { BookingImportPreviewItem } from '@trek/shared' +import { accommodationsApi, mapsApi } from '../../api/client' +import { getImportFiles, saveImportFiles } from '../../db/offlineDb' +import { useBackgroundTasksStore } from '../../store/backgroundTasksStore' +import { useTripStore, type TripStoreState } from '../../store/tripStore' +import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode' +import { resetAllStores } from '../../../tests/helpers/store' +import { buildPlace, buildReservation, buildTrip } from '../../../tests/helpers/factories' +import type { Translate } from './plannerTypes' +import { useBookingEdits } from './useBookingEdits' + +type Options = Parameters[0] +type ImportedStay = { place_id?: number; venue?: { name?: string; address?: string | null }; address?: string } + +const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() } +const t = ((key: string) => key) as Translate +const trip = buildTrip({ id: 42 }) + +const ryokan = buildPlace({ id: 7, name: 'Ryokan', address: 'Old street' }) +const hut = buildPlace({ id: -3, name: 'Hut', address: null }) +const flightItem = { type: 'flight', title: 'NRT to CDG', source: { fileName: 'mail.pdf' } } +const hotelItem = { type: 'hotel', title: 'Ryokan' } +const oddItem = { type: 'shuttle-voucher', title: 'Airport transfer' } +const items = (...list: object[]) => list as unknown as BookingImportPreviewItem[] +const RECEIPT = { merchant: 'Cafe', date: '2026-09-20', total: 12.5, currency: 'EUR', items: [{ name: 'Tart', price: 12.5 }] } + +function makeActions() { + return { + updatePlace: vi.fn(async () => undefined), + addPlace: vi.fn(async (): Promise<{ id: number } | null> => ({ id: 900 })), + updateReservation: vi.fn(async () => ({ id: 5 })), + addReservation: vi.fn(async () => ({ id: 6 })), + deleteReservation: vi.fn(async () => undefined), + loadBudgetItems: vi.fn(async () => undefined), + } +} + +let actions: ReturnType +let fixed: Options + +function renderEdits(over: Partial = {}) { + return renderHook((props: Options) => useBookingEdits(props), { initialProps: { ...fixed, ...over } }) +} + +/** A finished task the background widget sent here for review. */ +function doneTask(over: Record) { + return { tripId: '42', label: 'mail.pdf', status: 'done', done: 1, total: 1, reviewRequested: true, items: [], ...over } +} + +/** Saves a hotel whose stay carries `acc`, and hands `acc` back to read what was linked. */ +async function saveHotel(result: { current: ReturnType }, acc: ImportedStay) { + await act(async () => { await result.current.handleSaveReservation({ title: 'Stay', type: 'hotel', create_accommodation: acc } as never) }) + return acc +} + +beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + resetAllStores() + _resetNetworkMode() + useBackgroundTasksStore.setState({ tasks: [] }) + vi.spyOn(accommodationsApi, 'list').mockResolvedValue({ accommodations: [{ id: 3 }] } as never) + vi.spyOn(mapsApi, 'search').mockResolvedValue({ places: [] } as never) + actions = makeActions() + fixed = { + tripId: 42, trip, toast, t, + tripActions: { ...useTripStore.getState(), ...actions } as unknown as TripStoreState, + places: [ryokan, hut], selectedDayId: 10, canUploadFiles: true, setTripAccommodations: vi.fn(), + editingReservation: null, setEditingReservation: vi.fn(), setShowReservationModal: vi.fn(), + editingTransport: null, setEditingTransport: vi.fn(), setShowTransportModal: vi.fn(), setTransportModalDayId: vi.fn(), + } +}) + +afterEach(() => { _resetNetworkMode() }) + +describe('useBookingEdits', () => { + it('FE-TP-BOOKING-001: an edited booking is updated and its editor closes, and only a hotel refreshes the stays', async () => { + const { result } = renderEdits({ editingReservation: buildReservation({ id: 5 }) }) + + let saved: unknown + await act(async () => { saved = await result.current.handleSaveReservation({ title: 'Dinner', type: 'restaurant' }) }) + expect(saved).toEqual({ id: 5 }) + expect(actions.updateReservation).toHaveBeenCalledWith(42, 5, { title: 'Dinner', type: 'restaurant' }) + expect(toast.success).toHaveBeenCalledWith('trip.toast.reservationUpdated') + expect(fixed.setShowReservationModal).toHaveBeenCalledWith(false) + expect(fixed.setEditingReservation).toHaveBeenCalledWith(null) + expect(accommodationsApi.list).not.toHaveBeenCalled() + + await act(async () => { await result.current.handleSaveReservation({ title: 'Ryokan', type: 'hotel' }) }) + await waitFor(() => expect(fixed.setTripAccommodations).toHaveBeenCalledWith([{ id: 3 }])) + }) + + it('FE-TP-BOOKING-002: a new booking lands on the selected day, or on none, and its linked cost is loaded', async () => { + const { result } = renderEdits() + + let saved: unknown + await act(async () => { + saved = await result.current.handleSaveReservation({ title: 'Ryokan', type: 'hotel', create_budget_entry: 1 }) + }) + expect(saved).toEqual({ id: 6 }) + expect(actions.addReservation).toHaveBeenCalledWith(42, { title: 'Ryokan', type: 'hotel', create_budget_entry: 1, day_id: 10 }) + expect(toast.success).toHaveBeenCalledWith('trip.toast.reservationAdded') + expect(actions.loadBudgetItems).toHaveBeenCalledWith(42) + await waitFor(() => expect(fixed.setTripAccommodations).toHaveBeenCalledWith([{ id: 3 }])) + + const undated = renderEdits({ selectedDayId: null }) + actions.loadBudgetItems.mockClear() + vi.mocked(accommodationsApi.list).mockClear() + await act(async () => { await undated.result.current.handleSaveReservation({ title: 'Museum', type: 'tour' }) }) + expect(actions.addReservation).toHaveBeenLastCalledWith(42, { title: 'Museum', type: 'tour', day_id: null }) + expect(actions.loadBudgetItems).not.toHaveBeenCalled() + expect(accommodationsApi.list).not.toHaveBeenCalled() + }) + + it('FE-TP-BOOKING-003: a stay refresh that fails or answers without stays leaves the list empty or alone', async () => { + const { result } = renderEdits() + + vi.mocked(accommodationsApi.list).mockResolvedValueOnce({} as never) + await act(async () => { await result.current.handleSaveReservation({ title: 'Ryokan', type: 'hotel' }) }) + await waitFor(() => expect(fixed.setTripAccommodations).toHaveBeenCalledWith([])) + + vi.mocked(fixed.setTripAccommodations).mockClear() + vi.mocked(accommodationsApi.list).mockRejectedValueOnce(new Error('offline')) + await act(async () => { await result.current.handleSaveReservation({ title: 'Ryokan', type: 'hotel' }) }) + await act(async () => { await Promise.resolve() }) + expect(fixed.setTripAccommodations).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('FE-TP-BOOKING-004: a hotel address edited in the booking is written to its place, and only when it changed', async () => { + const { result } = renderEdits({ places: [ryokan, buildPlace({ id: 8, name: 'Inn', address: null })] }) + + const edited = await saveHotel(result, { place_id: 7, address: ' New street ' }) + expect(actions.updatePlace).toHaveBeenCalledWith(42, 7, { address: 'New street' }) + expect(edited.address).toBeUndefined() + + await saveHotel(result, { place_id: 8, address: 'First street' }) + expect(actions.updatePlace).toHaveBeenLastCalledWith(42, 8, { address: 'First street' }) + + actions.updatePlace.mockClear() + await saveHotel(result, { place_id: 7, address: 'Old street' }) + await saveHotel(result, { address: 'Nowhere street' }) + await saveHotel(result, { place_id: 7, address: ' ' }) + expect(actions.updatePlace).not.toHaveBeenCalled() + + // A failed write to the place still saves the booking. + actions.updatePlace.mockRejectedValueOnce(new Error('locked')) + actions.addReservation.mockClear() + await saveHotel(result, { place_id: 7, address: 'New street' }) + expect(actions.addReservation).toHaveBeenCalledTimes(1) + expect(toast.error).not.toHaveBeenCalled() + }) + + it('FE-TP-BOOKING-005: an imported stay is linked to a place of the trip by name, never to an offline one', async () => { + const { result } = renderEdits() + + const exact = await saveHotel(result, { venue: { name: ' ryokan ', address: 'Kyoto' } }) + expect(exact).toEqual({ place_id: 7 }) + const partial = await saveHotel(result, { venue: { name: 'Ryokan Sakura' } }) + expect(partial.place_id).toBe(7) + expect(mapsApi.search).not.toHaveBeenCalled() + + // The hut only exists offline, so a new place is found and made instead. + vi.mocked(mapsApi.search).mockResolvedValueOnce({ places: [{ lat: 1, lng: 2, address: 'Geo street' }] } as never) + const temp = await saveHotel(result, { venue: { name: 'Hut' } }) + expect(mapsApi.search).toHaveBeenCalledWith('Hut') + expect(actions.addPlace).toHaveBeenCalledWith(42, { name: 'Hut', lat: 1, lng: 2, address: 'Geo street' }) + expect(temp.place_id).toBe(900) + + // A place already picked is kept. + actions.addPlace.mockClear() + const picked = await saveHotel(result, { place_id: 7, venue: { name: 'Hut' } }) + expect(picked.place_id).toBe(7) + expect(picked.venue).toEqual({ name: 'Hut' }) + expect(actions.addPlace).not.toHaveBeenCalled() + }) + + it('FE-TP-BOOKING-006: offline nothing is linked, and a failed search still makes the place without coordinates', async () => { + const { result } = renderEdits() + + setForcedOffline(true) + const offline = await saveHotel(result, { venue: { name: 'Lodge', address: 'Main 1' } }) + expect(offline.place_id).toBeUndefined() + expect(mapsApi.search).not.toHaveBeenCalled() + expect(actions.addPlace).not.toHaveBeenCalled() + _resetNetworkMode() + + vi.mocked(mapsApi.search).mockRejectedValueOnce(new Error('overpass down')) + await saveHotel(result, { venue: { name: 'Lodge', address: 'Main 1' } }) + expect(mapsApi.search).toHaveBeenCalledWith('Lodge Main 1') + expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Lodge', lat: null, lng: null, address: 'Main 1' }) + + // A hit without coordinates is no hit, and the address keeps what the booking read. + vi.mocked(mapsApi.search).mockResolvedValueOnce({ places: [{ lat: null, lng: 2, address: 'Elsewhere' }] } as never) + await saveHotel(result, { venue: { name: '', address: 'Main 1' } }) + expect(mapsApi.search).toHaveBeenLastCalledWith('Main 1') + expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Main 1', lat: null, lng: null, address: 'Main 1' }) + + vi.mocked(mapsApi.search).mockResolvedValueOnce(undefined as never) + await saveHotel(result, { venue: { name: 'Lodge', address: 'Main 1' } }) + expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Lodge', lat: null, lng: null, address: 'Main 1' }) + }) + + it('FE-TP-BOOKING-007: a stay with nothing to go by is still made, and one the store could not keep links nothing', async () => { + const { result } = renderEdits() + + vi.mocked(mapsApi.search).mockClear() + const nameless = await saveHotel(result, { venue: {} }) + expect(mapsApi.search).not.toHaveBeenCalled() + expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Accommodation', lat: null, lng: null, address: null }) + expect(nameless.place_id).toBe(900) + + actions.addPlace.mockResolvedValueOnce(null) + expect((await saveHotel(result, { venue: { name: 'Lodge' } })).place_id).toBeUndefined() + actions.addPlace.mockResolvedValueOnce({ id: -5 }) + expect((await saveHotel(result, { venue: { name: 'Lodge' } })).place_id).toBeUndefined() + actions.addPlace.mockRejectedValueOnce(new Error('quota')) + expect((await saveHotel(result, { venue: { name: 'Lodge' } })).place_id).toBeUndefined() + expect(actions.addReservation).toHaveBeenCalledTimes(4) + }) + + it('FE-TP-BOOKING-008: a booking that cannot be saved says so, whatever was thrown', async () => { + const { result } = renderEdits() + + actions.addReservation.mockRejectedValueOnce(new Error('limit')).mockRejectedValueOnce('nope') + let saved: unknown = 'unset' + await act(async () => { saved = await result.current.handleSaveReservation({ title: 'Dinner' }) }) + expect(saved).toBeUndefined() + expect(toast.error).toHaveBeenLastCalledWith('limit') + await act(async () => { await result.current.handleSaveReservation({ title: 'Dinner' }) }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + }) + + it('FE-TP-BOOKING-009: a transport is updated or added, its editor closes, and a linked cost is loaded', async () => { + const edit = renderEdits({ editingTransport: buildReservation({ id: 5, type: 'flight' }) }) + let saved: unknown + await act(async () => { saved = await edit.result.current.handleSaveTransport({ title: 'NRT to CDG' }) }) + expect(saved).toEqual({ id: 5 }) + expect(actions.updateReservation).toHaveBeenCalledWith(42, 5, { title: 'NRT to CDG' }) + expect(toast.success).toHaveBeenLastCalledWith('trip.toast.reservationUpdated') + expect(fixed.setShowTransportModal).toHaveBeenCalledWith(false) + expect(fixed.setEditingTransport).toHaveBeenCalledWith(null) + expect(fixed.setTransportModalDayId).toHaveBeenCalledWith(null) + + const { result } = renderEdits() + await act(async () => { saved = await result.current.handleSaveTransport({ title: 'Train' }) }) + expect(saved).toEqual({ id: 6 }) + expect(actions.addReservation).toHaveBeenCalledWith(42, { title: 'Train' }) + expect(actions.loadBudgetItems).not.toHaveBeenCalled() + await act(async () => { await result.current.handleSaveTransport({ title: 'Train', create_budget_entry: true }) }) + expect(actions.loadBudgetItems).toHaveBeenCalledWith(42) + + actions.addReservation.mockRejectedValueOnce(new Error('limit')).mockRejectedValueOnce('nope') + await act(async () => { await result.current.handleSaveTransport({ title: 'Train' }) }) + expect(toast.error).toHaveBeenLastCalledWith('limit') + await act(async () => { await result.current.handleSaveTransport({ title: 'Train' }) }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + }) + + it('FE-TP-BOOKING-010: a deleted booking refreshes the stays, and a failed delete says so', async () => { + const { result } = renderEdits() + + await act(async () => { await result.current.handleDeleteReservation(5) }) + expect(actions.deleteReservation).toHaveBeenCalledWith(42, 5) + expect(toast.success).toHaveBeenCalledWith('trip.toast.deleted') + await waitFor(() => expect(fixed.setTripAccommodations).toHaveBeenCalledWith([{ id: 3 }])) + + vi.mocked(accommodationsApi.list).mockResolvedValueOnce({} as never) + await act(async () => { await result.current.handleDeleteReservation(5) }) + await waitFor(() => expect(fixed.setTripAccommodations).toHaveBeenLastCalledWith([])) + + actions.deleteReservation.mockRejectedValueOnce(new Error('gone')).mockRejectedValueOnce('nope') + await act(async () => { await result.current.handleDeleteReservation(5) }) + expect(toast.error).toHaveBeenLastCalledWith('gone') + await act(async () => { await result.current.handleDeleteReservation(5) }) + expect(toast.error).toHaveBeenLastCalledWith('common.unknownError') + }) + + it('FE-TP-BOOKING-011: the review walks its queue through the right editors and closes everything at the end', async () => { + const { result } = renderEdits() + const file = new File(['x'], 'mail.pdf') + + act(() => { result.current.startImportReview([]) }) + expect(result.current.importReviewActive).toBe(false) + expect(fixed.setShowReservationModal).not.toHaveBeenCalled() + + act(() => { result.current.startImportReview(items(flightItem, hotelItem, oddItem), [file], 'transports') }) + expect(result.current.importReviewActive).toBe(true) + expect(result.current.transportPrefill).toMatchObject({ title: 'NRT to CDG', _sourceFiles: [file] }) + expect(fixed.setShowTransportModal).toHaveBeenLastCalledWith(true) + expect(fixed.setShowReservationModal).toHaveBeenLastCalledWith(false) + + act(() => { result.current.advanceImportReview() }) + expect(result.current.reservationPrefill).toMatchObject({ title: 'Ryokan' }) + expect(result.current.reservationPrefill?._sourceFiles).toBeUndefined() + expect(result.current.transportPrefill).toBeNull() + expect(fixed.setShowReservationModal).toHaveBeenLastCalledWith(true) + + // Neither form knows it, so it goes where the import began. + act(() => { result.current.advanceImportReview() }) + expect(result.current.transportPrefill).toMatchObject({ title: 'Airport transfer' }) + expect(result.current.reservationPrefill).toBeNull() + + act(() => { result.current.advanceImportReview() }) + expect(result.current.importReviewActive).toBe(false) + expect(result.current.transportPrefill).toBeNull() + expect(fixed.setShowTransportModal).toHaveBeenLastCalledWith(false) + expect(fixed.setShowReservationModal).toHaveBeenLastCalledWith(false) + expect(actions.loadBudgetItems).toHaveBeenCalledWith(42) + await waitFor(() => expect(fixed.setTripAccommodations).toHaveBeenCalledWith([{ id: 3 }])) + + // Started without a tab, an item neither form knows goes to the booking form. + act(() => { result.current.startImportReview(items(oddItem)) }) + expect(result.current.reservationPrefill).toMatchObject({ title: 'Airport transfer' }) + vi.mocked(accommodationsApi.list).mockResolvedValueOnce({} as never) + act(() => { result.current.advanceImportReview() }) + await waitFor(() => expect(fixed.setTripAccommodations).toHaveBeenLastCalledWith([])) + }) + + it('FE-TP-BOOKING-012: the bridge hands this trip\'s finished import to the review, from memory or from IndexedDB', async () => { + const file = new File(['x'], 'mail.pdf') + useBackgroundTasksStore.setState({ + tasks: [ + doneTask({ id: 'job-t', kind: 'transports', items: [oddItem], sourceFiles: [file] }), + doneTask({ id: 'job-o', tripId: '99', items: [flightItem] }), + ] as never, + }) + const { result } = renderEdits() + + await waitFor(() => expect(result.current.transportPrefill).toMatchObject({ title: 'Airport transfer' })) + expect(useBackgroundTasksStore.getState().tasks.map(task => task.id)).toEqual(['job-o']) + + await saveImportFiles('job-db', [file]) + act(() => { useBackgroundTasksStore.setState({ tasks: [doneTask({ id: 'job-db', items: [{ ...hotelItem, source: { fileName: 'mail.pdf' } }] })] as never }) }) + await waitFor(() => expect(result.current.reservationPrefill?._sourceFiles?.map(f => f.name)).toEqual(['mail.pdf'])) + await waitFor(async () => expect(await getImportFiles('job-db')).toEqual([])) + }) + + it('FE-TP-BOOKING-013: a scanned receipt waits for its trip, then fills the expense, with the photo only for an uploader and from IndexedDB after a reload', async () => { + const photo = new File(['x'], 'bill.jpg', { type: 'image/jpeg' }) + const receiptTask = (id: string, receipt: unknown, sourceFiles?: File[]) => doneTask({ id, kind: 'costs', receipt, sourceFiles }) + useBackgroundTasksStore.setState({ tasks: [receiptTask('job-r', RECEIPT, [photo])] as never }) + const { result, rerender } = renderEdits({ trip: null }) + + await act(async () => { await Promise.resolve() }) + expect(useBackgroundTasksStore.getState().tasks).toHaveLength(1) + expect(result.current.receiptExpense).toBeNull() + + rerender({ ...fixed, trip }) + await waitFor(() => expect(result.current.receiptExpense).toMatchObject({ name: 'Cafe', amount: 12.5, receiptFiles: [photo] })) + expect(useBackgroundTasksStore.getState().tasks).toHaveLength(0) + + act(() => { result.current.setReceiptExpense(null) }) + rerender({ ...fixed, canUploadFiles: false }) + act(() => { useBackgroundTasksStore.setState({ tasks: [receiptTask('job-u', RECEIPT, [photo])] as never }) }) + await waitFor(() => expect(result.current.receiptExpense).toMatchObject({ name: 'Cafe', receiptFiles: [] })) + + // After a reload the photo comes back from IndexedDB, and is cleared there once read. + act(() => { result.current.setReceiptExpense(null) }) + rerender({ ...fixed }) + await saveImportFiles('job-db', [photo]) + act(() => { useBackgroundTasksStore.setState({ tasks: [receiptTask('job-db', RECEIPT)] as never }) }) + await waitFor(() => expect(result.current.receiptExpense?.receiptFiles?.map(f => f.name)).toEqual(['bill.jpg'])) + await waitFor(async () => expect(await getImportFiles('job-db')).toEqual([])) + + // A scan that read nothing only clears the widget. + act(() => { result.current.setReceiptExpense(null) }) + act(() => { useBackgroundTasksStore.setState({ tasks: [receiptTask('job-n', null, [photo])] as never }) }) + await waitFor(() => expect(useBackgroundTasksStore.getState().tasks).toHaveLength(0)) + expect(result.current.receiptExpense).toBeNull() + }) +}) diff --git a/client/src/pages/tripPlanner/useBookingEdits.ts b/client/src/pages/tripPlanner/useBookingEdits.ts new file mode 100644 index 000000000..3e8217371 --- /dev/null +++ b/client/src/pages/tripPlanner/useBookingEdits.ts @@ -0,0 +1,297 @@ +import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { BookingImportPreviewItem } from '@trek/shared' +import { accommodationsApi, mapsApi } from '../../api/client' +import { receiptToPrefill } from '../../components/Budget/CostsPanel.helpers' +import type { ExpensePrefill } from '../../components/Budget/CostsPanel' +import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft' +import { getImportFiles, deleteImportFiles } from '../../db/offlineDb' +import { useBackgroundTasksStore } from '../../store/backgroundTasksStore' +import type { TripStoreState } from '../../store/tripStore' +import { isEffectivelyOffline } from '../../sync/networkMode' +import type { Accommodation, Place } from '../../types' +import type { Can, PlannerBase } from './plannerTypes' +import type { PlannerDialogs } from './usePlannerDialogs' + +interface BookingEditsOptions + extends Pick, + Pick, + Pick { + /** The places the planner lists, which an imported venue is matched against by name. */ + places: Place[] + selectedDayId: TripStoreState['selectedDayId'] + /** Whether a scanned receipt's photo may go up with the expense it fills in. */ + canUploadFiles: ReturnType + setTripAccommodations: Dispatch> +} + +/** + * Writes to the trip's bookings and the review that comes before an import is saved: + * saving a reservation or a transport, deleting a booking, finding or creating the place + * an imported stay belongs to, the queue that walks the imported items through their + * editors one at a time, and the bridge that picks up an import or a scanned receipt + * the background tasks send here for review. + * + * The bridge is an effect, so useTripPlanner calls this hook where that effect always + * ran: after the day wiring and before the splash. + */ +export function useBookingEdits(options: BookingEditsOptions) { + const { + tripId, trip, tripActions, toast, t, places, selectedDayId, canUploadFiles, setTripAccommodations, + editingReservation, setEditingReservation, setShowReservationModal, + editingTransport, setEditingTransport, setShowTransportModal, setTransportModalDayId, + } = options + // 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) + const [transportPrefill, setTransportPrefill] = useState(null) + const [importReviewActive, setImportReviewActive] = useState(false) + // The expense a scanned receipt pre-fills, opened by the page's expense editor. + const [receiptExpense, setReceiptExpense] = useState(null) + const importQueueRef = useRef([]) + // The files this import was parsed from, so each reviewed booking can attach its source doc. + const importSourceFilesRef = useRef([]) + // The tab the items under review came from. A ref, not the bookingImportKind + // state: the parse outlives navigation and reload, and the review is triggered + // by the global widget, so by then the state has remounted back to its default. + // The value comes off the persisted job (#2076). + const importKindRef = useRef<'transports' | 'bookings'>('bookings') + + const handleSaveReservation = async (data: Record & { title: string }) => { + try { + // Imported hotel with a reviewed address but no existing place picked: match + // an existing place by name, else geocode the address and create one, then link it. + const acc = (data as Record).create_accommodation + if (data.type === 'hotel' && acc && acc.venue && !acc.place_id) { + acc.place_id = (await resolveImportedPlace(acc.venue)) ?? undefined + delete acc.venue + } + // A hotel's address lives on the linked place. Write an edited address + // through to it, otherwise the typed value was silently dropped and the + // old one reappeared on the next open (#1496). + if (data.type === 'hotel' && acc && typeof acc.address === 'string') { + const address = acc.address.trim() + const linkedPlace = acc.place_id ? places.find(p => p.id === Number(acc.place_id)) : undefined + if (address && linkedPlace && (linkedPlace.address || '') !== address) { + try { await tripActions.updatePlace(tripId, linkedPlace.id, { address }) } + catch { /* keep saving the booking; the address still lands in location */ } + } + delete acc.address + } + if (editingReservation) { + // Don't force a day here. The old code pinned it to the (often empty) + // selected day, which dropped the booking out of the Plan; preserving the + // old day_id instead left it stale when the date changed. Omitting it lets + // the server derive the day from the booking's date, or keep the current + // one when there is no date. + const r = await tripActions.updateReservation(tripId, editingReservation.id, data) + toast.success(t('trip.toast.reservationUpdated')) + setShowReservationModal(false) + setEditingReservation(null) + if (data.type === 'hotel') { + accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) + } + return r + } else { + const r = await tripActions.addReservation(tripId, { ...data, day_id: selectedDayId || null }) + toast.success(t('trip.toast.reservationAdded')) + setShowReservationModal(false) + // An imported booking auto-creates a linked cost server-side; the saving client gets + // no budget:created echo, so refresh the budget items here to surface it without a reload. + if ((data as Record).create_budget_entry) await tripActions.loadBudgetItems?.(tripId) + // Refresh accommodations if hotel was created + if (data.type === 'hotel') { + accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) + } + return r + } + } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } + } + + const handleSaveTransport = async (data: Record & { title: string }) => { + try { + if (editingTransport) { + const r = await tripActions.updateReservation(tripId, editingTransport.id, data) + toast.success(t('trip.toast.reservationUpdated')) + setShowTransportModal(false) + setEditingTransport(null) + setTransportModalDayId(null) + return r + } else { + const r = await tripActions.addReservation(tripId, data) + toast.success(t('trip.toast.reservationAdded')) + setShowTransportModal(false) + setEditingTransport(null) + setTransportModalDayId(null) + // Surface the auto-created linked cost without a reload (no budget:created echo to us). + if (data.create_budget_entry) await tripActions.loadBudgetItems?.(tripId) + return r + } + } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } + } + + const handleDeleteReservation = async (id) => { + try { + await tripActions.deleteReservation(tripId, id) + toast.success(t('trip.toast.deleted')) + // Refresh accommodations in case a hotel booking was deleted + accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) + } + catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } + } + + // ── Review-before-save booking import ─────────────────────────────────────── + // Match an existing trip place by name, else geocode the reviewed address and + // create one. Returns the place id (or null if even creation failed). + const resolveImportedPlace = async (venue: { name?: string; address?: string | null }): Promise => { + const name = (venue.name || '').trim() + const n = name.toLowerCase() + if (n) { + const existing = places.find(p => p.name?.trim().toLowerCase() === n) + ?? places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase()))) + // Only a server-side id may be linked. A negative id is an offline temp id + // (mutationQueue.nextTempId): the reservation write is online-only, the queue + // rewrites temp ids in a URL but never inside another entity's body, and + // day_accommodations.place_id carries a foreign key, so a temp id here is a + // rolled-back insert and a 500 instead of a saved booking. + if (existing && existing.id > 0) return existing.id + } + // Offline the booking itself cannot be written (reservations are online-only), + // so minting a place here would only leave an orphan behind on the next flush, + // and its temp id could never be linked anyway. Link nothing, and skip the + // geocode round-trip too; the retry online matches this venue by name. + if (isEffectivelyOffline()) return null + let lat: number | null = null + let lng: number | null = null + let address: string | null = venue.address ?? null + try { + const query = venue.address ? `${name} ${venue.address}`.trim() : name + if (query) { + const res = await mapsApi.search(query) + const hit = res?.places?.[0] as { lat?: number; lng?: number; address?: string } | undefined + if (hit && hit.lat != null && hit.lng != null) { + lat = hit.lat; lng = hit.lng + if (!address && hit.address) address = hit.address + } + } + } catch { /* geocode failure is non-fatal: create the place without coords */ } + try { + // Through the store, not placesApi directly: the API answers { place }, + // and reading .id off that wrapper linked nothing. Every save of the + // hotel then minted another orphan place, because the store never + // learned about the previous one and the name match above could not + // find it. addPlace unwraps the response and puts the place into + // `places`, so the next save reuses it. + const place = await tripActions.addPlace(tripId, { name: name || address || 'Accommodation', lat, lng, address }) + return place && place.id > 0 ? place.id : null + } catch { return null } + } + + // Open the right edit modal for a parsed item, pre-filled, in create mode. + // + // A type neither form can express belongs to whichever tab the user started from. + // Handing an unreadable transport document to the booking form is what left them + // with six chips, none of them a transport, and 'other' as the only honest pick + // (#2076). A type either form DOES know always wins over the tab: one PDF + // routinely holds a flight and a hotel. + const openImportItem = (item: BookingImportPreviewItem) => { + const draft = parsedItemToDraft(item) + // Attach the file this item was parsed from so it lands in the booking's Files on save. + const srcName = item.source?.fileName + const srcFile = srcName ? importSourceFilesRef.current.find(f => f.name === srcName) : undefined + if (srcFile) draft._sourceFiles = [srcFile] + if (isTransportItem(item) || (isUnplaceableItem(item) && importKindRef.current === 'transports')) { + setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null) + setEditingTransport(null); setTransportModalDayId(null) + setTransportPrefill(draft); setShowTransportModal(true) + } else { + setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null) + setEditingReservation(null) + setReservationPrefill(draft); setShowReservationModal(true) + } + } + + const startImportReview = ( + items: BookingImportPreviewItem[], + sourceFiles: File[] = [], + kind: 'transports' | 'bookings' = 'bookings', + ) => { + if (!items.length) return + importSourceFilesRef.current = sourceFiles + importKindRef.current = kind + importQueueRef.current = items.slice(1) + setImportReviewActive(true) + openImportItem(items[0]) + } + + // Bridge: when a finished background import is sent here for review (the user hit + // "review" in the background widget, on this or any page), open the per-item flow. + // Lives in the hook so the page stays a pure wiring container. + const bgTasks = useBackgroundTasksStore((s) => s.tasks) + const dismissBgTask = useBackgroundTasksStore((s) => s.dismiss) + const loadedTripId = trip?.id + useEffect(() => { + const task = bgTasks.find( + (tk) => tk.tripId === String(tripId) && tk.status === 'done' && tk.reviewRequested && !tk.consumed, + ) + if (task && task.kind === 'costs') { + // A scanned receipt is reviewed in the expense editor, pre-filled with what + // was read and with the photo waiting to be attached when it is saved. The + // photo goes up through the trip's file upload, so it is only put there for + // someone who may upload files: for anyone else it made the whole save fail, + // expense included, over an attachment they never picked. Whether they may + // is only known once this trip is loaded, so the review waits for it. + if (loadedTripId !== tripId) return + const receipt = task.receipt + const jobId = task.id + const inMemory = task.sourceFiles + dismissBgTask(jobId) + if (!receipt) return + void (async () => { + const files = inMemory && inMemory.length ? inMemory : await getImportFiles(jobId) + void deleteImportFiles(jobId) + setReceiptExpense(receiptToPrefill(receipt, canUploadFiles ? files : [])) + })() + } else if (task && task.items && task.items.length > 0) { + // Hand the items (and the source files, to attach to each booking) to the review flow + // and clear the widget entry: once the user hit "review", the background card is done. + const items = task.items + const jobId = task.id + const inMemory = task.sourceFiles + const kind = task.kind === 'transports' ? 'transports' : 'bookings' + dismissBgTask(jobId) + // Prefer the in-memory files (immediate path); after a reload they live in IndexedDB. + void (async () => { + const files = inMemory && inMemory.length ? inMemory : await getImportFiles(jobId) + void deleteImportFiles(jobId) + startImportReview(items, files, kind) + })() + } + }, [bgTasks, tripId, startImportReview, dismissBgTask, canUploadFiles, loadedTripId]) + + // Called when a reviewed item's modal closes (saved or skipped): open the next, + // or finish the review session and refresh accommodations. + const advanceImportReview = () => { + const queue = importQueueRef.current + if (queue.length > 0) { + importQueueRef.current = queue.slice(1) + openImportItem(queue[0]) + return + } + importQueueRef.current = [] + setImportReviewActive(false) + setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null) + setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null) + accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) + // Imported bookings auto-create their linked costs server-side, but the saving client + // suppresses its own budget:created echo (X-Socket-Id), so reload the budget items here + // to surface those expenses without a manual page refresh. + void tripActions.loadBudgetItems?.(tripId) + } + + return { + handleSaveReservation, handleSaveTransport, handleDeleteReservation, startImportReview, advanceImportReview, + reservationPrefill, transportPrefill, importReviewActive, receiptExpense, setReceiptExpense, + } +} + +export type BookingEdits = ReturnType diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 9ec1d4a8b..2193b56ee 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -9,16 +9,11 @@ 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 } from '../../i18n' -import { addonsApi, accommodationsApi, authApi, tripsApi, healthApi, mapsApi, placesApi } from '../../api/client' +import { addonsApi, authApi, tripsApi, healthApi, placesApi } from '../../api/client' import { TRANSPORT_TYPES } from '../../utils/dayMerge' -import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft' -import type { BookingImportPreviewItem } from '@trek/shared' import { accommodationRepo } from '../../repo/accommodationRepo' -import { offlineDb, getImportFiles, deleteImportFiles } from '../../db/offlineDb' +import { offlineDb } from '../../db/offlineDb' import { isEffectivelyOffline } from '../../sync/networkMode' -import { useBackgroundTasksStore } from '../../store/backgroundTasksStore' -import { receiptToPrefill } from '../../components/Budget/CostsPanel.helpers' -import type { ExpensePrefill } from '../../components/Budget/CostsPanel' import { useAuthStore } from '../../store/authStore' import { useResizablePanels } from '../../hooks/useResizablePanels' import { useTripWebSocket } from '../../hooks/useTripWebSocket' @@ -58,13 +53,14 @@ import { useRoadtripDriveShaping } from './useRoadtripDriveShaping' import { usePlaceEdits } from './usePlaceEdits' import { usePlaceFormOpeners } from './usePlaceFormOpeners' import { useDayPlanEdits } from './useDayPlanEdits' +import { useBookingEdits } from './useBookingEdits' import { useIsPhone } from '../../mobile/useIsPhone' /** * Trip planner page logic, the big one. Owns the trip store wiring, addon * gating, accommodations/members loading, the tab + resizable-panel + selection - * state, the reservation and transport saves and the splash gate. TripPlannerPage - * stays a wiring container that lays out the day/map/places panes and modals. + * state, the booking detail 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 @@ -325,21 +321,6 @@ export function useTripPlanner() { 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) - const [transportPrefill, setTransportPrefill] = useState(null) - const [importReviewActive, setImportReviewActive] = useState(false) - // The expense a scanned receipt pre-fills, opened by the page's expense editor. - const [receiptExpense, setReceiptExpense] = useState(null) - const importQueueRef = useRef([]) - // The files this import was parsed from, so each reviewed booking can attach its source doc. - const importSourceFilesRef = useRef([]) - // The tab the items under review came from. A ref, not the bookingImportKind - // state: the parse outlives navigation and reload, and the review is triggered - // by the global widget, so by then the state has remounted back to its default. - // The value comes off the persisted job (#2076). - const importKindRef = useRef<'transports' | 'bookings'>('bookings') const { mapLocked, toggleMapLocked, mapLockedRef, isMobileRef, fitKey, setFitKey, overviewShown, toggleOverview, dawarichTrailShown, toggleDawarichTrail, dawarichTrail, @@ -592,88 +573,15 @@ export function useTripPlanner() { onToursChanged: reloadTourPlaceIds, }) - const handleSaveReservation = async (data: Record & { title: string }) => { - try { - // Imported hotel with a reviewed address but no existing place picked: match - // an existing place by name, else geocode the address and create one, then link it. - const acc = (data as Record).create_accommodation - if (data.type === 'hotel' && acc && acc.venue && !acc.place_id) { - acc.place_id = (await resolveImportedPlace(acc.venue)) ?? undefined - delete acc.venue - } - // A hotel's address lives on the linked place. Write an edited address - // through to it, otherwise the typed value was silently dropped and the - // old one reappeared on the next open (#1496). - if (data.type === 'hotel' && acc && typeof acc.address === 'string') { - const address = acc.address.trim() - const linkedPlace = acc.place_id ? places.find(p => p.id === Number(acc.place_id)) : undefined - if (address && linkedPlace && (linkedPlace.address || '') !== address) { - try { await tripActions.updatePlace(tripId, linkedPlace.id, { address }) } - catch { /* keep saving the booking; the address still lands in location */ } - } - delete acc.address - } - if (editingReservation) { - // Don't force a day here. The old code pinned it to the (often empty) - // selected day, which dropped the booking out of the Plan; preserving the - // old day_id instead left it stale when the date changed. Omitting it lets - // the server derive the day from the booking's date, or keep the current - // one when there is no date. - const r = await tripActions.updateReservation(tripId, editingReservation.id, data) - toast.success(t('trip.toast.reservationUpdated')) - setShowReservationModal(false) - setEditingReservation(null) - if (data.type === 'hotel') { - accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) - } - return r - } else { - const r = await tripActions.addReservation(tripId, { ...data, day_id: selectedDayId || null }) - toast.success(t('trip.toast.reservationAdded')) - setShowReservationModal(false) - // An imported booking auto-creates a linked cost server-side; the saving client gets - // no budget:created echo, so refresh the budget items here to surface it without a reload. - if ((data as Record).create_budget_entry) await tripActions.loadBudgetItems?.(tripId) - // Refresh accommodations if hotel was created - if (data.type === 'hotel') { - accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) - } - return r - } - } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - } - - const handleSaveTransport = async (data: Record & { title: string }) => { - try { - if (editingTransport) { - const r = await tripActions.updateReservation(tripId, editingTransport.id, data) - toast.success(t('trip.toast.reservationUpdated')) - setShowTransportModal(false) - setEditingTransport(null) - setTransportModalDayId(null) - return r - } else { - const r = await tripActions.addReservation(tripId, data) - toast.success(t('trip.toast.reservationAdded')) - setShowTransportModal(false) - setEditingTransport(null) - setTransportModalDayId(null) - // Surface the auto-created linked cost without a reload (no budget:created echo to us). - if (data.create_budget_entry) await tripActions.loadBudgetItems?.(tripId) - return r - } - } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - } - - const handleDeleteReservation = async (id) => { - try { - await tripActions.deleteReservation(tripId, id) - toast.success(t('trip.toast.deleted')) - // Refresh accommodations in case a hotel booking was deleted - accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) - } - catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } - } + // Called here so the bridge from the background tasks keeps its place among the effects. + const { + reservationPrefill, transportPrefill, importReviewActive, receiptExpense, setReceiptExpense, + handleSaveReservation, handleSaveTransport, handleDeleteReservation, startImportReview, advanceImportReview, + } = useBookingEdits({ + tripId, trip, tripActions, toast, t, places, selectedDayId, canUploadFiles, setTripAccommodations, + editingReservation, setEditingReservation, setShowReservationModal, + editingTransport, setEditingTransport, setShowTransportModal, setTransportModalDayId, + }) // ── The plan's booking detail ─────────────────────────────────────────────── // A click on a booking in the plan shows it first; the editor is one Edit away. @@ -703,155 +611,6 @@ export function useTripPlanner() { }) const isBookingOnMap = (r: Reservation) => visibleConnections.includes(r.id) - // ── Review-before-save booking import ─────────────────────────────────────── - // Match an existing trip place by name, else geocode the reviewed address and - // create one. Returns the place id (or null if even creation failed). - const resolveImportedPlace = async (venue: { name?: string; address?: string | null }): Promise => { - const name = (venue.name || '').trim() - const n = name.toLowerCase() - if (n) { - const existing = places.find(p => p.name?.trim().toLowerCase() === n) - ?? places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase()))) - // Only a server-side id may be linked. A negative id is an offline temp id - // (mutationQueue.nextTempId): the reservation write is online-only, the queue - // rewrites temp ids in a URL but never inside another entity's body, and - // day_accommodations.place_id carries a foreign key — so a temp id here is a - // rolled-back insert and a 500 instead of a saved booking. - if (existing && existing.id > 0) return existing.id - } - // Offline the booking itself cannot be written (reservations are online-only), - // so minting a place here would only leave an orphan behind on the next flush - // — and its temp id could never be linked anyway. Link nothing, and skip the - // geocode round-trip too; the retry online matches this venue by name. - if (isEffectivelyOffline()) return null - let lat: number | null = null - let lng: number | null = null - let address: string | null = venue.address ?? null - try { - const query = venue.address ? `${name} ${venue.address}`.trim() : name - if (query) { - const res = await mapsApi.search(query) - const hit = res?.places?.[0] as { lat?: number; lng?: number; address?: string } | undefined - if (hit && hit.lat != null && hit.lng != null) { - lat = hit.lat; lng = hit.lng - if (!address && hit.address) address = hit.address - } - } - } catch { /* geocode failure is non-fatal — create the place without coords */ } - try { - // Through the store, not placesApi directly: the API answers { place }, - // and reading .id off that wrapper linked nothing — every save of the - // hotel then minted another orphan place, because the store never - // learned about the previous one and the name match above could not - // find it. addPlace unwraps the response and puts the place into - // `places`, so the next save reuses it. - const place = await tripActions.addPlace(tripId, { name: name || address || 'Accommodation', lat, lng, address }) - return place && place.id > 0 ? place.id : null - } catch { return null } - } - - // Open the right edit modal for a parsed item, pre-filled, in create mode. - // - // A type neither form can express belongs to whichever tab the user started from. - // Handing an unreadable transport document to the booking form is what left them - // with six chips, none of them a transport, and 'other' as the only honest pick - // (#2076). A type either form DOES know always wins over the tab — one PDF - // routinely holds a flight and a hotel. - const openImportItem = (item: BookingImportPreviewItem) => { - const draft = parsedItemToDraft(item) - // Attach the file this item was parsed from so it lands in the booking's Files on save. - const srcName = item.source?.fileName - const srcFile = srcName ? importSourceFilesRef.current.find(f => f.name === srcName) : undefined - if (srcFile) draft._sourceFiles = [srcFile] - if (isTransportItem(item) || (isUnplaceableItem(item) && importKindRef.current === 'transports')) { - setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null) - setEditingTransport(null); setTransportModalDayId(null) - setTransportPrefill(draft); setShowTransportModal(true) - } else { - setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null) - setEditingReservation(null) - setReservationPrefill(draft); setShowReservationModal(true) - } - } - - const startImportReview = ( - items: BookingImportPreviewItem[], - sourceFiles: File[] = [], - kind: 'transports' | 'bookings' = 'bookings', - ) => { - if (!items.length) return - importSourceFilesRef.current = sourceFiles - importKindRef.current = kind - importQueueRef.current = items.slice(1) - setImportReviewActive(true) - openImportItem(items[0]) - } - - // Bridge: when a finished background import is sent here for review (the user hit - // "review" in the background widget, on this or any page), open the per-item flow. - // Lives in the hook so the page stays a pure wiring container. - const bgTasks = useBackgroundTasksStore((s) => s.tasks) - const dismissBgTask = useBackgroundTasksStore((s) => s.dismiss) - const loadedTripId = trip?.id - useEffect(() => { - const task = bgTasks.find( - (tk) => tk.tripId === String(tripId) && tk.status === 'done' && tk.reviewRequested && !tk.consumed, - ) - if (task && task.kind === 'costs') { - // A scanned receipt is reviewed in the expense editor, pre-filled with what - // was read and with the photo waiting to be attached when it is saved. The - // photo goes up through the trip's file upload, so it is only put there for - // someone who may upload files: for anyone else it made the whole save fail, - // expense included, over an attachment they never picked. Whether they may - // is only known once this trip is loaded, so the review waits for it. - if (loadedTripId !== tripId) return - const receipt = task.receipt - const jobId = task.id - const inMemory = task.sourceFiles - dismissBgTask(jobId) - if (!receipt) return - void (async () => { - const files = inMemory && inMemory.length ? inMemory : await getImportFiles(jobId) - void deleteImportFiles(jobId) - setReceiptExpense(receiptToPrefill(receipt, canUploadFiles ? files : [])) - })() - } else if (task && task.items && task.items.length > 0) { - // Hand the items (and the source files, to attach to each booking) to the review flow - // and clear the widget entry — once the user hit "review", the background card is done. - const items = task.items - const jobId = task.id - const inMemory = task.sourceFiles - const kind = task.kind === 'transports' ? 'transports' : 'bookings' - dismissBgTask(jobId) - // Prefer the in-memory files (immediate path); after a reload they live in IndexedDB. - void (async () => { - const files = inMemory && inMemory.length ? inMemory : await getImportFiles(jobId) - void deleteImportFiles(jobId) - startImportReview(items, files, kind) - })() - } - }, [bgTasks, tripId, startImportReview, dismissBgTask, canUploadFiles, loadedTripId]) - - // Called when a reviewed item's modal closes (saved or skipped): open the next, - // or finish the review session and refresh accommodations. - const advanceImportReview = () => { - const queue = importQueueRef.current - if (queue.length > 0) { - importQueueRef.current = queue.slice(1) - openImportItem(queue[0]) - return - } - importQueueRef.current = [] - setImportReviewActive(false) - setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null) - setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null) - accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) - // Imported bookings auto-create their linked costs server-side, but the saving client - // suppresses its own budget:created echo (X-Socket-Id) — so reload the budget items here - // to surface those expenses without a manual page refresh. - tripActions.loadBudgetItems?.(tripId) - } - const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null const selectedTour = toursEnabled && selectedPlaceId ? tours.find(tour => tour.place_id === selectedPlaceId) ?? null