383 lines
17 KiB
JavaScript
383 lines
17 KiB
JavaScript
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 <div className="af__err">Could not load the form — {describeError(error).title}</div>
|
|
if (!schema) return <p className="af__loading">Loading the form…</p>
|
|
|
|
return (
|
|
<form className="af" onSubmit={submit}>
|
|
<div className="af__grid">
|
|
{/* Stamped fields are in `fields` — they validate and they submit —
|
|
but they are not drawn. See STAMPED_FIELDS.
|
|
|
|
A stamped field that came back EMPTY is drawn anyway. source_channel
|
|
is mandatory, so a prefill that did not resolve would otherwise fail
|
|
validation against a box that is not on the screen and cannot be
|
|
filled — the exact failure the field filter elsewhere in this file
|
|
guards against. Hidden when it is answered; asked when it is not. */}
|
|
{fields.filter((f) => !fieldIsStamped(f.id) || isEmpty(values[f.id])).map((f) => {
|
|
const opts = f.properties?.options || []
|
|
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
|
|
return (
|
|
<label
|
|
key={f.uid}
|
|
className={'af__field'
|
|
+ (f.data_type === 'longtext' ? ' af__field--wide' : '')
|
|
+ (missing.includes(f.id) ? ' is-missing' : '')}
|
|
>
|
|
<span>{f.name}{f.mandatory ? <em className="af__req">required</em> : null}</span>
|
|
|
|
{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) => applyExtraction(f, fields)}
|
|
/>
|
|
) : f.data_type === 'id_gen' ? (
|
|
<div className="af__auto">
|
|
{v || 'Issued on submit'}
|
|
<span>auto</span>
|
|
</div>
|
|
) : f.data_type === 'select' ? (
|
|
<select value={v} onChange={(e) => set(f.id, e.target.value)}>
|
|
<option value="">—</option>
|
|
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
</select>
|
|
) : f.data_type === 'multiselect' ? (
|
|
<div className="af__multi">
|
|
{opts.map((o) => (
|
|
<button
|
|
key={o.value} type="button"
|
|
className={'af__chip' + (v.includes(o.value) ? ' is-on' : '')}
|
|
onClick={() => set(f.id, v.includes(o.value) ? v.filter((x) => x !== o.value) : [...v, o.value])}
|
|
>{o.label}</button>
|
|
))}
|
|
</div>
|
|
) : f.data_type === 'longtext' ? (
|
|
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} />
|
|
) : (
|
|
<input
|
|
type={INPUT_TYPES[f.data_type] ?? 'text'}
|
|
value={v} onChange={(e) => set(f.id, e.target.value)}
|
|
/>
|
|
)}
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* The form's own complaint, before anything is sent. */}
|
|
{missing.length ? (
|
|
<div className="af__err af__err--soft" role="alert">
|
|
<strong>
|
|
{missing.length === 1
|
|
? `${fields.find((f) => f.id === missing[0])?.name ?? 'A field'} is required`
|
|
: 'Some required details are missing'}
|
|
</strong>
|
|
{missing.length > 1 ? (
|
|
<ul>
|
|
{missing.map((id) => (
|
|
<li key={id}>{fields.find((f) => f.id === id)?.name ?? id}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{error ? (() => {
|
|
// A field-validation reply is rendered in the form's own words. The
|
|
// status code is dropped with it: "400" tells an operator nothing they
|
|
// can act on, and it is the first thing they read.
|
|
const bad = describeValidation(error?.message, fields)
|
|
if (bad) {
|
|
return (
|
|
<div className="af__err af__err--soft" role="alert">
|
|
<strong>{bad.title}</strong>
|
|
{bad.items.length > 1 ? (
|
|
<ul>{bad.items.map((t) => <li key={t}>{t}</li>)}</ul>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
const said = describeError(error)
|
|
return (
|
|
<div className="af__err" role="alert">
|
|
<strong>{said.title}</strong>
|
|
{said.detail ? <p>{said.detail}</p> : null}
|
|
</div>
|
|
)
|
|
})() : null}
|
|
|
|
<div className="af__actions">
|
|
{onCancel ? <button type="button" className="af__ghost" onClick={onCancel}>Cancel</button> : null}
|
|
<button type="submit" className="af__submit" disabled={busy}>
|
|
{busy ? 'Submitting…' : 'Submit'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)
|
|
}
|