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 (
{/* 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. */}
{/* 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'}
))}