zurich_kotak/src/screens/Overview.jsx

382 lines
17 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')
}
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)
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))
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>
<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>
{adding ? <NewLeadDialog onClose={() => setAdding(false)} /> : null}
</section>
)
}