diff --git a/src/api/config.js b/src/api/config.js index a4daff7..bf472ae 100644 --- a/src/api/config.js +++ b/src/api/config.js @@ -335,3 +335,71 @@ export function fieldApplies(fieldId, lead) { const slot = DOC_SLOTS[baseFieldId(fieldId)] return slot?.when ? slot.when(lead) : true } + +/** + * A STAGE IS NOT ALWAYS ONE SITUATION. + * + * Document Pending covers two that could not look more different to whoever is + * watching. Before the upload a person has to act and nothing moves until they + * do; after it the lead stays in the same state while Engage captures the risk, + * the Advisor recommends cover and the rating engine prices it — three AI steps, + * two to five minutes, no one to chase. + * + * Rendered from `current_state_name` alone both read as "waiting on the partner + * agent", so a lead that had just been served showed an Upload documents button + * and sat in the Action-required queue, and the AI work behind it was invisible. + * The state machine is right to hold one state here — nothing has been decided + * yet — so the distinction belongs in the read model, once, rather than in each + * screen's own guess. + * + * `documents_status` is what separates them. The workflow derives it from the + * files themselves (75), so it says what is actually attached rather than what + * anyone claimed. + */ +export function phaseOf(stage, lead) { + if (!stage || !lead) return stage + if (stage.uid === 'zk-state-docs' && lead.documents_status === 'complete') { + return { + ...stage, + kind: 'auto', + doing: 'Reading the documents and pricing the cover', + by: 'Engage AI, then the Advisor', + // Kept so a screen can say WHY this is not the queue it looks like. + after: 'documents received', + } + } + return stage +} + +/** + * Why an automated stage has stopped, when it has. + * + * The AI chain behind Document Pending is honest about refusing: the rating + * engine writes `quoted_breakup` = "Not priced: no insured value (IDV) + * captured…" rather than inventing a premium, and Engage then declines to + * raise a quote it would have to fabricate. Both are the right call. + * + * What was missing is that NOBODY WAS TOLD. The refusal lives in a field on a + * tab, the AI's task dies in a queue no operator can see, and the lead sits in + * Document Pending looking exactly like one whose documents never arrived — + * indefinitely, because nothing wakes the chain again. A workflow that stops + * for a good reason and a workflow that is broken must not look the same. + * + * Recovery is always the same shape: put the missing value in and let the + * chain re-run. Collect Documents carries the IDV field and re-performing it + * wakes Engage again, so the action is one the partner agent already holds. + */ +export function blockedOn(lead) { + if (!lead) return null + const breakup = String(lead.quoted_breakup || '') + if (breakup.startsWith('Not priced')) { + return { + what: breakup.replace(/^Not priced:\s*/, ''), + // Named rather than described: the operator has to find this field, and + // it is not where they would look for it. + fix: 'Re-open Collect Documents and enter the IDV, or attach the expiring policy again so it can be read off.', + via: 'zk-act-collect-docs', + } + } + return null +} diff --git a/src/components/Timeline.css b/src/components/Timeline.css index b3213e6..2503d37 100644 --- a/src/components/Timeline.css +++ b/src/components/Timeline.css @@ -203,3 +203,45 @@ background: var(--zk-tint); color: var(--zk-blue-dark); } + +/* ── What was attached ──────────────────────────────────────────────────── + The audit trail's job at this step is to prove receipt. A line saying + "Collect Documents" and nothing else left the operator to open the record + and count for themselves — and until the views were fixed, the record could + not see the files either. */ +.tl__docs { + margin-top: 8px; + padding: 9px 11px; + border: 1px solid var(--zk-line-soft); + border-radius: var(--r-sm, 6px); + background: var(--zk-tint); +} + +.tl__docshead { + display: block; + font-size: 0.74rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--zk-muted); + margin-bottom: 7px; +} + +.tl__doclist { display: flex; flex-wrap: wrap; gap: 6px; } + +.tl__doc { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.78rem; + text-decoration: none; + padding: 4px 10px 4px 8px; + border-radius: var(--r-pill, 999px); + background: var(--zk-white); + border: 1px solid var(--zk-line); + color: var(--zk-blue-dark); + transition: border-color .12s, background .12s; +} +.tl__doc svg { width: 12px; height: 12px; flex: none; opacity: .7; } +.tl__doc:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-light); } +.tl__doc:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; } diff --git a/src/components/Timeline.jsx b/src/components/Timeline.jsx index 97c2350..6e47a75 100644 --- a/src/components/Timeline.jsx +++ b/src/components/Timeline.jsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useZino } from '../api/provider.jsx' import ClampText from './ClampText.jsx' -import { STAGES } from '../api/config.js' +import { APP_ID, STAGES, baseFieldId } from '../api/config.js' import './Timeline.css' /** Which employee holds which role — used to badge an entry as machine work. */ @@ -17,6 +17,14 @@ const AI_ROLES = { * The fields worth surfacing per activity — an agent's reasoning, a rule's * output, a call's notes. Everything else stays in the file below; a timeline * that shows every field is a table, not a story. + * + * Keyed on the BASE field id. An audit row's data is keyed by the ACTIVITY's + * field ids, which carry a per-form suffix — the call notes arrive as + * `contact_notes_2`, the document request as `documents_notes_3`. Matching the + * global ids directly, as this did, meant almost nothing ever matched: the + * timeline showed a bare list of activity names, and the one narrative line + * that did appear was an accident (a DATA_UPDATE row happens to write the + * unsuffixed key). */ const NARRATIVE = [ ['attribution_reason', 'Attribution'], @@ -29,10 +37,31 @@ const NARRATIVE = [ ['referral_analysis', 'Referral analysis'], ['uw_decision_notes', 'Underwriting decision'], ['documents_notes', 'Documents'], + ['lost_reason', 'Why it was dropped'], + ['resume_note', 'Why now'], ] const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv']) +/** Document slots, in the order they are worth reading. Labels are shorter than + * the workflow's own — "Registration Certificate (RC)" is a chip, not a form + * label — and a slot missing from here still renders under its server label. */ +const DOC_LABELS = { + doc_rc: 'RC', + doc_prev_policy: 'Expiring policy', + doc_pan: 'PAN', + doc_gst_cert: 'GST certificate', + doc_udyam_cert: 'Udyam certificate', + doc_address_proof: 'Address proof', + doc_premises_proof: 'Premises proof', + doc_stock_statement: 'Stock statement', + doc_premises_photos: 'Premises photos', + doc_vehicle_photos: 'Vehicle photos', + doc_financials: 'Financials', +} + +const FILE_TYPES = new Set(['file', 'ocr']) + function when(ts) { if (!ts) return '' const d = new Date(ts) @@ -60,6 +89,13 @@ function prettyUid(uid) { .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ') } + +/** An uploaded file, as the platform stores it. */ +function filesIn(value) { + if (!Array.isArray(value)) return [] + return value.filter((f) => f && typeof f === 'object' && f.uuid) +} + export default function Timeline({ instanceId }) { const { client } = useZino() const [rows, setRows] = useState(null) @@ -125,8 +161,7 @@ export default function Timeline({ instanceId }) { execution_state: isStage(r.execution_state) ? r.execution_state : prev.execution_state, // Whichever row actually carries the submission and the AI's working. data: (r.data && Object.keys(r.data).length) ? r.data : prev.data, - ai_reasoning: r.ai_reasoning || prev.ai_reasoning, - ai_confidence: r.ai_confidence ?? prev.ai_confidence, + fields: (r.fields && r.fields.length) ? r.fields : prev.fields, user_name: prev.user_name || r.user_name, user_roles: (prev.user_roles && prev.user_roles.length) ? prev.user_roles : r.user_roles, created_at: prev.created_at, @@ -136,25 +171,69 @@ export default function Timeline({ instanceId }) { merged.push(r) } + let lastStage = null const items = merged.map((r, i) => { const roles = r.user_roles || [] - const aiRole = roles.find((x) => AI_ROLES[x]) + // SYSTEM FIRST. A DATA_UPDATE row inherits the roles of whoever caused it, + // so an AI's own field write matched AI_ROLES, was classed as agent work, + // and appeared in the trail as an entry titled "Data updated" — twice, + // while the toggle below still offered to reveal two others. The row's + // activity id is what says it is bookkeeping; the roles say who triggered + // the bookkeeping, which is a different question. const isSystem = r.activity_id === 'DATA_UPDATE' - const kind = aiRole ? 'ai' : isSystem ? 'sys' : 'human' - const d = r.data || {} + const aiRole = roles.find((x) => AI_ROLES[x]) + const kind = isSystem ? 'sys' : aiRole ? 'ai' : 'human' + + /** + * The value view. `fields[]` is the platform's own typed rendering of the + * submission — one entry per configured field with its label, data type and + * value — and it is what makes a file field knowable as a file. `data` is + * the raw untyped fallback for a row the workflow could not resolve. + */ + const fields = Array.isArray(r.fields) && r.fields.length + ? r.fields + : Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, label: '', data_type: '', value: v })) + + const byBase = new Map() + for (const f of fields) { + const base = baseFieldId(f.field_id) + // `_system` is the platform's own marker on every submission. + if (base === '_system') continue + if (!byBase.has(base)) byBase.set(base, f) + } + + // What was attached. This is the answer to "show me that the documents + // were captured": the names, from the submission that carried them, each + // one openable. + const docs = [] + for (const [base, f] of byBase) { + if (!FILE_TYPES.has(f.data_type) && !base.startsWith('doc_')) continue + for (const file of filesIn(f.value)) { + docs.push({ slot: DOC_LABELS[base] || f.label || base, ...file }) + } + } + + const stage = STAGES.find((s) => s.uid === r.execution_state) + // A self-loop — Capture Motor Risk runs inside Document Pending and settles + // back into it — is not a move, and printing "→ Document Pending" against + // four consecutive entries reads as the lead bouncing. + const moved = stage && stage.uid !== lastStage + if (stage) lastStage = stage.uid + return { key: r.id ?? i, kind, actor: aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System'), what: isSystem ? 'Data updated' : (r.activity_name || prettyUid(r.activity_id)), - stage: STAGES.find((s) => s.uid === r.execution_state), + stage: moved ? stage : null, when: when(r.created_at), + docs, narrative: NARRATIVE - .map(([k, label]) => [label, d[k]]) - .filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== ''), - figures: Object.entries(d) - .filter(([k, v]) => MONEY.has(k) && v) - .map(([k, v]) => [k.replace(/_/g, ' '), '₹' + Number(v).toLocaleString('en-IN')]), + .map(([k, label]) => [label, byBase.get(k)?.value]) + .filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''), + figures: [...byBase] + .filter(([base, f]) => MONEY.has(base) && f.value) + .map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]), } }) @@ -185,6 +264,37 @@ export default function Timeline({ instanceId }) { {it.when} + {/* What was received, named and openable. The count is stated + rather than left to be inferred from a list — "3 documents + received" is the sentence the operator is looking for. */} + {it.docs.length ? ( +
+ + {it.docs.length} document{it.docs.length === 1 ? '' : 's'} received + +
+ {it.docs.map((d) => ( + + + {d.slot} + + ))} +
+
+ ) : null} + {it.narrative.map(([label, v]) => (
{label} diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx index 75c62a7..bc4b2a0 100644 --- a/src/screens/Lead.jsx +++ b/src/screens/Lead.jsx @@ -4,7 +4,7 @@ import { useZino } from '../api/provider.jsx' import ActivityForm from '../components/ActivityForm.jsx' import ClampText from '../components/ClampText.jsx' import Timeline from '../components/Timeline.jsx' -import { DV_LEAD, PRODUCTS, STAGES } from '../api/config.js' +import { APP_ID, DV_LEAD, PRODUCTS, STAGES, blockedOn, phaseOf } from '../api/config.js' import { actionsFor, rolesOf } from '../api/permissions.js' import { describeError } from '../api/errors.js' import './screens.css' @@ -58,7 +58,13 @@ const GROUPS = [ ['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']], ['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']], ['Contact', ['contact_outcome','outreach_window','contact_notes']], - ['Documents', ['documents_status','doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos','doc_vehicle_photos','doc_financials','documents_notes']], + // The five OCR slots led this list and were absent from it, which is how a + // lead with an RC, an expiring policy and a PAN attached showed "Documents 2" + // — the status and the notes. They were also absent from the view itself + // until 76; adding them here without that would have changed nothing. + ['Documents', ['doc_rc','doc_prev_policy','doc_pan','doc_gst_cert','doc_udyam_cert', + 'doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos', + 'doc_vehicle_photos','doc_financials','documents_status','documents_notes']], ['SME risk', ['sme_product_variant','sme_occupancy','sme_location_address','sme_building_si','sme_plant_si','sme_furniture_si','sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_stock_si','sme_other_si','sme_value_at_risk','sme_floor','sme_num_floors','sme_floor_material','sme_walls','sme_roof','sme_building_age_band','sme_unit_age_years','sme_fire_protection','sme_fire_amc','sme_fire_brigade_km','sme_claims_36m_count','sme_claims_36m_amount','sme_sections','sme_bi_gross_profit','sme_bi_indemnity_months','sme_burglary_si','sme_ee_si']], ['Motor risk', ['motor_reg_no','motor_make_model','motor_mfg_year','motor_cc','motor_fuel','motor_idv','motor_ncb_pct','motor_addons','motor_prev_insurer','motor_prev_policy_no','motor_prev_expiry','motor_prev_claim']], ['AI advice', ['ai_recommended_cover','ai_recommended_addons','ai_recommendation_rationale','ai_recommendation_confidence']], @@ -94,6 +100,11 @@ function label(k) { return words.charAt(0).toUpperCase() + words.slice(1) } +/** The uploaded files on a field value, if that is what it holds. */ +function filesOf(v) { + return Array.isArray(v) ? v.filter((f) => f && typeof f === 'object' && f.uuid) : [] +} + function fmt(k, v) { if (v === null || v === undefined || v === '') return null if (k === 'product_line') return PRODUCTS[v] ?? String(v) @@ -203,12 +214,17 @@ export default function Lead() { // Only groups that actually hold something; an empty tab is a dead end. const groups = GROUPS - .map(([title, keys]) => [title, keys.map((k) => [k, fmt(k, row[k])]).filter(([, v]) => v !== null)]) + .map(([title, keys]) => [ + title, + keys.map((k) => [k, fmt(k, row[k]), filesOf(row[k])]).filter(([, v]) => v !== null), + ]) .filter(([, shown]) => shown.length) const active = Math.min(tab, Math.max(groups.length - 1, 0)) const stateName = row.current_state_name || '' - const stage = STAGES.find((s) => s.name === stateName) + // The state, then what is really happening inside it — see phaseOf. Actions + // are still looked up on the state's own uid; only the presentation moves. + const stage = phaseOf(STAGES.find((s) => s.name === stateName), row) // Only what THIS user may run. The workflow refuses the rest server-side // anyway; showing them a row of buttons that all 403 reads as a broken app // rather than as a control. @@ -218,6 +234,10 @@ export default function Lead() { // somebody else, and saying "closed" about it would be a lie. const isClosed = stage?.kind === 'end' const heldByOthers = !isClosed && actions.length === 0 + // An automated stage that has stopped for a stated reason. Checked before + // the "in progress" strip below, which would otherwise keep promising that + // something is happening for as long as the lead is left alone. + const blocked = stage?.kind === 'auto' ? blockedOn(row) : null return ( @@ -331,12 +351,33 @@ export default function Lead() { says what is happening and who is doing it. The activities below it stay available — ops can run them by hand — but they are not the answer to "why is nothing moving?". */} - {stage?.kind === 'auto' ? ( + {blocked ? ( +
+
+ ) : stage?.kind === 'auto' ? (
) : null} @@ -348,18 +389,22 @@ 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. */} {(() => { - // 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') + // Once the documents are in, the upload is not the step, not an + // alternative to the step, and not a loop worth offering: it is + // recovery. Demoting it only to 'again' still printed an Upload + // documents button under a panel saying the documents had been + // received, which is the contradiction that was reported. It goes + // into the fold with the other recovery levers, relabelled for what + // it is actually for — a wrong file, or one more. + const docsDone = stage?.after === 'documents received' + const recast = (a) => (docsDone && a.uid === 'zk-act-collect-docs' + ? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' } + : a) + const shown = actions.map(recast) + const step = shown.filter((a) => a.role === 'do') + const again = shown.filter((a) => a.role === 'again') + const force = shown.filter((a) => a.role === 'force') + const exit = shown.filter((a) => a.role === 'exit') const extras = [...force, ...exit] const form = open ? ( @@ -391,8 +436,8 @@ export default function Lead() { ? 'This is what this lead is waiting on.' : isClosed ? 'This lead is closed.' - : docsDone - ? 'Documents received. Engage AI is capturing the risk and preparing the quote.' + : blocked + ? 'The chain has stopped. The fix is above.' : `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}

@@ -482,8 +527,27 @@ export default function Lead() { ))}
- {groups[active][1].map(([k, v]) => ( - v.length > LONG ? ( + {groups[active][1].map(([k, v, files]) => ( + files.length ? ( + /* An attached document is a thing to open, not a filename + to read. The preview route is app-scoped and public, so + a plain link works with no token plumbing. */ +
+
{labels[k] ?? label(k)}
+
+ {files.map((f) => ( + + {f.original_name || 'Document'} + + ))} +
+
+ ) : v.length > LONG ? (
{labels[k] ?? label(k)}
diff --git a/src/screens/Overview.jsx b/src/screens/Overview.jsx index 8a4ff1a..f5d018d 100644 --- a/src/screens/Overview.jsx +++ b/src/screens/Overview.jsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { useZino } from '../api/provider.jsx' -import { RV_LEADS, STAGES } from '../api/config.js' +import { RV_LEADS, STAGES, phaseOf } from '../api/config.js' import { rolesOf, visibleStages } from '../api/permissions.js' import { describeError } from '../api/errors.js' import './screens.css' @@ -117,7 +117,30 @@ export default function Overview() { const attention = dated.filter((r) => r._d <= 30).sort((a, b) => a._d - b._d) // Queues a person has to clear, in the order the pipeline runs. - const actionable = STAGES.filter((s) => s.kind === 'needs' || s.kind === 'customer') + // + // The count is of leads a person actually has to act on — NOT of leads + // sitting in the state. Document Pending holds both: the ones waiting for + // an upload, and the ones whose upload arrived and are now being worked by + // three AI steps inside the same state. Counting the state told an operator + // that four leads needed them when two did, which is how a queue stops + // being believed. `working` is reported separately rather than dropped — + // the leads are still there, they are just not anyone's task. + const actionable = STAGES + .filter((s) => s.kind === 'needs' || s.kind === 'customer') + .map((s) => { + const here = open.filter((r) => r.current_state_name === s.name) + const working = here.filter((r) => phaseOf(s, r).kind === 'auto') + const waiting = here.filter((r) => phaseOf(s, r).kind !== 'auto') + return { + ...s, + n: waiting.length, + working: working.length, + oldest: waiting + .map((r) => ageInDays(r.created_at)) + .filter((x) => x !== null) + .sort((a, b) => b - a)[0], + } + }) return { byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, @@ -197,27 +220,20 @@ export default function Overview() {

Action required

- {m.actionable.map((s) => { - const n = m.byStage[s.name] || 0 - const oldest = m.open - .filter((r) => r.current_state_name === s.name) - .map((r) => ageInDays(r.created_at)) - .filter((x) => x !== null) - .sort((a, b) => b - a)[0] - return ( - - {n} - - {s.need ?? s.name} - {s.by} - - - {n === 0 ? 'clear' : oldest !== undefined ? `oldest ${oldest}d` : ''} - - {!mine.has(s.uid) ? view : null} - - ) - })} + {m.actionable.map((s) => ( + + {s.n} + + {s.need ?? s.name} + {s.by} + + + {s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest}d` : ''} + {s.working ? +{s.working} with the AI : null} + + {!mine.has(s.uid) ? view : null} + + ))}

Renewal exposureopen leads by time to expiry

diff --git a/src/screens/Pipeline.jsx b/src/screens/Pipeline.jsx index 205b25d..faa063d 100644 --- a/src/screens/Pipeline.jsx +++ b/src/screens/Pipeline.jsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useZino } from '../api/provider.jsx' -import { CHANNELS, RV_LEADS, STAGES } from '../api/config.js' +import { CHANNELS, RV_LEADS, STAGES, phaseOf } from '../api/config.js' import { describeError } from '../api/errors.js' /** Short absolute date plus how long ago — a queue needs both: the absolute @@ -35,7 +35,12 @@ function added(ts) { */ function progress(row, stage) { if (!stage || stage.kind !== 'auto') return null - const t = Date.parse(row.updated_at || row.created_at) + // `updated_at` ONLY. Falling back to created_at dates the measure from when + // the lead was filed rather than from its last step, so every lead older than + // half an hour reports "stalled" — an alarm on every row is an alarm on none. + // The view returns the column since 76; before that it was silently absent, + // which is exactly how that fallback got there. + const t = Date.parse(row.updated_at) if (isNaN(t)) return null const mins = (Date.now() - t) / 60000 if (mins < 3) return { state: 'working', label: stage.doing } @@ -183,7 +188,10 @@ export default function Pipeline() { const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' } const exp = expiry(r.renewal_due_date) const add = added(r.created_at) - const rowStage = STAGES.find((x) => x.name === r.current_state_name) + // What is really happening in the state, not just its name — + // a lead whose documents are in is being worked by the AI, not + // waiting on the agent whose queue it is sitting in. + const rowStage = phaseOf(STAGES.find((x) => x.name === r.current_state_name), r) const prog = progress(r, rowStage) return (