lead: show the next step, not the permission list
The app read as a control panel. Every activity a stage allowed was
rendered as an equal button, so operating it required already knowing
the workflow — which is the opposite of what a workflow tool is for.
Two concrete failures, both visible in the data:
Ops on Document collection got SIX equal buttons. Exactly one was the
next step: upload the three documents. Capture Motor Risk, Capture SME
Risk, AI Cover Recommendation and Generate Quote are the AI's own chain,
present only so a stalled lead can be pushed by hand.
And FOUR stages offered a partner agent nothing but "Mark Lost" — New
Lead, Contacted, Underwriting referral, Premium confirmation. At each of
those the system was actively working the lead, and the only thing the
app suggested was giving up on it.
So an activity now carries what it IS at a stage, not only who may press
it:
do the step this stage is waiting on. Usually one. Referred has
two, because clear and decline are a decision pair rather than
a step and the option of not taking it.
again a bounded loop — retry, reminder, re-quote. Legitimate, never
the answer to "what now".
force an AI employee's own job, offered to a person only so a stalled
lead can be moved. Folded behind "Lead not moving?".
exit Mark Lost. Always reachable, never presented as a step.
The panel is "Next step". With a step, one button at full weight. Without
one it says what is actually happening and who has it, which is the true
answer at six of the nine working stages and was previously rendered as
an empty bar or a lone Mark Lost.
Result per role, checked rather than assumed: an underwriter sees two
buttons, on one stage, and nothing anywhere else. A partner agent sees a
step at three stages. Ops sees a step at four and can still force any
AI activity from the folded section.
Nothing about permissions changed, and the action list still matches
tbl_wf_state_allowed_activities exactly — this is only about which of
them is presented as the thing to do.
This commit is contained in:
parent
2d9c072572
commit
07d4608888
@ -96,67 +96,79 @@ export const DV_LEAD = 'zk-dv-lead'
|
|||||||
* the platform allows it, the console never offers it, and nothing errors.
|
* the platform allows it, the console never offers it, and nothing errors.
|
||||||
* Collect Documents shipped in that state for one round.
|
* Collect Documents shipped in that state for one round.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* What each activity IS at a stage, not merely who may press it.
|
||||||
|
*
|
||||||
|
* `do` the expected next step here. Usually one; Referred has two,
|
||||||
|
* because clear and decline are a decision pair rather than a
|
||||||
|
* step and an alternative to it.
|
||||||
|
* `again` a bounded loop — a retry, a reminder, a re-quote. Legitimate,
|
||||||
|
* never the thing to do next.
|
||||||
|
* `force` an AI employee's own job, offered to a person ONLY so a
|
||||||
|
* stalled lead can be pushed by hand. Presenting these as
|
||||||
|
* actions is what made the app read as a control panel: six
|
||||||
|
* equal buttons where five are recovery levers.
|
||||||
|
* `exit` Mark Lost. Always reachable, never a step. Four stages used
|
||||||
|
* to offer a partner agent this and nothing else, which told
|
||||||
|
* them their only option was to give up on a lead the system
|
||||||
|
* was actively working.
|
||||||
|
*/
|
||||||
const STATE_ACTIVITIES = {
|
const STATE_ACTIVITIES = {
|
||||||
'zk-state-new': [{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI' }],
|
'zk-state-new': [
|
||||||
// Retry Call is scheduled automatically four hours after an unanswered
|
{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI', role: 'force' },
|
||||||
// call, clamped into 09:00-21:00 IST and bounded at three attempts. It
|
],
|
||||||
// records the attempt; whether it also DIALS is gated on the lead's
|
'zk-state-qualified': [
|
||||||
// `auto_retry` field, which ships unset. Offered here so a person can
|
{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI', role: 'force' },
|
||||||
// also place the retry by hand.
|
{ uid: 'zk-act-retry-call', label: 'Retry Call', by: 'Scheduled', role: 'again' },
|
||||||
'zk-state-qualified': [{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI' },
|
],
|
||||||
{ uid: 'zk-act-retry-call', label: 'Retry Call', by: 'Scheduled — or you' }],
|
'zk-state-contacted': [
|
||||||
// Contacted has exactly one way forward and it belongs to Engage. An AI
|
{ uid: 'zk-act-request-docs', label: 'Request Documents', by: 'Engage AI', role: 'force' },
|
||||||
// cannot receive a file, so it writes the ask; the agent uploads one state
|
],
|
||||||
// later. Giving the AI the activity that RECEIVES documents is how you get an
|
// The one stage whose next step is a person's: three documents have to
|
||||||
// agent recording documents it never saw.
|
// be found and uploaded. Everything else here is the AI's own chain.
|
||||||
'zk-state-contacted': [{ uid: 'zk-act-request-docs', label: 'Request Documents', by: 'Engage AI' }],
|
'zk-state-docs': [
|
||||||
// Four steps that used to be two stages: the upload, the capture, the advice
|
{ uid: 'zk-act-collect-docs', label: 'Upload documents', by: 'you', role: 'do' },
|
||||||
// and the quote. The lead stays here for all of them and leaves only once a
|
{ uid: 'zk-act-doc-reminder', label: 'Send a reminder', by: 'Scheduled', role: 'again' },
|
||||||
// premium exists.
|
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI', role: 'force' },
|
||||||
'zk-state-docs': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — upload here' },
|
{ uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI', role: 'force' },
|
||||||
{ uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI' },
|
{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI', role: 'force' },
|
||||||
{ uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI' },
|
{ uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine', role: 'force' },
|
||||||
{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI' },
|
],
|
||||||
{ uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine' },
|
'zk-state-quoted': [
|
||||||
// Fires on its own at 24h, 72h and 120h from the ask. On the
|
{ uid: 'zk-act-accept', label: 'Record the acceptance', by: 'you, on consent', role: 'do' },
|
||||||
// third unanswered one the lead leaves this queue — Parked if
|
{ uid: 'zk-act-quote', label: 'Re-quote', by: 'Engage AI', role: 'again' },
|
||||||
// the renewal is still weeks off, Lost if the cover is going.
|
{ uid: 'zk-act-collect-docs', label: 'Add a document', by: 'you', role: 'again' },
|
||||||
{ uid: 'zk-act-doc-reminder', label: 'Send Document Reminder', by: 'Scheduled — or you' }],
|
{ uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI', role: 'force' },
|
||||||
// Likewise absorbs acceptance, identity and the underwriting screen — none of
|
{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'KYC AI', role: 'force' },
|
||||||
// which was ever a stage a lead rested in. Generate Quote appears again as a
|
],
|
||||||
// RE-quote for a customer who wants different cover; the lead does not move,
|
'zk-state-referred': [
|
||||||
// and quote_revisions counts them (0 on the first, 2 is the ceiling).
|
{ uid: 'zk-act-uw-clear', label: 'Clear the referral', by: 'you', role: 'do' },
|
||||||
'zk-state-quoted': [{ uid: 'zk-act-accept', label: 'Accept Proposal', by: 'Customer' },
|
{ uid: 'zk-act-uw-decline', label: 'Decline the risk', by: 'you', role: 'do' },
|
||||||
{ uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI' },
|
{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI', role: 'force' },
|
||||||
{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'KYC AI' },
|
],
|
||||||
{ uid: 'zk-act-quote', label: 'Generate Quote (revise)', by: 'Engage AI' },
|
'zk-state-payment': [
|
||||||
{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' }],
|
{ uid: 'zk-act-realise', label: 'Confirm premium received', by: 'you', role: 'do' },
|
||||||
'zk-state-referred': [{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI' },
|
{ uid: 'zk-act-nudge', label: 'Chase the payment', by: 'Engage AI', role: 'again' },
|
||||||
{ uid: 'zk-act-uw-clear', label: 'Clear Referral', by: 'Underwriter' },
|
{ uid: 'zk-act-payment', label: 'Request Premium', by: 'Engage AI', role: 'force' },
|
||||||
{ uid: 'zk-act-uw-decline', label: 'Decline Referral', by: 'Underwriter' }],
|
],
|
||||||
// 'Payment webhook' was aspirational — there is no payment integration on the
|
'zk-state-issued': [
|
||||||
// platform and none is planned here. Collection happens on the insurer's own
|
{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Engage AI', role: 'force' },
|
||||||
// rails; ops records the reference. See 35_reframe_to_what_exists.sql.
|
],
|
||||||
//
|
'zk-state-parked': [
|
||||||
// Request Premium moved here when Underwriting Cleared was removed: entering
|
{ uid: 'zk-act-resume', label: 'Resume outreach now', by: 'you', role: 'do' },
|
||||||
// this state IS the decision to collect, so the ask happens inside it.
|
],
|
||||||
'zk-state-payment': [{ uid: 'zk-act-payment', label: 'Request Premium', by: 'Engage AI' },
|
|
||||||
{ uid: 'zk-act-nudge', label: 'Log Payment Nudge', by: 'Engage AI' },
|
|
||||||
{ uid: 'zk-act-realise', label: 'Confirm Premium Realisation', by: 'Ops' }],
|
|
||||||
'zk-state-issued': [{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Engage AI' }],
|
|
||||||
'zk-state-parked': [{ uid: 'zk-act-resume', label: 'Resume Outreach', by: 'Whoever owns the book' }],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark Lost is not tied to one state: a lead can be dropped from anywhere before
|
* Mark Lost is not tied to one state: a lead can be dropped from anywhere before
|
||||||
* the policy issues, and every role that files a lead may do it. It was missing
|
* the policy issues, and every role that files a lead may do it. Appended rather
|
||||||
* from the table entirely, which is the exact failure the note above describes —
|
* than written into every entry so there is one place to change it, and so a
|
||||||
* the platform allowed it, the console never offered it, and nothing errored.
|
* stage added above cannot silently lose it.
|
||||||
*
|
*
|
||||||
* Appended rather than written into all ten entries so there is one place to
|
* It carries role 'exit' precisely so it is never rendered as a step. It is the
|
||||||
* change it, and so a state added above cannot silently lose it.
|
* way out, not the way on.
|
||||||
*/
|
*/
|
||||||
const DROP = { uid: 'zk-act-drop', label: 'Mark Lost', by: 'Whoever holds the lead' }
|
const DROP = { uid: 'zk-act-drop', label: 'Mark Lost', by: 'whoever holds the lead', role: 'exit' }
|
||||||
|
|
||||||
const DROPPABLE = [
|
const DROPPABLE = [
|
||||||
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
|
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
|
||||||
|
|||||||
@ -341,25 +341,20 @@ export default function Lead() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{actions.length ? (
|
{/* WHAT TO DO NEXT, not what is permitted.
|
||||||
<div className="panel">
|
Every action carries a role: `do` is the step this stage is
|
||||||
<div className="panel__head">
|
waiting on, `again` is a bounded loop, `force` is an AI's own job
|
||||||
<div>
|
offered only so a stalled lead can be pushed by hand, `exit` is
|
||||||
<h2 className="panel__title">Actions</h2>
|
Mark Lost. Rendering them as one flat row of equals is what made
|
||||||
<p className="panel__sub">Available to your role at this stage.</p>
|
six buttons appear where one was the answer. */}
|
||||||
</div>
|
{(() => {
|
||||||
</div>
|
const step = actions.filter((a) => a.role === 'do')
|
||||||
<div className="acts">
|
const again = actions.filter((a) => a.role === 'again')
|
||||||
{actions.map((a) => (
|
const force = actions.filter((a) => a.role === 'force')
|
||||||
<button key={a.uid} type="button"
|
const exit = actions.filter((a) => a.role === 'exit')
|
||||||
className={'act' + (open === a.uid ? ' is-open' : '')}
|
const extras = [...force, ...exit]
|
||||||
onClick={() => setOpen(open === a.uid ? null : a.uid)}>
|
|
||||||
<strong>{a.label}</strong>
|
const form = open ? (
|
||||||
<span className="act__by">{a.by}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{open ? (
|
|
||||||
<div className="acts__form">
|
<div className="acts__form">
|
||||||
<ActivityForm
|
<ActivityForm
|
||||||
activityUid={open}
|
activityUid={open}
|
||||||
@ -374,10 +369,86 @@ export default function Lead() {
|
|||||||
onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }}
|
onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null
|
||||||
|
|
||||||
|
if (!step.length && !again.length && !extras.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="panel__head">
|
||||||
|
<div>
|
||||||
|
<h2 className="panel__title">Next step</h2>
|
||||||
|
<p className="panel__sub">
|
||||||
|
{step.length
|
||||||
|
? 'This is what this lead is waiting on.'
|
||||||
|
: isClosed
|
||||||
|
? 'This lead is closed.'
|
||||||
|
: `Nothing is waiting on you — ${stage?.by ?? 'someone else'} has this one.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The step. One button, sized like a decision. Referred has
|
||||||
|
two because clear and decline are a pair, not a choice
|
||||||
|
between doing something and doing nothing. */}
|
||||||
|
{step.length ? (
|
||||||
|
<div className="step">
|
||||||
|
{step.map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.uid} type="button"
|
||||||
|
className={'step__btn' + (open === a.uid ? ' is-open' : '')
|
||||||
|
+ (a.uid === 'zk-act-uw-decline' ? ' step__btn--no' : '')}
|
||||||
|
onClick={() => setOpen(open === a.uid ? null : a.uid)}
|
||||||
|
>
|
||||||
|
<strong>{a.label}</strong>
|
||||||
|
<span>{a.by}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{again.length ? (
|
||||||
|
<div className="alt">
|
||||||
|
{again.map((a) => (
|
||||||
|
<button key={a.uid} type="button"
|
||||||
|
className={'alt__btn' + (open === a.uid ? ' is-open' : '')}
|
||||||
|
onClick={() => setOpen(open === a.uid ? null : a.uid)}>
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{form}
|
||||||
|
|
||||||
|
{/* Recovery, folded. An operator needs these on the day an
|
||||||
|
agent stalls and never otherwise, and putting them in the
|
||||||
|
open makes an AI's own work look like an outstanding task. */}
|
||||||
|
{extras.length ? (
|
||||||
|
<details className="stuck">
|
||||||
|
<summary>Lead not moving?</summary>
|
||||||
|
<p className="stuck__why">
|
||||||
|
{force.length
|
||||||
|
? 'These normally run by themselves. Use one only if this lead has been sitting longer than it should.'
|
||||||
|
: 'Close this lead if it is going nowhere.'}
|
||||||
|
</p>
|
||||||
|
<div className="alt">
|
||||||
|
{extras.map((a) => (
|
||||||
|
<button key={a.uid} type="button"
|
||||||
|
className={'alt__btn' + (a.role === 'exit' ? ' alt__btn--exit' : '')
|
||||||
|
+ (open === a.uid ? ' is-open' : '')}
|
||||||
|
onClick={() => setOpen(open === a.uid ? null : a.uid)}>
|
||||||
|
{a.label}
|
||||||
|
<em>{a.by}</em>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* The flow's own detail. Each group is one click away rather than one
|
{/* The flow's own detail. Each group is one click away rather than one
|
||||||
long scroll, and prose is folded to a few lines — the page can be
|
long scroll, and prose is folded to a few lines — the page can be
|
||||||
scanned, and still holds everything for whoever wants it. */}
|
scanned, and still holds everything for whoever wants it. */}
|
||||||
|
|||||||
@ -1079,3 +1079,72 @@
|
|||||||
lapsed renewal reads the same wherever it appears. */
|
lapsed renewal reads the same wherever it appears. */
|
||||||
.lm__v.due--lapsed { color: var(--zk-danger-ink); }
|
.lm__v.due--lapsed { color: var(--zk-danger-ink); }
|
||||||
.lm__v.due--urgent { color: var(--zk-amber-ink); }
|
.lm__v.due--urgent { color: var(--zk-amber-ink); }
|
||||||
|
|
||||||
|
/* ── Next step ───────────────────────────────────────────────────────────
|
||||||
|
One decision, sized like one. The stage's own step is the only thing at
|
||||||
|
full weight; loops sit under it, and an AI's own work is folded away
|
||||||
|
entirely — it is recovery, not an outstanding task. */
|
||||||
|
|
||||||
|
.step { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.step__btn {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
display: block;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border: 1px solid var(--zk-blue);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--zk-blue);
|
||||||
|
color: var(--zk-white);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .12s, border-color .12s, box-shadow .12s;
|
||||||
|
}
|
||||||
|
.step__btn:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); box-shadow: 0 2px 10px rgba(33,103,174,.25); }
|
||||||
|
.step__btn strong { display: block; font-size: 15px; font-weight: 500; }
|
||||||
|
.step__btn span { display: block; margin-top: 2px; font-size: 12px; opacity: .85; }
|
||||||
|
.step__btn.is-open { background: var(--zk-blue-deep); border-color: var(--zk-blue-deep); }
|
||||||
|
|
||||||
|
/* Decline sits beside Clear as an equal — an underwriter is choosing between
|
||||||
|
two decisions, not deciding whether to act. It is outlined rather than
|
||||||
|
filled so the pair does not read as two identical recommendations. */
|
||||||
|
.step__btn--no {
|
||||||
|
background: var(--zk-white);
|
||||||
|
color: var(--zk-danger-ink);
|
||||||
|
border-color: var(--zk-danger-line);
|
||||||
|
}
|
||||||
|
.step__btn--no:hover { background: var(--zk-danger-tint); border-color: var(--zk-danger); box-shadow: none; }
|
||||||
|
.step__btn--no.is-open { background: var(--zk-danger-tint); border-color: var(--zk-danger); }
|
||||||
|
|
||||||
|
/* Loops. Real, bounded, and never the answer to "what now". */
|
||||||
|
.alt { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||||
|
.alt__btn {
|
||||||
|
padding: 8px 13px;
|
||||||
|
border: 1px solid var(--zk-line);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--zk-white);
|
||||||
|
color: var(--zk-ink);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .12s, background .12s;
|
||||||
|
}
|
||||||
|
.alt__btn:hover { border-color: var(--zk-blue-light); background: var(--zk-tint); }
|
||||||
|
.alt__btn.is-open { border-color: var(--zk-blue); background: var(--zk-tint-blue); }
|
||||||
|
.alt__btn em { font-style: normal; margin-left: 7px; font-size: 11.5px; color: var(--zk-grey); }
|
||||||
|
.alt__btn--exit { color: var(--zk-danger-ink); }
|
||||||
|
.alt__btn--exit:hover { border-color: var(--zk-danger-line); background: var(--zk-danger-tint); }
|
||||||
|
|
||||||
|
/* Recovery, folded. Needed on the day an agent stalls and never otherwise. */
|
||||||
|
.stuck { margin-top: 18px; border-top: 1px solid var(--zk-line-soft); padding-top: 14px; }
|
||||||
|
.stuck > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--zk-muted);
|
||||||
|
list-style: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.stuck > summary::-webkit-details-marker { display: none; }
|
||||||
|
.stuck > summary::before { content: '▸'; font-size: 10px; color: var(--zk-grey); }
|
||||||
|
.stuck[open] > summary::before { content: '▾'; }
|
||||||
|
.stuck > summary:hover { color: var(--zk-blue-dark); }
|
||||||
|
.stuck__why { margin: 10px 0 0; font-size: 12.5px; color: var(--zk-muted); max-width: 64ch; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user