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; }