upload the invoice as a PDF and autofill the form via OCR extraction
This commit is contained in:
parent
cc997838bb
commit
db8233fa45
@ -13,12 +13,9 @@ function headers(): Record<string, string> {
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
// Shared response handling — 401 bounces to login, error bodies are unwrapped
|
||||||
const res = await fetch(`${baseUrl()}${path}`, {
|
// from { error }. Used by both the JSON and multipart request paths.
|
||||||
method,
|
async function parseResponse<T>(res: Response): Promise<T> {
|
||||||
headers: headers(),
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
localStorage.removeItem("p2p_user");
|
localStorage.removeItem("p2p_user");
|
||||||
@ -34,6 +31,37 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
|||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${baseUrl()}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: headers(),
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return parseResponse<T>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multipart POST for the field-scoped core-service endpoints (/upload,
|
||||||
|
// /ocr-extract). Content-Type is deliberately omitted so the browser sets the
|
||||||
|
// multipart boundary itself.
|
||||||
|
async function postFile<T>(path: string, file: File, ctx: FieldFileContext): Promise<T> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("workflow_uuid", ctx.workflowUuid);
|
||||||
|
form.append("activity_id", ctx.activityId);
|
||||||
|
form.append("field_id", ctx.fieldId);
|
||||||
|
if (ctx.columnId) form.append("column_id", ctx.columnId);
|
||||||
|
if (ctx.versionUuid) form.append("version_uuid", ctx.versionUuid);
|
||||||
|
if (ctx.instanceId) form.append("instance_id", String(ctx.instanceId));
|
||||||
|
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
const res = await fetch(`${baseUrl()}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
return parseResponse<T>(res);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Header
|
// Header
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -248,6 +276,33 @@ export function getDetailViewLegacy(dvUid: string, instanceId: string): Promise<
|
|||||||
// Form Screens
|
// Form Screens
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// One key the vision model must return, as configured in studio's OCR modal.
|
||||||
|
export interface OcrExtractionField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
data_type?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// extraction_key → the form field that receives the extracted value.
|
||||||
|
export interface OcrFieldMapping {
|
||||||
|
extraction_key: string;
|
||||||
|
target_field: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Studio's ocr_config block. `prompt` / `extraction_fields` are resolved
|
||||||
|
// server-side by /ocr-extract and are present here only for picker UX; the
|
||||||
|
// client genuinely needs `field_mappings` to know where to write the results.
|
||||||
|
export interface OcrConfig {
|
||||||
|
prompt?: string;
|
||||||
|
template?: string;
|
||||||
|
allowed_types?: string[] | string;
|
||||||
|
max_size_mb?: number | string;
|
||||||
|
placeholder?: string;
|
||||||
|
extraction_fields?: OcrExtractionField[];
|
||||||
|
field_mappings?: OcrFieldMapping[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface FormScreenField {
|
export interface FormScreenField {
|
||||||
id: string;
|
id: string;
|
||||||
uid: string;
|
uid: string;
|
||||||
@ -259,6 +314,15 @@ export interface FormScreenField {
|
|||||||
properties?: {
|
properties?: {
|
||||||
options?: Array<{ label: string; value: string }>;
|
options?: Array<{ label: string; value: string }>;
|
||||||
country_code?: string;
|
country_code?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
ocr_config?: OcrConfig;
|
||||||
|
// Some studio configs put the OCR keys flat on properties instead of
|
||||||
|
// nested under ocr_config — mirrored from FFOcrField's resolveOcrConfig.
|
||||||
|
allowed_types?: string[] | string;
|
||||||
|
max_size_mb?: number | string;
|
||||||
|
extraction_fields?: OcrExtractionField[];
|
||||||
|
field_mappings?: OcrFieldMapping[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -270,6 +334,10 @@ export interface FormScreenResponse {
|
|||||||
fields: FormScreenField[];
|
fields: FormScreenField[];
|
||||||
grid_config: Array<{ i: string; x: number; y: number; w: number; h: number }>;
|
grid_config: Array<{ i: string; x: number; y: number; w: number; h: number }>;
|
||||||
layout: any[];
|
layout: any[];
|
||||||
|
// Identifiers the field-scoped core endpoints (/upload, /ocr-extract) need
|
||||||
|
// so they can resolve the field's config + RBAC server-side.
|
||||||
|
workflow_uuid?: string;
|
||||||
|
version_uuid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFormScreen(
|
export function getFormScreen(
|
||||||
@ -287,6 +355,53 @@ export function getFormScreen(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Field files & OCR (core-service)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Identifiers sent alongside every field-scoped file upload. The backend uses
|
||||||
|
// these to resolve the field's deployed allowed_types / max_size_mb / ocr_config
|
||||||
|
// and to run activity RBAC — anything the client sends about that config is
|
||||||
|
// ignored, so this trio is mandatory.
|
||||||
|
export interface FieldFileContext {
|
||||||
|
workflowUuid: string;
|
||||||
|
activityId: string;
|
||||||
|
fieldId: string;
|
||||||
|
columnId?: string;
|
||||||
|
versionUuid?: string;
|
||||||
|
instanceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What /upload returns and what gets stored in instance data. `uuid` is the
|
||||||
|
// stable app-scoped handle; blob_path is kept for the legacy serve fallback.
|
||||||
|
export interface FileMeta {
|
||||||
|
uuid: string;
|
||||||
|
blob_path: string;
|
||||||
|
original_name: string;
|
||||||
|
mime_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrExtractResponse {
|
||||||
|
// Keyed by the studio-configured extraction_fields[].key. A key whose value
|
||||||
|
// wasn't found in the document comes back as null.
|
||||||
|
extracted: Record<string, unknown>;
|
||||||
|
raw?: string;
|
||||||
|
// Set when the model's reply wasn't parseable JSON — `raw` still holds the text.
|
||||||
|
parse_error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stores a form-field file and returns the metadata to submit in its place.
|
||||||
|
export function uploadFieldFile(file: File, ctx: FieldFileContext): Promise<FileMeta> {
|
||||||
|
return postFile<FileMeta>(`/app/${APP_ID}/upload`, file, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs vision OCR over an uploaded document. The prompt and extraction schema
|
||||||
|
// live in the deployed ocr_config, so only the file + identifiers go over the wire.
|
||||||
|
export function extractOcr(file: File, ctx: FieldFileContext): Promise<OcrExtractResponse> {
|
||||||
|
return postFile<OcrExtractResponse>(`/app/${APP_ID}/ocr-extract`, file, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Workflow Actions
|
// Workflow Actions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -1,12 +1,24 @@
|
|||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { X, ArrowRight, AlertCircle, CheckCircle2, ChevronDown } from "lucide-react";
|
import { X, ArrowRight, AlertCircle, CheckCircle2, ChevronDown } from "lucide-react";
|
||||||
import { getFormScreen, startWorkflow, performActivity } from "../api/viewService";
|
import { getFormScreen, startWorkflow, performActivity, uploadFieldFile } from "../api/viewService";
|
||||||
|
import type { FormScreenField, FieldFileContext } from "../api/viewService";
|
||||||
import { WORKFLOW_ID } from "../config";
|
import { WORKFLOW_ID } from "../config";
|
||||||
import { Skeleton } from "./Skeleton";
|
import { Skeleton } from "./Skeleton";
|
||||||
|
import OcrUploadField, { resolveOcrConfig } from "./OcrUploadField";
|
||||||
|
|
||||||
interface FormField { id: string; uid: string; name: string; type: string; data_type: string; mandatory: boolean; value?: any; properties?: { options?: Array<{ label: string; value: string }>; country_code?: string }; }
|
type FormField = FormScreenField;
|
||||||
interface GridItem { i: string; x: number; y: number; w: number; h: number; }
|
interface GridItem { i: string; x: number; y: number; w: number; h: number; }
|
||||||
|
|
||||||
|
// Field data types whose value is a file, not a scalar. They skip the
|
||||||
|
// string-based validation and number coercion paths, and their File is uploaded
|
||||||
|
// via /upload before submit so instance data references the blob by metadata.
|
||||||
|
const MEDIA_TYPES = new Set(["ocr", "file", "image"]);
|
||||||
|
|
||||||
|
const isEmpty = (v: any) =>
|
||||||
|
v == null ||
|
||||||
|
(typeof v === "string" && v.trim() === "") ||
|
||||||
|
(Array.isArray(v) && v.length === 0);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
activityId: string;
|
activityId: string;
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
@ -19,7 +31,13 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
|
|||||||
const [fields, setFields] = useState<FormField[]>([]);
|
const [fields, setFields] = useState<FormField[]>([]);
|
||||||
const [gridConfig, setGridConfig] = useState<GridItem[]>([]);
|
const [gridConfig, setGridConfig] = useState<GridItem[]>([]);
|
||||||
const [formTitle, setFormTitle] = useState(title || "");
|
const [formTitle, setFormTitle] = useState(title || "");
|
||||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
// Values are strings for scalar fields and a File for media fields.
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
// Identifiers /upload + /ocr-extract need to resolve a field's deployed
|
||||||
|
// config and RBAC server-side. The form screen echoes them; WORKFLOW_ID is
|
||||||
|
// the fallback for older view-service builds that don't.
|
||||||
|
const [wfUuid, setWfUuid] = useState<string>(WORKFLOW_ID);
|
||||||
|
const [versionUuid, setVersionUuid] = useState<string | undefined>(undefined);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -38,8 +56,14 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
|
|||||||
setFields(f);
|
setFields(f);
|
||||||
setGridConfig(res.grid_config ?? []);
|
setGridConfig(res.grid_config ?? []);
|
||||||
setFormTitle(res.activity_name || title || "Form");
|
setFormTitle(res.activity_name || title || "Form");
|
||||||
const pre: Record<string, string> = {};
|
if (res.workflow_uuid) setWfUuid(res.workflow_uuid);
|
||||||
f.forEach((field) => { if (field.value != null) pre[field.id] = String(field.value); });
|
setVersionUuid(res.version_uuid || undefined);
|
||||||
|
const pre: Record<string, any> = {};
|
||||||
|
f.forEach((field) => {
|
||||||
|
// Media prefills are already-uploaded metadata arrays, not strings.
|
||||||
|
if (field.value == null) return;
|
||||||
|
pre[field.id] = MEDIA_TYPES.has(field.data_type) ? field.value : String(field.value);
|
||||||
|
});
|
||||||
setFormData(pre);
|
setFormData(pre);
|
||||||
})
|
})
|
||||||
.catch((err: any) => setError(err.message))
|
.catch((err: any) => setError(err.message))
|
||||||
@ -51,11 +75,44 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
|
|||||||
setTimeout(callback, 200);
|
setTimeout(callback, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Base context for the field-scoped file endpoints; each call adds its fieldId.
|
||||||
|
const fileCtx: Omit<FieldFileContext, "fieldId"> = {
|
||||||
|
workflowUuid: wfUuid,
|
||||||
|
activityId,
|
||||||
|
versionUuid,
|
||||||
|
instanceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write an OCR result into the fields ocr_config.field_mappings points at.
|
||||||
|
// Returns the number of fields that actually received a value, which the OCR
|
||||||
|
// field reports back to the user ("Filled 9 fields…").
|
||||||
|
const applyExtraction = (ocrField: FormField, extracted: Record<string, unknown>): number => {
|
||||||
|
const mappings = resolveOcrConfig(ocrField).field_mappings ?? [];
|
||||||
|
const next: Record<string, any> = {};
|
||||||
|
for (const m of mappings) {
|
||||||
|
if (!m?.extraction_key || !m?.target_field) continue;
|
||||||
|
const raw = extracted[m.extraction_key];
|
||||||
|
if (raw == null || raw === "") continue; // key absent, or the model found nothing
|
||||||
|
const target = fields.find((f) => f.id === m.target_field);
|
||||||
|
const coerced = coerceExtracted(raw, target?.data_type);
|
||||||
|
if (coerced === "") continue;
|
||||||
|
next[m.target_field] = coerced;
|
||||||
|
}
|
||||||
|
const filled = Object.keys(next);
|
||||||
|
if (filled.length > 0) {
|
||||||
|
setFormData((p) => ({ ...p, ...next }));
|
||||||
|
// Mark them touched so a value the model got wrong still shows validation.
|
||||||
|
setTouched((t) => { const s = new Set(t); filled.forEach((k) => s.add(k)); return s; });
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
return filled.length;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setTouched(new Set(fields.map((f) => f.id)));
|
setTouched(new Set(fields.map((f) => f.id)));
|
||||||
|
|
||||||
const missing = fields.filter((f) => f.mandatory && !formData[f.id]?.trim());
|
const missing = fields.filter((f) => f.mandatory && isEmpty(formData[f.id]));
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
setError(`Please fill in: ${missing.map((f) => f.name).join(", ")}`);
|
setError(`Please fill in: ${missing.map((f) => f.name).join(", ")}`);
|
||||||
return;
|
return;
|
||||||
@ -64,7 +121,22 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
|
|||||||
setSubmitting(true); setError(null);
|
setSubmitting(true); setError(null);
|
||||||
try {
|
try {
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
for (const f of fields) { const v = formData[f.id] ?? ""; data[f.id] = f.data_type === "number" ? (v ? Number(v) : 0) : v; }
|
for (const f of fields) {
|
||||||
|
const v = formData[f.id];
|
||||||
|
if (MEDIA_TYPES.has(f.data_type)) {
|
||||||
|
// Freshly picked Files are uploaded now and submitted as metadata;
|
||||||
|
// an array is already-uploaded metadata from a prefill. Instance data
|
||||||
|
// holds an array either way, matching the platform's file fields.
|
||||||
|
if (v instanceof File) {
|
||||||
|
data[f.id] = [await uploadFieldFile(v, { ...fileCtx, fieldId: f.id })];
|
||||||
|
} else if (Array.isArray(v)) {
|
||||||
|
data[f.id] = v;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const s = v ?? "";
|
||||||
|
data[f.id] = f.data_type === "number" ? (s !== "" ? Number(s) : 0) : String(s);
|
||||||
|
}
|
||||||
instanceId ? await performActivity(WORKFLOW_ID, instanceId, activityId, data) : await startWorkflow(WORKFLOW_ID, activityId, data);
|
instanceId ? await performActivity(WORKFLOW_ID, instanceId, activityId, data) : await startWorkflow(WORKFLOW_ID, activityId, data);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setTimeout(() => animateClose(onSuccess), 800);
|
setTimeout(() => animateClose(onSuccess), 800);
|
||||||
@ -81,7 +153,7 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
|
|||||||
const rows: FormField[][] = []; let cy = -1;
|
const rows: FormField[][] = []; let cy = -1;
|
||||||
for (const f of sorted) { const g = gridConfig.find((gi) => gi.i === f.uid); const y = g?.y ?? rows.length; if (y !== cy) { rows.push([]); cy = y; } rows[rows.length - 1].push(f); }
|
for (const f of sorted) { const g = gridConfig.find((gi) => gi.i === f.uid); const y = g?.y ?? rows.length; if (y !== cy) { rows.push([]); cy = y; } rows[rows.length - 1].push(f); }
|
||||||
|
|
||||||
const isMissing = (f: FormField) => f.mandatory && touched.has(f.id) && !formData[f.id]?.trim();
|
const isMissing = (f: FormField) => f.mandatory && touched.has(f.id) && isEmpty(formData[f.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => animateClose(onClose)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => animateClose(onClose)}>
|
||||||
@ -141,11 +213,26 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
|
|||||||
{f.name}
|
{f.name}
|
||||||
{f.mandatory && <span className="text-red-400 ml-0.5">*</span>}
|
{f.mandatory && <span className="text-red-400 ml-0.5">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{renderInput(f, formData[f.id] ?? "", (v) => {
|
{MEDIA_TYPES.has(f.data_type) ? (
|
||||||
|
<OcrUploadField
|
||||||
|
field={f}
|
||||||
|
ctx={fileCtx}
|
||||||
|
file={formData[f.id] instanceof File ? formData[f.id] : null}
|
||||||
|
onFileChange={(file) => {
|
||||||
|
setFormData((p) => ({ ...p, [f.id]: file }));
|
||||||
|
setTouched((t) => new Set(t).add(f.id));
|
||||||
|
if (error) setError(null);
|
||||||
|
}}
|
||||||
|
onExtracted={(extracted) => applyExtraction(f, extracted)}
|
||||||
|
hasError={isMissing(f)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
renderInput(f, formData[f.id] ?? "", (v) => {
|
||||||
setFormData((p) => ({ ...p, [f.id]: v }));
|
setFormData((p) => ({ ...p, [f.id]: v }));
|
||||||
setTouched((t) => new Set(t).add(f.id));
|
setTouched((t) => new Set(t).add(f.id));
|
||||||
if (error) setError(null);
|
if (error) setError(null);
|
||||||
}, isMissing(f))}
|
}, isMissing(f))
|
||||||
|
)}
|
||||||
{isMissing(f) && (
|
{isMissing(f) && (
|
||||||
<p className="text-[11px] text-red-500 mt-1 flex items-center gap-1">
|
<p className="text-[11px] text-red-500 mt-1 flex items-center gap-1">
|
||||||
<AlertCircle size={11} /> Required
|
<AlertCircle size={11} /> Required
|
||||||
@ -201,6 +288,32 @@ export default function FormModal({ activityId, instanceId, title, onClose, onSu
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OCR value coercion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The vision model returns JSON scalars, but every input here is string-valued.
|
||||||
|
// The OCR prompt asks for plain numbers and YYYY-MM-DD dates; this is the
|
||||||
|
// belt-and-braces pass for when it doesn't comply — a currency symbol or
|
||||||
|
// thousands separator on a number, or a date in some other notation. Returns ""
|
||||||
|
// when the value can't be used, which the caller treats as "not filled".
|
||||||
|
function coerceExtracted(raw: unknown, dataType?: string): string {
|
||||||
|
if (typeof raw === "boolean") return raw ? "true" : "false";
|
||||||
|
const s = String(raw).trim();
|
||||||
|
if (s === "") return "";
|
||||||
|
if (dataType === "number") {
|
||||||
|
const n = Number(s.replace(/[^0-9.\-]/g, ""));
|
||||||
|
return Number.isFinite(n) ? String(n) : "";
|
||||||
|
}
|
||||||
|
if (dataType === "date") {
|
||||||
|
const iso = s.match(/^\d{4}-\d{2}-\d{2}/);
|
||||||
|
if (iso) return iso[0];
|
||||||
|
const d = new Date(s);
|
||||||
|
return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Input renderer
|
// Input renderer
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
241
src/components/OcrUploadField.tsx
Normal file
241
src/components/OcrUploadField.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
// OcrUploadField — document dropzone for the file-valued form field types.
|
||||||
|
//
|
||||||
|
// Handles `ocr`, `file` and `image`. The Extract action only appears for `ocr`
|
||||||
|
// fields that actually carry an extraction schema and field mappings; for a
|
||||||
|
// plain file/image field this is just a picker.
|
||||||
|
//
|
||||||
|
// The user drops a PDF (or image), we POST it to the core-service's
|
||||||
|
// /ocr-extract, and hand the extracted key→value map back to the parent, which
|
||||||
|
// walks ocr_config.field_mappings and writes each value into its target form
|
||||||
|
// field. The file itself stays in the parent's form state as a raw File and is
|
||||||
|
// uploaded via /upload at submit time.
|
||||||
|
//
|
||||||
|
// The prompt and extraction schema are NOT sent from here — the backend
|
||||||
|
// resolves them from the deployed ocr_config using the identifier trio
|
||||||
|
// (workflow_uuid / activity_id / field_id) plus activity RBAC. What we read
|
||||||
|
// out of the local config is only picker UX (allowed_types / max_size_mb /
|
||||||
|
// placeholder); mirrors renderer-desktop's FFOcrField.
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { FileText, UploadCloud, X, ScanText, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
|
import { extractOcr } from "../api/viewService";
|
||||||
|
import type { FieldFileContext, FormScreenField, OcrConfig } from "../api/viewService";
|
||||||
|
|
||||||
|
// Studio writes the OCR keys either nested under properties.ocr_config or flat
|
||||||
|
// on properties. The nested shape wins when it actually carries a schema.
|
||||||
|
export function resolveOcrConfig(field: FormScreenField): OcrConfig {
|
||||||
|
const nested = field.properties?.ocr_config;
|
||||||
|
if (nested && Array.isArray(nested.extraction_fields)) return nested;
|
||||||
|
return (field.properties ?? {}) as OcrConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowed_types arrives as a CSV string or an array, holding any of ".pdf",
|
||||||
|
// "pdf", "application/pdf" or "image/*". Normalise to what <input accept> wants.
|
||||||
|
function toAcceptAttr(raw: OcrConfig["allowed_types"]): string {
|
||||||
|
const entries = Array.isArray(raw)
|
||||||
|
? raw
|
||||||
|
: typeof raw === "string"
|
||||||
|
? raw.split(",")
|
||||||
|
: [];
|
||||||
|
const cleaned = entries
|
||||||
|
.map((e) => e.trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((e) => (e.includes("/") || e.startsWith(".") ? e : `.${e}`));
|
||||||
|
return cleaned.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtractState =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "done"; applied: number }
|
||||||
|
| { status: "error"; message: string };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: FormScreenField;
|
||||||
|
/** Identifiers the backend needs to resolve this field's ocr_config + RBAC. */
|
||||||
|
ctx: Omit<FieldFileContext, "fieldId">;
|
||||||
|
file: File | null;
|
||||||
|
onFileChange: (file: File | null) => void;
|
||||||
|
/** Receives the raw extraction map; the parent applies field_mappings. */
|
||||||
|
onExtracted: (extracted: Record<string, unknown>) => number;
|
||||||
|
disabled?: boolean;
|
||||||
|
hasError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OcrUploadField({
|
||||||
|
field, ctx, file, onFileChange, onExtracted, disabled, hasError,
|
||||||
|
}: Props) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [state, setState] = useState<ExtractState>({ status: "idle" });
|
||||||
|
|
||||||
|
const config = resolveOcrConfig(field);
|
||||||
|
const accept = toAcceptAttr(config.allowed_types);
|
||||||
|
const maxSizeMb = Number(config.max_size_mb) > 0 ? Number(config.max_size_mb) : null;
|
||||||
|
const mappings = Array.isArray(config.field_mappings) ? config.field_mappings : [];
|
||||||
|
// Without extraction_fields the backend has nothing to ask for, and without
|
||||||
|
// mappings there is nowhere to put the answer — either way, no Extract button.
|
||||||
|
const canExtract =
|
||||||
|
(config.extraction_fields?.length ?? 0) > 0 && mappings.length > 0 && !!ctx.workflowUuid;
|
||||||
|
|
||||||
|
const acceptHint = accept
|
||||||
|
? accept.replace(/\./g, "").replace(/,/g, ", ").toUpperCase()
|
||||||
|
: "PDF or image";
|
||||||
|
|
||||||
|
const pick = (next: File | null) => {
|
||||||
|
setState({ status: "idle" });
|
||||||
|
if (next && maxSizeMb && next.size > maxSizeMb * 1024 * 1024) {
|
||||||
|
onFileChange(null);
|
||||||
|
setState({ status: "error", message: `File exceeds the ${maxSizeMb} MB limit` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFileChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runExtract = async () => {
|
||||||
|
if (!file || !canExtract) return;
|
||||||
|
setState({ status: "loading" });
|
||||||
|
try {
|
||||||
|
const res = await extractOcr(file, { ...ctx, fieldId: field.id });
|
||||||
|
const applied = onExtracted(res?.extracted ?? {});
|
||||||
|
if (applied === 0) {
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
message: res?.parse_error
|
||||||
|
? `Could not read the document: ${res.parse_error}`
|
||||||
|
: "Nothing could be read from this document — please fill the fields manually.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState({ status: "done", applied });
|
||||||
|
} catch (err: any) {
|
||||||
|
setState({ status: "error", message: err?.message || "Extraction failed" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
pick(null);
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept || undefined}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => pick(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!file ? (
|
||||||
|
/* ── Empty dropzone ─────────────────────────────────────────────── */
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
if (!disabled) pick(e.dataTransfer.files?.[0] ?? null);
|
||||||
|
}}
|
||||||
|
className={`w-full rounded-xl border border-dashed px-5 py-7 flex flex-col items-center gap-2 transition-colors disabled:opacity-50 ${
|
||||||
|
dragging
|
||||||
|
? "border-violet-400 bg-violet-50/70 dark:border-violet-500 dark:bg-violet-950/20"
|
||||||
|
: hasError
|
||||||
|
? "border-red-300 bg-red-50/40 dark:border-red-700 dark:bg-red-950/20"
|
||||||
|
: "border-slate-200 bg-slate-50/50 hover:border-violet-300 hover:bg-violet-50/40 dark:border-zinc-700 dark:bg-zinc-800/40 dark:hover:border-violet-600 dark:hover:bg-violet-950/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-white dark:bg-zinc-800 shadow-sm flex items-center justify-center">
|
||||||
|
<UploadCloud size={18} className="text-violet-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] font-medium text-slate-700 dark:text-zinc-200">
|
||||||
|
{config.placeholder || field.properties?.placeholder || `Drop ${field.name} here`}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 dark:text-zinc-500">
|
||||||
|
or click to browse · {acceptHint}
|
||||||
|
{maxSizeMb ? ` up to ${maxSizeMb} MB` : ""}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
/* ── Picked file + extract action ────────────────────────────────── */
|
||||||
|
<div className="rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800/60 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 shrink-0 rounded-lg bg-violet-50 dark:bg-violet-950/40 flex items-center justify-center">
|
||||||
|
<FileText size={16} className="text-violet-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[13px] font-medium text-slate-800 dark:text-zinc-100 truncate">
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 dark:text-zinc-500">
|
||||||
|
{formatBytes(file.size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canExtract && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || state.status === "loading"}
|
||||||
|
onClick={runExtract}
|
||||||
|
className="h-8 px-3 shrink-0 text-[12px] font-semibold text-white rounded-lg flex items-center gap-1.5 disabled:opacity-60 transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
style={{ background: "linear-gradient(135deg, #7c3aed, #a855f7)" }}
|
||||||
|
>
|
||||||
|
{state.status === "loading" ? (
|
||||||
|
<>
|
||||||
|
<div className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
Reading…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ScanText size={13} />
|
||||||
|
{state.status === "done" ? "Re-read" : "Read invoice"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled || state.status === "loading"}
|
||||||
|
onClick={clear}
|
||||||
|
className="w-7 h-7 shrink-0 flex items-center justify-center text-slate-400 dark:text-zinc-500 hover:text-slate-600 dark:hover:text-zinc-200 hover:bg-slate-100 dark:hover:bg-zinc-700 rounded-lg transition-colors disabled:opacity-40"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.status === "done" && (
|
||||||
|
<div className="mt-2.5 flex items-center gap-1.5 text-[11.5px] text-emerald-600 dark:text-emerald-400">
|
||||||
|
<CheckCircle2 size={12} />
|
||||||
|
Filled {state.applied} field{state.applied === 1 ? "" : "s"} from the document — review before submitting.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state.status === "error" && (
|
||||||
|
<div className="mt-2.5 flex items-start gap-1.5 text-[11.5px] text-red-600 dark:text-red-400">
|
||||||
|
<AlertCircle size={12} className="shrink-0 mt-0.5" />
|
||||||
|
{state.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "error" && !file && (
|
||||||
|
<div className="mt-2 flex items-start gap-1.5 text-[11.5px] text-red-600 dark:text-red-400">
|
||||||
|
<AlertCircle size={12} className="shrink-0 mt-0.5" />
|
||||||
|
{state.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user