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); }