Updated to new frontend specifications
This commit is contained in:
parent
ae548a3a10
commit
66ea08e317
2
.gitignore
vendored
2
.gitignore
vendored
@ -24,3 +24,5 @@ dist-ssr
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
|
||||
CLAUDE.md
|
||||
|
||||
57
CLAUDE.md
57
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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
72
src/api/errors.js
Normal file
72
src/api/errors.js
Normal file
@ -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 }
|
||||
}
|
||||
@ -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 <div className="af__err">Could not load the form — {error.status} {error.message}</div>
|
||||
if (error && !schema) return <div className="af__err">Could not load the form — {describeError(error).title}</div>
|
||||
if (!schema) return <p className="af__loading">Loading the form…</p>
|
||||
|
||||
return (
|
||||
<form className="af" onSubmit={submit}>
|
||||
<div className="af__grid">
|
||||
{schema.fields.map((f) => {
|
||||
{fields.map((f) => {
|
||||
const opts = f.properties?.options || []
|
||||
const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '')
|
||||
return (
|
||||
@ -123,7 +163,7 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
|
||||
<textarea rows={3} value={v} onChange={(e) => set(f.id, e.target.value)} />
|
||||
) : (
|
||||
<input
|
||||
type={f.data_type === 'number' ? 'number' : f.data_type === 'date' ? 'date' : 'text'}
|
||||
type={INPUT_TYPES[f.data_type] ?? 'text'}
|
||||
value={v} onChange={(e) => set(f.id, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
@ -132,14 +172,15 @@ export default function ActivityForm({ activityUid, instanceId, onDone, onCancel
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="af__err">
|
||||
<strong>{error.status}</strong> {error.message}
|
||||
{error.status === 403 || String(error.message).includes('permission denied') ? (
|
||||
<p>This is the workflow refusing, not the console. The signed-in role does not hold this activity.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (() => {
|
||||
const said = describeError(error)
|
||||
return (
|
||||
<div className="af__err">
|
||||
<strong>{error.status}</strong> {said.title}
|
||||
{said.detail ? <p>{said.detail}</p> : null}
|
||||
</div>
|
||||
)
|
||||
})() : null}
|
||||
|
||||
<div className="af__actions">
|
||||
{onCancel ? <button type="button" className="af__ghost" onClick={onCancel}>Cancel</button> : null}
|
||||
|
||||
@ -95,8 +95,8 @@
|
||||
color: var(--zk-grey);
|
||||
}
|
||||
|
||||
.shell__navlabel--closed {
|
||||
margin-top: 26px;
|
||||
.shell__group + .shell__group {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
/* The stage list is drawn as a rail with a node per state: the sidebar should
|
||||
@ -168,13 +168,13 @@
|
||||
box-shadow: 0 0 0 4px rgba(33, 103, 174, 0.14);
|
||||
}
|
||||
|
||||
/* The one queue where the machine stops and a person decides. */
|
||||
.shell__stage.is-human .shell__stagename {
|
||||
/* Queues where the machine stops and a person decides. */
|
||||
.shell__stage.is-needed .shell__stagename {
|
||||
font-weight: 500;
|
||||
color: var(--zk-ink);
|
||||
}
|
||||
|
||||
.shell__stage.is-human.is-active .shell__stagename {
|
||||
.shell__stage.is-needed.is-active .shell__stagename {
|
||||
color: var(--zk-blue-dark);
|
||||
}
|
||||
|
||||
@ -185,19 +185,6 @@
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.shell__badge {
|
||||
flex: none;
|
||||
font-size: 0.58rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--grad-blue);
|
||||
color: var(--zk-white);
|
||||
box-shadow: var(--sh-xs);
|
||||
}
|
||||
|
||||
.shell__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@ -1,18 +1,22 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { STAGES } from '../api/config.js'
|
||||
import { NAV_GROUPS, STAGES } from '../api/config.js'
|
||||
import UserMenu from './UserMenu.jsx'
|
||||
import logo from '../assets/brand/zurich_logo.webp'
|
||||
import './Shell.css'
|
||||
|
||||
/**
|
||||
* The sidebar IS the pipeline, in the order a lead moves — so the shape of the
|
||||
* process is legible before any record is opened. The one human queue is marked
|
||||
* as such, because it is the only place the machine stops.
|
||||
* The sidebar groups the pipeline by WHO IS HOLDING each lead, not by where it
|
||||
* sits in the process. Fourteen flat stages answer "what is the process?", which
|
||||
* is not the question anyone signing in has; these answer "is anything waiting
|
||||
* on me?". Queues that need a person are named by what they want done.
|
||||
*
|
||||
* It deliberately carries no counts. A tally here can only come from fetching
|
||||
* every lead on a timer and counting client-side, which is a second full list
|
||||
* call per open tab that disagrees with the queue's own total the moment either
|
||||
* one is paged. The count belongs on the queue, which already has it from the
|
||||
* server.
|
||||
*/
|
||||
export default function Shell() {
|
||||
const work = STAGES.filter((s) => s.kind !== 'end')
|
||||
const closed = STAGES.filter((s) => s.kind === 'end')
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="shell__top">
|
||||
@ -35,33 +39,30 @@ export default function Shell() {
|
||||
</svg>
|
||||
Add a lead
|
||||
</NavLink>
|
||||
<p className="shell__navlabel">Pipeline</p>
|
||||
<div className="shell__stages">
|
||||
{work.map((s) => (
|
||||
<NavLink
|
||||
key={s.uid}
|
||||
to={`/stage/${s.uid}`}
|
||||
className={({ isActive }) =>
|
||||
'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'human' ? ' is-human' : '')
|
||||
}
|
||||
>
|
||||
<span className="shell__stagename">{s.name}</span>
|
||||
{s.kind === 'human' ? <span className="shell__badge">you</span> : null}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<p className="shell__navlabel shell__navlabel--closed">Closed</p>
|
||||
<div className="shell__stages">
|
||||
{closed.map((s) => (
|
||||
<NavLink
|
||||
key={s.uid}
|
||||
to={`/stage/${s.uid}`}
|
||||
className={({ isActive }) => 'shell__stage' + (isActive ? ' is-active' : '')}
|
||||
>
|
||||
<span className="shell__stagename">{s.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{NAV_GROUPS.map((group) => {
|
||||
const stages = STAGES.filter((s) => s.kind === group.kind)
|
||||
if (!stages.length) return null
|
||||
return (
|
||||
<div className="shell__group" key={group.kind}>
|
||||
<p className="shell__navlabel">{group.label}</p>
|
||||
<div className="shell__stages">
|
||||
{stages.map((s) => (
|
||||
<NavLink
|
||||
key={s.uid}
|
||||
to={`/stage/${s.uid}`}
|
||||
title={s.need ? s.name : undefined}
|
||||
className={({ isActive }) =>
|
||||
'shell__stage' + (isActive ? ' is-active' : '') + (s.kind === 'needs' ? ' is-needed' : '')
|
||||
}
|
||||
>
|
||||
<span className="shell__stagename">{s.need ?? s.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<main className="shell__main">
|
||||
|
||||
@ -48,7 +48,10 @@ export default function AddLead() {
|
||||
activityUid={entry.uid}
|
||||
onDone={(res) => {
|
||||
const id = res?.instance_id || res?.data?.instance_id
|
||||
if (id) navigate(`/lead/${id}`)
|
||||
// Carry the workflow's own message across the navigation — "Lead
|
||||
// received — Intake is qualifying it" is the answer to "did that
|
||||
// work?", and it is only said once.
|
||||
if (id) navigate(`/lead/${id}`, { state: { message: res?.message ?? null } })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,8 @@ import { useZino } from '../api/provider.jsx'
|
||||
import ActivityForm from '../components/ActivityForm.jsx'
|
||||
import ClampText from '../components/ClampText.jsx'
|
||||
import Timeline from '../components/Timeline.jsx'
|
||||
import { ACTIONS, DV_LEAD, STAGES } from '../api/config.js'
|
||||
import { ACTIONS, DV_LEAD, PRODUCTS, STAGES } from '../api/config.js'
|
||||
import { describeError } from '../api/errors.js'
|
||||
import './screens.css'
|
||||
|
||||
function fmtWhen(ts) {
|
||||
@ -32,22 +33,25 @@ function expiryOf(dateStr) {
|
||||
const MONEY = new Set(['quoted_premium','quoted_od_premium','quoted_tp_premium','quoted_addon_premium',
|
||||
'quoted_gst','commission_base','commission_amount','sme_building_si','sme_plant_si','sme_furniture_si',
|
||||
'sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_other_si','sme_value_at_risk','motor_idv',
|
||||
'sme_burglary_si','sme_ee_si','sme_bi_gross_profit','sme_claims_36m_amount'])
|
||||
'sme_burglary_si','sme_ee_si','sme_bi_gross_profit','sme_claims_36m_amount','sme_stock_si'])
|
||||
|
||||
/** Field groups, in the order the lead was actually worked. */
|
||||
const GROUPS = [
|
||||
['Who sourced it', ['lead_ref','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']],
|
||||
['Who sourced it', ['lead_ref','product_line','source_channel','partner_code','partner_branch','rm_or_agent_id','consent_artefact']],
|
||||
['Customer', ['customer_name','entity_name','mobile','email','pan','gstin','udyam_no']],
|
||||
['Intake', ['lead_score','attribution_status','attribution_reason','dedupe_match_ref','eligibility_outcome','eligibility_reason']],
|
||||
['Contact', ['contact_outcome','contact_notes']],
|
||||
['SME risk', ['sme_product_variant','sme_occupancy','sme_location_address','sme_building_si','sme_plant_si','sme_furniture_si','sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_other_si','sme_walls','sme_roof','sme_building_age_band','sme_fire_protection','sme_fire_amc','sme_fire_brigade_km','sme_sections']],
|
||||
['Motor risk', ['motor_reg_no','motor_make_model','motor_mfg_year','motor_cc','motor_fuel','motor_idv','motor_ncb_pct','motor_addons','motor_prev_insurer','motor_prev_claim']],
|
||||
['Contact', ['contact_outcome','outreach_window','contact_notes']],
|
||||
['Documents', ['documents_status','doc_address_proof','doc_premises_proof','doc_stock_statement','doc_premises_photos','doc_vehicle_photos','doc_financials','documents_notes']],
|
||||
['SME risk', ['sme_product_variant','sme_occupancy','sme_location_address','sme_building_si','sme_plant_si','sme_furniture_si','sme_rawmaterial_si','sme_wip_si','sme_finished_si','sme_stock_si','sme_other_si','sme_value_at_risk','sme_floor','sme_num_floors','sme_floor_material','sme_walls','sme_roof','sme_building_age_band','sme_unit_age_years','sme_fire_protection','sme_fire_amc','sme_fire_brigade_km','sme_claims_36m_count','sme_claims_36m_amount','sme_sections','sme_bi_gross_profit','sme_bi_indemnity_months','sme_burglary_si','sme_ee_si']],
|
||||
['Motor risk', ['motor_reg_no','motor_make_model','motor_mfg_year','motor_cc','motor_fuel','motor_idv','motor_ncb_pct','motor_addons','motor_prev_insurer','motor_prev_policy_no','motor_prev_expiry','motor_prev_claim']],
|
||||
['AI recommendation (non-binding)', ['ai_recommended_cover','ai_recommended_addons','ai_recommendation_rationale','ai_recommendation_confidence']],
|
||||
['Quote', ['product_code','quoted_od_premium','quoted_tp_premium','quoted_addon_premium','quoted_section_premiums','quoted_gst','quoted_premium','quote_valid_till']],
|
||||
['Quote', ['product_code','quoted_od_premium','quoted_tp_premium','quoted_addon_premium','quoted_section_premiums','quoted_gst','quoted_premium','quoted_breakup','quote_valid_till']],
|
||||
['Proposal', ['acceptance_ref','accepted_at']],
|
||||
['KYC', ['kyc_mode','kyc_ref','kyc_outcome','kyc_mismatch_notes']],
|
||||
['Underwriting', ['uw_outcome','uw_survey_required','referral_analysis','uw_referral_reason','uw_decision_notes']],
|
||||
['Policy', ['payment_ref','premium_realised','policy_no','policy_issued_at']],
|
||||
['Policy', ['payment_link','payment_ref','premium_realised','realised_at','policy_no','policy_issued_at']],
|
||||
["The agent's payout", ['commission_rate_pct','commission_base','commission_amount','payout_status','payout_ref','payout_at']],
|
||||
['How it ended', ['lost_reason','welcome_sent','renewal_due']],
|
||||
]
|
||||
|
||||
/** Past this, a value is prose and gets folded rather than printed in full. */
|
||||
@ -57,6 +61,9 @@ const LONG = 150
|
||||
* Field ids are snake_case and several carry acronyms, which sentence-casing
|
||||
* turns into "Pan" and "Gstin". Only the words that need it are listed; every
|
||||
* other word passes through.
|
||||
*
|
||||
* This is the FALLBACK. The detail view ships an output_label for every field
|
||||
* and that is preferred — this covers a field the config does not describe.
|
||||
*/
|
||||
const WORDS = {
|
||||
pan: 'PAN', gstin: 'GSTIN', kyc: 'KYC', ai: 'AI', sme: 'SME', uw: 'UW',
|
||||
@ -72,7 +79,18 @@ function label(k) {
|
||||
|
||||
function fmt(k, v) {
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
if (Array.isArray(v)) return v.join(', ')
|
||||
if (k === 'product_line') return PRODUCTS[v] ?? String(v)
|
||||
if (Array.isArray(v)) {
|
||||
// A file field holds an array of upload references — {uuid, original_name,
|
||||
// blob_path, …} — so joining it raw prints [object Object].
|
||||
return v
|
||||
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.file_name || x.uuid || '') : x))
|
||||
.filter((x) => x !== '' && x !== null && x !== undefined)
|
||||
.join(', ') || null
|
||||
}
|
||||
// No field carries a bare object today, but one arriving as JSON should not
|
||||
// print as [object Object].
|
||||
if (typeof v === 'object') return JSON.stringify(v)
|
||||
if (MONEY.has(k)) { const n = Number(v); return Number.isFinite(n) ? '₹' + n.toLocaleString('en-IN') : String(v) }
|
||||
return String(v)
|
||||
}
|
||||
@ -82,21 +100,87 @@ export default function Lead() {
|
||||
const { client } = useZino()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// What the workflow said when the last activity was submitted. It is written
|
||||
// per activity — "Documents received — capturing the risk" — and is the
|
||||
// workflow telling the operator what it just set in motion, so it is shown
|
||||
// verbatim rather than replaced with a generic "Saved".
|
||||
const [note, setNote] = useState(location.state?.message ?? null)
|
||||
const [row, setRow] = useState(null)
|
||||
const [err, setErr] = useState(null)
|
||||
const [open, setOpen] = useState(null)
|
||||
const [tab, setTab] = useState(0)
|
||||
const [labels, setLabels] = useState({})
|
||||
|
||||
const load = useCallback(() => {
|
||||
setErr(null)
|
||||
client.detailView(DV_LEAD, instanceId)
|
||||
.then((r) => setRow(r?.data ?? r?.record ?? r))
|
||||
.catch(setErr)
|
||||
const load = useCallback((quiet = false) => {
|
||||
if (!quiet) setErr(null)
|
||||
return client.detailView(DV_LEAD, instanceId)
|
||||
.then((r) => {
|
||||
setRow(r?.data ?? r?.record ?? r)
|
||||
// The detail view ships an output_label for every field. Using the
|
||||
// server's names means a field renamed in Studio is renamed here.
|
||||
const fields = r?.config?.fields
|
||||
if (Array.isArray(fields)) {
|
||||
setLabels(Object.fromEntries(
|
||||
fields.filter((f) => f.field_key && f.output_label).map((f) => [f.field_key, f.output_label]),
|
||||
))
|
||||
}
|
||||
})
|
||||
// A poll that fails leaves the screen exactly as it is. Only a first load
|
||||
// becomes an error page: a gateway blip must not throw away a lead the
|
||||
// operator is reading, or unmount a half-filled form under them.
|
||||
.catch((e) => { if (!quiet) setErr(e) })
|
||||
}, [client, instanceId])
|
||||
|
||||
useEffect(load, [load])
|
||||
// `load` is CALLED here, not handed to useEffect: it returns a promise so the
|
||||
// poll below and onDone can chain on it, and an effect that returns anything
|
||||
// but a function has that value called as cleanup — React invokes the promise
|
||||
// as destroy(), throws, and unmounts the whole tree to a blank screen.
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
if (err) return <div className="notice"><strong>Could not load lead {instanceId}.</strong><p className="notice__detail">{err.status} {err.message}</p></div>
|
||||
// The message is handed over on the history entry, so it would show again
|
||||
// every time this lead is reached by Back — on a lead that has since moved on.
|
||||
// Consume it once, then strike it from the entry.
|
||||
useEffect(() => {
|
||||
if (location.state?.message) navigate(location.pathname, { replace: true, state: null })
|
||||
}, [location.state, location.pathname, navigate])
|
||||
|
||||
// A lead that no longer exists is not a page to look at. Send the operator
|
||||
// back to the queue rather than leaving them on an empty shell.
|
||||
useEffect(() => {
|
||||
if (err && describeError(err).kind === 'gone') {
|
||||
const id = setTimeout(() => navigate('/', { replace: true }), 1600)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
}, [err, navigate])
|
||||
|
||||
/**
|
||||
* Nothing is pushed — no socket, no SSE — and most of this workflow is carried
|
||||
* by AI employees that take one to three minutes a step, so a lead genuinely
|
||||
* moves while it is on screen. Poll quietly: no spinner, no skeleton, and not
|
||||
* at all while the tab is in the background.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Not while a form is open. A successful poll rewrites `actions`, and if the
|
||||
// lead has moved on the open form unmounts — taking whatever was typed into
|
||||
// it with no warning. A submission against a stale state is refused by the
|
||||
// workflow anyway, and that refusal is handled properly below.
|
||||
if (open) return undefined
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') load(true)
|
||||
}, 12000)
|
||||
return () => clearInterval(id)
|
||||
}, [load, open])
|
||||
|
||||
if (err) {
|
||||
const said = describeError(err)
|
||||
return (
|
||||
<div className="notice">
|
||||
<strong>{said.title}</strong>
|
||||
{said.detail ? <p>{said.detail}</p> : null}
|
||||
<p className="notice__detail">Lead {instanceId} · {err.status} {err.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!row) return <p className="empty">Loading…</p>
|
||||
|
||||
// Only groups that actually hold something; an empty tab is a dead end.
|
||||
@ -129,8 +213,7 @@ export default function Lead() {
|
||||
<h1 className="page__title">{row.customer_name || row.lead_ref || `Lead ${instanceId}`}</h1>
|
||||
<p className="page__sub">
|
||||
{row.lead_ref || `Lead ${instanceId}`}
|
||||
{row.entity_name ? ` · ${row.entity_name}` : ''} ·{' '}
|
||||
{row.product_line === 'motor' ? 'Motor' : 'SME Package'}
|
||||
{row.entity_name ? ` · ${row.entity_name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -150,6 +233,7 @@ export default function Lead() {
|
||||
<dd><span className={'due due--' + e.tone}>{e.label}</span> <span className="grid__sub">{e.on}</span></dd>
|
||||
</div>
|
||||
) : null })()}
|
||||
<div><dt>Product</dt><dd>{PRODUCTS[row.product_line] ?? '—'}</dd></div>
|
||||
{row.quoted_premium ? (
|
||||
<div className="lead__meta--money">
|
||||
<dt>Quoted premium</dt>
|
||||
@ -164,6 +248,27 @@ export default function Lead() {
|
||||
|
||||
<div className="lead">
|
||||
<div className="lead__main">
|
||||
{note ? (
|
||||
<div className="said" role="status">
|
||||
<p>{note}</p>
|
||||
<button type="button" onClick={() => setNote(null)} aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* An AI-carried stage has no empty action bar and no idle screen: it
|
||||
says what is happening and who is doing it. The activities below it
|
||||
stay available — ops can run them by hand — but they are not the
|
||||
answer to "why is nothing moving?". */}
|
||||
{stage?.kind === 'auto' ? (
|
||||
<div className="doing">
|
||||
<span className="doing__pulse" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{stage.doing}</strong>
|
||||
<span>{stage.by} is carrying this lead. It usually takes a minute or two, and this page is watching for it.</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actions.length ? (
|
||||
<div className="panel">
|
||||
<div className="panel__head">
|
||||
@ -190,8 +295,14 @@ export default function Lead() {
|
||||
<ActivityForm
|
||||
activityUid={open}
|
||||
instanceId={Number(instanceId)}
|
||||
lead={row}
|
||||
onCancel={() => setOpen(null)}
|
||||
onDone={() => { setOpen(null); load() }}
|
||||
onStale={() => {
|
||||
setOpen(null)
|
||||
setNote('That activity is not available at this stage any more — refreshed to show what is.')
|
||||
load()
|
||||
}}
|
||||
onDone={(res) => { setOpen(null); setNote(res?.message ?? null); load() }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@ -224,12 +335,12 @@ export default function Lead() {
|
||||
{groups[active][1].map(([k, v]) => (
|
||||
v.length > LONG ? (
|
||||
<div className="prop prop--note" key={k}>
|
||||
<dt>{label(k)}</dt>
|
||||
<dt>{labels[k] ?? label(k)}</dt>
|
||||
<dd><ClampText text={v} /></dd>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prop" key={k}>
|
||||
<dt>{label(k)}</dt>
|
||||
<dt>{labels[k] ?? label(k)}</dt>
|
||||
<dd className={MONEY.has(k) ? 'prop__num' : undefined}>{v}</dd>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useZino } from '../api/provider.jsx'
|
||||
import { CHANNELS, RV_LEADS, STAGES } from '../api/config.js'
|
||||
import { describeError } from '../api/errors.js'
|
||||
|
||||
/** Short absolute date plus how long ago — a queue needs both: the absolute
|
||||
* for "when exactly", the relative for "is this going stale". */
|
||||
@ -44,26 +45,43 @@ export default function Pipeline() {
|
||||
const { client } = useZino()
|
||||
const stage = STAGES.find((s) => s.uid === stageUid)
|
||||
|
||||
const [state, setState] = useState({ status: 'loading', rows: [], error: null })
|
||||
const [state, setState] = useState({ status: 'loading', rows: [], total: 0, error: null })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setState({ status: 'loading', rows: [], error: null })
|
||||
client
|
||||
.recordView(RV_LEADS, {
|
||||
limit: 100,
|
||||
filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }],
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
const rows = res?.data ?? res?.rows ?? res?.records ?? []
|
||||
setState({ status: 'ready', rows, error: null })
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
setState({ status: 'error', rows: [], error: err })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
|
||||
// `quiet` is what makes polling bearable: the first load may show a skeleton,
|
||||
// a refresh may not — dropping back to the loading state every 30 seconds
|
||||
// would flash the whole queue away under whoever is reading it.
|
||||
function fetchRows(quiet) {
|
||||
if (!quiet) setState({ status: 'loading', rows: [], total: 0, error: null })
|
||||
return client
|
||||
.recordView(RV_LEADS, {
|
||||
limit: 100,
|
||||
filters: [{ field_key: 'current_state_name', value: stage?.name ?? '' }],
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
const rows = res?.data ?? res?.rows ?? res?.records ?? []
|
||||
// total_count is the size of the QUEUE; rows is one page of at most
|
||||
// 100 of it. Counting the page would quietly under-report a busy stage.
|
||||
const total = res?.pagination?.total_count ?? rows.length
|
||||
setState({ status: 'ready', rows, total, error: null })
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
// A failed refresh must not throw away a queue that is already on
|
||||
// screen; only a failed first load is an error state.
|
||||
setState((prev) => (quiet && prev.status === 'ready' ? prev : { status: 'error', rows: [], total: 0, error: err }))
|
||||
})
|
||||
}
|
||||
|
||||
fetchRows(false)
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') fetchRows(true)
|
||||
}, 30000)
|
||||
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [client, stageUid, stage?.name])
|
||||
|
||||
if (!stage) return <p className="empty">Unknown stage.</p>
|
||||
@ -72,17 +90,20 @@ export default function Pipeline() {
|
||||
<section>
|
||||
<header className="page__head">
|
||||
<div>
|
||||
<h1 className="page__title">{stage.name}</h1>
|
||||
<h1 className="page__title">{stage.need ?? stage.name}</h1>
|
||||
<p className="page__sub">
|
||||
{stage.kind === 'human'
|
||||
? 'The only queue in the machine that waits for a person.'
|
||||
: 'Handled by the agents; shown here for visibility.'}
|
||||
{stage.kind === 'auto'
|
||||
? `${stage.doing} ${stage.by} is carrying these; nothing is waiting on a person.`
|
||||
: stage.kind === 'end'
|
||||
? 'Closed. Nothing runs from here.'
|
||||
: `Waiting on ${stage.by}.`}
|
||||
{stage.need ? <span className="page__stage">Stage · {stage.name}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
{state.status === 'ready' ? (
|
||||
<div className="page__count">
|
||||
<strong>{state.rows.length}</strong>
|
||||
<span>{state.rows.length === 1 ? 'lead' : 'leads'}</span>
|
||||
<strong>{state.total}</strong>
|
||||
<span>{state.total === 1 ? 'lead' : 'leads'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
@ -101,7 +122,7 @@ export default function Pipeline() {
|
||||
It is not seeded yet, so there is nothing to list. Everything else —
|
||||
sign-in, routing and the pipeline itself — is live.
|
||||
</p>
|
||||
<p className="notice__detail">{state.error?.status} {state.error?.message}</p>
|
||||
<p className="notice__detail">{describeError(state.error).title} · {state.error?.status} {state.error?.message}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@ -114,7 +135,7 @@ export default function Pipeline() {
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th><th>Channel</th>
|
||||
<th>Reference</th><th>Customer</th><th>Channel</th>
|
||||
<th>Product</th><th>Renewal due</th><th className="num">Premium</th>
|
||||
<th>Attribution</th><th>Added</th><th aria-label="Actions" />
|
||||
</tr>
|
||||
@ -127,6 +148,7 @@ export default function Pipeline() {
|
||||
const add = added(r.created_at)
|
||||
return (
|
||||
<tr key={id}>
|
||||
<td className="grid__ref">{r.lead_ref || `#${id}`}</td>
|
||||
<td>{r.customer_name || '—'}<div className="grid__sub">{r.entity_name || ''}</div></td>
|
||||
<td><span className="chip">{ch.label}</span></td>
|
||||
<td>{r.product_line === 'motor' ? 'Motor' : 'SME Package'}</td>
|
||||
|
||||
@ -117,7 +117,7 @@
|
||||
width: 100%;
|
||||
/* Eight columns of operational data do not compress: below this the card
|
||||
scrolls sideways rather than squeezing every cell into two words a line. */
|
||||
min-width: 900px;
|
||||
min-width: 1000px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 0.86rem;
|
||||
@ -168,6 +168,13 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The lead reference is the thing an operator quotes on a call. */
|
||||
.grid__ref {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
color: var(--zk-muted);
|
||||
}
|
||||
|
||||
.grid__sub {
|
||||
font-size: 0.76rem;
|
||||
color: var(--zk-muted);
|
||||
@ -433,6 +440,108 @@
|
||||
animation: zk-rise 0.24s var(--ease) both;
|
||||
}
|
||||
|
||||
/* ---- what the machine is doing ----
|
||||
Shown where an empty action bar used to be. Most stages are carried by an AI
|
||||
employee that takes a minute or two, and a screen that says nothing for two
|
||||
minutes reads as broken. */
|
||||
.doing {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--zk-blue-light);
|
||||
background: var(--zk-tint-blue);
|
||||
}
|
||||
|
||||
.doing div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doing strong {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--zk-blue-dark);
|
||||
}
|
||||
|
||||
.doing span {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
color: var(--zk-muted);
|
||||
}
|
||||
|
||||
.doing__pulse {
|
||||
flex: none;
|
||||
margin-top: 5px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--zk-blue);
|
||||
box-shadow: 0 0 0 0 rgba(33, 103, 174, 0.5);
|
||||
animation: zk-pulse 1.9s var(--ease) infinite;
|
||||
}
|
||||
|
||||
@keyframes zk-pulse {
|
||||
70% { box-shadow: 0 0 0 9px rgba(33, 103, 174, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(33, 103, 174, 0); }
|
||||
}
|
||||
|
||||
/* ---- what the workflow said ----
|
||||
The message the API returns per activity, shown verbatim: it is the workflow
|
||||
telling the operator what it just set in motion. */
|
||||
.said {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
padding: 14px 16px 14px 20px;
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--zk-line);
|
||||
border-left: 3px solid var(--zk-blue);
|
||||
background: var(--zk-white);
|
||||
box-shadow: var(--sh-sm);
|
||||
animation: zk-rise 0.24s var(--ease) both;
|
||||
}
|
||||
|
||||
.said p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
color: var(--zk-ink);
|
||||
}
|
||||
|
||||
.said button {
|
||||
flex: none;
|
||||
font: inherit;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--zk-grey);
|
||||
transition: background var(--t-fast), color var(--t-fast);
|
||||
}
|
||||
|
||||
.said button:hover {
|
||||
background: var(--zk-tint);
|
||||
color: var(--zk-ink);
|
||||
}
|
||||
|
||||
/* The stage a queue named by need actually maps to. */
|
||||
.page__stage {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--zk-grey);
|
||||
}
|
||||
|
||||
/* ---- back ----
|
||||
An icon, not a sentence: the way out of a record is a known shape and does not
|
||||
need naming twice on the page. It carries a title and an aria-label, so the
|
||||
|
||||
Loading…
Reference in New Issue
Block a user