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 (
+
+ )
+}
diff --git a/src/layout/Shell.css b/src/layout/Shell.css
index ed31bc0..680a253 100644
--- a/src/layout/Shell.css
+++ b/src/layout/Shell.css
@@ -49,3 +49,10 @@
}
.shell__main { flex: 1; min-width: 0; padding: 26px 28px 60px; }
+
+.shell__add {
+ display: block; text-align: center; text-decoration: none; margin: 0 2px 18px;
+ padding: 9px 12px; border-radius: 4px; font-size: .85rem; font-weight: 500;
+ background: var(--zk-blue); color: var(--zk-white); border: 1px solid var(--zk-blue);
+}
+.shell__add:hover { background: var(--zk-blue-dark); border-color: var(--zk-blue-dark); }
diff --git a/src/layout/Shell.jsx b/src/layout/Shell.jsx
index 0ed67f0..d8eea25 100644
--- a/src/layout/Shell.jsx
+++ b/src/layout/Shell.jsx
@@ -40,6 +40,7 @@ export default function Shell() {