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>
252 lines
11 KiB
JavaScript
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>
|
|
)
|
|
}
|