zurich_kotak/src/screens/Overview.jsx
Yashas 171aa4c2fa console: a live trail, faces for the agents, and the thread as a thread
Five of the six asks. The sixth — the whole row as the click target —
shipped in 704afe1 and is already live on both grids.

THE AUDIT TRAIL WAS FROZEN. Timeline fetched the rows once on mount and
never again, so a lead being worked by five agents in three minutes
showed the trail as it was when the page opened — on the one screen whose
job is watching work happen. The rows move to the lead page, which
already polls every 12s, and Timeline becomes presentational. One fetch,
one poll, and the conversation view reads the same rows so the two cannot
disagree.

THE ACCEPTANCE BUTTON OUTLIVED THE ACCEPTANCE. Recording an acceptance
twice is not a loop, a recovery or an alternative — it is meaningless,
and offering it invites someone to overwrite a customer's WhatsApp
acceptance with a worse record of the same event. Gone once
acceptance_ref is set, replaced by a line saying who accepted and when.

THE AGENTS HAD NO FACE. Five of them carry this workflow and the console
named them three different ways — "ai_engage" here, "Engage" there,
"Engage AI" elsewhere — so nobody could see that the thing which called
the customer and the thing which wrote the quote were one worker. One
roster now (api/agents.js): a short name, a colour and two initials each,
used wherever an agent is named. People get a disc too, in grey — a trail
where the machines are decorated and the humans are plain text reads as
though the machines are the important ones, which is backwards on a
screen built for oversight. A "Worked by" strip above the trail shows the
team at a glance.

THE CONVERSATION WAS A LOG, NOT A THREAD. Each turn sat as its own entry
among twenty others. Now one button opens it as a thread — theirs left,
ours right, oldest first — and the footer says plainly which parts it
holds: the quote, the read receipt and the acceptance confirmation are
sent by trigger nodes and never written to a field, so they are not
there. A thread that quietly omitted them would be worse than one that
admits what it is.

CONFIRMING A PREMIUM IS A SIGN-OFF, NOT A TASK. It is the only step
locked to one role and the only one that touches money — an employee that
could mark a premium received could put a customer on risk for a policy
nobody paid for. It sat fourth in a list of six queues. It gets its own
band now, shown only to the role that owns it, amber only when something
is actually waiting: a permanent alert colour on an empty queue teaches
people to stop seeing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 15:29:02 +05:30

319 lines
13 KiB
JavaScript

import { useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { usePortfolio } from '../api/portfolio.jsx'
import { useZino } from '../api/provider.jsx'
import { STAGES, phaseOf } from '../api/config.js'
import { rolesOf, visibleStages } from '../api/permissions.js'
import { describeError } from '../api/errors.js'
import './screens.css'
/**
* Portfolio overview — the landing screen.
*
* Everything is derived from ONE record-view call and computed here. That call
* now lives in PortfolioProvider and is shared with the sidebar's tallies, so
* the two cannot disagree and there is no second fetch. If the book outgrows a
* single page the answer is an aggregate endpoint, not a larger limit.
*
* Counts cover the WHOLE portfolio for every role, including queues the role
* cannot work in. Reporting and permission are different questions: an agent
* tracking leads they filed is reasonable, acting on them is not, and the
* sidebar already gates the second.
*/
const DAY = 86400000
const today = () => Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')
function daysToExpiry(v) {
if (!v) return null
const d = Date.parse(String(v).substring(0, 10) + 'T00:00:00Z')
return isNaN(d) ? null : Math.round((d - today()) / DAY)
}
function ageInDays(v) {
if (!v) return null
const d = Date.parse(v)
return isNaN(d) ? null : Math.floor((Date.now() - d) / DAY)
}
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
/** Indian money, short. A dashboard tile has no room for eight digits. */
function inr(n) {
if (!n) return '₹0'
if (n >= 1e7) return '₹' + (n / 1e7).toFixed(n >= 1e8 ? 0 : 2) + ' Cr'
if (n >= 1e5) return '₹' + (n / 1e5).toFixed(n >= 1e6 ? 0 : 2) + ' L'
return '₹' + Math.round(n).toLocaleString('en-IN')
}
function expiryTone(d) {
if (d === null) return 'later'
if (d < 0) return 'lapsed'
if (d <= 7) return 'urgent'
if (d <= 30) return 'soon'
return 'later'
}
export default function Overview() {
const { user } = useZino()
const navigate = useNavigate()
const roles = rolesOf(user)
const state = usePortfolio()
const m = useMemo(() => {
const rows = state.rows
const byStage = {}
for (const r of rows) byStage[r.current_state_name || '—'] = (byStage[r.current_state_name || '—'] || 0) + 1
const endNames = new Set(STAGES.filter((s) => s.kind === 'end').map((s) => s.name))
const open = rows.filter((r) => !endNames.has(r.current_state_name))
const won = rows.filter((r) => r.current_state_name === 'Onboarded' || r.current_state_name === 'Policy Issued')
const lost = rows.filter((r) => r.current_state_name === 'Lost / Dropped' || r.current_state_name === 'Declined')
const closed = won.length + lost.length
// Written premium counts only policies that exist. A quote nobody accepted
// is pipeline, not production, and adding the two is how a dashboard ends
// up flattering itself.
const gwp = won.reduce((t, r) => t + num(r.quoted_premium), 0)
const commission = won.reduce((t, r) => t + num(r.commission_amount), 0)
// Policy Issued is written business AND not yet terminal, so it appears in
// `open` as well as in `won`. Summing both would count the same premium
// twice — once as pipeline, once as production. Pipeline is what is still
// to be placed, so anything already on risk comes out of it.
const wonIds = new Set(won.map((r) => r.instance_id ?? r.id))
const pipeline = open
.filter((r) => !wonIds.has(r.instance_id ?? r.id))
.reduce((t, r) => t + num(r.quoted_premium), 0)
const dated = open
.map((r) => ({ ...r, _d: daysToExpiry(r.renewal_due_date), _age: ageInDays(r.created_at) }))
.filter((r) => r._d !== null)
const exposure = {
lapsed: dated.filter((r) => r._d < 0),
week: dated.filter((r) => r._d >= 0 && r._d <= 7),
month: dated.filter((r) => r._d > 7 && r._d <= 30),
}
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.
//
// 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],
}
})
// The sign-off queues this ROLE owns. Empty for everyone else, so the band
// never appears to a partner agent who could not act on it anyway.
const approvals = actionable.filter((s) => s.approval && roles.includes(s.approval))
return {
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, approvals,
conversion: closed ? Math.round((won.length / closed) * 100) : null,
maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)),
}
}, [state.rows, roles])
const mine = new Set(visibleStages(roles).map((s) => s.uid))
if (state.status === 'error') {
const said = describeError(state.error)
return (
<section>
<header className="page__head"><div><h1 className="page__title">Portfolio overview</h1></div></header>
<div className="notice">
<strong>{said.title}</strong>
{said.detail ? <p>{said.detail}</p> : null}
<p className="notice__detail">{state.error?.status} {state.error?.message}</p>
</div>
</section>
)
}
if (state.status === 'loading' && !state.rows.length) {
return (
<section>
<header className="page__head"><div><h1 className="page__title">Portfolio overview</h1></div></header>
<div className="skel" aria-busy="true"><div className="skel__row" /><div className="skel__row" /></div>
</section>
)
}
return (
<section>
<header className="page__head">
<div>
<h1 className="page__title">Portfolio overview</h1>
<p className="page__sub">Motor and SME renewals · organisation-wide</p>
</div>
{state.at ? (
<span className="stamp">
Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
</span>
) : null}
</header>
<div className="kpis">
<div className="kpi">
<span className="kpi__l">Open leads</span>
<strong className="kpi__v">{m.open.length}</strong>
<span className="kpi__s">{m.closed} closed to date</span>
</div>
<div className="kpi">
<span className="kpi__l">Pipeline value</span>
<strong className="kpi__v">{inr(m.pipeline)}</strong>
<span className="kpi__s">quoted, not yet on risk</span>
</div>
<div className="kpi">
<span className="kpi__l">Written premium</span>
<strong className="kpi__v">{inr(m.gwp)}</strong>
<span className="kpi__s">{m.won.length} {m.won.length === 1 ? 'policy' : 'policies'} issued</span>
</div>
<div className="kpi">
<span className="kpi__l">Conversion</span>
<strong className="kpi__v">{m.conversion === null ? '—' : m.conversion + '%'}</strong>
<span className="kpi__s">{m.won.length} won · {m.lost.length} lost</span>
</div>
<div className="kpi">
<span className="kpi__l">Commission</span>
<strong className="kpi__v">{inr(m.commission)}</strong>
<span className="kpi__s">payable to partners</span>
</div>
</div>
{/* SIGN-OFF, not work. The only queue that is a person's approval rather
than a task, shown to the role that owns it and to nobody else. It sat
fourth in a list of six and read like any other item, which is the
wrong weight for the step that decides whether cover begins. */}
{m.approvals.map((s) => (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'appr' + (s.n ? ' is-live' : '')}>
<strong className="appr__n">{s.n}</strong>
<span className="appr__t">
{s.approvalLabel}
<em>{s.approvalNote}</em>
</span>
<span className="appr__go">
{s.n ? (s.oldest !== undefined ? `oldest ${s.oldest}d` : 'review') : 'nothing waiting'}
</span>
</Link>
))}
<div className="split">
<div>
<h2 className="sec">Action required</h2>
<div className="qlist">
{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">
{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>
<div className="buckets">
<div className="bucket bucket--lapsed">
<strong>{m.exposure.lapsed.length}</strong><span>Lapsed</span>
</div>
<div className="bucket bucket--urgent">
<strong>{m.exposure.week.length}</strong><span>Within 7 days</span>
</div>
<div className="bucket bucket--soon">
<strong>{m.exposure.month.length}</strong><span>8 to 30 days</span>
</div>
</div>
{m.attention.length ? (
<div className="gridwrap">
<table className="grid grid--tight">
<thead>
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr>
</thead>
<tbody>
{m.attention.slice(0, 8).map((r) => {
const id = r.instance_id ?? r.id
return (
<tr
key={id}
className="grid__row"
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 || r.lead_ref || `#${id}`}</span>
<div className="grid__sub">{r.lead_ref || ''}</div>
</td>
<td><span className={'due due--' + expiryTone(r._d)}>
{r._d < 0 ? Math.abs(r._d) + 'd overdue' : r._d === 0 ? 'today' : r._d + 'd'}
</span></td>
<td><span className="grid__sub">{r.current_state_name}</span></td>
<td className="num">{r.quoted_premium ? inr(num(r.quoted_premium)) : '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : (
<p className="empty">No renewals due within 30 days.</p>
)}
</div>
<div>
<h2 className="sec">Pipeline distribution</h2>
{/* Every stage, zeroes included. A stage that has quietly stopped
receiving leads is only visible if its zero is on the page. */}
<div className="dist">
{STAGES.map((s) => {
const n = m.byStage[s.name] || 0
return (
<Link key={s.uid} to={`/stage/${s.uid}`} className={'dist__r' + (n ? '' : ' is-zero')}>
<span className="dist__l">{s.name}</span>
<span className="dist__bar" aria-hidden="true">
<span style={{ width: (n / m.maxStage) * 100 + '%' }} className={'dist__f dist__f--' + s.kind} />
</span>
<span className="dist__n">{n}</span>
</Link>
)
})}
</div>
</div>
</div>
</section>
)
}