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 ? (
+
{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 ? (
+
+
+
+ Stopped — {blocked.what}
+
+ {blocked.fix} The agents will pick the lead up again on their own once it
+ is there; nothing else is running in the meantime.
+
+
+
+
+ ) : stage?.kind === 'auto' ? (
{stage.doing}
- Handled by {stage.by}. Typically completes within two minutes; this view refreshes automatically.
+
+ Handled by {stage.by}.
+ {stage.after ? ` The lead stays in ${stateName} until a quote exists — nothing is waiting on you.` : ''}
+ {' '}Typically completes within two minutes; this view refreshes automatically.
+
) : 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. */
+
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() {
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 (