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 (

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

{/* 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. */}
{state.at ? ( Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })} ) : null} {canFile ? ( ) : null}
{/* 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. */}
With the AI {m.withAI} being qualified, rated, checked or chased — no one waiting
Waiting on a person {m.withPerson} {m.withPerson ? 'a decision or an upload is owed' : 'nothing owed by anyone'}
With the customer {m.withCustomer} a quote or a payment is theirs to answer
Renewals at risk {m.exposure.lapsed.length + m.exposure.week.length} {m.exposure.lapsed.length} lapsed · {m.exposure.week.length} within 7 days
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

{/* 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. */}
{[ ['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]) => ( ))}
{expo ? ( ) : null} {expoRows.length ? (
{expoRows.slice(0, expo ? 50 : 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)) : '—'}
) : (

{expo ? 'No open leads in this bucket.' : '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}
{adding ? setAdding(false)} /> : null}
) }