console: show what the workflow actually did, and when it stopped

Traced the console against the workflow it renders. Five gaps, all of
which made a working chain look like a broken one.

THE AUDIT TRAIL WAS READING THE WRONG KEYS. 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 — and the timeline matched the unsuffixed global ids.
Almost nothing ever matched, so the trail was a list of activity names
with the agents' reasoning invisible behind it. The one narrative line
that did appear was an accident. It now reads the platform's own typed
`fields[]` (label, data_type, value) and matches on the base id.

That is also what lets it answer the question that was asked: an upload
now renders as "3 documents received" with each one named and openable,
instead of a bare "Collect Documents".

DATA_UPDATE ROWS WERE CLASSED AS AGENT WORK. A bookkeeping row inherits
the roles of whoever caused it, and the AI check ran first — so an AI's
field write appeared in the trail as an entry titled "Data updated",
while the toggle underneath still offered to reveal the others. The
activity id says a row is bookkeeping; the roles say who triggered it.

DOCUMENT PENDING IS TWO SITUATIONS. Before the upload a person has to
act; after it the lead stays in the same state while three AI steps run.
Both rendered as "waiting on the partner agent", so a lead that had just
been served showed an Upload documents button under a panel saying the
documents had been received, and counted against the Action-required
queue. phaseOf() derives the difference once, from documents_status, and
the header, the queue count, the row status and the action list all read
it. The upload demotes to recovery — "Replace or add a document", folded
away with the other levers.

A STOPPED CHAIN LOOKED IDENTICAL TO A RUNNING ONE. The rating engine
refuses to price without an IDV and writes so into quoted_breakup;
Engage then declines to raise a quote it would have to fabricate. Both
are right, and nobody was told: the refusal sat in a field on a tab and
the lead never moved again. blockedOn() surfaces it as an amber strip
that names the missing value and opens the form that carries it.

THE STALL MEASURE WAS DATED FROM THE WRONG COLUMN. progress() fell back
to created_at when updated_at was absent — which it always was, because
neither view returned it — so every lead older than half an hour would
have reported stalled. It reads updated_at only; the view supplies it as
of 76.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-08 12:17:52 +05:30
parent 704afe170e
commit d6756b4d2e
7 changed files with 441 additions and 60 deletions

View File

@ -335,3 +335,71 @@ export function fieldApplies(fieldId, lead) {
const slot = DOC_SLOTS[baseFieldId(fieldId)] const slot = DOC_SLOTS[baseFieldId(fieldId)]
return slot?.when ? slot.when(lead) : true 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
}

View File

@ -203,3 +203,45 @@
background: var(--zk-tint); background: var(--zk-tint);
color: var(--zk-blue-dark); 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; }

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useZino } from '../api/provider.jsx' import { useZino } from '../api/provider.jsx'
import ClampText from './ClampText.jsx' import ClampText from './ClampText.jsx'
import { STAGES } from '../api/config.js' import { APP_ID, STAGES, baseFieldId } from '../api/config.js'
import './Timeline.css' import './Timeline.css'
/** Which employee holds which role — used to badge an entry as machine work. */ /** 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 * 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 * output, a call's notes. Everything else stays in the file below; a timeline
* that shows every field is a table, not a story. * 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 = [ const NARRATIVE = [
['attribution_reason', 'Attribution'], ['attribution_reason', 'Attribution'],
@ -29,10 +37,31 @@ const NARRATIVE = [
['referral_analysis', 'Referral analysis'], ['referral_analysis', 'Referral analysis'],
['uw_decision_notes', 'Underwriting decision'], ['uw_decision_notes', 'Underwriting decision'],
['documents_notes', 'Documents'], ['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']) 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) { function when(ts) {
if (!ts) return '' if (!ts) return ''
const d = new Date(ts) const d = new Date(ts)
@ -60,6 +89,13 @@ function prettyUid(uid) {
.map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ') .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 }) { export default function Timeline({ instanceId }) {
const { client } = useZino() const { client } = useZino()
const [rows, setRows] = useState(null) 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, execution_state: isStage(r.execution_state) ? r.execution_state : prev.execution_state,
// Whichever row actually carries the submission and the AI's working. // Whichever row actually carries the submission and the AI's working.
data: (r.data && Object.keys(r.data).length) ? r.data : prev.data, data: (r.data && Object.keys(r.data).length) ? r.data : prev.data,
ai_reasoning: r.ai_reasoning || prev.ai_reasoning, fields: (r.fields && r.fields.length) ? r.fields : prev.fields,
ai_confidence: r.ai_confidence ?? prev.ai_confidence,
user_name: prev.user_name || r.user_name, user_name: prev.user_name || r.user_name,
user_roles: (prev.user_roles && prev.user_roles.length) ? prev.user_roles : r.user_roles, user_roles: (prev.user_roles && prev.user_roles.length) ? prev.user_roles : r.user_roles,
created_at: prev.created_at, created_at: prev.created_at,
@ -136,25 +171,69 @@ export default function Timeline({ instanceId }) {
merged.push(r) merged.push(r)
} }
let lastStage = null
const items = merged.map((r, i) => { const items = merged.map((r, i) => {
const roles = r.user_roles || [] 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 isSystem = r.activity_id === 'DATA_UPDATE'
const kind = aiRole ? 'ai' : isSystem ? 'sys' : 'human' const aiRole = roles.find((x) => AI_ROLES[x])
const d = r.data || {} 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 { return {
key: r.id ?? i, key: r.id ?? i,
kind, kind,
actor: aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System'), actor: aiRole ? AI_ROLES[aiRole] : (r.user_name || 'System'),
what: isSystem ? 'Data updated' : (r.activity_name || prettyUid(r.activity_id)), 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), when: when(r.created_at),
docs,
narrative: NARRATIVE narrative: NARRATIVE
.map(([k, label]) => [label, d[k]]) .map(([k, label]) => [label, byBase.get(k)?.value])
.filter(([, v]) => v !== undefined && v !== null && String(v).trim() !== ''), .filter(([, v]) => v !== undefined && v !== null && typeof v !== 'object' && String(v).trim() !== ''),
figures: Object.entries(d) figures: [...byBase]
.filter(([k, v]) => MONEY.has(k) && v) .filter(([base, f]) => MONEY.has(base) && f.value)
.map(([k, v]) => [k.replace(/_/g, ' '), '₹' + Number(v).toLocaleString('en-IN')]), .map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
} }
}) })
@ -185,6 +264,37 @@ export default function Timeline({ instanceId }) {
<span className="tl__when">{it.when}</span> <span className="tl__when">{it.when}</span>
</div> </div>
{/* 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 ? (
<div className="tl__docs">
<span className="tl__docshead">
{it.docs.length} document{it.docs.length === 1 ? '' : 's'} received
</span>
<div className="tl__doclist">
{it.docs.map((d) => (
<a
key={d.uuid}
className="tl__doc"
href={`${client.baseUrl}/app/${APP_ID}/view/files/${d.uuid}/preview`}
target="_blank"
rel="noreferrer"
title={d.original_name}
>
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3.5 1.5h4.2L11 4.8v7.7H3.5z" fill="none" stroke="currentColor" strokeWidth="1.2"
strokeLinejoin="round" />
<path d="M7.6 1.6v3.3H11" fill="none" stroke="currentColor" strokeWidth="1.2"
strokeLinejoin="round" />
</svg>
{d.slot}
</a>
))}
</div>
</div>
) : null}
{it.narrative.map(([label, v]) => ( {it.narrative.map(([label, v]) => (
<div className="tl__say" key={label}> <div className="tl__say" key={label}>
<span className="tl__saylabel">{label}</span> <span className="tl__saylabel">{label}</span>

View File

@ -4,7 +4,7 @@ import { useZino } from '../api/provider.jsx'
import ActivityForm from '../components/ActivityForm.jsx' import ActivityForm from '../components/ActivityForm.jsx'
import ClampText from '../components/ClampText.jsx' import ClampText from '../components/ClampText.jsx'
import Timeline from '../components/Timeline.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 { actionsFor, rolesOf } from '../api/permissions.js'
import { describeError } from '../api/errors.js' import { describeError } from '../api/errors.js'
import './screens.css' import './screens.css'
@ -58,7 +58,13 @@ const GROUPS = [
['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']], ['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']], ['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']],
['Contact', ['contact_outcome','outreach_window','contact_notes']], ['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']], ['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']], ['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']], ['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) 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) { function fmt(k, v) {
if (v === null || v === undefined || v === '') return null if (v === null || v === undefined || v === '') return null
if (k === 'product_line') return PRODUCTS[v] ?? String(v) 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. // Only groups that actually hold something; an empty tab is a dead end.
const groups = GROUPS 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) .filter(([, shown]) => shown.length)
const active = Math.min(tab, Math.max(groups.length - 1, 0)) const active = Math.min(tab, Math.max(groups.length - 1, 0))
const stateName = row.current_state_name || '' 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 // 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 // anyway; showing them a row of buttons that all 403 reads as a broken app
// rather than as a control. // rather than as a control.
@ -218,6 +234,10 @@ export default function Lead() {
// somebody else, and saying "closed" about it would be a lie. // somebody else, and saying "closed" about it would be a lie.
const isClosed = stage?.kind === 'end' const isClosed = stage?.kind === 'end'
const heldByOthers = !isClosed && actions.length === 0 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 ( return (
@ -331,12 +351,33 @@ export default function Lead() {
says what is happening and who is doing it. The activities below it 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 stay available ops can run them by hand but they are not the
answer to "why is nothing moving?". */} answer to "why is nothing moving?". */}
{stage?.kind === 'auto' ? ( {blocked ? (
<div className="doing doing--blocked">
<span className="doing__stop" aria-hidden="true" />
<div>
<strong>Stopped {blocked.what}</strong>
<span>
{blocked.fix} The agents will pick the lead up again on their own once it
is there; nothing else is running in the meantime.
</span>
<button
type="button" className="doing__fix"
onClick={() => setOpen(blocked.via)}
>
Open Collect Documents
</button>
</div>
</div>
) : stage?.kind === 'auto' ? (
<div className="doing"> <div className="doing">
<span className="doing__pulse" aria-hidden="true" /> <span className="doing__pulse" aria-hidden="true" />
<div> <div>
<strong>{stage.doing}</strong> <strong>{stage.doing}</strong>
<span>Handled by {stage.by}. Typically completes within two minutes; this view refreshes automatically.</span> <span>
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.
</span>
</div> </div>
</div> </div>
) : null} ) : null}
@ -348,18 +389,22 @@ export default function Lead() {
Mark Lost. Rendering them as one flat row of equals is what made Mark Lost. Rendering them as one flat row of equals is what made
six buttons appear where one was the answer. */} six buttons appear where one was the answer. */}
{(() => { {(() => {
// Once the three documents are in, the upload is no longer the step: // Once the documents are in, the upload is not the step, not an
// the lead stays in Document Pending while Engage captures the risk // alternative to the step, and not a loop worth offering: it is
// and the quote is built, and offering "Upload documents" through // recovery. Demoting it only to 'again' still printed an Upload
// that reads as though nothing was received. A further upload stays // documents button under a panel saying the documents had been
// possible as a loop a fourth document, a corrected one it is // received, which is the contradiction that was reported. It goes
// just not what the lead is waiting on. // into the fold with the other recovery levers, relabelled for what
const docsDone = stage?.uid === 'zk-state-docs' && row.documents_status === 'complete' // it is actually for a wrong file, or one more.
const roleOf = (a) => (docsDone && a.uid === 'zk-act-collect-docs' ? 'again' : a.role) const docsDone = stage?.after === 'documents received'
const step = actions.filter((a) => roleOf(a) === 'do') const recast = (a) => (docsDone && a.uid === 'zk-act-collect-docs'
const again = actions.filter((a) => roleOf(a) === 'again') ? { ...a, role: 'force', label: 'Replace or add a document', by: 'you' }
const force = actions.filter((a) => a.role === 'force') : a)
const exit = actions.filter((a) => a.role === 'exit') 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 extras = [...force, ...exit]
const form = open ? ( const form = open ? (
@ -391,8 +436,8 @@ export default function Lead() {
? 'This is what this lead is waiting on.' ? 'This is what this lead is waiting on.'
: isClosed : isClosed
? 'This lead is closed.' ? 'This lead is closed.'
: docsDone : blocked
? 'Documents received. Engage AI is capturing the risk and preparing the quote.' ? 'The chain has stopped. The fix is above.'
: `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`} : `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}
</p> </p>
</div> </div>
@ -482,8 +527,27 @@ export default function Lead() {
))} ))}
</div> </div>
<dl className="props"> <dl className="props">
{groups[active][1].map(([k, v]) => ( {groups[active][1].map(([k, v, files]) => (
v.length > LONG ? ( 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. */
<div className="prop" key={k}>
<dt>{labels[k] ?? label(k)}</dt>
<dd className="prop__files">
{files.map((f) => (
<a
key={f.uuid}
className="prop__file"
href={`${client.baseUrl}/app/${APP_ID}/view/files/${f.uuid}/preview`}
target="_blank" rel="noreferrer"
>
{f.original_name || 'Document'}
</a>
))}
</dd>
</div>
) : v.length > LONG ? (
<div className="prop prop--note" key={k}> <div className="prop prop--note" key={k}>
<dt>{labels[k] ?? label(k)}</dt> <dt>{labels[k] ?? label(k)}</dt>
<dd><ClampText text={v} /></dd> <dd><ClampText text={v} /></dd>

View File

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useZino } from '../api/provider.jsx' 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 { rolesOf, visibleStages } from '../api/permissions.js'
import { describeError } from '../api/errors.js' import { describeError } from '../api/errors.js'
import './screens.css' 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) 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. // 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 { return {
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable,
@ -197,27 +220,20 @@ export default function Overview() {
<div> <div>
<h2 className="sec">Action required</h2> <h2 className="sec">Action required</h2>
<div className="qlist"> <div className="qlist">
{m.actionable.map((s) => { {m.actionable.map((s) => (
const n = m.byStage[s.name] || 0 <Link key={s.uid} to={`/stage/${s.uid}`} className={'q' + (s.n ? ' is-live' : '')}>
const oldest = m.open <strong className="q__n">{s.n}</strong>
.filter((r) => r.current_state_name === s.name) <span className="q__t">
.map((r) => ageInDays(r.created_at)) {s.need ?? s.name}
.filter((x) => x !== null) <em>{s.by}</em>
.sort((a, b) => b - a)[0] </span>
return ( <span className="q__age">
<Link key={s.uid} to={`/stage/${s.uid}`} className={'q' + (n ? ' is-live' : '')}> {s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest}d` : ''}
<strong className="q__n">{n}</strong> {s.working ? <b className="q__auto">+{s.working} with the AI</b> : null}
<span className="q__t"> </span>
{s.need ?? s.name} {!mine.has(s.uid) ? <span className="q__ro" title="View only for your role">view</span> : null}
<em>{s.by}</em> </Link>
</span> ))}
<span className="q__age">
{n === 0 ? 'clear' : oldest !== undefined ? `oldest ${oldest}d` : ''}
</span>
{!mine.has(s.uid) ? <span className="q__ro" title="View only for your role">view</span> : null}
</Link>
)
})}
</div> </div>
<h2 className="sec">Renewal exposure<span className="sec__hint">open leads by time to expiry</span></h2> <h2 className="sec">Renewal exposure<span className="sec__hint">open leads by time to expiry</span></h2>

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx' 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' import { describeError } from '../api/errors.js'
/** Short absolute date plus how long ago a queue needs both: the absolute /** Short absolute date plus how long ago a queue needs both: the absolute
@ -35,7 +35,12 @@ function added(ts) {
*/ */
function progress(row, stage) { function progress(row, stage) {
if (!stage || stage.kind !== 'auto') return null 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 if (isNaN(t)) return null
const mins = (Date.now() - t) / 60000 const mins = (Date.now() - t) / 60000
if (mins < 3) return { state: 'working', label: stage.doing } 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 ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
const exp = expiry(r.renewal_due_date) const exp = expiry(r.renewal_due_date)
const add = added(r.created_at) 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) const prog = progress(r, rowStage)
return ( return (
<tr <tr

View File

@ -443,6 +443,46 @@
100% { box-shadow: 0 0 0 0 rgba(33, 103, 174, 0); } 100% { box-shadow: 0 0 0 0 rgba(33, 103, 174, 0); }
} }
/* ---- and when it has stopped ----
Same strip, amber, and NOT pulsing: the animation is what says "something is
happening", so it is the first thing that has to go when nothing is. The
agents refuse rather than invent the rating engine will not price without
an IDV and that refusal used to be visible only as a lead that never moved
again. */
.doing--blocked {
border-color: var(--zk-amber-line);
background: var(--zk-amber-tint);
align-items: flex-start;
}
.doing--blocked strong { color: var(--zk-amber-ink); }
.doing__stop {
flex: none;
margin-top: 5px;
width: 10px;
height: 10px;
border-radius: 2px;
background: var(--zk-amber);
}
.doing__fix {
align-self: flex-start;
margin-top: 9px;
font: inherit;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
padding: 7px 15px;
border-radius: var(--r-pill);
color: var(--zk-amber-ink);
background: var(--zk-white);
border: 1px solid var(--zk-amber-line);
transition: background var(--t-fast), border-color var(--t-fast);
}
.doing__fix:hover { background: #fff8f1; border-color: var(--zk-amber); }
.doing__fix:focus-visible { outline: 2px solid var(--zk-amber); outline-offset: 1px; }
/* ---- what the workflow said ---- /* ---- what the workflow said ----
The message the API returns per activity, shown verbatim: it is the workflow The message the API returns per activity, shown verbatim: it is the workflow
telling the operator what it just set in motion. */ telling the operator what it just set in motion. */
@ -1152,3 +1192,36 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.run--working .run__dot { animation: none; } .run--working .run__dot { animation: none; }
} }
/* An attached document is a thing to open
The record printed a filename as text, so the only way to see what an
agent had actually uploaded was to perform the activity again. */
.prop__files { display: flex; flex-wrap: wrap; gap: 6px; }
.prop__file {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 100%;
font-size: 0.8rem;
text-decoration: none;
padding: 3px 10px;
border-radius: var(--r-pill, 999px);
background: var(--zk-tint-blue);
border: 1px solid transparent;
color: var(--zk-blue-dark);
overflow-wrap: anywhere;
}
.prop__file:hover { border-color: var(--zk-blue-light); }
.prop__file:focus-visible { outline: 2px solid var(--zk-blue); outline-offset: 1px; }
/* The part of a queue that is not anyone's task. Reported rather than hidden:
the leads are in that state, they are just being carried by an agent, and an
operator who counts the queue by hand should find the same total. */
.q__auto {
display: block;
font-weight: 400;
font-size: 0.72rem;
color: var(--zk-muted);
margin-top: 2px;
}