zurich_kotak/src/components/ActivityForm.jsx

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>
)
}