Render file and OCR fields as uploads, not text boxes
Collect Documents shipped with every document field rendering as a plain text input — ActivityForm had no case for `file` or `ocr`, so they fell through to the default. There was a form asking you to type an RC book. Uploads happen ON PICK rather than on submit, because /ocr-extract takes a REFERENCE to a stored file: the document crosses the wire once, it survives a reload (a draft cannot carry a File), and re-extracting costs no second upload. For an ocr field the extracted values are shown inline — "read from the document" — and filled into the fields they map to. Only into fields that are still blank: a value a person typed is never overwritten by one a model read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b90c94686e
commit
6df594f0b4
@ -170,6 +170,47 @@ export class ZinoClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file and get back a reference — {uuid, blob_path, original_name,
|
||||||
|
* mime_type}. The field context matters: the backend resolves the field's
|
||||||
|
* own config (allowed types, size limit, ocr_config) server-side from it and
|
||||||
|
* ignores anything the client claims.
|
||||||
|
*/
|
||||||
|
async uploadFile(file, ctx) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
if (ctx.workflowUuid) form.append('workflow_uuid', ctx.workflowUuid)
|
||||||
|
if (ctx.activityId) form.append('activity_id', ctx.activityId)
|
||||||
|
if (ctx.fieldId) form.append('field_id', ctx.fieldId)
|
||||||
|
if (ctx.instanceId) form.append('instance_id', String(ctx.instanceId))
|
||||||
|
const headers = {}
|
||||||
|
if (this.token) headers['Authorization'] = `Bearer ${this.token}`
|
||||||
|
const res = await fetch(`${this.baseUrl}/app/${APP_ID}/upload`, { method: 'POST', headers, body: form })
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = res.statusText
|
||||||
|
try { const j = await res.json(); message = j.error || j.message || message } catch { /* non-JSON */ }
|
||||||
|
throw { status: res.status, message }
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract from an ALREADY-UPLOADED file. Sends a reference, not the bytes —
|
||||||
|
* the document crosses the wire once, survives a reload, and re-extracting
|
||||||
|
* costs no re-upload. The ocr_config (which fields to pull, where they map)
|
||||||
|
* is resolved server-side from the deployed workflow; anything the client
|
||||||
|
* sends is ignored.
|
||||||
|
*/
|
||||||
|
ocrExtract(fileRef, ctx) {
|
||||||
|
return this.request('POST', `/app/${APP_ID}/ocr-extract`, {
|
||||||
|
workflow_uuid: WORKFLOW,
|
||||||
|
activity_id: ctx.activityId,
|
||||||
|
field_id: ctx.fieldId,
|
||||||
|
instance_id: ctx.instanceId || undefined,
|
||||||
|
files: [fileRef],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
instance(instanceId) {
|
instance(instanceId) {
|
||||||
return this.request('POST', `/app/${APP_ID}/instance`, {
|
return this.request('POST', `/app/${APP_ID}/instance`, {
|
||||||
workflow_uuid: WORKFLOW,
|
workflow_uuid: WORKFLOW,
|
||||||
|
|||||||
@ -51,3 +51,32 @@
|
|||||||
font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
|
font-size: .6rem; letter-spacing: .07em; text-transform: uppercase;
|
||||||
color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: 3px; padding: 1px 5px;
|
color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: 3px; padding: 1px 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- file / ocr ---- */
|
||||||
|
.ff { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.ff__input { display: none; }
|
||||||
|
.ff__btn {
|
||||||
|
font: inherit; font-size: .84rem; padding: 8px 12px; border-radius: 4px; cursor: pointer;
|
||||||
|
border: 1px dashed var(--zk-blue-light); background: var(--zk-tint-blue);
|
||||||
|
color: var(--zk-blue-dark); text-align: center;
|
||||||
|
}
|
||||||
|
.ff__btn:hover:not(:disabled) { background: var(--zk-white); border-style: solid; }
|
||||||
|
.ff__btn:disabled { opacity: .6; cursor: default; border-style: solid; }
|
||||||
|
.ff__got {
|
||||||
|
font-size: .78rem; color: var(--zk-muted);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ff__read {
|
||||||
|
border-left: 2px solid var(--zk-blue); background: var(--zk-tint);
|
||||||
|
border-radius: 0 4px 4px 0; padding: 8px 10px; display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.ff__readlabel {
|
||||||
|
font-size: .62rem; letter-spacing: .08em; text-transform: uppercase;
|
||||||
|
color: var(--zk-blue-dark); margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.ff__read div { font-size: .8rem; color: var(--zk-ink); }
|
||||||
|
.ff__read em { font-style: normal; color: var(--zk-grey); text-transform: capitalize; margin-right: 5px; font-size: .72rem; }
|
||||||
|
.ff__err {
|
||||||
|
font-size: .78rem; color: var(--zk-danger);
|
||||||
|
background: #fdf0ef; border: 1px solid #f0c9c6; border-radius: 4px; padding: 6px 9px;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
|
import FileField from './FileField.jsx'
|
||||||
import './ActivityForm.css'
|
import './ActivityForm.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -80,7 +81,25 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
|
|||||||
<label key={f.uid} className={'af__field' + (f.data_type === 'longtext' ? ' af__field--wide' : '')}>
|
<label key={f.uid} className={'af__field' + (f.data_type === 'longtext' ? ' af__field--wide' : '')}>
|
||||||
<span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span>
|
<span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span>
|
||||||
|
|
||||||
{f.data_type === 'id_gen' ? (
|
{f.data_type === 'file' || f.data_type === 'ocr' ? (
|
||||||
|
<FileField
|
||||||
|
field={f} value={v} instanceId={instanceId} activityUid={activityUid}
|
||||||
|
onChange={(refs) => set(f.id, refs)}
|
||||||
|
onExtract={(fields) => {
|
||||||
|
// The document answered questions the form was about to ask.
|
||||||
|
// Only fill what is still blank — never overwrite something a
|
||||||
|
// person typed with something a model read.
|
||||||
|
setValues((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const [k, val] of Object.entries(fields)) {
|
||||||
|
if (val === null || val === undefined || String(val) === '') continue
|
||||||
|
if (next[k] === undefined || next[k] === '') next[k] = val
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : f.data_type === 'id_gen' ? (
|
||||||
<div className="af__auto">
|
<div className="af__auto">
|
||||||
{v || 'Issued on submit'}
|
{v || 'Issued on submit'}
|
||||||
<span>auto</span>
|
<span>auto</span>
|
||||||
|
|||||||
82
src/components/FileField.jsx
Normal file
82
src/components/FileField.jsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
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…')
|
||||||
|
const out = await client.ocrExtract(ref, ctx)
|
||||||
|
const fields = out?.fields ?? out?.extracted ?? out?.data ?? out
|
||||||
|
if (fields && typeof fields === 'object') {
|
||||||
|
setExtracted(fields)
|
||||||
|
onExtract?.(fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user