mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
feat(import): parse bookings in the background with a progress widget
Parsing a booking can take a while on a CPU host, so don't hold the upload modal open for it. The async import endpoint returns a job id right away; the parse runs server-side (one at a time per user) and pushes progress over the user's WebSocket, and a small widget in the bottom corner tracks it while the user keeps navigating and editing. A finished job opens the per-item review from the widget.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { reservationsApi } from '../../api/client'
|
||||
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
|
||||
|
||||
/**
|
||||
* Global, route-independent widget (bottom-right) that tracks background booking
|
||||
* imports. Mounted once at the app root so it survives navigation. It listens to the
|
||||
* user's WebSocket for import:progress / import:done / import:error and reflects each
|
||||
* job; a finished job offers a "review" action that takes the user to the trip, where
|
||||
* the per-item review flow opens. Polls running jobs as a backstop for missed pushes.
|
||||
*/
|
||||
export default function BackgroundTasksWidget() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const tasks = useBackgroundTasksStore((s) => s.tasks)
|
||||
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
|
||||
const setDone = useBackgroundTasksStore((s) => s.setDone)
|
||||
const setError = useBackgroundTasksStore((s) => s.setError)
|
||||
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
|
||||
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
|
||||
|
||||
// Server pushes import:* to the user on whatever page they're on.
|
||||
useEffect(() => {
|
||||
const handler = (e: Record<string, unknown>) => {
|
||||
const type = typeof e.type === 'string' ? e.type : ''
|
||||
if (!type.startsWith('import:')) return
|
||||
const id = String(e.jobId ?? '')
|
||||
const tripId = String(e.tripId ?? '')
|
||||
if (!id) return
|
||||
if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1))
|
||||
else if (type === 'import:done') {
|
||||
const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined
|
||||
setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? [])
|
||||
} else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [setProgress, setDone, setError])
|
||||
|
||||
// Backstop: poll running jobs in case a WebSocket push was missed on reconnect.
|
||||
useEffect(() => {
|
||||
const running = tasks.filter((task) => task.status === 'running')
|
||||
if (running.length === 0) return
|
||||
const iv = setInterval(() => {
|
||||
for (const task of running) {
|
||||
reservationsApi
|
||||
.importJobStatus(task.tripId, task.id)
|
||||
.then((s) => {
|
||||
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
|
||||
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
|
||||
else setProgress(task.id, task.tripId, s.done, s.total)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearInterval(iv)
|
||||
}, [tasks, setProgress, setDone, setError])
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
const review = (task: BackgroundImportTask) => {
|
||||
requestReview(task.id)
|
||||
navigate(`/trips/${task.tripId}`)
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
|
||||
>
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 12, border: '1px solid var(--border-primary)', boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '11px 13px', backdropFilter: 'blur(8px)', display: 'flex', gap: 10, alignItems: 'flex-start' }}
|
||||
>
|
||||
<div style={{ flexShrink: 0, marginTop: 1 }}>
|
||||
{task.status === 'running' && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
|
||||
{task.status === 'done' && <CheckCircle2 size={16} color="#10b981" />}
|
||||
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.label}
|
||||
</div>
|
||||
|
||||
{task.status === 'running' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
|
||||
{t('reservations.import.parsing')}
|
||||
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.status === 'done' && (
|
||||
(task.items?.length ?? 0) > 0 ? (
|
||||
<button
|
||||
onClick={() => review(task)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.import')}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.previewEmpty')}</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{task.status === 'error' && (
|
||||
<div style={{ fontSize: 11, color: '#b91c1c', marginTop: 1, whiteSpace: 'pre-wrap' }}>{task.error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.status !== 'running' && (
|
||||
<button
|
||||
onClick={() => dismiss(task.id)}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,95 +1,43 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Upload, Plane, Train, Hotel, UtensilsCrossed, Car, Anchor, Calendar, ArrowLeft, X } from 'lucide-react'
|
||||
import type { BookingImportPreviewItem, BookingImportFileReport } from '@trek/shared'
|
||||
import { Upload, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { reservationsApi, healthApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
|
||||
|
||||
interface BookingImportModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
tripId: number
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
// Fired after a successful import so the page can refresh state that lives
|
||||
// outside the trip store — notably the accommodations list a hotel booking
|
||||
// links to (loadTrip alone leaves it stale, so the edit modal shows blanks).
|
||||
onImported?: () => void
|
||||
// When provided, the parsed items aren't persisted directly: each is handed
|
||||
// back so the page can open the normal edit modal pre-filled for review before
|
||||
// the user saves it. Falls back to direct confirm() when absent.
|
||||
onReview?: (items: BookingImportPreviewItem[]) => void
|
||||
}
|
||||
|
||||
const ACCEPTED_EXTS = ['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024
|
||||
const MAX_FILES = 5
|
||||
|
||||
const TYPE_ICONS: Record<string, React.FC<{ size: number; color?: string }>> = {
|
||||
flight: Plane,
|
||||
train: Train,
|
||||
hotel: Hotel,
|
||||
restaurant: UtensilsCrossed,
|
||||
car: Car,
|
||||
cruise: Anchor,
|
||||
event: Calendar,
|
||||
}
|
||||
|
||||
function typeColor(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
flight: '#3b82f6',
|
||||
train: '#10b981',
|
||||
hotel: '#8b5cf6',
|
||||
restaurant: '#f59e0b',
|
||||
car: '#6b7280',
|
||||
cruise: '#06b6d4',
|
||||
event: '#ec4899',
|
||||
}
|
||||
return map[type] ?? 'var(--text-faint)'
|
||||
}
|
||||
|
||||
function formatDateTime(iso: unknown): string {
|
||||
if (!iso) return ''
|
||||
const str = typeof iso === 'string' ? iso : typeof iso === 'object' ? JSON.stringify(iso) : String(iso)
|
||||
const date = str.slice(0, 10)
|
||||
const time = str.length > 10 ? str.slice(11, 16) : ''
|
||||
return [date, time].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo, onImported, onReview }: BookingImportModalProps) {
|
||||
/**
|
||||
* Upload booking files and kick off a BACKGROUND parse. The modal closes at once;
|
||||
* the parse runs server-side and is tracked by the global BackgroundTasksWidget
|
||||
* (progress over the WebSocket). When it finishes, the trip page opens the per-item
|
||||
* review flow — so the user can navigate and keep editing while it works.
|
||||
*/
|
||||
export default function BookingImportModal({ isOpen, onClose, tripId }: BookingImportModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const addTask = useBackgroundTasksStore((s) => s.addTask)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
|
||||
type Phase = 'upload' | 'preview' | 'confirming'
|
||||
const [phase, setPhase] = useState<Phase>('upload')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [previewItems, setPreviewItems] = useState<BookingImportPreviewItem[]>([])
|
||||
const [warnings, setWarnings] = useState<string[]>([])
|
||||
const [excluded, setExcluded] = useState<Set<number>>(() => new Set())
|
||||
// AI fallback: addon-level availability + per-file report + in-flight retries.
|
||||
const [aiParsing, setAiParsing] = useState(false)
|
||||
const [fileReports, setFileReports] = useState<BookingImportFileReport[]>([])
|
||||
const [retrying, setRetrying] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const reset = () => {
|
||||
setPhase('upload')
|
||||
setFiles([])
|
||||
setIsDragOver(false)
|
||||
setLoading(false)
|
||||
setError('')
|
||||
setPreviewItems([])
|
||||
setWarnings([])
|
||||
setExcluded(new Set())
|
||||
setFileReports([])
|
||||
setRetrying(new Set())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -100,7 +48,7 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo,
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
healthApi.features().then(f => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
|
||||
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
|
||||
}, [isOpen])
|
||||
|
||||
const handleClose = () => { reset(); onClose() }
|
||||
@@ -140,116 +88,41 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo,
|
||||
if (list.length) selectFiles(list)
|
||||
}
|
||||
|
||||
// Start the parse in the background and close — the widget takes it from here.
|
||||
const handleParse = async () => {
|
||||
if (files.length === 0 || loading) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
// Auto-rescue: whenever AI parsing is available, files kitinerary can't
|
||||
// read fall back to the LLM automatically — no extra confirmation step.
|
||||
const mode = aiParsing ? 'fallback-on-empty' : 'no-ai'
|
||||
const result = await reservationsApi.importBookingPreview(tripId, files, mode)
|
||||
setPreviewItems(result.items ?? [])
|
||||
setWarnings(result.warnings ?? [])
|
||||
setFileReports(result.files ?? [])
|
||||
setExcluded(new Set())
|
||||
setPhase('preview')
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error ?? t('reservations.import.error')
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-run a single file through the LLM (force-ai) and merge any new items in.
|
||||
const handleRetryAi = async (fileName: string) => {
|
||||
const file = files.find(f => f.name === fileName)
|
||||
if (!file || retrying.has(fileName)) return
|
||||
setRetrying(prev => new Set(prev).add(fileName))
|
||||
try {
|
||||
const result = await reservationsApi.importBookingPreview(tripId, [file], 'force-ai')
|
||||
setPreviewItems(prev => [...prev, ...(result.items ?? [])])
|
||||
setWarnings(prev => [...prev, ...(result.warnings ?? [])])
|
||||
setFileReports(prev => prev.map(r => r.fileName === fileName ? { ...r, aiUsed: true } : r))
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error ?? t('reservations.import.error')
|
||||
setError(msg)
|
||||
} finally {
|
||||
setRetrying(prev => { const next = new Set(prev); next.delete(fileName); return next })
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const toImport = previewItems.filter((_, i) => !excluded.has(i))
|
||||
if (toImport.length === 0) return
|
||||
// Review-first flow: hand the parsed items to the page, which opens the
|
||||
// normal edit modal pre-filled for each so the user checks before saving.
|
||||
if (onReview) { onReview(toImport); handleClose(); return }
|
||||
setPhase('confirming')
|
||||
setError('')
|
||||
try {
|
||||
const result = await reservationsApi.importBookingConfirm(tripId, toImport)
|
||||
const created = result.created ?? []
|
||||
await loadTrip(tripId)
|
||||
// Refresh out-of-store state (accommodations) so a freshly imported hotel
|
||||
// resolves its place/date range in the reservation edit modal.
|
||||
onImported?.()
|
||||
|
||||
if (created.length > 0) {
|
||||
pushUndo?.(t('undo.importBooking'), async () => {
|
||||
try {
|
||||
const { reservationsApi: rApi } = await import('../../api/client')
|
||||
await Promise.all(created.map((r) => rApi.delete(tripId, r.id).catch(() => {})))
|
||||
} catch {}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
toast.success(t('reservations.import.success', { count: created.length }))
|
||||
} else {
|
||||
toast.warning(t('reservations.import.previewEmpty'))
|
||||
}
|
||||
|
||||
const { jobId } = await reservationsApi.importBookingAsync(tripId, files, mode)
|
||||
addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length })
|
||||
handleClose()
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error ?? t('reservations.import.error'))
|
||||
setPhase('preview')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExclude = (idx: number) => {
|
||||
setExcluded(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(idx)) next.delete(idx); else next.add(idx)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const activeCount = previewItems.filter((_, i) => !excluded.has(i)).length
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className="bg-[rgba(0,0,0,0.4)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
onMouseDown={(e) => { mouseDownTarget.current = e.target }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
|
||||
mouseDownTarget.current = null
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
{phase === 'preview' && (
|
||||
<button onClick={() => setPhase('upload')} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('reservations.import.title')}
|
||||
</div>
|
||||
@@ -259,155 +132,45 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo,
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{/* Upload phase */}
|
||||
{phase === 'upload' && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
||||
{t('reservations.import.acceptedFormats')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
||||
{t('reservations.import.acceptedFormats')}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTS.join(',')}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTS.join(',')}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
|
||||
style={{
|
||||
width: '100%', minHeight: 100, borderRadius: 12,
|
||||
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer',
|
||||
marginBottom: 12, padding: 16, boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
||||
{isDragOver ? (
|
||||
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
|
||||
) : files.length > 0 ? (
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map(f => f.name).join(', ')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
|
||||
style={{
|
||||
width: '100%', minHeight: 100, borderRadius: 12,
|
||||
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer',
|
||||
marginBottom: 12, padding: 16, boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
||||
{isDragOver ? (
|
||||
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
|
||||
) : files.length > 0 ? (
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map((f) => f.name).join(', ')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview phase */}
|
||||
{(phase === 'preview' || phase === 'confirming') && (
|
||||
<>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 10 }}>
|
||||
{t('reservations.import.previewHeading', { count: previewItems.length })}
|
||||
</div>
|
||||
|
||||
{previewItems.length === 0 && (
|
||||
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||||
{t('reservations.import.previewEmpty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewItems.map((item, idx) => {
|
||||
const Icon = TYPE_ICONS[item.type] ?? Calendar
|
||||
const isExcluded = excluded.has(idx)
|
||||
const fromEp = item.endpoints?.find(e => e.role === 'from')
|
||||
const toEp = item.endpoints?.find(e => e.role === 'to')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.source.fileName}-${idx}`}
|
||||
className={isExcluded ? 'bg-surface-tertiary' : 'bg-surface-secondary'}
|
||||
style={{
|
||||
borderRadius: 10, padding: '10px 12px', marginBottom: 8,
|
||||
border: `1px solid ${isExcluded ? 'var(--border-faint)' : 'var(--border-primary)'}`,
|
||||
opacity: isExcluded ? 0.5 : 1, transition: 'opacity 0.15s',
|
||||
display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, marginTop: 2 }}>
|
||||
<Icon size={15} color={typeColor(item.type)} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.needs_review && (
|
||||
<span className="bg-[rgba(245,158,11,0.15)] text-[#92400e]" style={{ flexShrink: 0, fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 6 }}>
|
||||
{t('reservations.import.needsReview')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{fromEp && toEp && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>
|
||||
{fromEp.code ?? fromEp.name} → {toEp.code ?? toEp.name}
|
||||
</div>
|
||||
)}
|
||||
{item.reservation_time && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||||
{formatDateTime(item.reservation_time)}
|
||||
{item.reservation_end_time && ` – ${formatDateTime(item.reservation_end_time)}`}
|
||||
</div>
|
||||
)}
|
||||
{item._accommodation?.check_in && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||||
{formatDateTime(item._accommodation.check_in)} – {formatDateTime(item._accommodation.check_out)}
|
||||
</div>
|
||||
)}
|
||||
{item.confirmation_number && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'monospace' }}>
|
||||
{item.confirmation_number}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleExclude(idx)}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, flexShrink: 0, fontSize: 11, fontFamily: 'inherit', fontWeight: 500 }}
|
||||
title={t('reservations.import.removeItem')}
|
||||
>
|
||||
{isExcluded ? '+' : <X size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Per-file AI fallback: offer a retry for files kitinerary couldn't read. */}
|
||||
{phase === 'preview' && fileReports.filter(r => r.aiAvailable && !r.aiUsed).map(r => (
|
||||
<div key={`ai-${r.fileName}`} className="bg-surface-secondary" style={{ borderRadius: 10, padding: '8px 12px', marginBottom: 8, border: '1px dashed var(--border-primary)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{r.fileName}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRetryAi(r.fileName)}
|
||||
disabled={retrying.has(r.fileName)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ flexShrink: 0, border: 'none', borderRadius: 8, padding: '5px 10px', fontSize: 12, fontWeight: 500, cursor: retrying.has(r.fileName) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: retrying.has(r.fileName) ? 0.6 : 1 }}
|
||||
>
|
||||
{retrying.has(r.fileName) ? t('reservations.import.aiParsing') : t('reservations.import.tryAi')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="bg-[rgba(245,158,11,0.08)] text-[#92400e]" style={{ border: '1px solid rgba(245,158,11,0.3)', borderRadius: 10, padding: '8px 10px', fontSize: 12, marginTop: 8, whiteSpace: 'pre-wrap' }}>
|
||||
{warnings.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||
{error}
|
||||
@@ -423,28 +186,14 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo,
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
|
||||
{phase === 'upload' && (
|
||||
<button
|
||||
onClick={handleParse}
|
||||
disabled={files.length === 0 || loading}
|
||||
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||
>
|
||||
{loading ? t('reservations.import.parsing') : t('common.import')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(phase === 'preview' || phase === 'confirming') && (
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={activeCount === 0 || phase === 'confirming'}
|
||||
className={activeCount > 0 && phase !== 'confirming' ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: activeCount > 0 && phase !== 'confirming' ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||
>
|
||||
{phase === 'confirming' ? t('common.loading') : t('reservations.import.confirm', { count: activeCount })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleParse}
|
||||
disabled={files.length === 0 || loading}
|
||||
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||
>
|
||||
{loading ? t('reservations.import.parsing') : t('common.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
Reference in New Issue
Block a user