`documents_status` was a dropdown — not yet / partially / all uploaded — put to the person who had just attached the files. It asked them to tell the system what it could see for itself. Not cosmetic. That field gates the AND-join that wakes Engage after an upload (55). Left on its default, the lead sits in Document Pending with all three documents attached and nothing happening, and it reads as the AI having stalled rather than as a form field nobody filled. It is derived in the trigger now (75), from the files themselves, after the commit and before the join reads it — server-side, because a value computed only in the browser would be right on screen and absent to the API. `documents_notes` goes with it: the same script writes what is still missing, and a person overwriting that would be arguing with the file list. So both are declared derived and no form asks for them, with or without a lead behind it. Matched on the base id, so every per-activity suffix is covered rather than the two that happen to exist today.
338 lines
16 KiB
JavaScript
338 lines
16 KiB
JavaScript
/**
|
|
* Environment-scoped identifiers for the Zurich Kotak "Lead to Policy" demo.
|
|
*
|
|
* Workflow config is seeded from sm2/custom-apps/zurich-kotak/ — org 84,
|
|
* app 536, workflow zk_wf_lead (110001 v1).
|
|
*/
|
|
export const ORG_ID = '84'
|
|
export const APP_ID = '536'
|
|
export const WORKFLOW = 'zk_wf_lead'
|
|
|
|
/**
|
|
* The pipeline, in the order a lead actually moves.
|
|
*
|
|
* Mirrors workflow.tbl_wf_states by hand. It is duplicated rather than fetched
|
|
* because the sidebar has to render its ORDER, and the API returns states as a
|
|
* set with no canonical sequence. Keep in step with 53_v2_state_model.sql.
|
|
*
|
|
* `kind` answers "who is holding this lead", which is the question an operator
|
|
* actually has — not "where is it in the process":
|
|
*
|
|
* needs a person has to act, and nothing moves until they do
|
|
* customer waiting on someone outside the business
|
|
* auto an AI employee is carrying it; `doing` is what to say meanwhile
|
|
* waiting real, but not yet — the renewal is too far off to work
|
|
* end terminal
|
|
*
|
|
* `need` names the queue by what it wants done. A queue called "Upload
|
|
* documents" answers "is anything waiting on me?"; one called "Document
|
|
* Pending" describes where the lead sits and leaves the operator to work it out.
|
|
*
|
|
* `name` MUST match workflow.tbl_wf_states.name exactly — the queue filter, the
|
|
* sidebar tally and the action lookup all resolve a lead through this string
|
|
* against current_state_name. A workflow rename not mirrored here does NOT
|
|
* error: the queue returns nothing, correctly, for a name no lead is in. That
|
|
* is the failure mode this table has, and it is silent.
|
|
*/
|
|
export const STAGES = [
|
|
{ uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Checking the lead', by: 'Intake AI' },
|
|
{ uid: 'zk-state-qualified', name: 'Awaiting Contact', kind: 'auto', doing: 'Calling the customer', by: 'the voice agent' },
|
|
{ uid: 'zk-state-contacted', name: 'Contacted', kind: 'auto', doing: 'Asking for the documents', by: 'Engage AI' },
|
|
// 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: '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: '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: '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' },
|
|
]
|
|
|
|
/**
|
|
* How the sidebar groups those. Fourteen flat stages answer "what is the
|
|
* process?" — the question nobody signing in has. These answer "is anything
|
|
* waiting on me?".
|
|
*/
|
|
export const NAV_GROUPS = [
|
|
{ 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. */
|
|
export const CHANNELS = {
|
|
direct: { label: 'Direct', short: 'D' },
|
|
bancassurance: { label: 'Bancassurance', short: 'B' },
|
|
agency: { label: 'Agency', short: 'A' },
|
|
}
|
|
|
|
/** View source-uids. Seeded by sm2/custom-apps/zurich-kotak/04_views.sql. */
|
|
export const RV_LEADS = 'zk-rv-leads'
|
|
export const DV_LEAD = 'zk-dv-lead'
|
|
|
|
/**
|
|
* Which activities are runnable from which state, and who is expected to do it.
|
|
*
|
|
* Mirrors workflow.tbl_wf_state_allowed_activities by hand — the same reason
|
|
* STAGES is duplicated: the console needs the ORDER and the labels, and the API
|
|
* returns neither.
|
|
*
|
|
* `by` is presentational ONLY. Nothing here enforces anything: the workflow
|
|
* refuses server-side and the console reports what it said. Showing an action
|
|
* the signed-in user cannot perform is deliberate — the refusal is the demo.
|
|
*
|
|
* This map is hand-maintained against workflow.tbl_wf_state_allowed_activities.
|
|
* An activity added to the workflow but not added here is simply invisible:
|
|
* 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', 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: 'force' },
|
|
],
|
|
'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: 'force' },
|
|
{ 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. 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.
|
|
*
|
|
* 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', role: 'exit' }
|
|
|
|
const DROPPABLE = [
|
|
'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-docs',
|
|
'zk-state-quoted', 'zk-state-referred', 'zk-state-payment', 'zk-state-parked',
|
|
]
|
|
|
|
export const ACTIONS = Object.fromEntries(
|
|
[...new Set([...Object.keys(STATE_ACTIVITIES), ...DROPPABLE])].map((uid) => [
|
|
uid,
|
|
[...(STATE_ACTIVITIES[uid] ?? []), ...(DROPPABLE.includes(uid) ? [DROP] : [])],
|
|
]),
|
|
)
|
|
|
|
/**
|
|
* Entry points surfaced in the console.
|
|
*
|
|
* The workflow has THREE INIT activities — direct, bancassurance and agency —
|
|
* and all three still work over the API with their own permissions. Only the
|
|
* agency door is shown here, because this demo is about the individual agent
|
|
* who sources a lead and gets paid when it onboards.
|
|
*
|
|
* The other two are hidden, not removed: the bancassurance door is what makes
|
|
* the cross-channel duplicate story possible, and deleting it would take the
|
|
* skip-ahead behaviour with it.
|
|
*/
|
|
export const ENTRY = [
|
|
{ uid: 'zk-act-init-agent', label: 'Partner Agent Lead', channel: 'agency',
|
|
note: 'POSP or broker sourcing a lead. Everything after this happens without them.' },
|
|
]
|
|
|
|
/** Product lines, as stored on the instance and as they should be read. */
|
|
export const PRODUCTS = {
|
|
motor: 'Motor',
|
|
sme_package: 'SME Package',
|
|
}
|
|
|
|
/**
|
|
* Which upload slot belongs to which product line — and, for the two slots that
|
|
* are conditional, when the condition holds.
|
|
*
|
|
* The Collect Documents activity serves BOTH lines from one form: it returns all
|
|
* eleven slots to every lead, so a motor renewal was being asked for a Udyam
|
|
* certificate and a stock statement. Nothing on the platform decides otherwise —
|
|
* `field_rules` comes back as `[]` for this activity, so there is no server-side
|
|
* visibility to honour.
|
|
*
|
|
* This is therefore a MIRROR, in the same sense as STAGES and ACTIONS above, and
|
|
* carries the same hazard: a slot added to the activity but not added here is
|
|
* shown to every product, and one renamed here stops matching and reverts to the
|
|
* same. The durable home for this is `field_rules` on the activity; when those
|
|
* are seeded, ActivityForm should read them and this table should go.
|
|
*
|
|
* Every slot is optional in the workflow (`mandatory: false` on all eleven), so
|
|
* hiding one cannot make a submission fail validation.
|
|
*/
|
|
export const DOC_SLOTS = {
|
|
// Motor.
|
|
doc_rc: { line: 'motor' },
|
|
doc_prev_policy: { line: 'motor' },
|
|
// "Break-in only" per the policy. The flag DOES exist now — 49 derives
|
|
// break_in at filing on all three doors — so the slot can honour its own
|
|
// caveat instead of being shown to every motor renewal.
|
|
doc_vehicle_photos: { line: 'motor', when: (lead) => lead.break_in === 'yes' },
|
|
|
|
// SME.
|
|
doc_gst_cert: { line: 'sme' },
|
|
doc_udyam_cert: { line: 'sme' },
|
|
doc_premises_proof: { line: 'sme' },
|
|
doc_stock_statement: { line: 'sme' },
|
|
doc_premises_photos: { line: 'sme' },
|
|
// Audited accounts are only read when business interruption is on the risk.
|
|
doc_financials: { line: 'sme',
|
|
when: (lead) => (lead.sme_sections || []).includes('business_interruption') },
|
|
|
|
// Both lines.
|
|
doc_pan: { line: 'both' },
|
|
// Identity is only re-evidenced when KYC actually referred; asking up front
|
|
// collects an Aadhaar the file does not need.
|
|
doc_address_proof: { line: 'both', when: (lead) => lead.kyc_outcome === 'refer' },
|
|
}
|
|
|
|
/**
|
|
* The fields that belong to one product line but do not say so in their name.
|
|
* Everything else is classified by prefix: `motor_*` is motor, `sme_*` is SME.
|
|
* A prefix rule rather than a list, so a field added to the workflow tomorrow is
|
|
* classified without anyone remembering to come back here.
|
|
*/
|
|
export const LINE_FIELDS = {
|
|
gstin: 'sme',
|
|
udyam_no: 'sme',
|
|
// entity_name is NOT listed: the form labels it "Business Name", but motor
|
|
// leads carry one too — a company-owned vehicle has an owner with a name.
|
|
}
|
|
|
|
/**
|
|
* Fields the workflow computes for itself, which a form must therefore not ask
|
|
* for. Matched on the base id, so every per-activity suffix is covered.
|
|
*
|
|
* `documents_status` is the one that matters. It gates the AND-join that wakes
|
|
* Engage after an upload (55), and it was rendered as a dropdown — asking the
|
|
* person who had just attached three files to tell the system what it could
|
|
* see. Left unset, the lead sits in Document Pending with everything attached
|
|
* and nothing happening, and it reads as the AI having stalled. It is derived
|
|
* in the trigger now (75), from the files themselves.
|
|
*
|
|
* `documents_notes` goes with it: the same script writes what is still missing,
|
|
* and a person overwriting that would be arguing with the file list.
|
|
*/
|
|
export const DERIVED_FIELDS = new Set([
|
|
'documents_status',
|
|
'documents_notes',
|
|
])
|
|
|
|
/**
|
|
* Form field ids carry a per-form suffix — `doc_rc` arrives as `doc_rc_2`, `pan`
|
|
* as `pan_3` — so a field is matched on the id with that suffix removed. No
|
|
* underlying field id ends in a number, which is what makes this safe.
|
|
*/
|
|
export function baseFieldId(id) {
|
|
return String(id).replace(/_\d+$/, '')
|
|
}
|
|
|
|
/** Which product line a field belongs to: 'motor', 'sme', or 'both'. */
|
|
export function fieldLine(fieldId) {
|
|
const id = baseFieldId(fieldId)
|
|
if (DOC_SLOTS[id]) return DOC_SLOTS[id].line
|
|
if (LINE_FIELDS[id]) return LINE_FIELDS[id]
|
|
if (id.startsWith('motor_')) return 'motor'
|
|
if (id.startsWith('sme_')) return 'sme'
|
|
return 'both'
|
|
}
|
|
|
|
/**
|
|
* The line a lead is on, in the vocabulary fieldLine answers in, or null when it
|
|
* cannot be told.
|
|
*
|
|
* Null matters: treating an unknown line as SME would quietly drop the RC and
|
|
* the expiring policy from Collect Documents on a lead whose product has not
|
|
* been stamped yet, with nothing on screen to say a slot was hidden. Not knowing
|
|
* means filtering nothing.
|
|
*/
|
|
export function leadLine(lead) {
|
|
if (lead?.product_line === 'motor') return 'motor'
|
|
if (lead?.product_line === 'sme_package') return 'sme'
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Whether an activity field should be offered for this lead — its line has to
|
|
* match, and a conditional upload slot has to have its condition hold.
|
|
*
|
|
* Everything is shown when there is no instance yet: an INIT form has no product
|
|
* line to filter on.
|
|
*/
|
|
export function fieldApplies(fieldId, lead) {
|
|
// Computed by the workflow — never asked for, on any form, whether or not
|
|
// there is a lead behind it.
|
|
if (DERIVED_FIELDS.has(baseFieldId(fieldId))) return false
|
|
if (!lead) return true
|
|
const on = leadLine(lead)
|
|
if (!on) return true
|
|
const line = fieldLine(fieldId)
|
|
if (line !== 'both' && line !== on) return false
|
|
const slot = DOC_SLOTS[baseFieldId(fieldId)]
|
|
return slot?.when ? slot.when(lead) : true
|
|
}
|