zurich_kotak/src/screens/Pipeline.jsx
Yashas d6756b4d2e 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>
2026-09-08 12:17:52 +05:30

252 lines
11 KiB
JavaScript

import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useZino } from '../api/provider.jsx'
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
* for "when exactly", the relative for "is this going stale". */
function added(ts) {
if (!ts) return { abs: '—', rel: '' }
const d = new Date(ts)
if (isNaN(d)) return { abs: String(ts), rel: '' }
const mins = Math.round((Date.now() - d.getTime()) / 60000)
const rel = mins < 1 ? 'just now'
: mins < 60 ? mins + 'm ago'
: mins < 1440 ? Math.round(mins / 60) + 'h ago'
: Math.round(mins / 1440) + 'd ago'
return {
abs: d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }),
rel,
}
}
/**
* Is an automated stage actually moving?
*
* The agents carry six of the nine stages, and a lead sitting in one is in
* exactly one of two states: being worked right now, or stuck. The row showed
* neither, so a lead three hours into "Calling the customer" looked identical
* to one thirty seconds in — which is the failure this console exists to catch.
*
* The threshold is generous on purpose. An employee wake takes a minute or two
* and a scheduled retry can be hours out, so `stalled` means "longer than any
* normal step", not "longer than average".
*/
function progress(row, stage) {
if (!stage || stage.kind !== 'auto') return null
// `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 }
if (mins < 30) return { state: 'waiting', label: stage.doing }
return { state: 'stalled', label: 'No movement for ' + (mins < 120 ? Math.round(mins) + ' minutes' : Math.round(mins / 60) + ' hours') }
}
/** Days to expiry, and how alarmed to be about it. This is a renewal book —
* the countdown is the most operationally useful number in the row. */
function expiry(dateStr) {
if (!dateStr) return null
const d = new Date(String(dateStr).substring(0, 10) + 'T00:00:00Z')
if (isNaN(d)) return null
const days = Math.round((d.getTime() - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / 86400000)
const tone = days < 0 ? 'lapsed' : days <= 7 ? 'urgent' : days <= 30 ? 'soon' : 'later'
const label = days < 0 ? Math.abs(days) + 'd overdue' : days === 0 ? 'today' : 'in ' + days + 'd'
return { days, tone, label, on: d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) }
}
import './screens.css'
/**
* One record view drives every queue; the stage is a server-side filter on
* current_state_name. Filtering server-side rather than fetching everything and
* narrowing in the browser is what keeps a queue honest once there are more
* leads than one page.
*/
export default function Pipeline() {
const { stageUid } = useParams()
const navigate = useNavigate()
const { client } = useZino()
// "all" is not a stage — it is every open lead in one list. An operator
// wanting to find a lead should not have to guess which queue it is in.
const isAll = stageUid === 'all'
const stage = isAll
? { uid: 'all', name: 'All open leads', kind: 'all', by: 'everyone' }
: STAGES.find((s) => s.uid === stageUid)
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
useEffect(() => {
let cancelled = false
// `quiet` is what makes polling bearable: the first load may show a skeleton,
// a refresh may not — dropping back to the loading state every 30 seconds
// would flash the whole queue away under whoever is reading it.
function fetchRows(quiet) {
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
return client
.recordView(RV_LEADS, {
limit: isAll ? 200 : 100,
// No stage filter on the all view. Closed leads are dropped below
// rather than in the query: one request beats three negations, and
// 200 covers a demo book comfortably.
...(isAll ? {} : { filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }] }),
})
.then((res) => {
if (cancelled) return
let rows = res?.data ?? res?.rows ?? res?.records ?? []
if (isAll) {
const closed = new Set(STAGES.filter((x) => x.kind === 'end').map((x) => x.name))
rows = rows.filter((r) => !closed.has(r.current_state_name))
}
// total_count is the size of the QUEUE; rows is one page of at most
// 100 of it. Counting the page would quietly under-report a busy stage.
const total = isAll ? rows.length : (res?.pagination?.total_count ?? rows.length)
setState({ status: 'ready', rows, total, error: null })
})
.catch((err) => {
if (cancelled) return
// A failed refresh must not throw away a queue that is already on
// screen; only a failed first load is an error state.
setState((prev) => (quiet && prev.status === 'ready' ? prev : { status: 'error', rows: [], total: 0, error: err }))
})
}
fetchRows(false)
const id = setInterval(() => {
if (document.visibilityState === 'visible') fetchRows(true)
}, 30000)
return () => { cancelled = true; clearInterval(id) }
}, [client, stageUid, stage?.name, isAll])
if (!stage) return <p className="empty">Unknown stage.</p>
return (
<section>
<header className="page__head">
<div>
<h1 className="page__title">{stage.need ?? stage.name}</h1>
<p className="page__sub">
{isAll
? 'Every lead not yet closed, across all stages.'
: stage.kind === 'auto'
? `Automated · ${stage.doing.toLowerCase()}`
: stage.kind === 'end'
? 'Closed — retained for reporting.'
: stage.kind === 'waiting'
? 'Held until the renewal window opens. Re-enters outreach automatically.'
: `Pending action by ${stage.by}.`}
{stage.need ? <span className="page__stage">Stage · {stage.name}</span> : null}
</p>
</div>
{state.status === 'ready' ? (
<div className="page__count">
<strong>{state.total}</strong>
<span>{state.total === 1 ? 'lead' : 'leads'}</span>
</div>
) : null}
</header>
{state.status === 'loading' ? (
<div className="skel" aria-busy="true" aria-label="Loading the queue">
{Array.from({ length: 6 }, (_, i) => <div className="skel__row" key={i} />)}
</div>
) : null}
{state.status === 'error' ? (
<div className="notice">
<strong>Unable to load this stage.</strong>
<p>The lead list did not return. Other functions are unaffected; retry before escalating.</p>
<p className="notice__detail">{describeError(state.error).title} · {state.error?.status} {state.error?.message}</p>
</div>
) : null}
{state.status === 'ready' && state.rows.length === 0 ? (
<p className="empty">No records in this stage.</p>
) : null}
{state.status === 'ready' && state.rows.length > 0 ? (
<div className="gridwrap">
<table className="grid">
<thead>
<tr>
<th>Customer</th>
<th>{isAll ? 'Stage' : 'Status'}</th>
<th>Renewal due</th>
<th className="num">Premium</th>
<th>Waiting</th>
</tr>
</thead>
<tbody>
{state.rows.map((r) => {
const id = r.instance_id ?? r.id
const ch = CHANNELS[r.source_channel] || { label: r.source_channel || '—' }
const exp = expiry(r.renewal_due_date)
const add = added(r.created_at)
// 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
key={id}
className={'grid__row' + (prog ? ' is-' + prog.state : '')}
onClick={() => navigate(`/lead/${id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/lead/${id}`) }
}}
tabIndex={0}
role="link"
aria-label={`Open ${r.customer_name || r.lead_ref || id}`}
>
<td>
<span className="grid__name">{r.customer_name || '—'}</span>
<div className="grid__sub">
{r.lead_ref || `#${id}`}
{r.product_line ? ` · ${r.product_line === 'motor' ? 'Motor' : 'SME'}` : ''}
{ch.label ? ` · ${ch.label}` : ''}
</div>
</td>
{/* Who holds it, and — where an agent does — whether it is
moving. On the all-leads view the stage name is the
useful column; inside a queue every row shares it, so
the useful thing is progress. */}
<td>
{prog ? (
<span className={'run run--' + prog.state}>
<span className="run__dot" aria-hidden="true" />
{prog.label}
</span>
) : isAll ? (
<span className="grid__sub">{r.current_state_name || '—'}</span>
) : (
<span className="grid__sub">
{rowStage?.by ? `with ${rowStage.by}` : '—'}
</span>
)}
</td>
<td>
{exp
? <><span className={'due due--' + exp.tone}>{exp.label}</span>
<div className="grid__sub">{exp.on}</div></>
: <span className="grid__sub"></span>}
</td>
<td className="num">{r.quoted_premium ? '₹' + Number(r.quoted_premium).toLocaleString('en-IN') : '—'}</td>
<td><span className="grid__sub">{add.rel || add.abs}</span></td>
</tr>
)
})}
</tbody>
</table>
</div>
) : null}
</section>
)
}