/** * 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 }