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 (

Portfolio overview

{said.title} {said.detail ?

{said.detail}

: null}

{state.error?.status} {state.error?.message}

) } if (state.status === 'loading' && !state.rows.length) { return (

Portfolio overview

) } return (

Portfolio overview

Motor and SME renewals · organisation-wide

{state.at ? ( Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })} ) : null}
Open leads {m.open.length} {m.closed} closed to date
Pipeline value {inr(m.pipeline)} quoted, not yet on risk
Written premium {inr(m.gwp)} {m.won.length} {m.won.length === 1 ? 'policy' : 'policies'} issued
Conversion {m.conversion === null ? '—' : m.conversion + '%'} {m.won.length} won · {m.lost.length} lost
Commission {inr(m.commission)} payable to partners
{/* 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) => ( {s.n} {s.approvalLabel} {s.approvalNote} {s.n ? (s.oldest !== undefined ? `oldest ${s.oldest}d` : 'review') : 'nothing waiting'} ))}

Action required

{m.actionable.map((s) => ( {s.n} {s.need ?? s.name} {s.by} {s.n === 0 ? 'clear' : s.oldest !== undefined ? `oldest ${s.oldest}d` : ''} {s.working ? +{s.working} with the AI : null} {!mine.has(s.uid) ? view : null} ))}

Renewal exposureopen leads by time to expiry

{m.exposure.lapsed.length}Lapsed
{m.exposure.week.length}Within 7 days
{m.exposure.month.length}8 to 30 days
{m.attention.length ? (
{m.attention.slice(0, 8).map((r) => { const id = r.instance_id ?? r.id return ( 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}`} > ) })}
LeadExpiryStagePremium
{r.customer_name || r.lead_ref || `#${id}`}
{r.lead_ref || ''}
{r._d < 0 ? Math.abs(r._d) + 'd overdue' : r._d === 0 ? 'today' : r._d + 'd'} {r.current_state_name} {r.quoted_premium ? inr(num(r.quoted_premium)) : '—'}
) : (

No renewals due within 30 days.

)}

Pipeline distribution

{/* Every stage, zeroes included. A stage that has quietly stopped receiving leads is only visible if its zero is on the page. */}
{STAGES.map((s) => { const n = m.byStage[s.name] || 0 return ( {s.name}
) }