From aca5d1a043e9ee2a9f79c717462186600b978d66 Mon Sep 17 00:00:00 2001 From: Yashas Date: Mon, 7 Sep 2026 16:31:04 +0530 Subject: [PATCH] console: role-scoped access, portfolio analytics, enterprise register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things, and the first is the one that mattered. ROLE-BASED ACCESS. The console showed every action to everyone on purpose — "the refusal is the demo" — and the workflow refused server-side. That is a defensible engineering position and a poor product: an underwriter saw a row of six buttons, five of which 403. src/api/permissions.js now mirrors tbl_wf_activity_permissions, and the session already carries user.roles, so nothing new had to be fetched. Action buttons, sidebar queues and the entry doors are all filtered. It remains presentation only — the platform still refuses, and a role added there but missing here hides a button that would have worked, which is the failure mode to watch. Each role's app now looks like their job. Ops sees the whole board and still cannot clear a referral. An underwriter sees one queue and two buttons. Agents see the three queues they work in. Reporting is NOT gated: the overview counts the whole portfolio for everyone, because an agent tracking leads they filed is reasonable and acting on them is not. One deliberate divergence from the workflow, commented where it lives: the two scheduled activities carry no roles at all, because that is the only configuration under which the scheduler can perform them. Open to the SYSTEM is not open to everyone signing in, so the UI narrows them — otherwise an underwriter is offered "Send Document Reminder". ANALYTICS. The overview was three lists. It is now a measured page: open leads, pipeline value, written premium, conversion, commission — then renewal exposure in three buckets, the queues needing a person with the age of the oldest item in each, and stage distribution as bars. Checked against the live book rather than assumed, which found a real bug before it shipped: Policy Issued is written business AND not yet terminal, so its premium was counted in pipeline and production both. ₹42,912 double counted on 37 leads. Pipeline now excludes anything already on risk. The lead page gained the same treatment: renewal countdown, lead age, time in current stage, premium, commission, AI confidence. Age and dwell are computed — neither is on the record, and they are the two figures that answer "is this moving?", which no field could. TERMINOLOGY. The previous pass over-corrected: fixing builder jargon ("the workflow decides, server-side") produced chat ("What you can do", "The agents have it", "Nothing here right now"). Neither is how an operations console reads. Now: Actions · Record · Audit trail · Action required · Customer response · Automated · Scheduled · Document collection · Underwriting referral · Premium confirmation. LAYOUT. Prose subtitles cut to one line or removed. Measures render as tabular figures in a bordered strip instead of label/value pairs. The overview splits into work on the left and distribution on the right. Stage bars replace a wall of equal-weight cards. Pre-existing lint errors unchanged at 11; none in the new files. --- src/api/config.js | 20 +-- src/api/permissions.js | 143 +++++++++++++++++ src/layout/Shell.jsx | 23 ++- src/screens/AddLead.jsx | 25 ++- src/screens/Lead.jsx | 136 ++++++++++++---- src/screens/Overview.jsx | 338 +++++++++++++++++++++++++-------------- src/screens/Pipeline.jsx | 18 +-- src/screens/screens.css | 221 ++++++++++++++++++------- 8 files changed, 676 insertions(+), 248 deletions(-) create mode 100644 src/api/permissions.js diff --git a/src/api/config.js b/src/api/config.js index c644799..6ce7083 100644 --- a/src/api/config.js +++ b/src/api/config.js @@ -41,16 +41,16 @@ export const STAGES = [ // The principal stall in the workflow, and the one that had no stage until // 7 September: a lead waited for its three documents inside "Contacted", so // the queue that most needed watching was the one that did not exist. - { uid: 'zk-state-docs', name: 'Document Pending', kind: 'needs', need: 'Documents needed', by: 'the partner agent' }, - { uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Waiting on the customer', by: 'the customer' }, + { uid: 'zk-state-docs', name: 'Document Pending', kind: 'needs', need: 'Document collection', by: 'the partner agent' }, + { uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Customer decision', by: 'the customer' }, // The one queue where the machine stops and a person decides. - { uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Underwriter to review', by: 'an underwriter' }, - { uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Payment to confirm', by: 'ops' }, + { uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Underwriting referral', by: 'an underwriter' }, + { uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Premium confirmation', by: 'operations' }, { uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file', by: 'Engage AI' }, // Nurture. There is no scheduler yet, so Resume Outreach is a button and it // is the only thing that wakes a parked lead — do not present this as a // stage with nothing to do. - { uid: 'zk-state-parked', name: 'Parked / Nurture', kind: 'waiting', need: 'Too early to call', by: 'nobody yet' }, + { uid: 'zk-state-parked', name: 'Parked / Nurture', kind: 'waiting', need: 'Nurture', by: 'the renewal calendar' }, { uid: 'zk-state-onboarded', name: 'Onboarded', kind: 'end' }, { uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' }, { uid: 'zk-state-declined', name: 'Declined', kind: 'end' }, @@ -62,11 +62,11 @@ export const STAGES = [ * waiting on me?". */ export const NAV_GROUPS = [ - { label: 'Waiting on a person', kind: 'needs' }, - { label: 'With the customer', kind: 'customer' }, - { label: 'The agents have it', kind: 'auto' }, - { label: 'Not due yet', kind: 'waiting' }, - { label: 'Closed', kind: 'end' }, + { label: 'Action required', kind: 'needs' }, + { label: 'Customer response', kind: 'customer' }, + { label: 'Automated', kind: 'auto' }, + { label: 'Scheduled', kind: 'waiting' }, + { label: 'Closed', kind: 'end' }, ] /** Channel presentation. source_channel is data on the instance, never a branch. */ diff --git a/src/api/permissions.js b/src/api/permissions.js new file mode 100644 index 0000000..afda8b1 --- /dev/null +++ b/src/api/permissions.js @@ -0,0 +1,143 @@ +import { ACTIONS, STAGES } from './config.js' + +/** + * Who may perform what. + * + * A MIRROR of workflow.tbl_wf_activity_permissions on app 536, in the same + * sense as STAGES and ACTIONS: the console needs it to decide what to draw, + * and the API returns a refusal rather than a capability list. + * + * ── This is presentation, never enforcement ────────────────────────────── + * The workflow refuses server-side on every submission, and that refusal is + * the real control. Nothing here can grant anything; a role added here that + * the platform does not recognise gets a 403 the moment it submits. What this + * buys is that an operator is not shown four buttons they will be refused for + * pressing — which on a demo reads as a broken app rather than as a fence. + * + * Keep in step with the permissions table. A role REMOVED there but left here + * shows a button that 403s: recoverable and visible. A role ADDED there but + * missing here hides a button that would have worked: invisible, and the + * failure mode worth watching for. + */ +export const ACTIVITY_ROLES = { + 'zk-act-init-agent': ['ops_admin', 'partner_agent'], + 'zk-act-init-bank': ['ops_admin', 'bank_rm'], + 'zk-act-init-direct': ['ops_admin', 'csr_direct'], + + 'zk-act-qualify': ['ops_admin', 'ai_intake'], + 'zk-act-contact': ['ops_admin', 'ai_engage'], + 'zk-act-request-docs': ['ops_admin', 'ai_engage'], + 'zk-act-collect-docs': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], + 'zk-act-capture-motor':['ops_admin', 'ai_engage'], + 'zk-act-capture-sme': ['ops_admin', 'ai_engage'], + 'zk-act-advise': ['ai_advisor'], + 'zk-act-quote': ['ops_admin', 'ai_engage'], + 'zk-act-accept': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], + 'zk-act-kyc': ['ops_admin', 'ai_kyc'], + 'zk-act-uw-screen': ['ops_admin', 'ai_kyc'], + 'zk-act-uw-prepare': ['ai_uw_referral'], + + // The only exclusive permission in the application. Ops runs everything + // else and cannot touch these two — a referral is cleared by an underwriter + // or it is not cleared. + 'zk-act-uw-clear': ['sme_underwriter'], + 'zk-act-uw-decline': ['sme_underwriter'], + + 'zk-act-payment': ['ops_admin', 'ai_engage'], + 'zk-act-nudge': ['ops_admin', 'ai_engage'], + 'zk-act-realise': ['ops_admin'], + 'zk-act-onboard': ['ops_admin', 'ai_engage'], + 'zk-act-resume': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], + 'zk-act-drop': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], + + // ── The one place this mirror deliberately DISAGREES with the workflow ── + // + // Both of these carry NO roles in tbl_wf_activity_permissions, which the + // platform reads as "open to anyone". That is not generosity: the scheduler + // performs them as the synthetic `system` actor, and the trigger's own + // perform step ignores allow_system_perform, so zero roles is the only + // configuration under which a scheduled activity can run at all. See + // 63_scheduled_activities_rbac.sql. + // + // Open to the SYSTEM is not the same as open to everyone signing in. Left + // literal, an underwriter is offered "Send Document Reminder" on a queue + // they have no business in. So the UI narrows them to the roles that would + // plausibly chase by hand. The workflow still accepts either from anyone — + // this hides a button, it does not close a door. + 'zk-act-doc-reminder': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], + 'zk-act-retry-call': ['ops_admin', 'partner_agent', 'bank_rm', 'csr_direct'], +} + +/** Roles that belong to an AI employee. They never sign in. */ +export const AI_ROLES = new Set([ + 'ai_intake', 'ai_engage', 'ai_advisor', 'ai_kyc', 'ai_uw_referral', +]) + +export function rolesOf(user) { + const r = user?.roles + if (Array.isArray(r)) return r.filter(Boolean).map(String) + if (Array.isArray(user?.role_assignments)) { + return user.role_assignments.map((x) => x?.role_id).filter(Boolean).map(String) + } + return [] +} + +/** + * An empty role list means the activity is open to any signed-in user — that + * is what the platform's RBAC does with no roles attached, so mirroring it + * here keeps the two from disagreeing. + */ +export function canPerform(roles, activityUid) { + const allowed = ACTIVITY_ROLES[activityUid] + if (!allowed) return true // unknown activity: show it, let the API decide + if (allowed.length === 0) return true + return roles.some((r) => allowed.includes(r)) +} + +/** The activities this user may actually run from a given stage. */ +export function actionsFor(roles, stageUid) { + return (ACTIONS[stageUid] || []).filter((a) => canPerform(roles, a.uid)) +} + +/** + * Activities that are available almost everywhere, or that no person runs. + * + * They must not count towards "does this role have business in this queue". + * Mark Lost is offered in eight stages to every agent role, and the two + * scheduled activities carry no roles at all — which the platform reads as + * open. Left in the test, between them they make every queue look relevant + * to everybody, which is the whole thing this is trying to avoid. + */ +const AMBIENT = new Set(['zk-act-drop', 'zk-act-doc-reminder', 'zk-act-retry-call']) + +/** + * Which queues a role has any business WORKING IN. + * + * Terminal stages stay visible to everyone — closed business is reporting, + * not work, and hiding it would leave an underwriter unable to see what + * became of a file they declined. Everything else appears only where the + * role can do something substantive. + * + * This gates the SIDEBAR, not the data. The overview still counts the whole + * book for every role, because an agent tracking leads they filed is a + * reasonable thing to want and a queue they cannot act in is not. + */ +export function visibleStages(roles) { + // Operations runs the desk. They cannot clear a referral — that stays + // exclusive to the underwriter — but the underwriting queue is theirs to + // watch, and an ops console that hides a queue because it holds one + // activity they may not press is hiding the wrong thing. + if (roles.includes('ops_admin')) return STAGES + + return STAGES.filter((s) => { + if (s.kind === 'end') return true + return (ACTIONS[s.uid] || []) + .filter((a) => !AMBIENT.has(a.uid)) + .some((a) => canPerform(roles, a.uid)) + }) +} + +/** Whether this user can file a lead at all, and through which doors. */ +export function entryDoorsFor(roles, entries) { + return entries.filter((e) => canPerform(roles, e.uid)) +} diff --git a/src/layout/Shell.jsx b/src/layout/Shell.jsx index ec77596..446d1d3 100644 --- a/src/layout/Shell.jsx +++ b/src/layout/Shell.jsx @@ -1,5 +1,7 @@ import { NavLink, Outlet } from 'react-router-dom' -import { NAV_GROUPS, STAGES } from '../api/config.js' +import { ENTRY, NAV_GROUPS } from '../api/config.js' +import { entryDoorsFor, rolesOf, visibleStages } from '../api/permissions.js' +import { useZino } from '../api/provider.jsx' import UserMenu from './UserMenu.jsx' import logo from '../assets/brand/zurich_logo.webp' import './Shell.css' @@ -17,6 +19,13 @@ import './Shell.css' * server. */ export default function Shell() { + const { user } = useZino() + const roles = rolesOf(user) + // The sidebar shows the queues this role WORKS in. Reporting on the rest + // lives on the overview, which counts the whole book for everyone. + const stages = visibleStages(roles) + const canFile = entryDoorsFor(roles, ENTRY).length > 0 + return (
@@ -37,25 +46,27 @@ export default function Shell() { end className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')} > - Today + Overview + {canFile ? ( - Add a lead + New lead + ) : null} {NAV_GROUPS.map((group) => { - const stages = STAGES.filter((s) => s.kind === group.kind) - if (!stages.length) return null + const inGroup = stages.filter((s) => s.kind === group.kind) + if (!inGroup.length) return null return (

{group.label}

- {stages.map((s) => ( + {inGroup.map((s) => ( +
+

New lead

+
+

Your role does not create leads.

+ + ) + } + return (
-

Add a lead

-

Three doors, one machine. The door decides who may submit and where it lands — nothing after that.

+

New lead

+

Capture the customer, the vehicle and the renewal date. Everything after this runs automatically.

{!entry ? (
- {ENTRY.map((e) => ( + {doors.map((e) => (
-
- {(() => { const e = expiryOf(row.renewal_due_date); return e ? ( -
-
Renewal due
-
{e.label} {e.on}
+ {/* Measures, not fields. Age and dwell time are computed — neither is on + the record — and they are the two that answer "is this lead moving?", + which no label/value pair on the page could. */} + {(() => { + const e = expiryOf(row.renewal_due_date) + const age = daysSince(row.created_at) + const dwell = daysSince(row.updated_at) + const premium = inrShort(row.quoted_premium) + const commission = inrShort(row.commission_amount) + const conf = Number(row.ai_recommendation_confidence) + return ( +
+
+ Renewal + {e ? e.label : '—'} + {e ? e.on : 'no date on file'} +
+
+ Lead age + {age === null ? '—' : age === 0 ? 'Today' : age + 'd'} + {row.created_at ? fmtWhen(row.created_at) : ''} +
+
+ In this stage + {dwell === null ? '—' : dwell === 0 ? '<1d' : dwell + 'd'} + last activity {row.updated_at ? fmtWhen(row.updated_at) : '—'} +
+
+ Premium + {premium ?? '—'} + {premium ? 'quoted' : 'not yet rated'} +
+
+ Commission + {commission ?? '—'} + + {row.commission_rate_pct ? row.commission_rate_pct + '% of premium' : 'on placement'} + +
+
+ Product + {PRODUCTS[row.product_line] ?? '—'} + + {row.motor_reg_no || row.entity_name || ''} + {row.current_insurer ? ` · from ${row.current_insurer}` : ''} + +
+ {Number.isFinite(conf) && conf > 0 ? ( +
+ AI confidence + {conf}% + on the cover advice +
+ ) : null}
- ) : null })()} -
Product
{PRODUCTS[row.product_line] ?? '—'}
- {row.quoted_premium ? ( -
-
Quoted premium
-
₹{Number(row.quoted_premium).toLocaleString('en-IN')}
-
- ) : null} - {row.current_insurer ?
Current insurer
{row.current_insurer}
: null} - {row.motor_reg_no ?
Registration
{row.motor_reg_no}
: null} - {row.created_at ?
Lead added
{fmtWhen(row.created_at)}
: null} - {row.updated_at ?
Last update
{fmtWhen(row.updated_at)}
: null} -
+ ) + })()}
@@ -264,7 +336,7 @@ export default function Lead() {
) : null} @@ -273,12 +345,8 @@ export default function Lead() {
-

What you can do

-

- Everything this lead allows right now. Some of these belong to - an agent or an underwriter — if it is not yours, it will say so - when you try. -

+

Actions

+

Available to your role at this stage.

@@ -300,7 +368,7 @@ export default function Lead() { onCancel={() => setOpen(null)} onStale={() => { setOpen(null) - setNote('This lead has moved on — showing what you can do now.') + setNote('Stage changed. Available actions have been refreshed.') load() }} onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }} @@ -317,8 +385,8 @@ export default function Lead() {
-

Lead details

-

Everything captured so far, in the order it was collected.

+

Record

+

Captured data, grouped by stage of collection.

@@ -357,8 +425,8 @@ export default function Lead() {
-

History

-

Every step so far, and who took it.

+

Audit trail

+

Every action recorded against this lead.

diff --git a/src/screens/Overview.jsx b/src/screens/Overview.jsx index ecf3ffa..18a066f 100644 --- a/src/screens/Overview.jsx +++ b/src/screens/Overview.jsx @@ -2,103 +2,136 @@ import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { useZino } from '../api/provider.jsx' import { RV_LEADS, STAGES } from '../api/config.js' +import { rolesOf, visibleStages } from '../api/permissions.js' import { describeError } from '../api/errors.js' import './screens.css' /** - * The page somebody opens in the morning. + * Portfolio overview — the landing screen. * - * Every other screen answers "show me this queue". None of them answered "how - * is the book doing, and is anything waiting on me?" — which is the only - * question a person has before they have picked a queue. The app used to open - * on the underwriting queue: one stage, usually empty, and somebody else's. + * Everything is derived from one record-view call and computed here. That is + * honest at this size and it is the same call every queue makes; if the book + * outgrows a single page the answer is an aggregate endpoint, not a larger + * limit. * - * One list call, counted here. That is honest at this size and it is the same - * call the queues make; a demo book is a few dozen leads, not a few thousand. - * If it ever outgrows that, the fix is a counts endpoint, not a bigger page. + * 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(dateStr) { - if (!dateStr) return null - const d = Date.parse(String(dateStr).substring(0, 10) + 'T00:00:00Z') - if (isNaN(d)) return null - return Math.round((d - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / DAY) +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 expiryLabel(days) { - if (days === null) return { text: '—', tone: 'later' } - if (days < 0) return { text: Math.abs(days) + ' days overdue', tone: 'lapsed' } - if (days === 0) return { text: 'expires today', tone: 'urgent' } - if (days === 1) return { text: 'expires tomorrow', tone: 'urgent' } - return { text: 'in ' + days + ' days', tone: days <= 7 ? 'urgent' : days <= 30 ? 'soon' : 'later' } +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 { client } = useZino() - const [state, setState] = useState({ status: 'loading', rows: [], error: null }) + const { client, user } = useZino() + const roles = rolesOf(user) + const [state, setState] = useState({ status: 'loading', rows: [], at: null, error: null }) useEffect(() => { let cancelled = false - function fetchRows(quiet) { - if (!quiet) setState({ status: 'loading', rows: [], error: null }) + function load(quiet) { + if (!quiet) setState((p) => ({ ...p, status: 'loading' })) return client.recordView(RV_LEADS, { limit: 200 }) .then((res) => { if (cancelled) return - setState({ status: 'ready', rows: res?.data ?? res?.rows ?? res?.records ?? [], error: null }) + setState({ status: 'ready', rows: res?.data ?? res?.rows ?? res?.records ?? [], at: new Date(), error: null }) }) .catch((err) => { if (cancelled) return - // A failed refresh must never blank a page that is already readable. - setState((prev) => (quiet && prev.status === 'ready' ? prev : { status: 'error', rows: [], error: err })) + setState((p) => (quiet && p.status === 'ready' ? p : { status: 'error', rows: [], at: null, error: err })) }) } - fetchRows(false) - const id = setInterval(() => { if (document.visibilityState === 'visible') fetchRows(true) }, 30000) + load(false) + const id = setInterval(() => { if (document.visibilityState === 'visible') load(true) }, 30000) return () => { cancelled = true; clearInterval(id) } }, [client]) - const view = useMemo(() => { + const m = useMemo(() => { + const rows = state.rows const byStage = {} - for (const r of state.rows) { - const k = r.current_state_name || '—' - byStage[k] = (byStage[k] || 0) + 1 + 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 closedNames = new Set(STAGES.filter((s) => s.kind === 'end').map((s) => s.name)) - const open = state.rows.filter((r) => !closedNames.has(r.current_state_name)) + const attention = dated.filter((r) => r._d <= 30).sort((a, b) => a._d - b._d) - // Worst first, and only what is actually near. A renewal six months out is - // not a thing to look at today, so it does not compete for the space. - const running = open - .map((r) => ({ ...r, _d: daysToExpiry(r.renewal_due_date) })) - .filter((r) => r._d !== null && r._d <= 30) - .sort((a, b) => a._d - b._d) + // Queues a person has to clear, in the order the pipeline runs. + const actionable = STAGES.filter((s) => s.kind === 'needs' || s.kind === 'customer') return { - byStage, - openCount: open.length, - running, - needs: STAGES.filter((s) => s.kind === 'needs'), - won: state.rows.filter((r) => r.current_state_name === 'Onboarded').length, + byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable, + conversion: closed ? Math.round((won.length / closed) * 100) : null, + maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)), } }, [state.rows]) - if (state.status === 'loading') { - return ( -
-

Today

-
-
- ) - } + const mine = new Set(visibleStages(roles).map((s) => s.uid)) if (state.status === 'error') { const said = describeError(state.error) return (
-

Today

+

Portfolio overview

{said.title} {said.detail ?

{said.detail}

: null} @@ -108,83 +141,146 @@ export default function Overview() { ) } + if (state.status === 'loading' && !state.rows.length) { + return ( +
+

Portfolio overview

+
+
+ ) + } + return (
-

Today

-

- {view.openCount} {view.openCount === 1 ? 'lead is' : 'leads are'} open ·{' '} - {view.won} onboarded so far -

+

Portfolio overview

+

Motor and SME renewals · organisation-wide

+ {state.at ? ( + + Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })} + + ) : null}
- {/* The only section that is about the reader. Everything a person can - actually do sits in one of these three queues. */} -

Waiting on a person

-
- {view.needs.map((s) => { - const n = view.byStage[s.name] || 0 - return ( - - {n} - {s.need ?? s.name} - {n === 0 ? 'Nothing waiting' : `with ${s.by}`} - - ) - })} +
+
+ 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 +
-

- Renewals running out - Open leads with 30 days or less on the clock -

- {view.running.length === 0 ? ( -

Nothing expiring in the next 30 days.

- ) : ( -
- - - - - - {view.running.slice(0, 8).map((r) => { - const id = r.instance_id ?? r.id - const e = expiryLabel(r._d) - return ( - - - - - - - ) - })} - -
CustomerRenewalWhere it is
- {r.customer_name || r.lead_ref || `#${id}`} -
{r.lead_ref || ''}
-
{e.text}{r.current_state_name || '—'}Open
-
- )} +
+
+

Action required

+
+ {m.actionable.map((s) => { + const n = m.byStage[s.name] || 0 + const oldest = m.open + .filter((r) => r.current_state_name === s.name) + .map((r) => ageInDays(r.created_at)) + .filter((x) => x !== null) + .sort((a, b) => b - a)[0] + return ( + + {n} + + {s.need ?? s.name} + {s.by} + + + {n === 0 ? 'clear' : oldest !== undefined ? `oldest ${oldest}d` : ''} + + {!mine.has(s.uid) ? view : null} + + ) + })} +
- {/* Every stage, including the empty ones. A stage reading zero is - information — it is how you notice the agents have stopped. */} -

- The whole book - Every lead, by where it has got to -

-
- {STAGES.map((s) => { - const n = view.byStage[s.name] || 0 - return ( - - {n} - {s.name} - - ) - })} +

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 ( + + + + + + + + ) + })} + +
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)) : '—'}Open
+
+ ) : ( +

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} +
+
) diff --git a/src/screens/Pipeline.jsx b/src/screens/Pipeline.jsx index 975f5ce..612a9b6 100644 --- a/src/screens/Pipeline.jsx +++ b/src/screens/Pipeline.jsx @@ -93,12 +93,12 @@ export default function Pipeline() {

{stage.need ?? stage.name}

{stage.kind === 'auto' - ? `${stage.doing}. Nothing here is waiting on you.` + ? `Automated · ${stage.doing.toLowerCase()}` : stage.kind === 'end' - ? 'These are finished. Nothing more happens to them.' + ? 'Closed — retained for reporting.' : stage.kind === 'waiting' - ? 'Good leads, too early to work. They come back on their own.' - : `Waiting on ${stage.by}.`} + ? 'Held until the renewal window opens. Re-enters outreach automatically.' + : `Pending action by ${stage.by}.`} {stage.need ? Stage · {stage.name} : null}

@@ -118,18 +118,14 @@ export default function Pipeline() { {state.status === 'error' ? (
- This queue could not be loaded. -

- The list of leads did not come back. Everything else — sign-in and - the rest of the app — is working, so this is worth a retry before - anything else. -

+ Unable to load this stage. +

The lead list did not return. Other functions are unaffected; retry before escalating.

{describeError(state.error).title} · {state.error?.status} {state.error?.message}

) : null} {state.status === 'ready' && state.rows.length === 0 ? ( -

Nothing here right now.

+

No records in this stage.

) : null} {state.status === 'ready' && state.rows.length > 0 ? ( diff --git a/src/screens/screens.css b/src/screens/screens.css index 8528b2d..99f3f94 100644 --- a/src/screens/screens.css +++ b/src/screens/screens.css @@ -900,71 +900,79 @@ font-variant-numeric: tabular-nums; } -/* ── Overview ──────────────────────────────────────────────────────────── - The morning page. Three blocks, in the order a person cares: what is - waiting on me, what is running out of time, and how the book looks. - Nothing here is a chart — a demo book is small enough that a number and a - name beat any visualisation of them. */ +/* ── Portfolio overview ────────────────────────────────────────────────── + An operations dashboard: numbers first, prose nowhere. Every measure on + this page is derived from the lead list in the browser, so nothing here is + a figure the reader cannot get back to by opening a queue. */ + +.stamp { + flex: none; + align-self: center; + font-size: 11.5px; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--zk-grey); + font-variant-numeric: tabular-nums; +} + +/* KPI strip. Tabular figures so the row does not jitter as it refreshes. */ +.kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); + gap: 1px; + background: var(--zk-line); + border: 1px solid var(--zk-line); + border-radius: var(--r-md); + overflow: hidden; + margin-bottom: 26px; +} +.kpi { background: var(--zk-white); padding: 16px 18px 14px; } +.kpi__l { + display: block; + font-size: 11px; + font-weight: 500; + letter-spacing: .07em; + text-transform: uppercase; + color: var(--zk-grey); +} +.kpi__v { + display: block; + margin: 7px 0 3px; + font-size: 26px; + font-weight: 400; + line-height: 1.05; + color: var(--zk-navy); + font-variant-numeric: tabular-nums; + letter-spacing: -.02em; +} +.kpi__s { display: block; font-size: 12px; color: var(--zk-muted); } + +/* Two columns on a desk, one on anything narrower. The distribution is the + half that survives being pushed below the fold. */ +.split { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); gap: 30px; align-items: start; } .sec { display: flex; align-items: baseline; gap: 10px; - margin: 28px 0 12px; - font-size: 13px; + margin: 0 0 12px; + font-size: 12px; font-weight: 500; - letter-spacing: .02em; + letter-spacing: .07em; text-transform: uppercase; color: var(--zk-muted); } -.sec:first-of-type { margin-top: 8px; } -.sec__hint { - font-size: 12px; - font-weight: 400; - letter-spacing: 0; - text-transform: none; - color: var(--zk-grey); -} +.split > div > .sec:not(:first-child) { margin-top: 30px; } +.sec__hint { font-size: 11.5px; font-weight: 400; letter-spacing: 0; text-transform: none; color: var(--zk-grey); } -/* Three cards, never more. These are the only queues a person acts in, and - keeping the row short is what makes it readable at a glance. */ -.cards { +/* Work queues. A row rather than a card: five of them as cards is a wall. */ +.qlist { display: flex; flex-direction: column; gap: 6px; } +.q { display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + grid-template-columns: 44px 1fr auto auto; + align-items: center; gap: 12px; -} -.card { - display: block; - padding: 18px 18px 16px; - border: 1px solid var(--zk-line); - border-radius: var(--r-md); - background: var(--zk-white); - text-decoration: none; - color: inherit; - transition: border-color .12s, box-shadow .12s, transform .12s; -} -.card:hover { border-color: var(--zk-blue-light); box-shadow: 0 2px 10px rgba(35,54,111,.07); transform: translateY(-1px); } -.card__n { display: block; font-size: 34px; line-height: 1; font-weight: 300; color: var(--zk-grey); } -.card__t { display: block; margin-top: 10px; font-size: 15px; font-weight: 500; color: var(--zk-ink); } -.card__w { display: block; margin-top: 3px; font-size: 12.5px; color: var(--zk-muted); } - -/* A queue with something in it earns colour. One with nothing stays quiet — - the point of the row is that the eye lands on the number that matters. */ -.card.is-live { border-color: var(--zk-amber-line); background: var(--zk-amber-tint); } -.card.is-live .card__n { color: var(--zk-amber-ink); font-weight: 400; } - -/* Every stage, small. Zeroes are shown rather than hidden: a stage that has - quietly stopped receiving leads is only visible if its zero is on screen. */ -.tally { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(124px, 1fr)); - gap: 8px; -} -.tally__i { - display: flex; - align-items: baseline; - gap: 8px; - padding: 10px 12px; + padding: 11px 14px; border: 1px solid var(--zk-line-soft); border-radius: var(--r-sm); background: var(--zk-white); @@ -972,13 +980,102 @@ color: inherit; transition: border-color .12s, background .12s; } -.tally__i:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); } -.tally__n { font-size: 18px; font-weight: 500; color: var(--zk-ink); min-width: 1.2em; } -.tally__l { font-size: 12.5px; color: var(--zk-muted); line-height: 1.25; } -.tally__i.is-zero { background: transparent; } -.tally__i.is-zero .tally__n { color: var(--zk-grey); font-weight: 300; } -.tally__i.is-zero .tally__l { color: var(--zk-grey); } - -@media (max-width: 720px) { - .cards { grid-template-columns: 1fr; } +.q:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); } +.q__n { font-size: 20px; font-weight: 500; color: var(--zk-grey); font-variant-numeric: tabular-nums; text-align: right; } +.q__t { font-size: 14px; color: var(--zk-ink); } +.q__t em { display: block; font-style: normal; font-size: 12px; color: var(--zk-muted); margin-top: 1px; } +.q__age { font-size: 12px; color: var(--zk-grey); font-variant-numeric: tabular-nums; } +.q__ro { + font-size: 10px; letter-spacing: .06em; text-transform: uppercase; + color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: 3px; padding: 1px 5px; } +/* Only a queue with work in it earns weight. The eye should land on the + number that needs clearing, not read five identical rows. */ +.q.is-live { border-color: var(--zk-line); } +.q.is-live .q__n { color: var(--zk-navy); } + +.buckets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.bucket { + padding: 13px 15px; + border: 1px solid var(--zk-line); + border-left-width: 3px; + border-radius: var(--r-sm); + background: var(--zk-white); +} +.bucket strong { display: block; font-size: 21px; font-weight: 500; line-height: 1.1; font-variant-numeric: tabular-nums; } +.bucket span { display: block; margin-top: 2px; font-size: 12px; color: var(--zk-muted); } +.bucket--lapsed { border-left-color: var(--zk-danger); } +.bucket--lapsed strong { color: var(--zk-danger-ink); } +.bucket--urgent { border-left-color: var(--zk-amber); } +.bucket--urgent strong { color: var(--zk-amber-ink); } +.bucket--soon { border-left-color: var(--zk-blue-mid); } +.bucket--soon strong { color: var(--zk-blue-dark); } + +/* Stage distribution. A bar per stage, scaled to the largest — enough to see + where the book is banked without pulling in a charting library. */ +.dist { display: flex; flex-direction: column; gap: 3px; } +.dist__r { + display: grid; + grid-template-columns: 1fr 74px 26px; + align-items: center; + gap: 10px; + padding: 5px 8px; + border-radius: var(--r-xs); + text-decoration: none; + color: inherit; +} +.dist__r:hover { background: var(--zk-tint); } +.dist__l { font-size: 12.5px; color: var(--zk-ink); } +.dist__bar { height: 6px; border-radius: 3px; background: var(--zk-line-soft); overflow: hidden; } +.dist__f { display: block; height: 100%; border-radius: 3px; background: var(--zk-blue-mid); min-width: 0; } +.dist__f--needs { background: var(--zk-amber); } +.dist__f--customer { background: var(--zk-blue); } +.dist__f--auto { background: var(--zk-blue-light); } +.dist__f--waiting { background: var(--zk-grey); } +.dist__f--end { background: var(--zk-line); } +.dist__n { font-size: 12.5px; text-align: right; color: var(--zk-muted); font-variant-numeric: tabular-nums; } +.dist__r.is-zero .dist__l, .dist__r.is-zero .dist__n { color: var(--zk-grey); } + +.grid--tight th, .grid--tight td { padding-top: 8px; padding-bottom: 8px; } + +@media (max-width: 1080px) { + .split { grid-template-columns: 1fr; gap: 26px; } +} +@media (max-width: 640px) { + .buckets { grid-template-columns: 1fr; } +} + +/* ── Lead measures ─────────────────────────────────────────────────────── + The same strip as the portfolio KPIs, at lead scale. Age and dwell time are + computed rather than stored, and they are the two figures that answer "is + this moving?" — a question the record itself cannot answer. */ +.lmetrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1px; + background: var(--zk-line); + border: 1px solid var(--zk-line); + border-radius: var(--r-md); + overflow: hidden; + margin-bottom: 22px; +} +.lm { background: var(--zk-white); padding: 13px 15px 12px; } +.lm__l { + display: block; + font-size: 10.5px; font-weight: 500; letter-spacing: .07em; + text-transform: uppercase; color: var(--zk-grey); +} +.lm__v { + display: block; margin: 6px 0 2px; + font-size: 19px; font-weight: 400; line-height: 1.1; + color: var(--zk-navy); font-variant-numeric: tabular-nums; letter-spacing: -.01em; +} +.lm__v--sm { font-size: 16px; } +.lm__s { + display: block; font-size: 11.5px; color: var(--zk-muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +/* The countdown carries its own tone here, the same one the queues use, so a + lapsed renewal reads the same wherever it appears. */ +.lm__v.due--lapsed { color: var(--zk-danger-ink); } +.lm__v.due--urgent { color: var(--zk-amber-ink); }