feat(import): attach the parsed source document to each booking

Keep the uploaded files on the background task and hand them to the review flow, so each reviewed booking pre-fills its Files with the document it was parsed from (uploaded with the booking on save). The two modals also adopt the shared resolveDayId helper.
This commit is contained in:
Maurice
2026-06-26 10:41:41 +02:00
committed by Maurice
parent 7bac753ff3
commit 4abe96fe01
7 changed files with 32 additions and 49 deletions
@@ -96,7 +96,8 @@ export default function BookingImportModal({ isOpen, onClose, tripId }: BookingI
try {
const mode = aiParsing ? 'fallback-on-empty' : 'no-ai'
const { jobId } = await reservationsApi.importBookingAsync(tripId, files, mode)
addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length })
// Keep the uploaded files so the review can attach each source document to its booking.
addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length, files })
handleClose()
} catch (err: any) {
setError(err?.response?.data?.error ?? t('reservations.import.error'))
@@ -11,6 +11,7 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import { openFile } from '../../utils/fileDownload'
import { resolveDayId } from '../../utils/formatters'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
@@ -92,7 +93,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
})
const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState([])
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
@@ -102,22 +103,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
)
useEffect(() => {
// Resolve an ISO date to a trip day id (exact match, else nearest).
const dayIdForDate = (iso: unknown): string | number => {
if (!iso) return ''
const date = String(iso).slice(0, 10)
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return ''
const exact = days.find(d => d.date === date)
if (exact) return exact.id
let best: string | number = ''
let bestDiff = Infinity
for (const d of days) {
if (!d.date) continue
const diff = Math.abs(new Date(d.date).getTime() - new Date(date).getTime())
if (diff < bestDiff) { bestDiff = diff; best = d.id }
}
return best
}
// Match an existing place by name (exact, then loose contains) for hotels.
const matchPlaceId = (name: string | undefined): string | number => {
const n = (name || '').trim().toLowerCase()
@@ -185,11 +170,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '',
hotel_place_id: matchPlaceId(prefill._venue?.name || prefill.title),
hotel_start_day: dayIdForDate(prefill._accommodation?.check_in),
hotel_end_day: dayIdForDate(prefill._accommodation?.check_out),
hotel_start_day: resolveDayId(days, prefill._accommodation?.check_in),
hotel_end_day: resolveDayId(days, prefill._accommodation?.check_out),
hotel_address: prefill._venue?.address || '',
})
setPendingFiles([])
// Seed the booking's Files with the document this item was parsed from.
setPendingFiles(prefill._sourceFiles ?? [])
} else {
setForm({
title: '', type: 'other', status: 'pending',
@@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
@@ -155,31 +155,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// Resolve a trip day from a YYYY-MM-DD string: exact match, else the nearest day so an
// imported booking still lands on one. An imported transport arrives without a day_id
// (only its parsed dates), and without a selected day the save would drop the date and
// store a bare "HH:MM" — see buildTime below.
const dayIdForDate = (dateStr: string | null): number | '' => {
if (!dateStr || days.length === 0) return ''
const exact = days.find(d => d.date === dateStr)
if (exact) return exact.id
const target = new Date(dateStr).getTime()
if (Number.isNaN(target)) return ''
let best = days[0]
let bestDiff = Infinity
for (const d of days) {
const diff = Math.abs(new Date(d.date).getTime() - target)
if (diff < bestDiff) { bestDiff = diff; best = d }
}
return best.id
}
useEffect(() => {
if (!isOpen) return
// Edit uses the saved `reservation`; a review-import populates from `prefill`.
// Either way the init reads the same fields — `reservation` still decides
// edit-vs-create at submit time.
const src = (reservation ?? prefill) as Reservation | null
// On a review-import, seed the booking's Files with the parsed source document.
setPendingFiles(!reservation && prefill?._sourceFiles ? prefill._sourceFiles : [])
if (src) {
const meta = typeof src.metadata === 'string'
? JSON.parse(src.metadata || '{}')
@@ -196,8 +179,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
status: src.status === 'confirmed' ? 'confirmed' : 'pending',
// For an edit, keep the saved day; for an imported prefill (no day_id), resolve it
// from the parsed pick-up/return date so the date isn't lost on save.
start_day_id: src.day_id ?? dayIdForDate(splitReservationDateTime(src.reservation_time).date),
end_day_id: src.end_day_id ?? dayIdForDate(splitReservationDateTime(src.reservation_end_time).date),
start_day_id: src.day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_time).date),
end_day_id: src.end_day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_end_time).date),
departure_time: splitReservationDateTime(src.reservation_time).time ?? '',
arrival_time: splitReservationDateTime(src.reservation_end_time).time ?? '',
confirmation_number: src.confirmation_number || '',
@@ -15,6 +15,8 @@ export interface BookingReviewDraft extends Omit<Partial<Reservation>, 'metadata
_venue?: BookingImportPreviewItem['_venue']
/** Parsed check-in/out + confirmation — hotels only. */
_accommodation?: BookingImportPreviewItem['_accommodation']
/** The uploaded source file(s) the item was parsed from — attached to the booking on save. */
_sourceFiles?: File[]
}
/**
+4 -3
View File
@@ -221,11 +221,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
(tk) => tk.tripId === String(tripId) && tk.status === 'done' && tk.reviewRequested && !tk.consumed,
)
if (task && task.items && task.items.length > 0) {
// Hand the items to the review flow and clear the widget entry — once the user
// hit "review", the background card has done its job.
// 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 sourceFiles = task.sourceFiles
dismissBgTask(task.id)
startImportReview(items)
startImportReview(items, sourceFiles)
}
}, [bgTasks, tripId, startImportReview, dismissBgTask])
@@ -166,6 +166,8 @@ export function useTripPlanner() {
const [transportPrefill, setTransportPrefill] = useState<BookingReviewDraft | null>(null)
const [importReviewActive, setImportReviewActive] = useState(false)
const importQueueRef = useRef<BookingImportPreviewItem[]>([])
// The files this import was parsed from, so each reviewed booking can attach its source doc.
const importSourceFilesRef = useRef<File[]>([])
// Manual route planning: off by default, toggled from the day-plan footer. Mode
// (driving/walking) is per-session and selects which travel time the connectors show.
const [routeShown, setRouteShown] = useState(false)
@@ -684,6 +686,10 @@ export function useTripPlanner() {
// Open the right edit modal for a parsed item, pre-filled, in create mode.
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)) {
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
setEditingTransport(null); setTransportModalDayId(null)
@@ -695,8 +701,9 @@ export function useTripPlanner() {
}
}
const startImportReview = (items: BookingImportPreviewItem[]) => {
const startImportReview = (items: BookingImportPreviewItem[], sourceFiles: File[] = []) => {
if (!items.length) return
importSourceFilesRef.current = sourceFiles
importQueueRef.current = items.slice(1)
setImportReviewActive(true)
openImportItem(items[0])
+5 -2
View File
@@ -27,11 +27,14 @@ export interface BackgroundImportTask {
error?: string
reviewRequested?: boolean // user clicked "review" — the trip page consumes it
consumed?: boolean // review has been handed to the trip page
/** The uploaded files this parse ran on — kept in memory so the review can attach the
* source document to each created booking. Not persisted (a File can't survive a reload). */
sourceFiles?: File[]
}
interface BackgroundTasksState {
tasks: BackgroundImportTask[]
addTask: (task: { id: string; tripId: string; label: string; total: number }) => void
addTask: (task: { id: string; tripId: string; label: string; total: number; files?: File[] }) => void
setProgress: (id: string, tripId: string, done: number, total: number) => void
setDone: (id: string, tripId: string, items: BookingImportPreviewItem[], warnings: string[]) => void
setError: (id: string, tripId: string, error: string) => void
@@ -58,7 +61,7 @@ export const useBackgroundTasksStore = create<BackgroundTasksState>()(
return {
tasks: [],
addTask: ({ id, tripId, label, total }) => upsert(id, tripId, { label, total, status: 'running', done: 0 }),
addTask: ({ id, tripId, label, total, files }) => upsert(id, tripId, { label, total, status: 'running', done: 0, sourceFiles: files }),
setProgress: (id, tripId, done, total) => upsert(id, tripId, { done, total, status: 'running' }),
setDone: (id, tripId, items, warnings) => upsert(id, tripId, { status: 'done', items, warnings, done: items?.length ?? 0 }),
setError: (id, tripId, error) => upsert(id, tripId, { status: 'error', error }),