From 5ea627b064db487e995db02808ae8144c156b574 Mon Sep 17 00:00:00 2001 From: Yashas Date: Mon, 24 Aug 2026 16:11:28 +0530 Subject: [PATCH] Make the console operable: add a lead, work a lead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline could be looked at but not driven. Adds the three pieces that make the machine walkable end to end from the UI. - ActivityForm renders whatever /view/form-screens returns — labels, types, select options, mandatory flags — and submits it back. No form is defined in this repo, so a field added in Studio appears here with no code change. - AddLead presents the three doors. Each is a separate INIT activity with its own permissions; the bank one lands further along because the bank already did CKYC. - Lead shows the file grouped in the order it was worked, ending with what the sourcing agent earns, plus the activities this state allows and who normally performs each. The "who normally performs this" label is presentational only. Nothing here enforces anything — the workflow refuses server-side and the form reports what it said, including a note when a 403 is the platform declining rather than the console misbehaving. Co-Authored-By: Claude Opus 5 (1M context) --- src/App.jsx | 2 + src/api/config.js | 39 ++++++++++ src/components/ActivityForm.css | 43 +++++++++++ src/components/ActivityForm.jsx | 113 ++++++++++++++++++++++++++++ src/layout/Shell.css | 7 ++ src/layout/Shell.jsx | 1 + src/screens/AddLead.jsx | 55 ++++++++++++++ src/screens/Lead.jsx | 129 +++++++++++++++++++++++++++++--- src/screens/screens.css | 43 +++++++++++ 9 files changed, 422 insertions(+), 10 deletions(-) create mode 100644 src/components/ActivityForm.css create mode 100644 src/components/ActivityForm.jsx create mode 100644 src/screens/AddLead.jsx diff --git a/src/App.jsx b/src/App.jsx index 499c548..f62f6d1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import Login from './pages/Login.jsx' import Shell from './layout/Shell.jsx' import Pipeline from './screens/Pipeline.jsx' import Lead from './screens/Lead.jsx' +import AddLead from './screens/AddLead.jsx' export default function App() { const { isAuthed } = useZino() @@ -23,6 +24,7 @@ export default function App() { }> } /> } /> + } /> } /> } /> diff --git a/src/api/config.js b/src/api/config.js index 6c7e02a..d379149 100644 --- a/src/api/config.js +++ b/src/api/config.js @@ -43,3 +43,42 @@ export const CHANNELS = { /** 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. + */ +export const ACTIONS = { + '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-capture-sme', label: 'Capture SME Risk', by: 'Engage AI' }, + { uid: 'zk-act-capture-motor', label: 'Capture Motor Risk', by: 'Engage AI' }], + 'zk-state-risk': [{ uid: 'zk-act-advise', label: 'AI Cover Recommendation', by: 'Advisor AI' }, + { uid: 'zk-act-quote', label: 'Generate Quote', by: 'Rating engine' }], + 'zk-state-quoted': [{ uid: 'zk-act-accept', label: 'Accept Proposal', by: 'Customer' }], + 'zk-state-accepted': [{ uid: 'zk-act-kyc', label: 'Verify KYC', by: 'KYC AI' }], + 'zk-state-kyc': [{ uid: 'zk-act-uw-screen', label: 'Underwriting Screen', by: 'Rules' }], + 'zk-state-referred': [{ uid: 'zk-act-uw-prepare', label: 'Prepare Referral', by: 'Referral AI' }, + { uid: 'zk-act-uw-clear', label: 'Clear Referral', by: 'Underwriter' }, + { uid: 'zk-act-uw-decline', label: 'Decline Referral', by: 'Underwriter' }], + 'zk-state-cleared': [{ uid: 'zk-act-payment', label: 'Issue Payment Link', by: 'Ops' }], + 'zk-state-payment': [{ uid: 'zk-act-realise', label: 'Confirm Premium Realisation', by: 'Payment webhook' }], + 'zk-state-issued': [{ uid: 'zk-act-onboard', label: 'Complete Onboarding', by: 'Ops' }], +} + +/** The three doors. Each is a separate INIT activity with its own permissions. */ +export const ENTRY = [ + { uid: 'zk-act-init-direct', label: 'Self-Serve Lead', channel: 'direct', + note: 'Customer or call centre. Self-declared, no KYC.' }, + { uid: 'zk-act-init-bank', label: 'Partner Bank Lead', channel: 'bancassurance', + note: 'Bank RM. Carries the bank’s CKYC, so it starts at Qualified.' }, + { uid: 'zk-act-init-agent', label: 'Partner Agent Lead', channel: 'agency', + note: 'POSP or broker. This is the door the demo is about.' }, +] diff --git a/src/components/ActivityForm.css b/src/components/ActivityForm.css new file mode 100644 index 0000000..fcfa14b --- /dev/null +++ b/src/components/ActivityForm.css @@ -0,0 +1,43 @@ +.af { display: flex; flex-direction: column; gap: 18px; } +.af__loading { color: var(--zk-muted); font-size: .86rem; } +.af__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px 18px; } +.af__field { display: flex; flex-direction: column; gap: 5px; } +.af__field--wide { grid-column: 1 / -1; } +.af__field > span { + font-size: .74rem; font-weight: 500; color: var(--zk-muted); + letter-spacing: .02em; display: flex; align-items: center; gap: 8px; +} +.af__req { + font-style: normal; font-size: .6rem; letter-spacing: .07em; text-transform: uppercase; + color: var(--zk-blue); background: var(--zk-tint-blue); padding: 1px 5px; border-radius: 3px; +} +.af input, .af select, .af textarea { + font: inherit; font-size: .88rem; padding: 8px 10px; border-radius: 4px; + border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-ink); width: 100%; +} +.af input:focus, .af select:focus, .af textarea:focus { + outline: none; border-color: var(--zk-blue); box-shadow: 0 0 0 3px var(--zk-tint-blue); +} +.af textarea { resize: vertical; } +.af__multi { display: flex; flex-wrap: wrap; gap: 6px; } +.af__chip { + font: inherit; font-size: .76rem; padding: 5px 10px; border-radius: 4px; cursor: pointer; + border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted); +} +.af__chip.is-on { background: var(--zk-blue); border-color: var(--zk-blue); color: var(--zk-white); } +.af__actions { display: flex; justify-content: flex-end; gap: 10px; } +.af__submit { + font: inherit; font-size: .88rem; font-weight: 500; padding: 9px 22px; border-radius: 4px; + cursor: pointer; border: 1px solid var(--zk-blue); background: var(--zk-blue); color: var(--zk-white); +} +.af__submit:disabled { opacity: .55; cursor: default; } +.af__ghost { + font: inherit; font-size: .88rem; padding: 9px 18px; border-radius: 4px; cursor: pointer; + border: 1px solid var(--zk-line); background: var(--zk-white); color: var(--zk-muted); +} +.af__err { + background: #fdf0ef; border: 1px solid #f0c9c6; border-left: 3px solid var(--zk-danger); + border-radius: 0 4px 4px 0; padding: 11px 14px; font-size: .84rem; color: var(--zk-ink); +} +.af__err strong { font-family: ui-monospace, monospace; margin-right: 6px; } +.af__err p { margin: 6px 0 0; font-size: .8rem; color: var(--zk-muted); } diff --git a/src/components/ActivityForm.jsx b/src/components/ActivityForm.jsx new file mode 100644 index 0000000..afb235f --- /dev/null +++ b/src/components/ActivityForm.jsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react' +import { useZino } from '../api/provider.jsx' +import './ActivityForm.css' + +/** + * Renders whatever /view/form-screens returns for an activity — labels, types, + * select options and which fields are mandatory — and submits it straight back. + * + * Nothing about this form is defined in the frontend. Add a field to the + * 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. + */ +export default function ActivityForm({ activityUid, instanceId, onDone, onCancel }) { + const { client } = useZino() + const [schema, setSchema] = useState(null) + const [values, setValues] = useState({}) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + useEffect(() => { + let dead = false + setSchema(null); setError(null); setValues({}) + client.formSchema(activityUid, instanceId) + .then((s) => { if (!dead) setSchema(s) }) + .catch((e) => { if (!dead) setError(e) }) + return () => { dead = true } + }, [client, activityUid, instanceId]) + + function set(id, v) { setValues((p) => ({ ...p, [id]: v })) } + + 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) { + const v = values[f.id] + if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) continue + payload[f.id] = f.data_type === 'number' ? Number(v) : v + } + try { + const res = instanceId + ? await client.activity(instanceId, activityUid, payload) + : await client.start(activityUid, payload) + onDone?.(res) + } catch (err) { + setError(err) + } finally { + setBusy(false) + } + } + + if (error && !schema) return
Could not load the form — {error.status} {error.message}
+ if (!schema) return

Loading the form…

+ + return ( +
+
+ {schema.fields.map((f) => { + const opts = f.properties?.options || [] + const v = values[f.id] ?? (f.data_type === 'multiselect' ? [] : '') + return ( +