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 ( +