mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
64ceecefb3
Every booking and stay write went straight to the API, so offline a new
booking failed with "Network Error" and was lost, and a status tick, the
travellers or a moved transport snapped back. Bookings (add, edit, status,
travellers, delete, day order) and stays (add, edit, delete) now go through
reservationRepo and accommodationRepo and the mutation queue like places and
costs: a temporary row offline, held behind an older queued write online,
since neither carries a version token. Desktop, the phone sheets, the day
plan, the road trip nights and the PDF all write and read through them.
The replay reads the booking from `reservation` only, so the travellers
answer ({ travelers, reservation }) lands on the booking it names. A booking
or its stay that names a place or stay created offline is sent with the real
id once that one has synced, inside create_accommodation too, so an imported
hotel reviewed offline can be linked to a place made for it.
What only works online says so instead of failing: an AirTrail import, a
booking file import, the AI retry and an attachment on a saved booking show
that they need a connection, and a save with newly picked files is stopped
with a hint, since an upload cannot wait in the queue.
Refs #2601
223 lines
10 KiB
TypeScript
223 lines
10 KiB
TypeScript
import { createPortal } 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 { llmRepo } from '../../repo/llmRepo'
|
|
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared'
|
|
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
|
|
import { saveImportFiles } from '../../db/offlineDb'
|
|
import { isEffectivelyOffline } from '../../sync/networkMode'
|
|
|
|
interface BookingImportModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
tripId: number
|
|
/** The tab this import was started from, carried into the job so the review
|
|
* can pick the right form for an item whose type had to be guessed (#2076). */
|
|
kind?: 'transports' | 'bookings'
|
|
}
|
|
|
|
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, kind }: BookingImportModalProps) {
|
|
const { t } = useTranslation()
|
|
const addTask = useBackgroundTasksStore((s) => s.addTask)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const mouseDownTarget = useRef<EventTarget | null>(null)
|
|
|
|
const [files, setFiles] = useState<File[]>([])
|
|
const [isDragOver, setIsDragOver] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [aiParsing, setAiParsing] = useState(false)
|
|
const [readsPhotos, setReadsPhotos] = useState(false)
|
|
// Only a model that reads images can do anything with a photo, so photos are offered only then.
|
|
const acceptedExts: readonly string[] = readsPhotos ? [...ACCEPTED_EXTS, ...LLM_PHOTO_EXTENSIONS] : ACCEPTED_EXTS
|
|
|
|
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))
|
|
llmRepo.readsPhotos().then(setReadsPhotos).catch(() => setReadsPhotos(false))
|
|
}, [isOpen])
|
|
|
|
const handleClose = () => { reset(); onClose() }
|
|
|
|
const validateFile = (f: File): string | null => {
|
|
const ext = ('.' + f.name.toLowerCase().split('.').pop()) as string
|
|
if (!acceptedExts.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<HTMLInputElement>) => {
|
|
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
|
|
// Reading a booking runs on the server: offline there is nothing to send it to.
|
|
if (isEffectivelyOffline()) { setError(t('reservations.needsConnection')); 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, mode, kind })
|
|
handleClose()
|
|
} catch (err: any) {
|
|
setError(err?.response?.data?.error ?? t('reservations.import.error'))
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
if (!isOpen) return null
|
|
|
|
return createPortal(
|
|
<div
|
|
role="presentation"
|
|
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) => {
|
|
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
|
|
mouseDownTarget.current = null
|
|
}}
|
|
>
|
|
<div
|
|
role="presentation"
|
|
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' }}
|
|
>
|
|
{/* Header */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
|
<div style={{ flex: 1, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
|
|
{t('reservations.import.title')}
|
|
</div>
|
|
<button type="button" onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
|
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
|
{t('reservations.import.acceptedFormats')}
|
|
{readsPhotos && <> {t('reservations.import.acceptedPhotos')}</>}
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={acceptedExts.join(',')}
|
|
multiple
|
|
style={{ display: 'none' }}
|
|
onChange={handleInputChange}
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
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: 'calc(13px * var(--fs-scale-body, 1))', 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>
|
|
)}
|
|
</button>
|
|
|
|
{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: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
|
|
<button type="button"
|
|
onClick={handleClose}
|
|
style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button type="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: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
|
>
|
|
{loading ? t('reservations.import.parsing') : t('common.import')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)
|
|
}
|