import ReactDOM from 'react-dom' import { useState, useRef, useEffect } from 'react' import { Upload, X } from 'lucide-react' import { useTranslation } from '../../i18n' import { reservationsApi, healthApi } from '../../api/client' import { useBackgroundTasksStore } from '../../store/backgroundTasksStore' import { saveImportFiles } from '../../db/offlineDb' interface BookingImportModalProps { isOpen: boolean onClose: () => void tripId: number } const ACCEPTED_EXTS = ['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt'] const MAX_FILE_BYTES = 10 * 1024 * 1024 const MAX_FILES = 5 /** * 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 addTask = useBackgroundTasksStore((s) => s.addTask) const fileInputRef = useRef(null) const mouseDownTarget = useRef(null) const [files, setFiles] = useState([]) const [isDragOver, setIsDragOver] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [aiParsing, setAiParsing] = useState(false) const reset = () => { setFiles([]) setIsDragOver(false) setLoading(false) setError('') } useEffect(() => { if (isOpen) reset() // reset is stable — intentional // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]) useEffect(() => { if (!isOpen) return healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false)) }, [isOpen]) const handleClose = () => { reset(); onClose() } const validateFile = (f: File): string | null => { const ext = ('.' + f.name.toLowerCase().split('.').pop()) as string if (!ACCEPTED_EXTS.includes(ext)) return t('reservations.import.unsupportedFormat') if (f.size > MAX_FILE_BYTES) return t('reservations.import.fileTooLarge', { name: f.name }) return null } const selectFiles = (incoming: File[]) => { const valid: File[] = [] let firstErr: string | null = null for (const f of incoming.slice(0, MAX_FILES)) { const err = validateFile(f) if (err) { firstErr = firstErr ?? err; continue } valid.push(f) } if (valid.length === 0) { setError(firstErr ?? ''); return } setFiles(valid) setError(firstErr ?? '') } const handleInputChange = (e: React.ChangeEvent) => { const list = e.target.files ? Array.from(e.target.files) : [] e.target.value = '' if (list.length) selectFiles(list) } const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true) } const handleDragLeave = (e: React.DragEvent) => { if (e.target === e.currentTarget) setIsDragOver(false) } const handleDrop = (e: React.DragEvent) => { e.preventDefault() setIsDragOver(false) const list = Array.from(e.dataTransfer.files) 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 { const mode = aiParsing ? 'fallback-on-empty' : 'no-ai' const { jobId } = await reservationsApi.importBookingAsync(tripId, files, mode) // Keep the uploaded files so the review can attach each source document to its booking — // in memory for the immediate path, and in IndexedDB so it survives a reload mid-parse. await saveImportFiles(jobId, files) 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')) setLoading(false) } } if (!isOpen) return null return ReactDOM.createPortal(
{ mouseDownTarget.current = e.target }} onClick={(e) => { if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose() mouseDownTarget.current = null }} >
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' }} > {/* Header */}
{t('reservations.import.title')}
{t('reservations.import.acceptedFormats')}
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: 'calc(13px * var(--fs-scale-text, 1))', fontWeight: 500, cursor: 'pointer', marginBottom: 12, padding: 16, boxSizing: 'border-box', transition: 'border-color 0.15s, background 0.15s', }} > {isDragOver ? ( {t('reservations.import.dropActive')} ) : files.length > 0 ? ( {files.map((f) => f.name).join(', ')} ) : ( {t('reservations.import.dropHere')} )}
{error && (
{error}
)}
{/* Footer */}
, document.body ) }