import { useEffect, useRef, useState } from 'react' import { useZino } from '../api/provider.jsx' import { baseFieldId, fieldApplies, fieldIsStamped } from '../api/config.js' import { describeError, describeValidation } from '../api/errors.js' import FileField from './FileField.jsx' import './ActivityForm.css' /** * The HTML input each workflow data_type maps to. `phone` and `email` were both * falling through to plain text, which costs the keyboard on a phone and the * browser's own validation everywhere. */ const INPUT_TYPES = { number: 'number', date: 'date', phone: 'tel', email: 'email', } /** * Renders whatever /view/form-screens returns for an activity — labels, types, * select options and which fields are mandatory — and submits it straight back. * * Nothing about this form is defined in the frontend. Add a field to the * activity in Studio, redeploy, and it appears here with no code change. That * is the point: the workflow is the source of truth, and a hardcoded form would * quietly drift from it. * * The ONE thing filtered here is which upload slots apply to the lead's product * line. Collect Documents serves motor and SME from a single form and the * activity carries no `field_rules`, so without this a motor renewal is asked * for a Udyam certificate. See DOC_SLOTS in api/config.js — including why that * table should stop existing once the rules are seeded on the activity. */ export default function ActivityForm({ activityUid, instanceId, lead, onDone, onCancel, onStale }) { const { client } = useZino() const [schema, setSchema] = useState(null) const [values, setValues] = useState({}) const [error, setError] = useState(null) // Which required fields were empty on the last attempt. Kept separate from // `error`, because this is the form talking about itself rather than the // server refusing something. const [missing, setMissing] = useState([]) // Field ids filled from the lead record rather than typed. OCR may overwrite // these — the document is more authoritative than a copy of the record — but // must never overwrite something a person typed. const seededRef = useRef(new Set()) const [busy, setBusy] = useState(false) useEffect(() => { let dead = false setSchema(null); setError(null); setValues({}) client.formSchema(activityUid, instanceId) .then((s) => { if (dead) return setSchema(s) // The server resolved a prefill pipeline for this activity — it stamps // the channel from the door and the agent from the signed-in user, so // the form never asks for either. Seed the inputs with what it sent. // // The two sides key differently, and matching only on f.id is why this // silently did nothing. A form field is the ACTIVITY key, which carries // a numeric suffix because a workflow version may use one global on // several activities — partner_code_3, rm_or_agent_id_3, // source_channel_4. The pipeline's fieldMapping names the GLOBAL — // partner_code, rm_or_agent_id, source_channel. Neither is wrong; they // are different names for the same field, and nothing between them // reconciles it. // // So: exact id first, then the field's uid, then the global name with // the suffix stripped. Accepting all three means this keeps working // whichever convention a pipeline is authored in, rather than breaking // again the next time one is written the other way. const pre = s.prefill_data || s.prefillData || s.field_defaults || {} const seed = {} if (pre && typeof pre === 'object') { for (const f of s.fields) { const base = baseFieldId(f.id ?? '') const v = pre[f.id] ?? pre[f.uid] ?? (base ? pre[base] : undefined) if (v !== undefined && v !== null && v !== '') seed[f.id] = v } } // THE LEAD ALREADY KNOWS MOST OF THIS. The document form mirrors // fourteen fields the record carries — registration, make and model, // previous insurer, expiry, policy number, PAN — so the OCR can write // into them. Rendered blank, they read as fourteen more things to type. // Seed each from the lead by its base key, so an agent uploading for // KA01MF6618 sees KA01MF6618 already there. // // Server prefill wins, then anything already typed. Files and generated // ids are never seeded — a file reference is not a value to copy, and an // id_gen is the platform's to issue. Only on an existing lead: an INIT // form has no record behind it. const seededFromLead = new Set() if (lead && instanceId) { for (const f of s.fields) { if (seed[f.id] !== undefined) continue if (['file', 'ocr', 'id_gen'].includes(f.data_type)) continue const v = lead[baseFieldId(f.id ?? '')] if (v !== undefined && v !== null && v !== '' && typeof v !== 'object') { seed[f.id] = v seededFromLead.add(f.id) } } } seededRef.current = seededFromLead if (Object.keys(seed).length) setValues(seed) }) .catch((e) => { if (!dead) setError(e) }) return () => { dead = true } // `lead` is READ here but deliberately not a dependency. It is the parent's // polled record object, so its identity changes on every refresh; listing it // would re-run this effect, re-fetch the schema and call setValues(seed) — // discarding whatever the operator had typed, every few seconds, mid-form. // The seed is a one-time starting point, not a subscription. // eslint-disable-next-line react-hooks/exhaustive-deps }, [client, activityUid, instanceId]) function set(id, v) { // Typed by hand: from here on it outranks any document. seededRef.current.delete(id) setValues((p) => ({ ...p, [id]: v })) // Clear the complaint as soon as the field is filled. Leaving a red field // marked after it has been corrected teaches people to ignore the marking. setMissing((p) => (p.includes(id) ? p.filter((x) => x !== id) : p)) } // Computed before the early returns below use it, and before submit: a field // that was never offered must never be sent. // // If the lead's line would hide EVERY field, the filter is not removing noise // any more — it is removing the activity. Capture Motor Risk is twelve motor // fields and is runnable from Contacted whatever the product is, so on an SME // lead this would otherwise render a form with nothing in it and a live Submit // button. Show the activity whole and let the operator see what it is asking. // // THE LINE CAN BE CHOSEN ON THE FORM ITSELF. On an INIT form there is no // lead yet, so `fieldApplies` had nothing to filter on and every entry form // asked a motor renewal for a business name. But the product line IS on that // form, three boxes up — the person has already said "Motor" by the time the // question is drawn. So visibility reads the live answer first and the saved // lead second, and the form narrows as it is filled. const effLead = (() => { const live = schema?.fields.find((f) => baseFieldId(f.id) === 'product_line') const chosen = live ? values[live.id] : undefined if (!chosen) return lead return { ...(lead || {}), product_line: chosen } })() const applicable = schema ? schema.fields.filter((f) => fieldApplies(f.id, effLead)) : [] // Filtering must never hide a field the workflow requires: `submit` sends only // what is rendered, so a hidden mandatory field becomes a 400 naming something // that is not on screen and cannot be filled. Hiding everything is the same // failure in the large — it removes the activity rather than its noise, and // Capture Motor Risk is twelve motor fields that stay runnable on an SME lead. const hidesMandatory = schema ? schema.fields.some((f) => f.mandatory && !fieldApplies(f.id, effLead)) : false const fields = applicable.length && !hidesMandatory ? applicable : (schema?.fields ?? []) /** * Turn an /ocr-extract response into form values. * * THE ENDPOINT DOES NOT SPEAK THE FORM'S LANGUAGE. It answers keyed by the * ocr_config's `extraction_fields[].key` — `reg_no`, `engine_cc`, `fuel` — * because that is what the vision prompt was asked to produce. The form's * fields are `motor_reg_no_6`, `motor_cc_3`, `motor_fuel_3`. Written straight * in, as they were, every extracted value landed on a key no field renders: * the read succeeded, the panel said so, and nothing filled. * * The bridge is already on the field. `ocr_config.field_mappings` maps * extraction_key -> target_field (the GLOBAL name), and the form field is that * global plus a per-form suffix. So: key -> target -> the field whose base id * matches. Same suffix rule as prefill and the same trap, in a third place. */ function applyExtraction(ocrField, extracted) { const maps = ocrField?.properties?.ocr_config?.field_mappings ?? ocrField?.ocr_config?.field_mappings ?? [] const all = schema?.fields ?? [] // extraction_key -> form field id const target = {} for (const m of maps) { if (!m?.extraction_key || !m?.target_field) continue const hit = all.find((f) => f.id === m.target_field) ?? all.find((f) => baseFieldId(f.id) === m.target_field) if (hit) target[m.extraction_key] = hit.id } setValues((prev) => { const next = { ...prev } for (const [key, val] of Object.entries(extracted)) { if (val === null || val === undefined || String(val) === '') continue // An unmapped key may still name a field directly on forms whose // extraction keys ARE the field names. const id = target[key] ?? all.find((f) => f.id === key)?.id ?? all.find((f) => baseFieldId(f.id) === key)?.id if (!id) continue // Never overwrite something a person typed. A value we copied off the // lead is fair game — the document is the better source for it. const isBlank = next[id] === undefined || next[id] === '' if (isBlank || seededRef.current.has(id)) { next[id] = val seededRef.current.delete(id) } } return next }) } function isEmpty(v) { return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0) } async function submit(e) { e.preventDefault() // Check here rather than letting the workflow do it. The server's answer is // correct and unreadable — "product_line_4(required)" — and it costs a round // trip to be told something this form already knew. id_gen is issued // server-side and is never the operator's to fill. const gaps = fields.filter((f) => f.mandatory && f.data_type !== 'id_gen' && isEmpty(values[f.id])) if (gaps.length) { setMissing(gaps.map((f) => f.id)) setError(null) // Put the first offender on screen. On a form this wide the empty field // is often above the fold and the message below it. Scrolling to the // LABEL rather than focusing an input works for every field type, // including the file and OCR widgets that render no input at all. requestAnimationFrame(() => { document .querySelector('.af__field.is-missing') ?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }) return } setMissing([]) setBusy(true); setError(null) // Send only fields the activity defines. A submission is schema-validated // and an unknown field is fatal, so empties are dropped rather than sent. const payload = {} for (const f of fields) { // id_gen is issued server-side and stripped from the submission. Sending // it would be forging a reference the platform owns. if (f.data_type === 'id_gen') continue const v = values[f.id] if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) continue payload[f.id] = f.data_type === 'number' ? Number(v) : v } try { const res = instanceId ? await client.activity(instanceId, activityUid, payload) : await client.start(activityUid, payload) onDone?.(res) } catch (err) { setError(err) // The lead moved under the form. Nothing the operator can fix by reading — // tell the page to re-fetch so the actions on offer are the real ones. if (describeError(err).kind === 'stale') onStale?.() } finally { setBusy(false) } } if (error && !schema) return
Loading the form…
return ( ) }