console: role-scoped access, portfolio analytics, enterprise register
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.
This commit is contained in:
parent
4c210c7e09
commit
aca5d1a043
@ -41,16 +41,16 @@ export const STAGES = [
|
|||||||
// The principal stall in the workflow, and the one that had no stage until
|
// 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
|
// 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.
|
// 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-docs', name: 'Document Pending', kind: 'needs', need: 'Document collection', by: 'the partner agent' },
|
||||||
{ uid: 'zk-state-quoted', name: 'Quote Presented', kind: 'customer', need: 'Waiting on the customer', by: 'the customer' },
|
{ 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.
|
// 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-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Underwriting referral', by: 'an underwriter' },
|
||||||
{ uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Payment to confirm', by: 'ops' },
|
{ 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' },
|
{ 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
|
// 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
|
// is the only thing that wakes a parked lead — do not present this as a
|
||||||
// stage with nothing to do.
|
// 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-onboarded', name: 'Onboarded', kind: 'end' },
|
||||||
{ uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' },
|
{ uid: 'zk-state-lost', name: 'Lost / Dropped', kind: 'end' },
|
||||||
{ uid: 'zk-state-declined', name: 'Declined', kind: 'end' },
|
{ uid: 'zk-state-declined', name: 'Declined', kind: 'end' },
|
||||||
@ -62,10 +62,10 @@ export const STAGES = [
|
|||||||
* waiting on me?".
|
* waiting on me?".
|
||||||
*/
|
*/
|
||||||
export const NAV_GROUPS = [
|
export const NAV_GROUPS = [
|
||||||
{ label: 'Waiting on a person', kind: 'needs' },
|
{ label: 'Action required', kind: 'needs' },
|
||||||
{ label: 'With the customer', kind: 'customer' },
|
{ label: 'Customer response', kind: 'customer' },
|
||||||
{ label: 'The agents have it', kind: 'auto' },
|
{ label: 'Automated', kind: 'auto' },
|
||||||
{ label: 'Not due yet', kind: 'waiting' },
|
{ label: 'Scheduled', kind: 'waiting' },
|
||||||
{ label: 'Closed', kind: 'end' },
|
{ label: 'Closed', kind: 'end' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
143
src/api/permissions.js
Normal file
143
src/api/permissions.js
Normal file
@ -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))
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom'
|
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 UserMenu from './UserMenu.jsx'
|
||||||
import logo from '../assets/brand/zurich_logo.webp'
|
import logo from '../assets/brand/zurich_logo.webp'
|
||||||
import './Shell.css'
|
import './Shell.css'
|
||||||
@ -17,6 +19,13 @@ import './Shell.css'
|
|||||||
* server.
|
* server.
|
||||||
*/
|
*/
|
||||||
export default function Shell() {
|
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 (
|
return (
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
<header className="shell__top">
|
<header className="shell__top">
|
||||||
@ -37,25 +46,27 @@ export default function Shell() {
|
|||||||
end
|
end
|
||||||
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
|
className={({ isActive }) => 'shell__today' + (isActive ? ' is-active' : '')}
|
||||||
>
|
>
|
||||||
Today
|
Overview
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
{canFile ? (
|
||||||
<NavLink to="/add" className="shell__add">
|
<NavLink to="/add" className="shell__add">
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor" strokeWidth="1.7"
|
<path d="M8 3.2v9.6M3.2 8h9.6" fill="none" stroke="currentColor" strokeWidth="1.7"
|
||||||
strokeLinecap="round" />
|
strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
Add a lead
|
New lead
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{NAV_GROUPS.map((group) => {
|
{NAV_GROUPS.map((group) => {
|
||||||
const stages = STAGES.filter((s) => s.kind === group.kind)
|
const inGroup = stages.filter((s) => s.kind === group.kind)
|
||||||
if (!stages.length) return null
|
if (!inGroup.length) return null
|
||||||
return (
|
return (
|
||||||
<div className="shell__group" key={group.kind}>
|
<div className="shell__group" key={group.kind}>
|
||||||
<p className="shell__navlabel">{group.label}</p>
|
<p className="shell__navlabel">{group.label}</p>
|
||||||
<div className="shell__stages">
|
<div className="shell__stages">
|
||||||
{stages.map((s) => (
|
{inGroup.map((s) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={s.uid}
|
key={s.uid}
|
||||||
to={`/stage/${s.uid}`}
|
to={`/stage/${s.uid}`}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import ActivityForm from '../components/ActivityForm.jsx'
|
import ActivityForm from '../components/ActivityForm.jsx'
|
||||||
import { ENTRY } from '../api/config.js'
|
import { ENTRY } from '../api/config.js'
|
||||||
|
import { entryDoorsFor, rolesOf } from '../api/permissions.js'
|
||||||
|
import { useZino } from '../api/provider.jsx'
|
||||||
import './screens.css'
|
import './screens.css'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -10,22 +12,37 @@ import './screens.css'
|
|||||||
* did CKYC. Whichever door is used, everything after it is identical.
|
* did CKYC. Whichever door is used, everything after it is identical.
|
||||||
*/
|
*/
|
||||||
export default function AddLead() {
|
export default function AddLead() {
|
||||||
|
const { user } = useZino()
|
||||||
|
// Only the doors this role may actually submit through. An agency door shown
|
||||||
|
// to a bank RM is a form they can fill in and then be refused for.
|
||||||
|
const doors = entryDoorsFor(rolesOf(user), ENTRY)
|
||||||
// One door surfaced: skip the chooser entirely rather than show a list of one.
|
// One door surfaced: skip the chooser entirely rather than show a list of one.
|
||||||
const [entry, setEntry] = useState(ENTRY.length === 1 ? ENTRY[0] : null)
|
const [entry, setEntry] = useState(doors.length === 1 ? doors[0] : null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
if (!doors.length) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<header className="page__head">
|
||||||
|
<div><h1 className="page__title">New lead</h1></div>
|
||||||
|
</header>
|
||||||
|
<p className="empty">Your role does not create leads.</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<header className="page__head">
|
<header className="page__head">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page__title">Add a lead</h1>
|
<h1 className="page__title">New lead</h1>
|
||||||
<p className="page__sub">Three doors, one machine. The door decides who may submit and where it lands — nothing after that.</p>
|
<p className="page__sub">Capture the customer, the vehicle and the renewal date. Everything after this runs automatically.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{!entry ? (
|
{!entry ? (
|
||||||
<div className="doors">
|
<div className="doors">
|
||||||
{ENTRY.map((e) => (
|
{doors.map((e) => (
|
||||||
<button key={e.uid} type="button" className="door" onClick={() => setEntry(e)}>
|
<button key={e.uid} type="button" className="door" onClick={() => setEntry(e)}>
|
||||||
<strong>{e.label}</strong>
|
<strong>{e.label}</strong>
|
||||||
<span className="chip">{e.channel}</span>
|
<span className="chip">{e.channel}</span>
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import { useZino } from '../api/provider.jsx'
|
|||||||
import ActivityForm from '../components/ActivityForm.jsx'
|
import ActivityForm from '../components/ActivityForm.jsx'
|
||||||
import ClampText from '../components/ClampText.jsx'
|
import ClampText from '../components/ClampText.jsx'
|
||||||
import Timeline from '../components/Timeline.jsx'
|
import Timeline from '../components/Timeline.jsx'
|
||||||
import { ACTIONS, DV_LEAD, PRODUCTS, STAGES } from '../api/config.js'
|
import { DV_LEAD, PRODUCTS, STAGES } from '../api/config.js'
|
||||||
|
import { actionsFor, rolesOf } from '../api/permissions.js'
|
||||||
import { describeError } from '../api/errors.js'
|
import { describeError } from '../api/errors.js'
|
||||||
import './screens.css'
|
import './screens.css'
|
||||||
|
|
||||||
@ -30,6 +31,22 @@ function expiryOf(dateStr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whole days since a timestamp. Age and dwell time are the two numbers that
|
||||||
|
* say whether a lead is moving, and neither is on the record. */
|
||||||
|
function daysSince(ts) {
|
||||||
|
if (!ts) return null
|
||||||
|
const t = Date.parse(ts)
|
||||||
|
return isNaN(t) ? null : Math.floor((Date.now() - t) / 86400000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inrShort(v) {
|
||||||
|
const n = Number(v)
|
||||||
|
if (!Number.isFinite(n) || !n) return null
|
||||||
|
if (n >= 1e7) return '₹' + (n / 1e7).toFixed(2) + ' Cr'
|
||||||
|
if (n >= 1e5) return '₹' + (n / 1e5).toFixed(2) + ' L'
|
||||||
|
return '₹' + Math.round(n).toLocaleString('en-IN')
|
||||||
|
}
|
||||||
|
|
||||||
const MONEY = new Set(['quoted_premium','quoted_od_premium','quoted_tp_premium','quoted_addon_premium',
|
const MONEY = new Set(['quoted_premium','quoted_od_premium','quoted_tp_premium','quoted_addon_premium',
|
||||||
'quoted_gst','commission_base','commission_amount','sme_building_si','sme_plant_si','sme_furniture_si',
|
'quoted_gst','commission_base','commission_amount','sme_building_si','sme_plant_si','sme_furniture_si',
|
||||||
'sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_other_si','sme_value_at_risk','motor_idv',
|
'sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_other_si','sme_value_at_risk','motor_idv',
|
||||||
@ -97,7 +114,8 @@ function fmt(k, v) {
|
|||||||
|
|
||||||
export default function Lead() {
|
export default function Lead() {
|
||||||
const { instanceId } = useParams()
|
const { instanceId } = useParams()
|
||||||
const { client } = useZino()
|
const { client, user } = useZino()
|
||||||
|
const roles = rolesOf(user)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
// What the workflow said when the last activity was submitted. It is written
|
// What the workflow said when the last activity was submitted. It is written
|
||||||
@ -191,7 +209,16 @@ export default function Lead() {
|
|||||||
|
|
||||||
const stateName = row.current_state_name || ''
|
const stateName = row.current_state_name || ''
|
||||||
const stage = STAGES.find((s) => s.name === stateName)
|
const stage = STAGES.find((s) => s.name === stateName)
|
||||||
const actions = ACTIONS[stage?.uid] || []
|
// Only what THIS user may run. The workflow refuses the rest server-side
|
||||||
|
// anyway; showing them a row of buttons that all 403 reads as a broken app
|
||||||
|
// rather than as a control.
|
||||||
|
const actions = actionsFor(roles, stage?.uid)
|
||||||
|
// Two different reasons for an empty action list, and they must not read the
|
||||||
|
// same. A terminal lead is finished; a live one you cannot act on belongs to
|
||||||
|
// somebody else, and saying "closed" about it would be a lie.
|
||||||
|
const isClosed = stage?.kind === 'end'
|
||||||
|
const heldByOthers = !isClosed && actions.length === 0
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@ -222,29 +249,74 @@ export default function Lead() {
|
|||||||
<strong className={stage?.kind === 'end' ? 'is-closed' : undefined}>{stateName || '—'}</strong>
|
<strong className={stage?.kind === 'end' ? 'is-closed' : undefined}>{stateName || '—'}</strong>
|
||||||
{/* An end state runs no activity. That is worth one line next to the
|
{/* An end state runs no activity. That is worth one line next to the
|
||||||
stage, not a panel of its own where the actions would be. */}
|
stage, not a panel of its own where the actions would be. */}
|
||||||
{!actions.length ? <span className="lead__closed">Closed — nothing more happens</span> : null}
|
{isClosed ? <span className="lead__closed">Closed — no further activity</span> : null}
|
||||||
|
{heldByOthers ? (
|
||||||
|
<span className="lead__closed">
|
||||||
|
{stage?.kind === 'auto'
|
||||||
|
? `In progress with ${stage.by}`
|
||||||
|
: `Assigned to ${stage?.by ?? 'another role'}`}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<dl className="lead__meta">
|
{/* Measures, not fields. Age and dwell time are computed — neither is on
|
||||||
{(() => { const e = expiryOf(row.renewal_due_date); return e ? (
|
the record — and they are the two that answer "is this lead moving?",
|
||||||
<div>
|
which no label/value pair on the page could. */}
|
||||||
<dt>Renewal due</dt>
|
{(() => {
|
||||||
<dd><span className={'due due--' + e.tone}>{e.label}</span> <span className="grid__sub">{e.on}</span></dd>
|
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 (
|
||||||
|
<div className="lmetrics">
|
||||||
|
<div className="lm">
|
||||||
|
<span className="lm__l">Renewal</span>
|
||||||
|
<strong className={'lm__v' + (e ? ' due--' + e.tone : '')}>{e ? e.label : '—'}</strong>
|
||||||
|
<span className="lm__s">{e ? e.on : 'no date on file'}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null })()}
|
<div className="lm">
|
||||||
<div><dt>Product</dt><dd>{PRODUCTS[row.product_line] ?? '—'}</dd></div>
|
<span className="lm__l">Lead age</span>
|
||||||
{row.quoted_premium ? (
|
<strong className="lm__v">{age === null ? '—' : age === 0 ? 'Today' : age + 'd'}</strong>
|
||||||
<div className="lead__meta--money">
|
<span className="lm__s">{row.created_at ? fmtWhen(row.created_at) : ''}</span>
|
||||||
<dt>Quoted premium</dt>
|
</div>
|
||||||
<dd>₹{Number(row.quoted_premium).toLocaleString('en-IN')}</dd>
|
<div className="lm">
|
||||||
|
<span className="lm__l">In this stage</span>
|
||||||
|
<strong className="lm__v">{dwell === null ? '—' : dwell === 0 ? '<1d' : dwell + 'd'}</strong>
|
||||||
|
<span className="lm__s">last activity {row.updated_at ? fmtWhen(row.updated_at) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lm">
|
||||||
|
<span className="lm__l">Premium</span>
|
||||||
|
<strong className="lm__v">{premium ?? '—'}</strong>
|
||||||
|
<span className="lm__s">{premium ? 'quoted' : 'not yet rated'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="lm">
|
||||||
|
<span className="lm__l">Commission</span>
|
||||||
|
<strong className="lm__v">{commission ?? '—'}</strong>
|
||||||
|
<span className="lm__s">
|
||||||
|
{row.commission_rate_pct ? row.commission_rate_pct + '% of premium' : 'on placement'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="lm">
|
||||||
|
<span className="lm__l">Product</span>
|
||||||
|
<strong className="lm__v lm__v--sm">{PRODUCTS[row.product_line] ?? '—'}</strong>
|
||||||
|
<span className="lm__s">
|
||||||
|
{row.motor_reg_no || row.entity_name || ''}
|
||||||
|
{row.current_insurer ? ` · from ${row.current_insurer}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{Number.isFinite(conf) && conf > 0 ? (
|
||||||
|
<div className="lm">
|
||||||
|
<span className="lm__l">AI confidence</span>
|
||||||
|
<strong className="lm__v">{conf}%</strong>
|
||||||
|
<span className="lm__s">on the cover advice</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{row.current_insurer ? <div><dt>Current insurer</dt><dd>{row.current_insurer}</dd></div> : null}
|
</div>
|
||||||
{row.motor_reg_no ? <div><dt>Registration</dt><dd>{row.motor_reg_no}</dd></div> : null}
|
)
|
||||||
{row.created_at ? <div><dt>Lead added</dt><dd>{fmtWhen(row.created_at)}</dd></div> : null}
|
})()}
|
||||||
{row.updated_at ? <div><dt>Last update</dt><dd>{fmtWhen(row.updated_at)}</dd></div> : null}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="lead">
|
<div className="lead">
|
||||||
<div className="lead__main">
|
<div className="lead__main">
|
||||||
@ -264,7 +336,7 @@ export default function Lead() {
|
|||||||
<span className="doing__pulse" aria-hidden="true" />
|
<span className="doing__pulse" aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<strong>{stage.doing}</strong>
|
<strong>{stage.doing}</strong>
|
||||||
<span>{stage.by} has this one. It usually takes a minute or two — this page updates on its own.</span>
|
<span>Handled by {stage.by}. Typically completes within two minutes; this view refreshes automatically.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -273,12 +345,8 @@ export default function Lead() {
|
|||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel__head">
|
<div className="panel__head">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="panel__title">What you can do</h2>
|
<h2 className="panel__title">Actions</h2>
|
||||||
<p className="panel__sub">
|
<p className="panel__sub">Available to your role at this stage.</p>
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="acts">
|
<div className="acts">
|
||||||
@ -300,7 +368,7 @@ export default function Lead() {
|
|||||||
onCancel={() => setOpen(null)}
|
onCancel={() => setOpen(null)}
|
||||||
onStale={() => {
|
onStale={() => {
|
||||||
setOpen(null)
|
setOpen(null)
|
||||||
setNote('This lead has moved on — showing what you can do now.')
|
setNote('Stage changed. Available actions have been refreshed.')
|
||||||
load()
|
load()
|
||||||
}}
|
}}
|
||||||
onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }}
|
onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }}
|
||||||
@ -317,8 +385,8 @@ export default function Lead() {
|
|||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel__head">
|
<div className="panel__head">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="panel__title">Lead details</h2>
|
<h2 className="panel__title">Record</h2>
|
||||||
<p className="panel__sub">Everything captured so far, in the order it was collected.</p>
|
<p className="panel__sub">Captured data, grouped by stage of collection.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="tabs" role="tablist">
|
<div className="tabs" role="tablist">
|
||||||
@ -357,8 +425,8 @@ export default function Lead() {
|
|||||||
<div className="panel panel--rail">
|
<div className="panel panel--rail">
|
||||||
<div className="panel__head">
|
<div className="panel__head">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="panel__title">History</h2>
|
<h2 className="panel__title">Audit trail</h2>
|
||||||
<p className="panel__sub">Every step so far, and who took it.</p>
|
<p className="panel__sub">Every action recorded against this lead.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lead__feed">
|
<div className="lead__feed">
|
||||||
|
|||||||
@ -2,103 +2,136 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useZino } from '../api/provider.jsx'
|
import { useZino } from '../api/provider.jsx'
|
||||||
import { RV_LEADS, STAGES } from '../api/config.js'
|
import { RV_LEADS, STAGES } from '../api/config.js'
|
||||||
|
import { rolesOf, visibleStages } from '../api/permissions.js'
|
||||||
import { describeError } from '../api/errors.js'
|
import { describeError } from '../api/errors.js'
|
||||||
import './screens.css'
|
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
|
* Everything is derived from one record-view call and computed here. That is
|
||||||
* is the book doing, and is anything waiting on me?" — which is the only
|
* honest at this size and it is the same call every queue makes; if the book
|
||||||
* question a person has before they have picked a queue. The app used to open
|
* outgrows a single page the answer is an aggregate endpoint, not a larger
|
||||||
* on the underwriting queue: one stage, usually empty, and somebody else's.
|
* limit.
|
||||||
*
|
*
|
||||||
* One list call, counted here. That is honest at this size and it is the same
|
* Counts cover the WHOLE portfolio for every role, including queues the role
|
||||||
* call the queues make; a demo book is a few dozen leads, not a few thousand.
|
* cannot work in. Reporting and permission are different questions: an agent
|
||||||
* If it ever outgrows that, the fix is a counts endpoint, not a bigger page.
|
* tracking leads they filed is reasonable, acting on them is not, and the
|
||||||
|
* sidebar already gates the second.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DAY = 86400000
|
const DAY = 86400000
|
||||||
|
const today = () => Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')
|
||||||
|
|
||||||
function daysToExpiry(dateStr) {
|
function daysToExpiry(v) {
|
||||||
if (!dateStr) return null
|
if (!v) return null
|
||||||
const d = Date.parse(String(dateStr).substring(0, 10) + 'T00:00:00Z')
|
const d = Date.parse(String(v).substring(0, 10) + 'T00:00:00Z')
|
||||||
if (isNaN(d)) return null
|
return isNaN(d) ? null : Math.round((d - today()) / DAY)
|
||||||
return Math.round((d - Date.parse(new Date().toISOString().substring(0, 10) + 'T00:00:00Z')) / DAY)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function expiryLabel(days) {
|
function ageInDays(v) {
|
||||||
if (days === null) return { text: '—', tone: 'later' }
|
if (!v) return null
|
||||||
if (days < 0) return { text: Math.abs(days) + ' days overdue', tone: 'lapsed' }
|
const d = Date.parse(v)
|
||||||
if (days === 0) return { text: 'expires today', tone: 'urgent' }
|
return isNaN(d) ? null : Math.floor((Date.now() - d) / DAY)
|
||||||
if (days === 1) return { text: 'expires tomorrow', tone: 'urgent' }
|
}
|
||||||
return { text: 'in ' + days + ' days', tone: days <= 7 ? 'urgent' : days <= 30 ? 'soon' : 'later' }
|
|
||||||
|
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() {
|
export default function Overview() {
|
||||||
const { client } = useZino()
|
const { client, user } = useZino()
|
||||||
const [state, setState] = useState({ status: 'loading', rows: [], error: null })
|
const roles = rolesOf(user)
|
||||||
|
const [state, setState] = useState({ status: 'loading', rows: [], at: null, error: null })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
function fetchRows(quiet) {
|
function load(quiet) {
|
||||||
if (!quiet) setState({ status: 'loading', rows: [], error: null })
|
if (!quiet) setState((p) => ({ ...p, status: 'loading' }))
|
||||||
return client.recordView(RV_LEADS, { limit: 200 })
|
return client.recordView(RV_LEADS, { limit: 200 })
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return
|
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) => {
|
.catch((err) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
// A failed refresh must never blank a page that is already readable.
|
setState((p) => (quiet && p.status === 'ready' ? p : { status: 'error', rows: [], at: null, error: err }))
|
||||||
setState((prev) => (quiet && prev.status === 'ready' ? prev : { status: 'error', rows: [], error: err }))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fetchRows(false)
|
load(false)
|
||||||
const id = setInterval(() => { if (document.visibilityState === 'visible') fetchRows(true) }, 30000)
|
const id = setInterval(() => { if (document.visibilityState === 'visible') load(true) }, 30000)
|
||||||
return () => { cancelled = true; clearInterval(id) }
|
return () => { cancelled = true; clearInterval(id) }
|
||||||
}, [client])
|
}, [client])
|
||||||
|
|
||||||
const view = useMemo(() => {
|
const m = useMemo(() => {
|
||||||
|
const rows = state.rows
|
||||||
const byStage = {}
|
const byStage = {}
|
||||||
for (const r of state.rows) {
|
for (const r of rows) byStage[r.current_state_name || '—'] = (byStage[r.current_state_name || '—'] || 0) + 1
|
||||||
const k = r.current_state_name || '—'
|
|
||||||
byStage[k] = (byStage[k] || 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 attention = dated.filter((r) => r._d <= 30).sort((a, b) => a._d - b._d)
|
||||||
const open = state.rows.filter((r) => !closedNames.has(r.current_state_name))
|
|
||||||
|
|
||||||
// Worst first, and only what is actually near. A renewal six months out is
|
// Queues a person has to clear, in the order the pipeline runs.
|
||||||
// not a thing to look at today, so it does not compete for the space.
|
const actionable = STAGES.filter((s) => s.kind === 'needs' || s.kind === 'customer')
|
||||||
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)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
byStage,
|
byStage, open, won, lost, closed, gwp, commission, pipeline, exposure, attention, actionable,
|
||||||
openCount: open.length,
|
conversion: closed ? Math.round((won.length / closed) * 100) : null,
|
||||||
running,
|
maxStage: Math.max(1, ...STAGES.map((s) => byStage[s.name] || 0)),
|
||||||
needs: STAGES.filter((s) => s.kind === 'needs'),
|
|
||||||
won: state.rows.filter((r) => r.current_state_name === 'Onboarded').length,
|
|
||||||
}
|
}
|
||||||
}, [state.rows])
|
}, [state.rows])
|
||||||
|
|
||||||
if (state.status === 'loading') {
|
const mine = new Set(visibleStages(roles).map((s) => s.uid))
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<header className="page__head"><div><h1 className="page__title">Today</h1></div></header>
|
|
||||||
<div className="skel" aria-busy="true" aria-label="Loading"><div className="skel__row" /><div className="skel__row" /></div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.status === 'error') {
|
if (state.status === 'error') {
|
||||||
const said = describeError(state.error)
|
const said = describeError(state.error)
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<header className="page__head"><div><h1 className="page__title">Today</h1></div></header>
|
<header className="page__head"><div><h1 className="page__title">Portfolio overview</h1></div></header>
|
||||||
<div className="notice">
|
<div className="notice">
|
||||||
<strong>{said.title}</strong>
|
<strong>{said.title}</strong>
|
||||||
{said.detail ? <p>{said.detail}</p> : null}
|
{said.detail ? <p>{said.detail}</p> : null}
|
||||||
@ -108,58 +141,115 @@ export default function Overview() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.status === 'loading' && !state.rows.length) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<header className="page__head"><div><h1 className="page__title">Portfolio overview</h1></div></header>
|
||||||
|
<div className="skel" aria-busy="true"><div className="skel__row" /><div className="skel__row" /></div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<header className="page__head">
|
<header className="page__head">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page__title">Today</h1>
|
<h1 className="page__title">Portfolio overview</h1>
|
||||||
<p className="page__sub">
|
<p className="page__sub">Motor and SME renewals · organisation-wide</p>
|
||||||
{view.openCount} {view.openCount === 1 ? 'lead is' : 'leads are'} open ·{' '}
|
|
||||||
{view.won} onboarded so far
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{state.at ? (
|
||||||
|
<span className="stamp">
|
||||||
|
Updated {state.at.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* The only section that is about the reader. Everything a person can
|
<div className="kpis">
|
||||||
actually do sits in one of these three queues. */}
|
<div className="kpi">
|
||||||
<h2 className="sec">Waiting on a person</h2>
|
<span className="kpi__l">Open leads</span>
|
||||||
<div className="cards">
|
<strong className="kpi__v">{m.open.length}</strong>
|
||||||
{view.needs.map((s) => {
|
<span className="kpi__s">{m.closed} closed to date</span>
|
||||||
const n = view.byStage[s.name] || 0
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="kpi__l">Pipeline value</span>
|
||||||
|
<strong className="kpi__v">{inr(m.pipeline)}</strong>
|
||||||
|
<span className="kpi__s">quoted, not yet on risk</span>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="kpi__l">Written premium</span>
|
||||||
|
<strong className="kpi__v">{inr(m.gwp)}</strong>
|
||||||
|
<span className="kpi__s">{m.won.length} {m.won.length === 1 ? 'policy' : 'policies'} issued</span>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="kpi__l">Conversion</span>
|
||||||
|
<strong className="kpi__v">{m.conversion === null ? '—' : m.conversion + '%'}</strong>
|
||||||
|
<span className="kpi__s">{m.won.length} won · {m.lost.length} lost</span>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<span className="kpi__l">Commission</span>
|
||||||
|
<strong className="kpi__v">{inr(m.commission)}</strong>
|
||||||
|
<span className="kpi__s">payable to partners</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="split">
|
||||||
|
<div>
|
||||||
|
<h2 className="sec">Action required</h2>
|
||||||
|
<div className="qlist">
|
||||||
|
{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 (
|
return (
|
||||||
<Link key={s.uid} to={`/stage/${s.uid}`} className={'card' + (n ? ' is-live' : '')}>
|
<Link key={s.uid} to={`/stage/${s.uid}`} className={'q' + (n ? ' is-live' : '')}>
|
||||||
<span className="card__n">{n}</span>
|
<strong className="q__n">{n}</strong>
|
||||||
<strong className="card__t">{s.need ?? s.name}</strong>
|
<span className="q__t">
|
||||||
<span className="card__w">{n === 0 ? 'Nothing waiting' : `with ${s.by}`}</span>
|
{s.need ?? s.name}
|
||||||
|
<em>{s.by}</em>
|
||||||
|
</span>
|
||||||
|
<span className="q__age">
|
||||||
|
{n === 0 ? 'clear' : oldest !== undefined ? `oldest ${oldest}d` : ''}
|
||||||
|
</span>
|
||||||
|
{!mine.has(s.uid) ? <span className="q__ro" title="View only for your role">view</span> : null}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="sec">
|
<h2 className="sec">Renewal exposure<span className="sec__hint">open leads by time to expiry</span></h2>
|
||||||
Renewals running out
|
<div className="buckets">
|
||||||
<span className="sec__hint">Open leads with 30 days or less on the clock</span>
|
<div className="bucket bucket--lapsed">
|
||||||
</h2>
|
<strong>{m.exposure.lapsed.length}</strong><span>Lapsed</span>
|
||||||
{view.running.length === 0 ? (
|
</div>
|
||||||
<p className="empty">Nothing expiring in the next 30 days.</p>
|
<div className="bucket bucket--urgent">
|
||||||
) : (
|
<strong>{m.exposure.week.length}</strong><span>Within 7 days</span>
|
||||||
|
</div>
|
||||||
|
<div className="bucket bucket--soon">
|
||||||
|
<strong>{m.exposure.month.length}</strong><span>8 to 30 days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{m.attention.length ? (
|
||||||
<div className="gridwrap">
|
<div className="gridwrap">
|
||||||
<table className="grid">
|
<table className="grid grid--tight">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Customer</th><th>Renewal</th><th>Where it is</th><th aria-label="Open" /></tr>
|
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th><th aria-label="Open" /></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{view.running.slice(0, 8).map((r) => {
|
{m.attention.slice(0, 8).map((r) => {
|
||||||
const id = r.instance_id ?? r.id
|
const id = r.instance_id ?? r.id
|
||||||
const e = expiryLabel(r._d)
|
|
||||||
return (
|
return (
|
||||||
<tr key={id}>
|
<tr key={id}>
|
||||||
<td>
|
<td>{r.customer_name || r.lead_ref || `#${id}`}
|
||||||
{r.customer_name || r.lead_ref || `#${id}`}
|
<div className="grid__sub">{r.lead_ref || ''}</div></td>
|
||||||
<div className="grid__sub">{r.lead_ref || ''}</div>
|
<td><span className={'due due--' + expiryTone(r._d)}>
|
||||||
</td>
|
{r._d < 0 ? Math.abs(r._d) + 'd overdue' : r._d === 0 ? 'today' : r._d + 'd'}
|
||||||
<td><span className={'due due--' + e.tone}>{e.text}</span></td>
|
</span></td>
|
||||||
<td><span className="grid__sub">{r.current_state_name || '—'}</span></td>
|
<td><span className="grid__sub">{r.current_state_name}</span></td>
|
||||||
|
<td className="num">{r.quoted_premium ? inr(num(r.quoted_premium)) : '—'}</td>
|
||||||
<td className="grid__act"><Link className="grid__btn" to={`/lead/${id}`}>Open</Link></td>
|
<td className="grid__act"><Link className="grid__btn" to={`/lead/${id}`}>Open</Link></td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
@ -167,25 +257,31 @@ export default function Overview() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="empty">No renewals due within 30 days.</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Every stage, including the empty ones. A stage reading zero is
|
<div>
|
||||||
information — it is how you notice the agents have stopped. */}
|
<h2 className="sec">Pipeline distribution</h2>
|
||||||
<h2 className="sec">
|
{/* Every stage, zeroes included. A stage that has quietly stopped
|
||||||
The whole book
|
receiving leads is only visible if its zero is on the page. */}
|
||||||
<span className="sec__hint">Every lead, by where it has got to</span>
|
<div className="dist">
|
||||||
</h2>
|
|
||||||
<div className="tally">
|
|
||||||
{STAGES.map((s) => {
|
{STAGES.map((s) => {
|
||||||
const n = view.byStage[s.name] || 0
|
const n = m.byStage[s.name] || 0
|
||||||
return (
|
return (
|
||||||
<Link key={s.uid} to={`/stage/${s.uid}`} className={'tally__i' + (n ? '' : ' is-zero')}>
|
<Link key={s.uid} to={`/stage/${s.uid}`} className={'dist__r' + (n ? '' : ' is-zero')}>
|
||||||
<span className="tally__n">{n}</span>
|
<span className="dist__l">{s.name}</span>
|
||||||
<span className="tally__l">{s.name}</span>
|
<span className="dist__bar" aria-hidden="true">
|
||||||
|
<span style={{ width: (n / m.maxStage) * 100 + '%' }} className={'dist__f dist__f--' + s.kind} />
|
||||||
|
</span>
|
||||||
|
<span className="dist__n">{n}</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -93,12 +93,12 @@ export default function Pipeline() {
|
|||||||
<h1 className="page__title">{stage.need ?? stage.name}</h1>
|
<h1 className="page__title">{stage.need ?? stage.name}</h1>
|
||||||
<p className="page__sub">
|
<p className="page__sub">
|
||||||
{stage.kind === 'auto'
|
{stage.kind === 'auto'
|
||||||
? `${stage.doing}. Nothing here is waiting on you.`
|
? `Automated · ${stage.doing.toLowerCase()}`
|
||||||
: stage.kind === 'end'
|
: stage.kind === 'end'
|
||||||
? 'These are finished. Nothing more happens to them.'
|
? 'Closed — retained for reporting.'
|
||||||
: stage.kind === 'waiting'
|
: stage.kind === 'waiting'
|
||||||
? 'Good leads, too early to work. They come back on their own.'
|
? 'Held until the renewal window opens. Re-enters outreach automatically.'
|
||||||
: `Waiting on ${stage.by}.`}
|
: `Pending action by ${stage.by}.`}
|
||||||
{stage.need ? <span className="page__stage">Stage · {stage.name}</span> : null}
|
{stage.need ? <span className="page__stage">Stage · {stage.name}</span> : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -118,18 +118,14 @@ export default function Pipeline() {
|
|||||||
|
|
||||||
{state.status === 'error' ? (
|
{state.status === 'error' ? (
|
||||||
<div className="notice">
|
<div className="notice">
|
||||||
<strong>This queue could not be loaded.</strong>
|
<strong>Unable to load this stage.</strong>
|
||||||
<p>
|
<p>The lead list did not return. Other functions are unaffected; retry before escalating.</p>
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
<p className="notice__detail">{describeError(state.error).title} · {state.error?.status} {state.error?.message}</p>
|
<p className="notice__detail">{describeError(state.error).title} · {state.error?.status} {state.error?.message}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.status === 'ready' && state.rows.length === 0 ? (
|
{state.status === 'ready' && state.rows.length === 0 ? (
|
||||||
<p className="empty">Nothing here right now.</p>
|
<p className="empty">No records in this stage.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.status === 'ready' && state.rows.length > 0 ? (
|
{state.status === 'ready' && state.rows.length > 0 ? (
|
||||||
|
|||||||
@ -900,71 +900,79 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Overview ────────────────────────────────────────────────────────────
|
/* ── Portfolio overview ──────────────────────────────────────────────────
|
||||||
The morning page. Three blocks, in the order a person cares: what is
|
An operations dashboard: numbers first, prose nowhere. Every measure on
|
||||||
waiting on me, what is running out of time, and how the book looks.
|
this page is derived from the lead list in the browser, so nothing here is
|
||||||
Nothing here is a chart — a demo book is small enough that a number and a
|
a figure the reader cannot get back to by opening a queue. */
|
||||||
name beat any visualisation of them. */
|
|
||||||
|
.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 {
|
.sec {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin: 28px 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: .02em;
|
letter-spacing: .07em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--zk-muted);
|
color: var(--zk-muted);
|
||||||
}
|
}
|
||||||
.sec:first-of-type { margin-top: 8px; }
|
.split > div > .sec:not(:first-child) { margin-top: 30px; }
|
||||||
.sec__hint {
|
.sec__hint { font-size: 11.5px; font-weight: 400; letter-spacing: 0; text-transform: none; color: var(--zk-grey); }
|
||||||
font-size: 12px;
|
|
||||||
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
|
/* Work queues. A row rather than a card: five of them as cards is a wall. */
|
||||||
keeping the row short is what makes it readable at a glance. */
|
.qlist { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.cards {
|
.q {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
grid-template-columns: 44px 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
padding: 11px 14px;
|
||||||
.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;
|
|
||||||
border: 1px solid var(--zk-line-soft);
|
border: 1px solid var(--zk-line-soft);
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
background: var(--zk-white);
|
background: var(--zk-white);
|
||||||
@ -972,13 +980,102 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
transition: border-color .12s, background .12s;
|
transition: border-color .12s, background .12s;
|
||||||
}
|
}
|
||||||
.tally__i:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); }
|
.q: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; }
|
.q__n { font-size: 20px; font-weight: 500; color: var(--zk-grey); font-variant-numeric: tabular-nums; text-align: right; }
|
||||||
.tally__l { font-size: 12.5px; color: var(--zk-muted); line-height: 1.25; }
|
.q__t { font-size: 14px; color: var(--zk-ink); }
|
||||||
.tally__i.is-zero { background: transparent; }
|
.q__t em { display: block; font-style: normal; font-size: 12px; color: var(--zk-muted); margin-top: 1px; }
|
||||||
.tally__i.is-zero .tally__n { color: var(--zk-grey); font-weight: 300; }
|
.q__age { font-size: 12px; color: var(--zk-grey); font-variant-numeric: tabular-nums; }
|
||||||
.tally__i.is-zero .tally__l { color: var(--zk-grey); }
|
.q__ro {
|
||||||
|
font-size: 10px; letter-spacing: .06em; text-transform: uppercase;
|
||||||
@media (max-width: 720px) {
|
color: var(--zk-grey); border: 1px solid var(--zk-line); border-radius: 3px; padding: 1px 5px;
|
||||||
.cards { grid-template-columns: 1fr; }
|
|
||||||
}
|
}
|
||||||
|
/* 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); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user