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)]
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);
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 { 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 }) {
<span className="tl__when">{it.when}</span>
</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]) => (
<div className="tl__say" key={label}>
<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 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 ? (
<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">
<span className="doing__pulse" aria-hidden="true" />
<div>
<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>
) : 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.`}
</p>
</div>
@ -482,8 +527,27 @@ export default function Lead() {
))}
</div>
<dl className="props">
{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. */
<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}>
<dt>{labels[k] ?? label(k)}</dt>
<dd><ClampText text={v} /></dd>

View File

@ -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() {
<div>
<h2 className="sec">Action required</h2>
<div className="qlist">
{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 (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'q' + (n ? ' is-live' : '')}>
<strong className="q__n">{n}</strong>
{m.actionable.map((s) => (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'q' + (s.n ? ' is-live' : '')}>
<strong className="q__n">{s.n}</strong>
<span className="q__t">
{s.need ?? s.name}
<em>{s.by}</em>
</span>
<span className="q__age">
{n === 0 ? 'clear' : oldest !== undefined ? `oldest ${oldest}d` : ''}
{s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest}d` : ''}
{s.working ? <b className="q__auto">+{s.working} with the AI</b> : null}
</span>
{!mine.has(s.uid) ? <span className="q__ro" title="View only for your role">view</span> : null}
</Link>
)
})}
))}
</div>
<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 { 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 (
<tr

View File

@ -443,6 +443,46 @@
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 ----
The message the API returns per activity, shown verbatim: it is the workflow
telling the operator what it just set in motion. */
@ -1152,3 +1192,36 @@
@media (prefers-reduced-motion: reduce) {
.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;
}