diff --git a/.gitignore b/.gitignore index 50c8dda..fade557 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ dist-ssr *.sw? .env + +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md index 88ad3ee..38ffbfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,8 +27,10 @@ build does **not** — see "Runtime config" below. A React + Vite operator console (no state library, no UI framework — plain CSS files next to their components) for the Zurich Kotak "Lead to Policy" demo on the Zino platform. Leads arrive through three channels — direct, bancassurance, agency — and run -one workflow state machine to policy issuance. AI employees perform almost every step; -a human appears at exactly one queue, `Referred to Underwriting`. +one workflow state machine to policy issuance. AI employees carry most of it; a person is +needed at three queues (`Contacted` → upload documents, `Referred to Underwriting` → +clear or decline, `Payment Pending` → confirm premium) and the customer holds one +(`Quoted`). The frontend owns **no business logic**. The workflow (org `84`, app `536`, workflow `zk_wf_lead`) is the source of truth; this repo is a thin renderer over its API. @@ -45,7 +47,11 @@ Workflow config is seeded outside this repo, from `sm2/custom-apps/zurich-kotak/ workflow tables (`STAGES`, `ACTIONS`, `ENTRY`). - `src/api/provider.jsx` — `ZinoProvider` / `useZino()`: one client instance plus session state. Restores an **identity**, never a permission set. -- `src/layout/Shell.jsx` — header + the pipeline sidebar (the sidebar *is* `STAGES`). +- `src/api/errors.js` — `describeError()` turns the gateway's five error shapes into a + sentence plus a `kind` the caller branches on (`stale` re-fetches, `gone` returns to the + list). +- `src/layout/Shell.jsx` — header + the sidebar, grouped by `NAV_GROUPS` (who is holding + the lead) rather than by pipeline position, with queue counts from one tallied list call. - `src/screens/Pipeline.jsx` — a queue: one record view filtered server-side on `current_state_name`. - `src/screens/Lead.jsx` — one lead: detail view, allowed activities, timeline, then @@ -90,11 +96,48 @@ URLs to be relative; a `public/` file referenced as `/fonts/…` stays absolute under the `/zurich-kotak/` mount. The favicon is the one exception — it stays in `public/brand/` and is referenced relatively. +**Activity fields are filtered by product line.** `Collect Documents` serves motor +and SME from one form — all eleven upload slots plus both lines' risk fields, to every +lead — and the activity carries no `field_rules` (`[]`), so nothing server-side decides +visibility. A motor renewal was being asked for a Udyam certificate, a stock statement +and a GSTIN. `fieldApplies()` in `api/config.js` decides: `motor_*` is motor and `sme_*` +is SME **by prefix**, so a field added to the workflow tomorrow classifies itself; +`LINE_FIELDS` names the exceptions that carry no prefix (`gstin`, `udyam_no`), and +`DOC_SLOTS` maps the upload slots plus the two genuine conditions (`doc_address_proof` +only when `kyc_outcome === 'refer'`, `doc_financials` only when `sme_sections` includes +`business_interruption`). `ActivityForm` filters on it for both render and submit. + +Two things keep this safe. Every slot is `mandatory: false`, so hiding one cannot fail +schema validation. And when the filter would hide EVERY field, `ActivityForm` renders the +form whole instead — `Capture Motor Risk` is twelve motor fields and is runnable from +Contacted whatever the product is, so on an SME lead the filter would otherwise remove +the activity rather than its noise. `entity_name` is deliberately **not** SME-only: motor +leads carry a business name too. + +The prefix rule is inference, not configuration — the durable home is `field_rules` on +the activity. When those are seeded, read them and delete this. + +**Nothing is pushed — the console polls.** No socket, no SSE, and most steps are carried +by an AI employee that takes one to three minutes, so a lead moves while it is on screen. +The lead detail polls every 12s, queues and the sidebar counts every 30s, all gated on +`document.visibilityState` and all *quiet* — a refresh must never drop a queue back to a +skeleton under whoever is reading it. + +**`STAGES` carries `kind`** — `needs` / `customer` / `auto` / `end` — answering "who is +holding this lead", plus `need` (what a queue wants done, which is what the sidebar shows) +and `doing`/`by` (what to say while an AI carries it, instead of an idle screen). + **`STAGES` and `ACTIONS` are hand-mirrored** from `workflow.tbl_wf_states` and `workflow.tbl_wf_state_allowed_activities`, because the API returns neither the order nor the labels the sidebar needs. An activity added to the workflow but not added to `ACTIONS` is simply invisible — the platform allows it, the console never offers it, nothing errors. -Keep both in step with the seed SQL. +This bit once already: `zk-act-drop` (Mark Lost) was missing entirely, so no lead could be +dropped from the console. It is now appended to every state in `DROPPABLE` rather than +written into ten entries. Keep all of it in step with the seed SQL. + +No endpoint exposes state → allowed activities (eight plausible paths probed, all 404) and +the detail view carries `current_state_id` but no activity list — which is *why* the map is +hand-maintained. If the platform ever serves it, delete the map. ## API shapes that have already bitten @@ -113,6 +156,12 @@ Keep both in step with the seed SQL. claims about them. - `/ocr-extract` takes a **reference** to an already-uploaded file, not bytes, and answers `{ extracted, raw }`. Files therefore upload on pick, not on submit. +- `POST /view/recordview` takes the view uid in the **body**. The path form the frontend + spec documents (`/view/recordview/zk-rv-leads`) returns **404**. +- The detail view returns `{ config, data }` — `config.fields[].output_label` names every + one of the 117 fields, so labels come from the server rather than from prettified ids. +- The `message` on a `/start` or `/activity` response is written per activity ("Documents + received — capturing the risk") and is surfaced verbatim, not replaced with a toast. - On submit, `ActivityForm` sends only fields the activity defines, drops empties (an unknown field is fatal to schema validation) and skips `id_gen` — that reference is issued server-side. diff --git a/src/api/config.js b/src/api/config.js index 1cab407..3fc4f76 100644 --- a/src/api/config.js +++ b/src/api/config.js @@ -14,23 +14,47 @@ export const WORKFLOW = 'zk_wf_lead' * 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 02_workflow.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 + * end terminal + * + * `need` names the queue by what it wants done. A queue called "Upload + * documents" answers "is anything waiting on me?"; one called "Contacted" + * describes where the lead sits and leaves the operator to work it out. */ export const STAGES = [ - { uid: 'zk-state-new', name: 'New Lead', kind: 'work' }, - { uid: 'zk-state-qualified', name: 'Qualified', kind: 'work' }, - { uid: 'zk-state-contacted', name: 'Contacted', kind: 'work' }, - { uid: 'zk-state-risk', name: 'Risk Captured', kind: 'work' }, - { uid: 'zk-state-quoted', name: 'Quoted', kind: 'work' }, - { uid: 'zk-state-accepted', name: 'Proposal Accepted', kind: 'work' }, - { uid: 'zk-state-kyc', name: 'KYC Verified', kind: 'work' }, - // The only human queue in the whole machine. - { uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'human' }, - { uid: 'zk-state-cleared', name: 'Underwriting Cleared', kind: 'work' }, - { uid: 'zk-state-payment', name: 'Payment Pending', kind: 'work' }, - { uid: 'zk-state-issued', name: 'Policy Issued', kind: 'work' }, - { 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' }, + { uid: 'zk-state-new', name: 'New Lead', kind: 'auto', doing: 'Qualifying…', by: 'Intake AI' }, + { uid: 'zk-state-qualified', name: 'Qualified', kind: 'auto', doing: 'Calling the customer…', by: 'Voice agent' }, + { uid: 'zk-state-contacted', name: 'Contacted', kind: 'needs', need: 'Upload documents', by: 'the partner agent' }, + { uid: 'zk-state-risk', name: 'Risk Captured', kind: 'auto', doing: 'Pricing and advising…', by: 'Rating + Advisor AI' }, + { uid: 'zk-state-quoted', name: 'Quoted', kind: 'customer', need: 'Accept the proposal', by: 'the customer' }, + { uid: 'zk-state-accepted', name: 'Proposal Accepted', kind: 'auto', doing: 'Verifying identity…', by: 'KYC AI' }, + { uid: 'zk-state-kyc', name: 'KYC Verified', kind: 'auto', doing: 'Screening for underwriting…', by: 'KYC AI' }, + // The one queue where the machine stops and a person decides. + { uid: 'zk-state-referred', name: 'Referred to Underwriting', kind: 'needs', need: 'Clear or decline', by: 'an underwriter' }, + { uid: 'zk-state-cleared', name: 'Underwriting Cleared', kind: 'auto', doing: 'Requesting premium…', by: 'Engage AI' }, + { uid: 'zk-state-payment', name: 'Payment Pending', kind: 'needs', need: 'Confirm premium', by: 'ops' }, + { uid: 'zk-state-issued', name: 'Policy Issued', kind: 'auto', doing: 'Closing the file…', by: 'Engage AI' }, + { 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: 'Needs someone', kind: 'needs' }, + { label: 'With the customer', kind: 'customer' }, + { label: 'Running by itself', kind: 'auto' }, + { label: 'Closed', kind: 'end' }, ] /** Channel presentation. source_channel is data on the instance, never a branch. */ @@ -60,7 +84,7 @@ 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. */ -export const ACTIONS = { +const STATE_ACTIVITIES = { 'zk-state-new': [{ uid: 'zk-act-qualify', label: 'Qualify Lead', by: 'Intake AI' }], 'zk-state-qualified': [{ uid: 'zk-act-contact', label: 'Log Contact', by: 'Engage AI' }], 'zk-state-contacted': [{ uid: 'zk-act-collect-docs', label: 'Collect Documents', by: 'Anyone — self-loop' }, @@ -84,6 +108,30 @@ export const ACTIONS = { 'zk-state-issued': [{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Ops' }], } +/** + * 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. + * + * 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. + */ +const DROP = { uid: 'zk-act-drop', label: 'Mark Lost', by: 'Whoever holds the lead' } + +const DROPPABLE = [ + 'zk-state-new', 'zk-state-qualified', 'zk-state-contacted', 'zk-state-risk', + 'zk-state-quoted', 'zk-state-accepted', 'zk-state-kyc', 'zk-state-referred', + 'zk-state-cleared', 'zk-state-payment', +] + +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. * @@ -100,3 +148,117 @@ 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, but no break_in flag exists on the instance + // to test — the field name carries the caveat and the slot stays on motor. + doc_vehicle_photos: { line: 'motor' }, + + // 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. +} + +/** + * 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) { + 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 +} diff --git a/src/api/errors.js b/src/api/errors.js new file mode 100644 index 0000000..1b0a531 --- /dev/null +++ b/src/api/errors.js @@ -0,0 +1,72 @@ +/** + * The gateway's error shapes, turned into something an operator can act on. + * + * Five shapes matter, and they are told apart by status plus a phrase in the + * message — there is no error code to switch on. `kind` is for the caller to + * branch on ('stale' and 'gone' need the screen to do something); `title` and + * `detail` are what gets shown. + * + * Anything unrecognised keeps its own message: a wrong guess reads worse than + * the server's own words. + */ +export function describeError(err) { + const status = err?.status + const raw = String(err?.message ?? '') + const has = (...words) => words.every((w) => raw.toLowerCase().includes(w)) + + if (status === 404 && has('no record found')) { + return { + kind: 'gone', + title: 'This lead is no longer there.', + detail: 'It may have been removed since the queue was loaded. Taking you back to the list.', + } + } + + if (status === 403 && has('not allowed in state')) { + return { + kind: 'stale', + // Deliberately not "the lead moved": the gateway returns this both when a + // lead advanced under the screen AND when the console offered an activity + // the state never allowed. Only one of those is a move, and claiming the + // wrong one sends the operator looking for something that did not happen. + title: 'That activity is not available at this stage.', + detail: 'Either the lead moved on while this was open, or it never allowed this. Refreshed to show what it does allow.', + } + } + + if (status === 403) { + return { + kind: 'forbidden', + title: 'The workflow refused this.', + detail: 'This is the platform deciding, not the console: the signed-in role does not hold this activity.', + } + } + + // Field validation runs BEFORE the permission check, so a disallowed + // submission carrying bad data arrives here as a 400 and not a 403. + if (status === 400 && has('validation failed')) { + // "field(unknown), field_2(required)" is the unsuffixed-key mistake, and it + // is a bug in this console rather than anything the operator did. + if (has('unknown') && has('required')) { + return { + kind: 'bug', + title: 'The console sent a field name the workflow does not know.', + detail: raw, + } + } + if (has('enum')) { + return { + kind: 'invalid', + title: 'One of the choices is not one the workflow accepts.', + detail: raw, + } + } + return { kind: 'invalid', title: 'The workflow rejected this submission.', detail: raw } + } + + if (status === 401) { + return { kind: 'auth', title: 'The session has expired.', detail: 'Sign in again to continue.' } + } + + return { kind: 'unknown', title: raw || 'Something went wrong.', detail: null } +} diff --git a/src/components/ActivityForm.jsx b/src/components/ActivityForm.jsx index 0534734..d72d005 100644 --- a/src/components/ActivityForm.jsx +++ b/src/components/ActivityForm.jsx @@ -1,8 +1,22 @@ import { useEffect, useState } from 'react' import { useZino } from '../api/provider.jsx' +import { fieldApplies } from '../api/config.js' +import { describeError } from '../api/errors.js' import FileField from './FileField.jsx' import './ActivityForm.css' +/** + * The HTML input each workflow data_type maps to. `phone` and `email` were both + * falling through to plain text, which costs the keyboard on a phone and the + * browser's own validation everywhere. + */ +const INPUT_TYPES = { + number: 'number', + date: 'date', + phone: 'tel', + email: 'email', +} + /** * Renders whatever /view/form-screens returns for an activity — labels, types, * select options and which fields are mandatory — and submits it straight back. @@ -11,8 +25,14 @@ import './ActivityForm.css' * activity in Studio, redeploy, and it appears here with no code change. That * is the point: the workflow is the source of truth, and a hardcoded form would * quietly drift from it. + * + * The ONE thing filtered here is which upload slots apply to the lead's product + * line. Collect Documents serves motor and SME from a single form and the + * activity carries no `field_rules`, so without this a motor renewal is asked + * for a Udyam certificate. See DOC_SLOTS in api/config.js — including why that + * table should stop existing once the rules are seeded on the activity. */ -export default function ActivityForm({ activityUid, instanceId, onDone, onCancel }) { +export default function ActivityForm({ activityUid, instanceId, lead, onDone, onCancel, onStale }) { const { client } = useZino() const [schema, setSchema] = useState(null) const [values, setValues] = useState({}) @@ -42,13 +62,30 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel function set(id, v) { setValues((p) => ({ ...p, [id]: v })) } + // Computed before the early returns below use it, and before submit: a field + // that was never offered must never be sent. + // + // If the lead's line would hide EVERY field, the filter is not removing noise + // any more — it is removing the activity. Capture Motor Risk is twelve motor + // fields and is runnable from Contacted whatever the product is, so on an SME + // lead this would otherwise render a form with nothing in it and a live Submit + // button. Show the activity whole and let the operator see what it is asking. + const applicable = schema ? schema.fields.filter((f) => fieldApplies(f.id, lead)) : [] + // Filtering must never hide a field the workflow requires: `submit` sends only + // what is rendered, so a hidden mandatory field becomes a 400 naming something + // that is not on screen and cannot be filled. Hiding everything is the same + // failure in the large — it removes the activity rather than its noise, and + // Capture Motor Risk is twelve motor fields that stay runnable on an SME lead. + const hidesMandatory = schema ? schema.fields.some((f) => f.mandatory && !fieldApplies(f.id, lead)) : false + const fields = applicable.length && !hidesMandatory ? applicable : (schema?.fields ?? []) + async function submit(e) { e.preventDefault() setBusy(true); setError(null) // Send only fields the activity defines. A submission is schema-validated // and an unknown field is fatal, so empties are dropped rather than sent. const payload = {} - for (const f of schema.fields) { + for (const f of fields) { // id_gen is issued server-side and stripped from the submission. Sending // it would be forging a reference the platform owns. if (f.data_type === 'id_gen') continue @@ -63,18 +100,21 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel onDone?.(res) } catch (err) { setError(err) + // The lead moved under the form. Nothing the operator can fix by reading — + // tell the page to re-fetch so the actions on offer are the real ones. + if (describeError(err).kind === 'stale') onStale?.() } finally { setBusy(false) } } - if (error && !schema) return
Loading the form…
return (