/ocr-extract answers { extracted: {...}, raw: "..." }. The client checked
`fields` first and `extracted` second, which was right by luck — but an
extraction that returned nothing, or threw, did so silently: the button
went back to idle and the operator had no way to tell a read from a
no-read.
Now an empty or failed extraction says so under the field, and either
way the UPLOAD survives. The file is already stored and referenced; a
failed read only means the fields are not pre-filled, which someone can
recover by typing. Losing the upload because the read failed would not
be recoverable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
94 lines
3.5 KiB
JavaScript
94 lines
3.5 KiB
JavaScript
import { useRef, useState } from 'react'
|
|
import { useZino } from '../api/provider.jsx'
|
|
|
|
/**
|
|
* A file or OCR field.
|
|
*
|
|
* Uploads ON PICK rather than on submit: the backend's extract endpoint takes
|
|
* a REFERENCE to a stored file, so the document crosses the wire once, survives
|
|
* a reload, and re-extracting costs no second upload.
|
|
*
|
|
* For an `ocr` field it then calls /ocr-extract, which resolves the field's own
|
|
* extraction config server-side and returns the values keyed by extraction key.
|
|
* Those are handed up so the form can fill the fields the document answers —
|
|
* which is the entire point: nobody should be typing an engine capacity that is
|
|
* printed on the RC.
|
|
*/
|
|
export default function FileField({ field, value, instanceId, activityUid, onChange, onExtract }) {
|
|
const { client } = useZino()
|
|
const inputRef = useRef(null)
|
|
const [busy, setBusy] = useState('')
|
|
const [err, setErr] = useState(null)
|
|
const [extracted, setExtracted] = useState(null)
|
|
|
|
const isOcr = field.data_type === 'ocr'
|
|
const files = Array.isArray(value) ? value : []
|
|
|
|
async function pick(e) {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
setErr(null); setExtracted(null)
|
|
const ctx = { activityId: activityUid, fieldId: field.id, instanceId }
|
|
try {
|
|
setBusy('Uploading…')
|
|
const ref = await client.uploadFile(file, ctx)
|
|
onChange([ref])
|
|
|
|
if (isOcr) {
|
|
setBusy('Reading the document…')
|
|
// Extraction failing must NOT lose the upload. The file is already
|
|
// stored and referenced; a failed read just means the fields are not
|
|
// pre-filled, which is recoverable by typing. Swallowing the upload
|
|
// because the OCR errored would not be.
|
|
try {
|
|
const out = await client.ocrExtract(ref, ctx)
|
|
// The endpoint answers { extracted: {...}, raw: "..." }.
|
|
const fields = out?.extracted ?? out?.fields ?? out?.data ?? null
|
|
if (fields && typeof fields === 'object' && Object.keys(fields).length) {
|
|
setExtracted(fields)
|
|
onExtract?.(fields)
|
|
} else {
|
|
setErr({ status: '', message: 'Uploaded, but nothing could be read from this document.' })
|
|
}
|
|
} catch (ox) {
|
|
setErr({ status: ox.status ?? '', message: 'Uploaded, but reading it failed — ' + (ox.message || 'unknown error') })
|
|
}
|
|
}
|
|
} catch (ex) {
|
|
setErr(ex)
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="ff">
|
|
<input
|
|
ref={inputRef} type="file" className="ff__input"
|
|
accept=".pdf,.png,.jpg,.jpeg" onChange={pick} disabled={Boolean(busy)}
|
|
/>
|
|
<button type="button" className="ff__btn" disabled={Boolean(busy)}
|
|
onClick={() => inputRef.current?.click()}>
|
|
{busy || (files.length ? 'Replace file' : isOcr ? 'Upload — we read it for you' : 'Upload')}
|
|
</button>
|
|
|
|
{files.length ? (
|
|
<div className="ff__got">{files[0].original_name || files[0].uuid}</div>
|
|
) : null}
|
|
|
|
{extracted ? (
|
|
<div className="ff__read">
|
|
<span className="ff__readlabel">Read from the document</span>
|
|
{Object.entries(extracted)
|
|
.filter(([, v]) => v !== null && v !== undefined && String(v) !== '')
|
|
.map(([k, v]) => (
|
|
<div key={k}><em>{k.replace(/_/g, ' ')}</em> {String(v)}</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{err ? <div className="ff__err">{err.status} {err.message}</div> : null}
|
|
</div>
|
|
)
|
|
}
|