Compare commits
2 Commits
07d4608888
...
fc5eace791
| Author | SHA1 | Date | |
|---|---|---|---|
| fc5eace791 | |||
| 6a6801d2bd |
@ -119,7 +119,7 @@ const STATE_ACTIVITIES = {
|
||||
],
|
||||
'zk-state-qualified': [
|
||||
{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI', role: 'force' },
|
||||
{ uid: 'zk-act-retry-call', label: 'Retry Call', by: 'Scheduled', role: 'again' },
|
||||
{ uid: 'zk-act-retry-call', label: 'Retry Call', by: 'Scheduled', role: 'force' },
|
||||
],
|
||||
'zk-state-contacted': [
|
||||
{ uid: 'zk-act-request-docs', label: 'Request Documents', by: 'Engage AI', role: 'force' },
|
||||
@ -128,7 +128,7 @@ const STATE_ACTIVITIES = {
|
||||
// be found and uploaded. Everything else here is the AI's own chain.
|
||||
'zk-state-docs': [
|
||||
{ uid: 'zk-act-collect-docs', label: 'Upload documents', by: 'you', role: 'do' },
|
||||
{ uid: 'zk-act-doc-reminder', label: 'Send a reminder', by: 'Scheduled', role: 'again' },
|
||||
{ uid: 'zk-act-doc-reminder', label: 'Send a reminder', by: 'Scheduled', role: 'force' },
|
||||
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI', role: 'force' },
|
||||
{ uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI', role: 'force' },
|
||||
{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI', role: 'force' },
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import { fieldApplies } from '../api/config.js'
|
||||
import { baseFieldId, fieldApplies } from '../api/config.js'
|
||||
import { describeError, describeValidation } from '../api/errors.js'
|
||||
import FileField from './FileField.jsx'
|
||||
import './ActivityForm.css'
|
||||
@ -41,6 +41,10 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
|
||||
// `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(() => {
|
||||
@ -68,21 +72,55 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
|
||||
// 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') {
|
||||
const seed = {}
|
||||
for (const f of s.fields) {
|
||||
const base = String(f.id ?? '').replace(/_\d+$/, '')
|
||||
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
|
||||
}
|
||||
if (Object.keys(seed).length) setValues(seed)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@ -106,6 +144,57 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
|
||||
const hidesMandatory = schema ? schema.fields.some((f) => f.mandatory && !fieldApplies(f.id, lead)) : 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)
|
||||
}
|
||||
@ -183,19 +272,7 @@ export default function ActivityForm({ activityUid, instanceId, lead, onDone, on
|
||||
<FileField
|
||||
field={f} value={v} instanceId={instanceId} activityUid={activityUid}
|
||||
onChange={(refs) => set(f.id, refs)}
|
||||
onExtract={(fields) => {
|
||||
// The document answered questions the form was about to ask.
|
||||
// Only fill what is still blank — never overwrite something a
|
||||
// person typed with something a model read.
|
||||
setValues((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const [k, val] of Object.entries(fields)) {
|
||||
if (val === null || val === undefined || String(val) === '') continue
|
||||
if (next[k] === undefined || next[k] === '') next[k] = val
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
onExtract={(fields) => applyExtraction(f, fields)}
|
||||
/>
|
||||
) : f.data_type === 'id_gen' ? (
|
||||
<div className="af__auto">
|
||||
|
||||
@ -48,6 +48,12 @@ export default function Shell() {
|
||||
>
|
||||
Overview
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/stage/all"
|
||||
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
|
||||
>
|
||||
All leads
|
||||
</NavLink>
|
||||
|
||||
{canFile ? (
|
||||
<NavLink to="/add" className="shell__add">
|
||||
|
||||
@ -348,8 +348,16 @@ export default function Lead() {
|
||||
Mark Lost. Rendering them as one flat row of equals is what made
|
||||
six buttons appear where one was the answer. */}
|
||||
{(() => {
|
||||
const step = actions.filter((a) => a.role === 'do')
|
||||
const again = actions.filter((a) => a.role === 'again')
|
||||
// Once the three documents are in, the upload is no longer the step:
|
||||
// the lead stays in Document Pending while Engage captures the risk
|
||||
// and the quote is built, and offering "Upload documents" through
|
||||
// that reads as though nothing was received. A further upload stays
|
||||
// possible as a loop — a fourth document, a corrected one — it is
|
||||
// just not what the lead is waiting on.
|
||||
const docsDone = stage?.uid === 'zk-state-docs' && row.documents_status === 'complete'
|
||||
const roleOf = (a) => (docsDone && a.uid === 'zk-act-collect-docs' ? 'again' : a.role)
|
||||
const step = actions.filter((a) => roleOf(a) === 'do')
|
||||
const again = actions.filter((a) => roleOf(a) === 'again')
|
||||
const force = actions.filter((a) => a.role === 'force')
|
||||
const exit = actions.filter((a) => a.role === 'exit')
|
||||
const extras = [...force, ...exit]
|
||||
@ -383,7 +391,9 @@ export default function Lead() {
|
||||
? 'This is what this lead is waiting on.'
|
||||
: isClosed
|
||||
? 'This lead is closed.'
|
||||
: `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}
|
||||
: docsDone
|
||||
? 'Documents received. Engage AI is capturing the risk and preparing the quote.'
|
||||
: `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -43,7 +43,12 @@ import './screens.css'
|
||||
export default function Pipeline() {
|
||||
const { stageUid } = useParams()
|
||||
const { client } = useZino()
|
||||
const stage = STAGES.find((s) => s.uid === stageUid)
|
||||
// "all" is not a stage — it is every open lead in one list. An operator
|
||||
// wanting to find a lead should not have to guess which queue it is in.
|
||||
const isAll = stageUid === 'all'
|
||||
const stage = isAll
|
||||
? { uid: 'all', name: 'All open leads', kind: 'all', by: 'everyone' }
|
||||
: STAGES.find((s) => s.uid === stageUid)
|
||||
|
||||
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
|
||||
|
||||
@ -57,15 +62,22 @@ export default function Pipeline() {
|
||||
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
|
||||
return client
|
||||
.recordView(RV_LEADS, {
|
||||
limit: 100,
|
||||
filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }],
|
||||
limit: isAll ? 200 : 100,
|
||||
// No stage filter on the all view. Closed leads are dropped below
|
||||
// rather than in the query: one request beats three negations, and
|
||||
// 200 covers a demo book comfortably.
|
||||
...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }),
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
const rows = res?.data ?? res?.rows ?? res?.records ?? []
|
||||
let rows = res?.data ?? res?.rows ?? res?.records ?? []
|
||||
if (isAll) {
|
||||
const closed = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name))
|
||||
rows = rows.filter((r) => !closed.has(r.current_state_name))
|
||||
}
|
||||
// total_count is the size of the QUEUE; rows is one page of at most
|
||||
// 100 of it. Counting the page would quietly under-report a busy stage.
|
||||
const total = res?.pagination?.total_count ?? rows.length
|
||||
const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length)
|
||||
setState({ status: 'ready', rows, total, error: null })
|
||||
})
|
||||
.catch((err) => {
|
||||
@ -82,7 +94,7 @@ export default function Pipeline() {
|
||||
}, 30000)
|
||||
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [client, stageUid, stage?.name])
|
||||
}, [client, stageUid, stage?.name, isAll])
|
||||
|
||||
if (!stage) return <p className="empty">Unknown stage.</p>
|
||||
|
||||
@ -92,7 +104,9 @@ export default function Pipeline() {
|
||||
<div>
|
||||
<h1 className="page__title">{stage.need ?? stage.name}</h1>
|
||||
<p className="page__sub">
|
||||
{stage.kind === 'auto'
|
||||
{isAll
|
||||
? 'Every lead not yet closed, across all stages.'
|
||||
: stage.kind === 'auto'
|
||||
? `Automated · ${stage.doing.toLowerCase()}`
|
||||
: stage.kind === 'end'
|
||||
? 'Closed — retained for reporting.'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user