mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
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:
@@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user