The three buckets (Lapsed / Within 7 days / 8 to 30 days) were static counts above a combined table. They now double as the filter: click one to narrow the table to that bucket, click again — or use the Clear affordance — to show every open renewal by soonest due. The active bucket fills with its own tint, an empty bucket is disabled, and the empty state is filter-aware. One predicate map drives both the counts and the filter so they cannot drift. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
417 lines
18 KiB
JavaScript
417 lines
18 KiB
JavaScript
import { useMemo, useState } from 'react'
|
|
import { Link, useNavigate } from 'react-router-dom'
|
|
import { usePortfolio } from '../api/portfolio.jsx'
|
|
import { useZino } from '../api/provider.jsx'
|
|
import { ENTRY, STAGES, phaseOf } from '../api/config.js'
|
|
import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js'
|
|
import { describeError } from '../api/errors.js'
|
|
import NewLeadDialog from '../components/NewLeadDialog.jsx'
|
|
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')
|
|
}
|
|
|
|
/* The renewal-exposure buckets, as predicates on days-to-expiry, so the filter
|
|
and the three counts stay one definition. */
|
|
const EXPO_BUCKETS = {
|
|
lapsed: (d) => d < 0,
|
|
week: (d) => d >= 0 && d <= 7,
|
|
month: (d) => d > 7 && d <= 30,
|
|
}
|
|
const EXPO_LABEL = { lapsed: 'Lapsed', week: 'Within 7 days', month: '8 to 30 days' }
|
|
function expoLabel(k) { return EXPO_LABEL[k] || '' }
|
|
|
|
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 canFile = entryDoorsFor(roles, ENTRY).length > 0
|
|
// Filing a lead happens over this page, not instead of it — see
|
|
// NewLeadDialog. The overview behind it keeps its counts and its poll.
|
|
const [adding, setAdding] = useState(false)
|
|
// Which renewal-exposure bucket the table is narrowed to, or null for all.
|
|
const [expo, setExpo] = useState(null)
|
|
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))
|
|
|
|
// THE HEADLINE OF AN AGENTIC CONSOLE IS WHO HOLDS THE WORK RIGHT NOW.
|
|
// Every open lead is in exactly one of three hands: a person's, the AI's,
|
|
// or the customer's. The page led with money, which is the outcome; this
|
|
// is the state, and it is the sentence the whole product exists to say.
|
|
const byStageDef = new Map(STAGES.map((s) => [s.name, s]))
|
|
let withAI = 0, withCustomer = 0, withPerson = 0
|
|
for (const r of open) {
|
|
const def = byStageDef.get(r.current_state_name)
|
|
if (!def) continue
|
|
const ph = phaseOf(def, r)
|
|
if (ph.kind === 'auto') withAI += 1
|
|
else if (ph.kind === 'customer') withCustomer += 1
|
|
else withPerson += 1
|
|
}
|
|
|
|
return {
|
|
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, approvals,
|
|
withAI, withCustomer, withPerson,
|
|
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))
|
|
|
|
// The exposure table, narrowed to the chosen bucket (or the soonest-due
|
|
// across all buckets when no filter is set).
|
|
const expoRows = expo ? m.attention.filter((r) => EXPO_BUCKETS[expo](r._d)) : m.attention
|
|
|
|
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>
|
|
{/* The action sits with the thing it acts on. It was in the sidebar,
|
|
which is for moving between places — an action among destinations
|
|
is the one item that does not behave like its neighbours. */}
|
|
<div className="page__right">
|
|
{state.at ? (
|
|
<span className="stamp">
|
|
Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
) : null}
|
|
{canFile ? (
|
|
<button type="button" className="newlead" onClick={() => setAdding(true)}>
|
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor"
|
|
strokeWidth="1.7" strokeLinecap="round" />
|
|
</svg>
|
|
New lead
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Who holds the work, right now. Three numbers, one sentence: this many
|
|
are being worked by the AI, this many are waiting on a person, this
|
|
many on a customer. It sits above the money because it is the claim
|
|
the money is evidence for. */}
|
|
<div className="now">
|
|
<div className="now__cell now__cell--ai">
|
|
<span className="now__l">With the AI</span>
|
|
<strong className="now__v">{m.withAI}</strong>
|
|
<span className="now__s">being qualified, rated, checked or chased — no one waiting</span>
|
|
</div>
|
|
<div className={'now__cell now__cell--person' + (m.withPerson ? ' is-live' : '')}>
|
|
<span className="now__l">Waiting on a person</span>
|
|
<strong className="now__v">{m.withPerson}</strong>
|
|
<span className="now__s">{m.withPerson ? 'a decision or an upload is owed' : 'nothing owed by anyone'}</span>
|
|
</div>
|
|
<div className="now__cell now__cell--cust">
|
|
<span className="now__l">With the customer</span>
|
|
<strong className="now__v">{m.withCustomer}</strong>
|
|
<span className="now__s">a quote or a payment is theirs to answer</span>
|
|
</div>
|
|
<div className={'now__cell now__cell--risk' + (m.exposure.lapsed.length ? ' is-live' : '')}>
|
|
<span className="now__l">Renewals at risk</span>
|
|
<strong className="now__v">{m.exposure.lapsed.length + m.exposure.week.length}</strong>
|
|
<span className="now__s">{m.exposure.lapsed.length} lapsed · {m.exposure.week.length} within 7 days</span>
|
|
</div>
|
|
</div>
|
|
|
|
<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>
|
|
{/* The three buckets double as the filter: click one to narrow the
|
|
table to it, click again (or Clear) to show every open renewal. A
|
|
bucket with nothing in it is not clickable — there is nothing to
|
|
show. */}
|
|
<div className="buckets" role="group" aria-label="Filter by time to expiry">
|
|
{[
|
|
['lapsed', 'lapsed', m.exposure.lapsed.length, 'Lapsed'],
|
|
['week', 'urgent', m.exposure.week.length, 'Within 7 days'],
|
|
['month', 'soon', m.exposure.month.length, '8 to 30 days'],
|
|
].map(([key, variant, count, label]) => (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
className={`bucket bucket--${variant}` + (expo === key ? ' is-active' : '')}
|
|
aria-pressed={expo === key}
|
|
disabled={!count}
|
|
onClick={() => setExpo(expo === key ? null : key)}
|
|
>
|
|
<strong>{count}</strong><span>{label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{expo ? (
|
|
<button type="button" className="expo__clear" onClick={() => setExpo(null)}>
|
|
Showing {expoLabel(expo)} only · Clear filter
|
|
</button>
|
|
) : null}
|
|
|
|
{expoRows.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>
|
|
{expoRows.slice(0, expo ? 50 : 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">
|
|
{expo ? 'No open leads in this bucket.' : '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>
|
|
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
|
|
</section>
|
|
)
|
|
}
|