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 (
{group.label}
Your role does not create leads.
+ + ) + } + return (Three doors, one machine. The door decides who may submit and where it lands — nothing after that.
+Capture the customer, the vehicle and the renewal date. Everything after this runs automatically.
- 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. -
+Available to your role at this stage.
Everything captured so far, in the order it was collected.
+Captured data, grouped by stage of collection.
Every step so far, and who took it.
+Every action recorded against this lead.
{said.detail}
: null} @@ -108,83 +141,146 @@ export default function Overview() { ) } + if (state.status === 'loading' && !state.rows.length) { + return ( +- {view.openCount} {view.openCount === 1 ? 'lead is' : 'leads are'} open ·{' '} - {view.won} onboarded so far -
+Motor and SME renewals · organisation-wide
Nothing expiring in the next 30 days.
- ) : ( -| Customer | Renewal | Where it is | |
|---|---|---|---|
|
- {r.customer_name || r.lead_ref || `#${id}`}
- {r.lead_ref || ''}
- |
- {e.text} | -{r.current_state_name || '—'} | -Open | -
| Lead | Expiry | Stage | Premium | |
|---|---|---|---|---|
| {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.
+ )} +{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}
- 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}
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); }