From 4abe96fe016278a5196f6d36ea71956f8fec499b Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 26 Jun 2026 10:41:41 +0200 Subject: [PATCH] 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. --- .../components/Planner/BookingImportModal.tsx | 3 ++- .../components/Planner/ReservationModal.tsx | 26 +++++------------- .../src/components/Planner/TransportModal.tsx | 27 ++++--------------- .../components/Planner/parsedItemToDraft.ts | 2 ++ client/src/pages/TripPlannerPage.tsx | 7 ++--- .../src/pages/tripPlanner/useTripPlanner.ts | 9 ++++++- client/src/store/backgroundTasksStore.ts | 7 +++-- 7 files changed, 32 insertions(+), 49 deletions(-) diff --git a/client/src/components/Planner/BookingImportModal.tsx b/client/src/components/Planner/BookingImportModal.tsx index 4d2790f2..9407ac79 100644 --- a/client/src/components/Planner/BookingImportModal.tsx +++ b/client/src/components/Planner/BookingImportModal.tsx @@ -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')) diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 50e39e12..2518f2dd 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -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([]) const [showFilePicker, setShowFilePicker] = useState(false) const [linkedFileIds, setLinkedFileIds] = useState([]) @@ -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', diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index f4b3e47d..cd36ef92 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -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([]) const fileInputRef = useRef(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 || '', diff --git a/client/src/components/Planner/parsedItemToDraft.ts b/client/src/components/Planner/parsedItemToDraft.ts index 6a222825..420250c9 100644 --- a/client/src/components/Planner/parsedItemToDraft.ts +++ b/client/src/components/Planner/parsedItemToDraft.ts @@ -15,6 +15,8 @@ export interface BookingReviewDraft extends Omit, '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[] } /** diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index decf73ad..2905add8 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -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]) diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index ac0625ad..b9abb7cd 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -166,6 +166,8 @@ export function useTripPlanner() { const [transportPrefill, setTransportPrefill] = useState(null) const [importReviewActive, setImportReviewActive] = useState(false) const importQueueRef = useRef([]) + // The files this import was parsed from, so each reviewed booking can attach its source doc. + const importSourceFilesRef = useRef([]) // 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]) diff --git a/client/src/store/backgroundTasksStore.ts b/client/src/store/backgroundTasksStore.ts index 7df9fa29..8b1289f0 100644 --- a/client/src/store/backgroundTasksStore.ts +++ b/client/src/store/backgroundTasksStore.ts @@ -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()( 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 }),