refactor(client): booking saves and the import review move into useBookingEdits

Saving a reservation or a transport, deleting a booking, finding or
creating the place an imported stay belongs to, the review that walks
imported items through their editors with its state, and the bridge
that picks up an import or a scanned receipt from the background tasks
move out of useTripPlanner into useBookingEdits.

useTripPlanner calls the hook where the save handlers used to sit,
after the day add, delete and clear wiring and before the booking
detail, so the bridge still runs between the rail snapshot and the
splash and the planner returns the same object. The review state only
moves later in the render, which no effect observes. The two existing
loose record types in the save handlers move along unchanged.
This commit is contained in:
Maurice
2026-10-08 04:02:15 +00:00
parent f9d0f960bc
commit 9b9ec44b5e
3 changed files with 691 additions and 255 deletions
@@ -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<typeof useBookingEdits>[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<typeof makeActions>
let fixed: Options
function renderEdits(over: Partial<Options> = {}) {
return renderHook((props: Options) => useBookingEdits(props), { initialProps: { ...fixed, ...over } })
}
/** A finished task the background widget sent here for review. */
function doneTask(over: Record<string, unknown>) {
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<typeof useBookingEdits> }, 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()
})
})
@@ -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<PlannerBase, 'tripId' | 'trip' | 'tripActions' | 'toast' | 't'>,
Pick<PlannerDialogs, 'editingReservation' | 'setEditingReservation' | 'setShowReservationModal'>,
Pick<PlannerDialogs, 'editingTransport' | 'setEditingTransport' | 'setShowTransportModal' | 'setTransportModalDayId'> {
/** 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<Can>
setTripAccommodations: Dispatch<SetStateAction<Accommodation[]>>
}
/**
* 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<BookingReviewDraft | null>(null)
const [transportPrefill, setTransportPrefill] = useState<BookingReviewDraft | null>(null)
const [importReviewActive, setImportReviewActive] = useState(false)
// The expense a scanned receipt pre-fills, opened by the page's expense editor.
const [receiptExpense, setReceiptExpense] = useState<ExpensePrefill | null>(null)
const importQueueRef = useRef<BookingImportPreviewItem[]>([])
// The files this import was parsed from, so each reviewed booking can attach its source doc.
const importSourceFilesRef = useRef<File[]>([])
// 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<string, string | number | null> & { 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<string, any>).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<string, unknown>).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<string, any> & { 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<number | null> => {
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<typeof useBookingEdits>
+14 -255
View File
@@ -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<BookingReviewDraft | null>(null)
const [transportPrefill, setTransportPrefill] = useState<BookingReviewDraft | null>(null)
const [importReviewActive, setImportReviewActive] = useState(false)
// The expense a scanned receipt pre-fills, opened by the page's expense editor.
const [receiptExpense, setReceiptExpense] = useState<ExpensePrefill | null>(null)
const importQueueRef = useRef<BookingImportPreviewItem[]>([])
// The files this import was parsed from, so each reviewed booking can attach its source doc.
const importSourceFilesRef = useRef<File[]>([])
// 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<string, string | number | null> & { 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<string, any>).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<string, unknown>).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<string, any> & { 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<number | null> => {
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