From 07d46088889b926b95ef4fbc8b38bd5cb316f2c4 Mon Sep 17 00:00:00 2001 From: Yashas Date: Mon, 7 Sep 2026 17:55:51 +0530 Subject: [PATCH] lead: show the next step, not the permission list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/api/config.js | 120 ++++++++++++++++++---------------- src/screens/Lead.jsx | 139 ++++++++++++++++++++++++++++++---------- src/screens/screens.css | 69 ++++++++++++++++++++ 3 files changed, 240 insertions(+), 88 deletions(-) diff --git a/src/api/config.js b/src/api/config.js index 6ce7083..58f1e2e 100644 --- a/src/api/config.js +++ b/src/api/config.js @@ -96,67 +96,79 @@ export const DV_LEAD = 'zk-dv-lead' * the platform allows it, the console never offers it, and nothing errors. * 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 = { - 'zk-state-new': [{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI' }], - // Retry Call is scheduled automatically four hours after an unanswered - // 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 - // `auto_retry` field, which ships unset. Offered here so a person can - // also place the retry by hand. - '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' }], - // Contacted has exactly one way forward and it belongs to Engage. An AI - // 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 - // agent recording documents it never saw. - 'zk-state-contacted': [{ uid: 'zk-act-request-docs', label: 'Request Documents', by: 'Engage AI' }], - // Four steps that used to be two stages: the upload, the capture, the advice - // and the quote. The lead stays here for all of them and leaves only once a - // premium exists. - 'zk-state-docs': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — upload here' }, - { uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI' }, - { uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI' }, - { uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI' }, - { uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine' }, - // Fires on its own at 24h, 72h and 120h from the ask. On the - // third unanswered one the lead leaves this queue — Parked if - // the renewal is still weeks off, Lost if the cover is going. - { uid: 'zk-act-doc-reminder', label: 'Send Document Reminder', by: 'Scheduled — or you' }], - // Likewise absorbs acceptance, identity and the underwriting screen — none of - // 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, - // and quote_revisions counts them (0 on the first, 2 is the ceiling). - 'zk-state-quoted': [{ uid: 'zk-act-accept', label: 'Accept Proposal', by: 'Customer' }, - { uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI' }, - { uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'KYC AI' }, - { uid: 'zk-act-quote', label: 'Generate Quote (revise)', by: 'Engage AI' }, - { uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' }], - 'zk-state-referred': [{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI' }, - { uid: 'zk-act-uw-clear', label: 'Clear Referral', by: 'Underwriter' }, - { uid: 'zk-act-uw-decline', label: 'Decline Referral', by: 'Underwriter' }], - // 'Payment webhook' was aspirational — there is no payment integration on the - // platform and none is planned here. Collection happens on the insurer's own - // rails; ops records the reference. See 35_reframe_to_what_exists.sql. - // - // Request Premium moved here when Underwriting Cleared was removed: entering - // 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' }], + 'zk-state-new': [ + { uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI', role: 'force' }, + ], + 'zk-state-qualified': [ + { uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI', role: 'force' }, + { uid: 'zk-act-retry-call', label: 'Retry Call', by: 'Scheduled', role: 'again' }, + ], + 'zk-state-contacted': [ + { uid: 'zk-act-request-docs', label: 'Request Documents', by: 'Engage AI', role: 'force' }, + ], + // The one stage whose next step is a person's: three documents have to + // be found and uploaded. Everything else here is the AI's own chain. + 'zk-state-docs': [ + { uid: 'zk-act-collect-docs', label: 'Upload documents', by: 'you', role: 'do' }, + { uid: 'zk-act-doc-reminder', label: 'Send a reminder', by: 'Scheduled', role: 'again' }, + { uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI', role: 'force' }, + { uid: 'zk-act-capture-sme', label: 'Capture SME Risk', by: 'Engage AI', role: 'force' }, + { uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI', role: 'force' }, + { uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine', role: 'force' }, + ], + 'zk-state-quoted': [ + { uid: 'zk-act-accept', label: 'Record the acceptance', by: 'you, on consent', role: 'do' }, + { uid: 'zk-act-quote', label: 'Re-quote', by: 'Engage AI', role: 'again' }, + { uid: 'zk-act-collect-docs', label: 'Add a document', by: 'you', role: 'again' }, + { uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI', role: 'force' }, + { uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'KYC AI', role: 'force' }, + ], + 'zk-state-referred': [ + { uid: 'zk-act-uw-clear', label: 'Clear the referral', by: 'you', role: 'do' }, + { uid: 'zk-act-uw-decline', label: 'Decline the risk', by: 'you', role: 'do' }, + { uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI', role: 'force' }, + ], + 'zk-state-payment': [ + { uid: 'zk-act-realise', label: 'Confirm premium received', by: 'you', role: 'do' }, + { uid: 'zk-act-nudge', label: 'Chase the payment', by: 'Engage AI', role: 'again' }, + { uid: 'zk-act-payment', label: 'Request Premium', by: 'Engage AI', role: 'force' }, + ], + 'zk-state-issued': [ + { uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Engage AI', role: 'force' }, + ], + 'zk-state-parked': [ + { uid: 'zk-act-resume', label: 'Resume outreach now', by: 'you', role: 'do' }, + ], } /** * 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 - * from the table entirely, which is the exact failure the note above describes — - * the platform allowed it, the console never offered it, and nothing errored. + * the policy issues, and every role that files a lead may do it. Appended rather + * than written into every entry so there is one place to change it, and so a + * stage added above cannot silently lose it. * - * Appended rather than written into all ten entries so there is one place to - * change it, and so a state added above cannot silently lose it. + * It carries role 'exit' precisely so it is never rendered as a step. It is the + * 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 = [ 'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs', diff --git a/src/screens/Lead.jsx b/src/screens/Lead.jsx index 72d6c66..1f1f13b 100644 --- a/src/screens/Lead.jsx +++ b/src/screens/Lead.jsx @@ -341,42 +341,113 @@ export default function Lead() { ) : null} - {actions.length ? ( -
-
-
-

Actions

-

Available to your role at this stage.

-
+ {/* WHAT TO DO NEXT, not what is permitted. + Every action carries a role: `do` is the step this stage is + waiting on, `again` is a bounded loop, `force` is an AI's own job + offered only so a stalled lead can be pushed by hand, `exit` is + Mark Lost. Rendering them as one flat row of equals is what made + six buttons appear where one was the answer. */} + {(() => { + const step = actions.filter((a) => a.role === 'do') + const again = actions.filter((a) => a.role === 'again') + const force = actions.filter((a) => a.role === 'force') + const exit = actions.filter((a) => a.role === 'exit') + const extras = [...force, ...exit] + + const form = open ? ( +
+ setOpen(null)} + onStale={() => { + setOpen(null) + setNote('Stage changed. Available actions have been refreshed.') + load() + }} + onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }} + />
-
- {actions.map((a) => ( - - ))} -
- {open ? ( -
- setOpen(null)} - onStale={() => { - setOpen(null) - setNote('Stage changed. Available actions have been refreshed.') - load() - }} - onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }} - /> + ) : null + + if (!step.length && !again.length && !extras.length) return null + + return ( +
+
+
+

Next step

+

+ {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.`} +

+
- ) : null} -
- ) : null} + + {/* 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 ? ( +
+ {step.map((a) => ( + + ))} +
+ ) : null} + + {again.length ? ( +
+ {again.map((a) => ( + + ))} +
+ ) : 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 ? ( +
+ Lead not moving? +

+ {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.'} +

+
+ {extras.map((a) => ( + + ))} +
+
+ ) : null} +
+ ) + })()} {/* 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 diff --git a/src/screens/screens.css b/src/screens/screens.css index 99f3f94..cac5190 100644 --- a/src/screens/screens.css +++ b/src/screens/screens.css @@ -1079,3 +1079,72 @@ 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); } + +/* ── 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; }