mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
b1c05bca83
Whether the AI Parsing model reads images is an admin setting now, and it lands with the two features that send a photo, so it has something to control. The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On Automatic a local Ollama is asked what the model can do (`/api/show`), and a cloud provider means no; Yes and No are applied as set. The old `multimodal` flag is not read: the admin screens wrote it false on every save, so a stored false meant nothing. The per-user toggle keeps its on/off meaning and is read by the extraction now. The lookup is server-side only: capped read, validated body, never throws, cached per server and model. GET /api/llm/capabilities answers it for the signed-in caller, and both photo pickers below ask it. The booking import takes JPG, PNG and WEBP when the model reads images. The photo goes as an image to every provider (image_url, an Anthropic image block, Ollama's native chat with `images`), capped at 1600px, and skips KDE Itinerary. A scanned PDF, which used to reach the model as empty text, has its first two pages drawn by pdf-parse and sent the same way. The rest is the import as it was: background job, review, the file attached to the booking, the linked cost. On a real qwen3.5:4b a train ticket came back as bare clock times until the router gave the model every type's field meanings, and its price as an object inside a string, which read as 4900; both are handled. Costs gets Scan receipt: a dialog laid out like the booking import's takes one photo, read on the import job queue, then reviewed in the existing expense editor pre-filled with merchant, total, currency, day and lines, the photo waiting in Receipts & Invoices to be uploaded on save. No table, no migration, no category guess, no document-type detection.
220 lines
9.8 KiB
TypeScript
220 lines
9.8 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'
|
|
|
|
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
|
|
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
|
|
)
|
|
}
|