diff --git a/src/api/client.js b/src/api/client.js index c9d8042..cd520b0 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -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) { return this.request('POST', `/app/${APP_ID}/instance`, { workflow_uuid: WORKFLOW, diff --git a/src/components/ActivityForm.css b/src/components/ActivityForm.css index 4459d71..0c395ef 100644 --- a/src/components/ActivityForm.css +++ b/src/components/ActivityForm.css @@ -51,3 +51,32 @@ 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; } + +/* ---- 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; +} diff --git a/src/components/ActivityForm.jsx b/src/components/ActivityForm.jsx index a200d4b..0534734 100644 --- a/src/components/ActivityForm.jsx +++ b/src/components/ActivityForm.jsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { useZino } from '../api/provider.jsx' +import FileField from './FileField.jsx' import './ActivityForm.css' /** @@ -80,7 +81,25 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel