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.
144 lines
6.4 KiB
JavaScript
144 lines
6.4 KiB
JavaScript
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))
|
|
}
|